ในคู่มือนี้มุ่งเน้นไปที่นักพัฒนาเว็บที่ต้องการใช้ประโยชน์จาก WebAssembly คุณจะได้เรียนรู้วิธีใช้ประโยชน์จาก Wasm ในการจัดจ้างงานที่ใช้ CPU มาก โดยใช้ตัวอย่างซึ่งทำงานอยู่ คู่มือนี้ครอบคลุมทุกสิ่ง ตั้งแต่แนวทางปฏิบัติแนะนำสำหรับการโหลดโมดูล Wasm ไปจนถึงการเพิ่มประสิทธิภาพการรวบรวมและการสร้างอินสแตนซ์ ซึ่งจะพูดถึงการเปลี่ยนงานที่ใช้ CPU มากให้กับ Web Workers และพิจารณาการตัดสินใจเกี่ยวกับการติดตั้งใช้งาน เช่น เวลาที่ควรสร้าง Web Worker และว่าจะให้ระบบคงการทำงานไว้อย่างถาวรหรือทำงานในเวลาที่ต้องการ คู่มือนี้จะพัฒนาวิธีการซ้ำๆ และนำเสนอรูปแบบประสิทธิภาพครั้งละ 1 รูปแบบ จนกว่าจะแนะนำวิธีแก้ปัญหาที่ดีที่สุด
สมมติฐาน
สมมติว่าคุณมีงานที่มีการใช้ 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 ก่อนจึงจะใช้โมดูล 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 ทั้งหมดไปไว้ใน Listener เหตุการณ์ แต่นั่นหมายความว่างานนี้จะต้องเกิดขึ้นกับทุกข้อความที่ได้รับ การแคช HTTP และแคช HTTP สามารถแคชไบต์โค้ด Wasm ที่คอมไพล์ได้ นี่ไม่ใช่วิธีแก้ปัญหาที่แย่ที่สุด แต่ก็ยังมีทางที่ดีกว่า
การย้ายโค้ดอะซิงโครนัสไปยังจุดเริ่มต้นของ Web Worker และไม่รอให้คำสัญญาทำตาม แต่แทนที่จะจัดเก็บคำสัญญาไว้ในตัวแปร โปรแกรมจะย้ายไปยังส่วน 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,
});
});
ในฝั่ง 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 แบบ Lazy Loading หรือกระตือรือร้น
ที่ผ่านมา ตัวอย่างโค้ดทั้งหมดแสดง 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 แบบให้คุณลองเล่น รายการหนึ่งมี Web Worker เฉพาะกิจ (ซอร์สโค้ด) และอีกชุดที่มี Web Worker แบบถาวร (ซอร์สโค้ด)
หากคุณเปิด Chrome DevTools และตรวจสอบคอนโซล คุณจะเห็นบันทึก User Timing API ที่วัดระยะเวลาที่ใช้ตั้งแต่การคลิกปุ่มไปจนถึงผลลัพธ์ที่แสดงบนหน้าจอ แท็บ "เครือข่าย" จะแสดงคำขอ
URL blob:
รายการ ในตัวอย่างนี้ ความแตกต่างของเวลาระหว่างเฉพาะกิจกับแบบถาวร
อยู่ที่ประมาณ 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