ในคู่มือนี้ซึ่งมีไว้สำหรับนักพัฒนาเว็บที่ต้องการใช้ประโยชน์จาก 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