أنماط أداء WebAssembly لتطبيقات الويب

في هذا الدليل، الذي يستهدف مطوّري الويب الذين يريدون الاستفادة من WebAssembly، ستتعلم كيفية الاستفادة من Wasm للتعهيد بمهام تستهلك وحدة المعالجة المركزية (CPU) من خلال مساعدة لمثال قيد التشغيل. يتناول الدليل كل شيء بدءًا من أفضل الممارسات تحميل وحدات Wasm لتحسين طريقة التجميع وإنشاء مثيل. أُنشأها جون هنتر، الذي كان متخصصًا تحويل المهام ذات الاستهلاك الكثيف لوحدة المعالجة المركزية إلى "عمال الويب" ويبحث في قرارات التنفيذ التي ستواجهها مثل وقت إنشاء شبكة الإنترنت عامل وما إذا كان ينبغي إبقاؤه حيًا بشكل دائم أو تدويره عند الحاجة. تشير رسالة الأشكال البيانية يوجه النهج بشكل متكرر ويضع نمط أداء واحدًا في كل مرة، إلى أن يتم اقتراح أفضل حل للمشكلة.

الفرضيّات

افترض أن لديك مهمة تستهلك قدرًا كبيرًا من طاقة وحدة المعالجة المركزية (CPU) وتريد إسنادها إلى جهات خارجية WebAssembly (Wasm) لأدائها التقريبي. تمثّل المهمة المكثفة لوحدة المعالجة المركزية يُستخدم كمثال في هذا الدليل على حساب معامل ضرب رقم ما. تشير رسالة الأشكال البيانية معامل الضرب هو حاصل ضرب عدد صحيح وجميع الأعداد الصحيحة التي تقع أسفله. بالنسبة على سبيل المثال، معامل ضرب أربعة (مكتوب بالصيغة 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() واجهة برمجة التطبيقات. تجدر الإشارة إلى أنّ تطبيق الويب يعتمد على وحدة Wasm في مهمة تستهلك وحدة المعالجة المركزية (CPU)، يجب تحميل ملف Wasm مسبقًا في أقرب وقت ممكن. إِنْتَ إجراء ذلك الجلب الذي يستخدم سياسة مشاركة الموارد المتعددة المصادر (CORS) في قسم "<head>" من تطبيقك

<link rel="preload" as="fetch" href="factorial.wasm" crossorigin />

في الواقع، واجهة برمجة التطبيقات fetch() غير متزامنة وعليك 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، فإن الخطوة الأولى هي إعادة هيكلة التطبيق. تنشئ سلسلة المحادثات الرئيسية الآن 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() هي عملية غير متزامنة. يعني ذلك أن الرمز عبارة عن محتوى للبالغين. وفي أسوأ الحالات، ترسل سلسلة التعليمات الرئيسية البيانات عندما عامل الويب ليس جاهزًا بعد، ولا يتلقى عامل الويب الرسالة أبدًا.

/* 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 وليس في انتظار الوفاء بالوعد، ولكن بدلاً من تخزينه في ينتقل البرنامج فورًا إلى جزء مستمع الحدث في ولن يتم فقدان أي رسالة من سلسلة المحادثات الرئيسية. داخل الحدث أيها المستمع، يمكن أن ينتظرها الوعد.

/* 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 المسؤول عن وحدات المعالجة المركزية التي تستهلك المهمة. يوضح الرمز التالي هذا التدفق.

/* 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 الرمز خارج أداة معالجة أحداث الزر.

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 إلى الطلبات. من ناحية أخرى، يحتفظ موقعك الإلكتروني قد يكون رمز التمهيد للعامل معقدًا إلى حد ما، لذلك قد يكون هناك الكثير من العامة إذا قمت بإنشاء واحدة جديدة في كل مرة. لحسن الحظ، هذا شيء يمكنك قياسها باستخدام واجهة برمجة تطبيقات وقت المستخدم.

حتى الآن، احتفظت عيّنات التعليمات البرمجية بعامل واحد دائم على الويب. ما يلي: نموذج التعليمات البرمجية يؤدي إلى إنشاء عنصر 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,
  });
});

إصدارات تجريبية

يتوفّر عرضان توضيحيان يمكنك اللعب بهما. أحدهما مزوّد عامل الويب المخصّص (رمز المصدر) وواحد مع العامل الدائم على الويب (رمز المصدر) إذا فتحت أدوات مطوري البرامج في Chrome وراجعت وحدة التحكم، يمكنك رؤية مستخدم سجلات Timing API التي تقيس الوقت المستغرَق بدءًا من النقر على الزر ووصولاً إلى النتيجة المعروضة على الشاشة. تعرض علامة التبويب "الشبكة" عنوان URL الخاص بـ "blob:". من الطلبات. في هذا المثال، يختلف فرق التوقيت بين المخصص والدائم حوالي 3×. من الناحية العملية، بالنسبة للعين البشرية، لا يمكن تمييز كليهما في هذا الحالة. من المرجح أن تختلف نتائج تطبيقك الحقيقي.

تطبيق Wasm التجريبي مع عامل مخصّص &quot;أدوات مطوري البرامج في Chrome&quot; مفتوحة. هناك نوعان من الكائن الثنائي الكبير (blob): طلبات عنوان URL في علامة التبويب &quot;الشبكة&quot; وتعرض وحدة التحكم وقتين للعمليات الحسابية.

تطبيق Wasm التجريبي مع عامل دائم &quot;أدوات مطوري البرامج في Chrome&quot; مفتوحة. هناك كائن ثنائي كبير واحد: طلب عنوان URL في علامة التبويب &quot;الشبكة&quot;، وتعرض وحدة التحكّم أربعة توقيتات حسابية.

الاستنتاجات

تناولت هذه المشاركة بعض أنماط الأداء للتعامل مع Wasm.

  • كقاعدة عامة، من الأفضل استخدام طرق البث (WebAssembly.compileStreaming() وWebAssembly.instantiateStreaming()) على التطبيقات المشابهة التي لا تبث المحتوى (WebAssembly.compile() WebAssembly.instantiate()).
  • إذا كان ذلك ممكنًا، فقم بالاستعانة بمصادر خارجية لإنجاز المهام الصعبة في Web Worker، وإجراء Wasm على الويب تحميل وتجميع العمل مرة واحدة فقط خارج Web Worker. بهذه الطريقة، يحتاج عامل الويب فقط إلى إنشاء مثيل لوحدة Wasm التي يتلقّاها من وحدة التي حدث فيها التحميل والتجميع مع WebAssembly.instantiate()، ما يعني أنّه يمكن تخزين المثيل في ذاكرة التخزين المؤقت إذا الحفاظ على عامل الويب بشكل دائم.
  • قياس بعناية ما إذا كان من المنطقي الاحتفاظ بعامل ويب دائم واحد إلى الأبد، أو لإنشاء عاملي ويب مخصصين وقت الحاجة. كذلك الوقت المناسب لإنشاء Web Worker. أمور يجب أخذها في الاعتبار الاعتبار هي استهلاك الذاكرة ومدة إنشاء مثيل Web Worker وأيضًا تعقيد احتمال الاضطرار إلى التعامل مع الطلبات المتزامنة.

إذا أخذت هذه الأنماط في الاعتبار، فأنت على المسار الصحيح لتحسين أداء Wasm

شكر وتقدير

تمت مراجعة هذا الدليل بواسطة أندرياس هاس، جاكوب كومروف، ديبتي غاندلوري، ألون زاكاي، فرانسيس مكابي، فرانسوا بوفورت، راشيل أندرو.