รูปแบบประสิทธิภาพของ WebAssembly สำหรับเว็บแอป

คู่มือนี้มีไว้สําหรับนักพัฒนาเว็บที่ต้องการใช้ประโยชน์จาก WebAssembly ซึ่งคุณจะได้เรียนรู้วิธีใช้ Wasm เพื่อส่งออกงานที่ต้องใช้ CPU มากโดยใช้ตัวอย่างที่ใช้งานจริง คู่มือนี้ครอบคลุมตั้งแต่แนวทางปฏิบัติแนะนำในการโหลดโมดูล Wasm ไปจนถึงการเพิ่มประสิทธิภาพการคอมไพล์และการสร้างอินสแตนซ์ นอกจากนี้ยังมีการกล่าวถึงการเปลี่ยนงานที่ต้องใช้ CPU มากไปยัง Web Workers และพิจารณาถึงการตัดสินใจติดตั้งใช้งานที่คุณจะได้พบเจอ เช่น เมื่อใดที่ควรสร้าง 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 ไม่ได้สตรีม ตอนนี้โค้ดใน 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 หรือแบบ 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 รูปแบบได้ในตัวอย่างนี้ ผลลัพธ์ของแอปในชีวิตจริงของคุณมีแนวโน้มที่จะแตกต่างกันไป

แอปสาธิต Factorial Wasm ที่มี Worker เฉพาะกิจ เครื่องมือสำหรับนักพัฒนาเว็บใน Chrome เปิดอยู่ BLOB มี 2 รายการ ได้แก่ คำขอ URL ในแท็บเครือข่าย และ Console จะแสดงเวลาคำนวณ 2 รายการ

แอปสาธิต Factorial Wasm ที่มี Worker แบบถาวร เครื่องมือสำหรับนักพัฒนาเว็บใน Chrome เปิดอยู่ มีเพียงข้อมูลก้อนเดียว: คำขอ URL ในแท็บเครือข่าย และคอนโซลแสดงเวลาในการคํานวณ 4 รายการ

สรุป

โพสต์นี้มีการสำรวจรูปแบบประสิทธิภาพบางอย่างในการจัดการกับ 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