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

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

الفرضيّات

أفترض أن لديك مهمة تستهلك قدرًا كبيرًا من وحدة المعالجة المركزية (CPU) وتريد الاستعانة بمصادر خارجية لتقنية WebAssembly (Wasm) نظرًا لأدائها الشبه الأصلي. تحسب المهمة التي تستهلك وحدة المعالجة المركزية CPU المستخدمة كمثال في هذا الدليل معامل ضرب أحد الأرقام. العامل العامل هو حاصل ضرب عدد صحيح وجميع الأعداد الصحيحة تحته. على سبيل المثال، معامل أربعة (مكتوب بالصيغة 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));
});

تحويل المهمة إلى "عامل ويب"

إذا نفذت هذا في سلسلة التعليمات الرئيسية، مع مهام تستهلك وحدة المعالجة المركزية (CPU) فعلاً، فأنت تخاطر بحظر التطبيق بالكامل. من الممارسات الشائعة تحويل هذه المهام إلى عامل ويب.

إعادة بنية سلسلة التعليمات الرئيسية

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

من خلال نقل الرمز غير المتزامن إلى بداية "عامل تشغيل الويب" وعدم انتظار تنفيذ الوعد فعليًا، بل تخزين الوعد بشكل متغيّر، ينتقل البرنامج فورًا إلى جزء أداة معالجة الحدث في الرمز، ولن يتم فقدان أي رسالة من سلسلة التعليمات الرئيسية. داخل مستمع الحدث، يمكن بعد ذلك انتظار الوعد.

/* 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 وتجميعها مرة واحدة فقط في سلسلة التعليمات الرئيسية (أو حتى أي عامل ويب آخر معنيّ تمامًا بالتحميل والتجميع)، ثم يتم نقلها إلى مشغّل الويب المسؤول عن المهام التي تستهلك وحدات المعالجة المركزية (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 المجمّعة إلى "عامل الويب" لإنشاء مثيل، لأنّ سياقَي "عامل الويب" وسلسلة التعليمات الرئيسية مختلفان، حتى لو كانا مستندَين إلى ملف مصدر 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 بشكل دائم إلى زيادة أثر تطبيقك على الذاكرة ويجعل التعامل مع المهام المتزامنة أكثر صعوبة، نظرًا لأنك تحتاج بطريقة ما إلى تعيين النتائج التي تأتي من Web Worker إلى الطلبات مرة أخرى. من ناحية أخرى، قد يكون رمز بدء تشغيل Web Worker معقدًا إلى حدٍ ما، لذلك قد يكون هناك الكثير من النفقات العامة إذا أنشأت رمزًا جديدًا في كل مرة. ولحسن الحظ، يمكنك قياس ذلك باستخدام User Timing API.

وقد احتفظت نماذج التعليمات البرمجية حتى الآن بـ "عامل ويب دائم" واحد. عينة التعليمات البرمجية التالية تعمل على إنشاء 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" والتحقّق من وحدة التحكّم، يمكنك الاطّلاع على سجلّات واجهة برمجة التطبيقات User Timing API التي تقيس الوقت المُستغرَق بدايةً من النقر على الزر إلى النتيجة المعروضة على الشاشة. تعرض علامة التبويب "الشبكة" blob: طلبات عناوين URL. في هذا المثال، الفرق في التوقيت بين المخصص والدائم يبلغ حوالي 3 مرات. من الناحية العملية، بالنسبة للعين البشرية، لا يمكن تمييز كليهما في هذه الحالة. من المرجح أن تختلف نتائج تطبيقك الحقيقي.

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

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

الاستنتاجات

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

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

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

شكر وتقدير

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