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

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

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

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

บทสรุป

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