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

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

โครงสร้างใหม่ของเทรดหลัก

ในการย้ายงานที่ใช้ 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 ที่รับผิดชอบค่า CPU ที่ใช้ 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 แบบ Lazy Loading ตามคำขอ ซึ่งก็คือ เวลาที่กดปุ่ม สิ่งที่ควรทำคือ ทั้งนี้ขึ้นอยู่กับแอปพลิเคชันของคุณ สร้าง 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 กลับไปยังคำขอ ในทางกลับกัน เว็บ โค้ด Bootstrapping ของผู้ปฏิบัติงานอาจค่อนข้างซับซ้อน จึงอาจมีข้อมูลจำนวนมาก ค่าใช้จ่าย หากคุณสร้างใหม่ในแต่ละครั้ง โชคดีที่นี่คือสิ่งที่คุณสามารถ วัดด้วยฟังก์ชัน 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 อย่างถาวร (ซอร์สโค้ด) หากคุณเปิดเครื่องมือสำหรับนักพัฒนาเว็บใน Chrome และตรวจสอบคอนโซล คุณจะเห็น บันทึก Timing API ที่วัดระยะเวลาที่ใช้ตั้งแต่การคลิกปุ่มไปจนถึง ผลลัพธ์ที่แสดงบนหน้าจอ แท็บเครือข่ายจะแสดง URL ของ blob: คำขอ ในตัวอย่างนี้ ความแตกต่างของเวลาระหว่างแบบเฉพาะกิจและถาวร ประมาณ 3× ในทางปฏิบัติแล้ว ในสายตามนุษย์แล้ว ทั้งสองสิ่งนี้ไม่สามารถแยกความแตกต่างได้ในเรื่องนี้ ผลลัพธ์สําหรับแอปในชีวิตจริงของคุณเองมักจะแตกต่างกัน

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

แอปเดโมของ Factorial Wasm ที่มีผู้ปฏิบัติงานถาวร เครื่องมือสำหรับนักพัฒนาเว็บใน Chrome เปิดอยู่ มี BLOB เพียงรายการเดียว คือ คำขอ URL ในแท็บ &quot;เครือข่าย&quot; และ Console แสดงเวลาคำนวณ 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