คู่มือนี้มีไว้สําหรับนักพัฒนาเว็บที่ต้องการใช้ประโยชน์จาก WebAssembly ซึ่งคุณจะได้เรียนรู้วิธีใช้ Wasm เพื่อส่งออกงานที่ต้องใช้ CPU มากโดยใช้ตัวอย่างที่ใช้งานจริง คู่มือนี้ครอบคลุมตั้งแต่แนวทางปฏิบัติแนะนำในการโหลดโมดูล Wasm ไปจนถึงการเพิ่มประสิทธิภาพการคอมไพล์และการสร้างอินสแตนซ์ บทความนี้ยังกล่าวถึงการเปลี่ยนงานที่ใช้ CPU มากไปยัง Web Worker และพิจารณาถึงการตัดสินใจในการใช้งานที่คุณจะต้องเผชิญ เช่น กรณีที่ควรสร้าง 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
และ submit
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 แต่โค้ดทำงานเร็วเกินไป
เว็บเวิร์กเกอร์จะสร้างอินสแตนซ์ของโมดูล 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 ทั้งหมดไปยัง EventListener แต่วิธีนี้หมายความว่าจะต้องทํางานนี้กับข้อความที่ได้รับทุกรายการ การใช้แคช HTTP และแคช HTTP ที่สามารถแคชไบต์โค้ด Wasm ที่คอมไพล์แล้วไม่ใช่วิธีแก้ปัญหาที่แย่ที่สุด แต่ยังมีวิธีอื่นที่ดีกว่า
การย้ายโค้ดแบบแอซิงโครนัสไปไว้ที่ส่วนต้นของ Web Worker และไม่ได้รอให้ Promise ดำเนินการ แต่เก็บ Promise ไว้ในตัวแปร โปรแกรมจะไปยังส่วน Listener เหตุการณ์ของโค้ดทันที และข้อความจากเธรดหลักจะไม่สูญหาย คุณสามารถรอพรอมต์ภายในตัวฟังเหตุการณ์
/* 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 และโหลดและคอมไพล์เพียงครั้งเดียว
ผลลัพธ์ของเมธอดแบบคงที่ WebAssembly.compileStreaming()
จะเป็นสัญญาที่แก้ไขเป็น 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,
});
});
ฝั่งเว็บเวิร์กเกอร์ สิ่งที่ต้องทำเหลือเพียงดึงข้อมูลออบเจ็กต์ WebAssembly.Module
ออกมาและสร้างอินสแตนซ์ เนื่องจากข้อความที่มี WebAssembly.Module
ไม่ได้สตรีม ตอนนี้โค้ดในเว็บเวิร์กเกอร์จึงใช้ 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 แบบแทรก inline และโหลดและคอมไพล์เพียงครั้งเดียว
แม้จะมีการแคช 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 แบบ Lazy หรือแบบ Eager
จนถึงตอนนี้ ตัวอย่างโค้ดทั้งหมดจะสร้าง Web Worker แบบ Lazy เมื่อมีการเรียกใช้ กล่าวคือ เมื่อมีการกดปุ่ม การสร้าง 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 แบบ Ad-hoc (ซอร์สโค้ด) และอีก 1 รายการที่มี Web Worker แบบถาวร (ซอร์สโค้ด)
หากเปิดเครื่องมือสำหรับนักพัฒนาเว็บของ Chrome และตรวจสอบคอนโซล คุณจะเห็นบันทึก User Timing 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 แบบถาวร 1 รายการตลอดไป หรือสร้าง Web Worker เฉพาะกิจทุกครั้งที่ต้องการ นอกจากนี้ ให้พิจารณาว่าควรสร้าง Web Worker ในช่วงเวลาใด สิ่งที่ต้องพิจารณา ได้แก่ การใช้หน่วยความจํา ระยะเวลาการสร้างอินสแตนซ์ของ Web Worker และอาจรวมถึงความซับซ้อนที่อาจต้องจัดการกับคําขอพร้อมกัน
เมื่อพิจารณารูปแบบเหล่านี้แล้ว แสดงว่าคุณกำลังเดินอยู่บนเส้นทางที่ถูกต้องเพื่อประสิทธิภาพ Wasm ที่ดีที่สุด
ขอขอบคุณ
คู่มือนี้ได้รับการตรวจสอบโดย Andreas Haas, Jakob Kummerow, Deepti Gandluri, Alon Zakai, Francis McCabe, François Beaufort และ Rachel Andrew