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

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

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

แอปเดโม Factorial Wasm ที่มี Worker แบบถาวร เครื่องมือสำหรับนักพัฒนาเว็บใน Chrome เปิดอยู่ มี Blob เพียงรายการเดียวคือคำขอ 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