ในคู่มือนี้ซึ่งมีไว้สำหรับนักพัฒนาเว็บที่ต้องการใช้ประโยชน์จาก WebAssembly คุณจะได้เรียนรู้วิธีใช้ Wasm เพื่อเอาต์ซอร์สงานที่ใช้ CPU อย่างหนักด้วย ตัวอย่างที่ใช้งานได้ คู่มือนี้ครอบคลุมทุกอย่างตั้งแต่แนวทางปฏิบัติแนะนำสำหรับ การโหลดโมดูล Wasm ไปจนถึงการเพิ่มประสิทธิภาพการคอมไพล์และการสร้างอินสแตนซ์ นอกจากนี้ ยังกล่าวถึงการเปลี่ยนงานที่ใช้ CPU อย่างหนักไปเป็น Web Worker และพิจารณาการตัดสินใจในการติดตั้งใช้งานที่คุณจะต้องเผชิญ เช่น เมื่อใดควรสร้าง Web Worker และควรให้ทำงานตลอดเวลาหรือเปิดใช้งานเมื่อจำเป็น คู่มือนี้จะพัฒนาแนวทางแบบวนซ้ำและแนะนำรูปแบบประสิทธิภาพทีละรูปแบบ จนกว่าจะแนะนำโซลูชันที่ดีที่สุดสำหรับปัญหา
สมมติฐาน
สมมติว่าคุณมีงานที่ใช้ CPU อย่างหนักซึ่งต้องการส่งต่อไปยัง WebAssembly (Wasm) เพื่อให้ได้ประสิทธิภาพที่ใกล้เคียงกับประสิทธิภาพดั้งเดิม งานที่ใช้ CPU สูง ซึ่งใช้เป็นตัวอย่างในคู่มือนี้จะคำนวณค่าแฟกทอเรียลของตัวเลข แฟกทอเรียลคือผลคูณของจำนวนเต็มและจำนวนเต็มทั้งหมดที่ต่ำกว่า ตัวอย่างเช่น แฟกทอเรียลของ 4 (เขียนเป็น 4!
) เท่ากับ 24
(นั่นคือ 4 * 3 * 2 * 1
) ตัวเลขจะเพิ่มขึ้นอย่างรวดเร็ว เช่น 16!
คือ 2,004,189,184
ตัวอย่างงานที่ใช้ CPU อย่างหนักที่สมจริงมากขึ้นอาจเป็น การสแกนบาร์โค้ดหรือ การติดตามรูปภาพแรสเตอร์
การใช้งานฟังก์ชัน factorial()
แบบวนซ้ำที่มีประสิทธิภาพ (ไม่ใช่แบบเรียกซ้ำ) แสดงอยู่ในตัวอย่างโค้ดต่อไปนี้ที่เขียนด้วย C++
#include <stdint.h> extern "C" { // Calculates the factorial of a non-negative integer n. uint64_t factorial(unsigned int n) { uint64_t result = 1; for (unsigned int i = 2; i <= n; ++i) { result *= i; } return result; } }
สำหรับส่วนที่เหลือของบทความนี้ ให้ถือว่ามีโมดูล Wasm ที่อิงตามการคอมไพล์factorial()
ฟังก์ชันนี้ด้วย Emscripten ในไฟล์ชื่อ factorial.wasm
โดยใช้แนวทางปฏิบัติแนะนำในการเพิ่มประสิทธิภาพโค้ดทั้งหมด หากต้องการทบทวนวิธีดำเนินการนี้ โปรดอ่านหัวข้อ การเรียกฟังก์ชัน C ที่คอมไพล์แล้วจาก JavaScript โดยใช้ ccall/cwrap ใช้คำสั่งต่อไปนี้เพื่อคอมไพล์ factorial.wasm
เป็น Wasm แบบสแตนด์อโลน
emcc -O3 factorial.cpp -o factorial.wasm -s WASM_BIGINT -s EXPORTED_FUNCTIONS='["_factorial"]' --no-entry
ใน HTML จะมี form
ที่มี input
จับคู่กับ output
และปุ่มส่ง button
องค์ประกอบเหล่านี้อ้างอิงจาก JavaScript ตามชื่อขององค์ประกอบ
<form> <label>The factorial of <input type="text" value="12" /></label> is <output>479001600</output>. <button type="submit">Calculate</button> </form>
const input = document.querySelector('input'); const output = document.querySelector('output'); const button = document.querySelector('button');
การโหลด การคอมไพล์ และการสร้างอินสแตนซ์ของโมดูล
คุณต้องโหลดโมดูล Wasm ก่อนจึงจะใช้งานได้ บนเว็บ การดำเนินการนี้จะเกิดขึ้น ผ่าน fetch()
API เนื่องจากคุณทราบว่าเว็บแอปของคุณขึ้นอยู่กับโมดูล Wasm สำหรับงานที่ใช้ CPU สูง คุณจึงควรโหลดไฟล์ Wasm ล่วงหน้าโดยเร็วที่สุด คุณ ทำได้โดยใช้ การดึงข้อมูลที่เปิดใช้ CORS ในส่วน <head>
ของแอป
<link rel="preload" as="fetch" href="factorial.wasm" crossorigin />
ในความเป็นจริง fetch()
API จะทำงานแบบไม่พร้อมกันและคุณต้องawait
ผลลัพธ์
fetch('factorial.wasm');
จากนั้นคอมไพล์และสร้างอินสแตนซ์โมดูล Wasm มีฟังก์ชันที่ตั้งชื่อไว้อย่างน่าสนใจว่า WebAssembly.compile()
(รวมถึง WebAssembly.compileStreaming()
) และ WebAssembly.instantiate()
สำหรับงานเหล่านี้ แต่ในทางกลับกัน เมธอด WebAssembly.instantiateStreaming()
จะคอมไพล์และสร้างอินสแตนซ์โมดูล Wasm โดยตรงจากแหล่งที่มาพื้นฐานที่สตรีม เช่น fetch()
โดยไม่ต้องใช้ await
นี่เป็นวิธีที่มีประสิทธิภาพ และได้รับการเพิ่มประสิทธิภาพมากที่สุดในการโหลดโค้ด Wasm หากโมดูล Wasm ส่งออกฟังก์ชัน factorial()
คุณจะใช้ฟังก์ชันนั้นได้ทันที
const importObject = {}; const resultObject = await WebAssembly.instantiateStreaming( fetch('factorial.wasm'), importObject, ); const factorial = resultObject.instance.exports.factorial; button.addEventListener('click', (e) => { e.preventDefault(); output.textContent = factorial(parseInt(input.value, 10)); });
ย้ายงานไปยัง Web Worker
หากคุณดำเนินการนี้ในเทรดหลักกับงานที่ใช้ CPU อย่างแท้จริง คุณอาจเสี่ยงต่อการบล็อกทั้งแอป แนวทางปฏิบัติทั่วไปคือการเปลี่ยนงานดังกล่าวไปเป็น Web Worker
การปรับโครงสร้างเทรดหลัก
หากต้องการย้ายงานที่ใช้ CPU สูงไปยัง Web Worker ขั้นตอนแรกคือการปรับโครงสร้างแอปพลิเคชัน ตอนนี้เทรดหลักจะสร้าง Worker
และนอกเหนือจากนั้น จะจัดการเฉพาะการส่งอินพุตไปยัง Web Worker จากนั้นรับ เอาต์พุตและแสดงเอาต์พุต
/* Main thread. */ let worker = null; // When the button is clicked, submit the input value // to the Web Worker. button.addEventListener('click', (e) => { e.preventDefault(); // Create the Web Worker lazily on-demand. if (!worker) { worker = new Worker('worker.js'); // Listen for incoming messages and display the result. worker.addEventListener('message', (e) => { output.textContent = e.result; }); } worker.postMessage({ integer: parseInt(input.value, 10) }); });
ไม่ดี: งานทำงานใน Web Worker แต่โค้ดไม่ปลอดภัย
Web Worker จะสร้างอินสแตนซ์ของโมดูล Wasm และเมื่อได้รับข้อความ จะทำงานที่ใช้ CPU สูงและส่งผลลัพธ์กลับไปยังเธรดหลัก ปัญหาของแนวทางนี้คือการสร้างอินสแตนซ์ของโมดูล Wasm ด้วย WebAssembly.instantiateStreaming()
เป็นการดำเนินการแบบไม่พร้อมกัน ซึ่งหมายความว่าโค้ดมีข้อบกพร่อง ในกรณีที่แย่ที่สุด เทรดหลักจะส่งข้อมูลเมื่อ Web Worker ยังไม่พร้อม และ Web Worker จะไม่ได้รับข้อความเลย
/* Worker thread. */ // Instantiate the Wasm module. // 🚫 This code is racy! If a message comes in while // the promise is still being awaited, it's lost. const importObject = {}; const resultObject = await WebAssembly.instantiateStreaming( fetch('factorial.wasm'), importObject, ); const factorial = resultObject.instance.exports.factorial; // Listen for incoming messages, run the task, // and post the result. self.addEventListener('message', (e) => { const { integer } = e.data; self.postMessage({ result: factorial(integer) }); });
ดีขึ้น: งานจะทำงานใน Web Worker แต่มีการโหลดและการคอมไพล์ที่อาจซ้ำซ้อน
วิธีแก้ปัญหาการเริ่มต้นโมดูล Wasm แบบอะซิงโครนัสคือการ ย้ายการโหลด การคอมไพล์ และการเริ่มต้นโมดูล Wasm ทั้งหมดไปยังเครื่อง ฟังเหตุการณ์ แต่การทำเช่นนี้หมายความว่าต้องดำเนินการนี้กับทุกข้อความที่ได้รับ การใช้แคช HTTP และแคช HTTP ที่แคชไบต์โค้ด Wasm ที่คอมไพล์แล้วอาจไม่ใช่ทางออกที่ดีที่สุด แต่ก็ไม่ได้แย่ ยังมีวิธีที่ดีกว่านี้
การย้ายโค้ดแบบอะซิงโครนัสไปที่จุดเริ่มต้นของ Web Worker และไม่รอให้ Promise ทำงานเสร็จ แต่จัดเก็บ Promise ไว้ในตัวแปรแทน จะทำให้โปรแกรมไปยังส่วนเครื่องมือฟังเหตุการณ์ของโค้ดได้ทันที และจะไม่มีข้อความจากเทรดหลักสูญหาย จากนั้นคุณจะรอให้ Promise ทำงานเสร็จสิ้นได้ภายใน เครื่องมือฟังเหตุการณ์
/* Worker thread. */ const importObject = {}; // Instantiate the Wasm module. // 🚫 If the `Worker` is spun up frequently, the loading // compiling, and instantiating work will happen every time. const wasmPromise = WebAssembly.instantiateStreaming( fetch('factorial.wasm'), importObject, ); // Listen for incoming messages self.addEventListener('message', async (e) => { const { integer } = e.data; const resultObject = await wasmPromise; const factorial = resultObject.instance.exports.factorial; const result = factorial(integer); self.postMessage({ result }); });
ดี: งานทำงานใน Web Worker และโหลดและคอมไพล์เพียงครั้งเดียว
ผลลัพธ์ของเมธอด static WebAssembly.compileStreaming()
คือ Promise ที่เปลี่ยนเป็น WebAssembly.Module
ข้อดีอย่างหนึ่งของออบเจ็กต์นี้คือสามารถโอนได้โดยใช้ postMessage()
ซึ่งหมายความว่าระบบจะโหลดและคอมไพล์โมดูล Wasm ได้เพียงครั้งเดียวในเทรดหลัก (หรือแม้แต่ Web Worker อื่นที่เกี่ยวข้องกับการโหลดและคอมไพล์โดยเฉพาะ) จากนั้นจะโอนไปยัง Web Worker ที่รับผิดชอบงานที่ใช้ CPU สูง โค้ดต่อไปนี้แสดงโฟลว์นี้
/* Main thread. */ const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm')); let worker = null; // When the button is clicked, submit the input value // and the Wasm module to the Web Worker. button.addEventListener('click', async (e) => { e.preventDefault(); // Create the Web Worker lazily on-demand. if (!worker) { worker = new Worker('worker.js'); // Listen for incoming messages and display the result. worker.addEventListener('message', (e) => { output.textContent = e.result; }); } worker.postMessage({ integer: parseInt(input.value, 10), module: await modulePromise, }); });
ในส่วนของ Web Worker คุณเพียงแค่ต้องดึงออบเจ็กต์ WebAssembly.Module
และสร้างอินสแตนซ์ เนื่องจากไม่ได้สตรีมข้อความที่มี WebAssembly.Module
โค้ดใน Web Worker จึงใช้ WebAssembly.instantiate()
แทนตัวแปร instantiateStreaming()
จากก่อนหน้านี้ ระบบจะแคชโมดูลที่สร้างอินสแตนซ์แล้วในตัวแปร ดังนั้นงานการสร้างอินสแตนซ์จึงต้องเกิดขึ้นเพียงครั้งเดียวเมื่อเปิดใช้ Web Worker
/* Worker thread. */ let instance = null; // Listen for incoming messages self.addEventListener('message', async (e) => { // Extract the `WebAssembly.Module` from the message. const { integer, module } = e.data; const importObject = {}; // Instantiate the Wasm module that came via `postMessage()`. instance = instance || (await WebAssembly.instantiate(module, importObject)); const factorial = instance.exports.factorial; const result = factorial(integer); self.postMessage({ result }); });
สมบูรณ์แบบ: งานทำงานใน Web Worker แบบอินไลน์ และโหลดและคอมไพล์เพียงครั้งเดียว
แม้จะใช้แคช HTTP แต่การรับโค้ด Web Worker ที่แคชไว้ (ในอุดมคติ) และการเข้าถึงเครือข่ายอาจมีค่าใช้จ่ายสูง เคล็ดลับด้านประสิทธิภาพที่ใช้กันโดยทั่วไปคือการ ฝัง Web Worker ไว้ในบรรทัดและโหลดเป็น URL ของ blob:
แต่ก็ยังต้องส่งโมดูล Wasm ที่คอมไพล์แล้วไปยัง Web Worker เพื่อสร้างอินสแตนซ์ เนื่องจากบริบทของ Web Worker และเทรดหลักแตกต่างกัน แม้ว่าจะอิงตามไฟล์ต้นฉบับ JavaScript เดียวกันก็ตาม
/* Main thread. */ const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm')); let worker = null; const blobURL = URL.createObjectURL( new Blob( [ ` let instance = null; self.addEventListener('message', async (e) => { // Extract the \`WebAssembly.Module\` from the message. const {integer, module} = e.data; const importObject = {}; // Instantiate the Wasm module that came via \`postMessage()\`. instance = instance || await WebAssembly.instantiate(module, importObject); const factorial = instance.exports.factorial; const result = factorial(integer); self.postMessage({result}); }); `, ], { type: 'text/javascript' }, ), ); button.addEventListener('click', async (e) => { e.preventDefault(); // Create the Web Worker lazily on-demand. if (!worker) { worker = new Worker(blobURL); // Listen for incoming messages and display the result. worker.addEventListener('message', (e) => { output.textContent = e.result; }); } worker.postMessage({ integer: parseInt(input.value, 10), module: await modulePromise, }); });
การสร้าง Web Worker แบบเลซีหรือแบบกระตือรือร้น
ที่ผ่านมา ตัวอย่างโค้ดทั้งหมดจะเปิดใช้งาน Web Worker แบบเลซีตามความต้องการ ซึ่งก็คือ เมื่อกดปุ่ม คุณอาจต้อง สร้าง Web Worker ให้เร็วขึ้น เช่น เมื่อแอปไม่ได้ใช้งาน หรือแม้กระทั่งเป็น ส่วนหนึ่งของกระบวนการเริ่มต้นแอป ดังนั้น ให้ย้ายโค้ดการสร้าง Web Worker ไปไว้นอกตัว Listener เหตุการณ์ของปุ่ม
const worker = new Worker(blobURL); // Listen for incoming messages and display the result. worker.addEventListener('message', (e) => { output.textContent = e.result; });
จะเก็บ Web Worker ไว้หรือไม่
คำถามหนึ่งที่คุณอาจถามตัวเองคือคุณควรเก็บ Web Worker ไว้ถาวรหรือสร้างใหม่ทุกครั้งที่ต้องการ ทั้ง 2 วิธี เป็นไปได้และมีทั้งข้อดีและข้อเสีย เช่น การเก็บ Web Worker ไว้ถาวรอาจเพิ่มการใช้หน่วยความจำของแอปและทำให้ การจัดการกับงานพร้อมกันยากขึ้น เนื่องจากคุณต้องแมปผลลัพธ์ ที่มาจาก Web Worker กลับไปยังคำขอ ในทางกลับกัน โค้ดการเริ่มต้นของ Web Worker อาจค่อนข้างซับซ้อน จึงอาจมีค่าใช้จ่ายสูงหากคุณสร้างโค้ดใหม่ทุกครั้ง โชคดีที่คุณสามารถวัดผลได้ด้วย User Timing API
ตัวอย่างโค้ดที่ผ่านมาจะเก็บ Web Worker ถาวรไว้ 1 รายการ ตัวอย่างโค้ดต่อไปนี้จะสร้าง Web Worker ใหม่เฉพาะกิจเมื่อใดก็ตามที่จำเป็น โปรดทราบว่าคุณต้อง ติดตาม การสิ้นสุด Web Worker ด้วยตนเอง (ข้อมูลโค้ดจะข้ามการจัดการข้อผิดพลาด แต่ในกรณีที่เกิดข้อผิดพลาด โปรดตรวจสอบว่าได้สิ้นสุดในทุกกรณี ไม่ว่าจะสำเร็จหรือล้มเหลว)
/* Main thread. */ let worker = null; const modulePromise = WebAssembly.compileStreaming(fetch('factorial.wasm')); const blobURL = URL.createObjectURL( new Blob( [ ` // Caching the instance means you can switch between // throw-away and permanent Web Worker freely. let instance = null; self.addEventListener('message', async (e) => { // Extract the \`WebAssembly.Module\` from the message. const {integer, module} = e.data; const importObject = {}; // Instantiate the Wasm module that came via \`postMessage()\`. instance = instance || await WebAssembly.instantiate(module, importObject); const factorial = instance.exports.factorial; const result = factorial(integer); self.postMessage({result}); }); `, ], { type: 'text/javascript' }, ), ); button.addEventListener('click', async (e) => { e.preventDefault(); // Terminate a potentially running Web Worker. if (worker) { worker.terminate(); } // Create the Web Worker lazily on-demand. worker = new Worker(blobURL); worker.addEventListener('message', (e) => { worker.terminate(); worker = null; output.textContent = e.data.result; }); worker.postMessage({ integer: parseInt(input.value, 10), module: await modulePromise, }); });
การสาธิต
คุณสามารถลองเล่นเดโม 2 รายการ โดยมี 1 รายการที่มี Web Worker แบบเฉพาะกิจ (ซอร์สโค้ด) และอีก 1 รายการที่มี Web Worker แบบถาวร (ซอร์สโค้ด) หากเปิดเครื่องมือสำหรับนักพัฒนาเว็บใน Chrome และตรวจสอบคอนโซล คุณจะเห็นบันทึก API การจับเวลาของผู้ใช้ ซึ่งวัดเวลาที่ใช้ตั้งแต่คลิกปุ่มจนถึงผลลัพธ์ที่แสดงบนหน้าจอ แท็บเครือข่ายแสดงblob:
URL คำขอ ในตัวอย่างนี้ ความแตกต่างของเวลาในการตอบสนองระหว่างการตอบสนองเฉพาะกิจกับการตอบสนองถาวร อยู่ที่ประมาณ 3 เท่า ในทางปฏิบัติแล้ว ทั้ง 2 อย่างนี้แทบจะแยกไม่ออกในกรณีนี้ ผลลัพธ์สำหรับแอปในชีวิตจริงของคุณเองอาจแตกต่างกันไป
บทสรุป
โพสต์นี้ได้สำรวจรูปแบบประสิทธิภาพบางอย่างในการจัดการกับ Wasm
- โดยทั่วไปแล้ว เราขอแนะนำให้ใช้เมธอดสตรีมมิง (
WebAssembly.compileStreaming()
และWebAssembly.instantiateStreaming()
) มากกว่าเมธอดที่ไม่ใช่สตรีมมิง (WebAssembly.compile()
และWebAssembly.instantiate()
) - หากทำได้ ให้เอาต์ซอร์สงานที่ใช้ประสิทธิภาพสูงใน Web Worker และโหลดและคอมไพล์ Wasm เพียงครั้งเดียวภายนอก Web Worker ด้วยวิธีนี้ Web Worker จึงต้องสร้างอินสแตนซ์ของโมดูล Wasm ที่ได้รับจากเทรดหลักเท่านั้น ซึ่งการโหลดและการคอมไพล์เกิดขึ้นกับ
WebAssembly.instantiate()
ซึ่งหมายความว่าระบบจะแคชอินสแตนซ์ได้หากคุณ เก็บ Web Worker ไว้ถาวร - พิจารณาอย่างรอบคอบว่าการมี Web Worker แบบถาวร ตลอดเวลาหรือการสร้าง Web Worker เฉพาะกิจเมื่อใดก็ตามที่จำเป็นนั้นสมเหตุสมผลหรือไม่ นอกจากนี้ ให้พิจารณาว่าเวลาใดที่เหมาะที่สุดในการสร้าง Web Worker สิ่งที่ควรพิจารณาคือการใช้หน่วยความจำ ระยะเวลาการเริ่มต้นอินสแตนซ์ของ Web Worker รวมถึงความซับซ้อนของการอาจต้องจัดการกับคำขอพร้อมกัน
หากคำนึงถึงรูปแบบเหล่านี้ คุณก็จะมาถูกทางในการเพิ่มประสิทธิภาพ Wasm ให้ได้สูงสุด
การรับทราบ
คู่มือนี้ได้รับการตรวจสอบโดย Andreas Haas Jakob Kummerow Deepti Gandluri Alon Zakai Francis McCabe François Beaufort และ Rachel Andrew