वेब ऐप्लिकेशन के लिए WebAssembly परफ़ॉर्मेंस के पैटर्न

यह गाइड, उन वेब डेवलपर के लिए है जो WebAssembly का फ़ायदा पाना चाहते हैं. इसमें आपको मौजूदा उदाहरण की मदद से, सीपीयू से जुड़े टास्क को आउटसोर्स करने के लिए, Wasm का इस्तेमाल करने का तरीका बताया जाएगा. इस गाइड में, Wasm मॉड्यूल को लोड करने के सबसे सही तरीकों से लेकर, उनके कंपाइलेशन और इंस्टैंशिएशन को ऑप्टिमाइज़ करने तक की जानकारी दी गई है. इसमें, सीपीयू पर ज़्यादा लोड डालने वाले टास्क को वेब वर्कर्स पर शिफ़्ट करने के बारे में भी बताया गया है. साथ ही, इसे लागू करने से जुड़े फ़ैसलों के बारे में भी बताया गया है. जैसे, वेब वर्कर्स को कब बनाना है और क्या इसे हमेशा चालू रखना है या ज़रूरत पड़ने पर चालू करना है. गाइड में, समस्या को हल करने के लिए एक बार में एक परफ़ॉर्मेंस पैटर्न को शामिल किया जाता है. ऐसा तब तक किया जाता है, जब तक कि समस्या का सबसे अच्छा समाधान नहीं मिल जाता.

अनुमान

मान लें कि आपके पास एक ऐसा टास्क है जिसमें सीपीयू का ज़्यादा इस्तेमाल होता है. आपको इस टास्क को नेटिव परफ़ॉर्मेंस के करीब के लिए, WebAssembly (Wasm) को आउटसोर्स करना है. इस गाइड में उदाहरण के तौर पर इस्तेमाल किए गए सीपीयू-इंटेंसिव टास्क में, किसी संख्या के फ़ैक्टरियल का हिसाब लगाया जाता है. फ़ैक्टरियल, किसी पूर्णांक और उससे छोटे सभी पूर्णांकों का गुणनफल होता है. उदाहरण के लिए, चार की फ़ैक्टरियल (4! के तौर पर लिखी गई) 24 (यानी 4 * 3 * 2 * 1) के बराबर होती है. संख्याएं तेज़ी से बड़ी हो जाती हैं. उदाहरण के लिए, 16!, 2,004,189,184 है. सीपीयू पर आधारित टास्क का ज़्यादा असली उदाहरण बारकोड को स्कैन करना या रास्टर इमेज को ट्रेस करना हो सकता है.

C++ में लिखे गए नीचे दिए गए कोड सैंपल में, factorial() फ़ंक्शन को बेहतर तरीके से बार-बार लागू करने (बजाय बार-बार लागू करने) का तरीका दिखाया गया है.

#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;
}

}

लेख के बाकी हिस्से के लिए, मान लें कि factorial() फ़ंक्शन को Emscripten की मदद से factorial.wasm नाम की फ़ाइल में इकट्ठा करके, एक Wasm मॉड्यूल बनाया गया है. इसके लिए, कोड ऑप्टिमाइज़ेशन के सभी सबसे सही तरीकों का इस्तेमाल किया गया है. ऐसा करने का तरीका जानने के लिए, ccall/cwrap का इस्तेमाल करके JavaScript से कंपाइल किए गए C फ़ंक्शन पढ़ें. factorial.wasm को स्टैंडअलोन Wasm के तौर पर कंपाइल करने के लिए, इस कमांड का इस्तेमाल किया गया था.

emcc -O3 factorial.cpp -o factorial.wasm -s WASM_BIGINT -s EXPORTED_FUNCTIONS='["_factorial"]'  --no-entry

एचटीएमएल में, 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 मॉड्यूल पर निर्भर करता है. इसलिए, आपको Wasm फ़ाइल को जल्द से जल्द प्रीलोड करना चाहिए. ऐसा करने के लिए, अपने ऐप्लिकेशन के <head> सेक्शन में, सीओआरएस की सुविधा वाले फ़ेच का इस्तेमाल करें.

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

असल में, fetch() एपीआई एसिंक्रोनस है और आपको नतीजे को await करना होगा.

fetch('factorial.wasm');

इसके बाद, Wasm मॉड्यूल को कंपाइल और इंस्टैंशिएट करें. इन टास्क के लिए, WebAssembly.compile() (साथ ही, WebAssembly.compileStreaming()) और WebAssembly.instantiate() नाम वाले फ़ंक्शन हैं. हालांकि, इसके बजाय, WebAssembly.instantiateStreaming() वाला तरीका, fetch() जैसे स्ट्रीम किए गए सोर्स से सीधे तौर पर Wasm मॉड्यूल को कंपाइल करता है और उसका इंस्टेंस बनाता है. इसके लिए, 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));
});

टास्क को वेब वर्कर पर शिफ़्ट करना

अगर आपने इसे मुख्य थ्रेड पर एक्ज़ीक्यूट किया, तो इसका मतलब है कि पूरे ऐप्लिकेशन को ब्लॉक किया जा सकता है. इस तरह के टास्क पूरे ऐप्लिकेशन को ब्लॉक कर दिए जाते हैं. आम तौर पर, ऐसे टास्क को किसी वेब वर्कर पर भेज दिया जाता है.

मुख्य थ्रेड का स्ट्रक्चर बदलना

सीपीयू पर ज़्यादा लोड डालने वाले टास्क को वेब वर्कर्स पर ले जाने के लिए, सबसे पहले ऐप्लिकेशन का स्ट्रक्चर फिर से बनाना होगा. मुख्य थ्रेड अब एक 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) });
});

खराब: टास्क, वेब वर्कर में चलता है, लेकिन कोड रैसी है

वेब वर्कर्स, Wasm मॉड्यूल को इंस्टैंशिएट करता है. साथ ही, मैसेज मिलने पर, सीपीयू पर ज़्यादा लोड डालने वाले टास्क को पूरा करता है और नतीजे को मुख्य थ्रेड पर भेजता है. इस तरीके की समस्या यह है कि WebAssembly.instantiateStreaming() की मदद से Wasm मॉड्यूल को इंस्टैंशिएट करना, एक असाइनिटिव ऑपरेशन है. इसका मतलब है कि कोड सिर्फ़ वयस्कों के लिए है. सबसे खराब स्थिति में, मुख्य थ्रेड तब डेटा भेजता है, जब वेब वर्कर अभी तक तैयार नहीं होता और वेब वर्कर को कभी भी यह मैसेज नहीं मिलता.

/* 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) });
});

बेहतर: टास्क, वेब वर्कर्स में चलता है, लेकिन हो सकता है कि लोड करने और कंपाइल करने में ज़रूरत से ज़्यादा समय लगे

Wasm मॉड्यूल को असाइनोसाइनस तरीके से इंस्टैंशिएट करने की समस्या को हल करने का एक तरीका यह है कि Wasm मॉड्यूल को लोड करने, कंपाइल करने, और इंस्टैंशिएट करने की प्रोसेस को इवेंट लिसनर में ले जाया जाए. हालांकि, इसका मतलब है कि यह काम, मिले हर मैसेज पर करना होगा. एचटीटीपी कैश मेमोरी और एचटीटीपी कैश मेमोरी, कंपाइल किए गए 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 });
});

अच्छा: टास्क, वेब वर्कर्स में चलता है और सिर्फ़ एक बार लोड और कंपाइल होता है

स्टैटिक WebAssembly.compileStreaming() तरीका इस्तेमाल करने पर, एक प्रॉमिस मिलता है, जो WebAssembly.Module पर रिज़ॉल्व होता है. इस ऑब्जेक्ट की एक अच्छी सुविधा यह है कि इसे postMessage() का इस्तेमाल करके ट्रांसफ़र किया जा सकता है. इसका मतलब है कि Wasm मॉड्यूल को मुख्य धागे (या सिर्फ़ लोड करने और कंपाइल करने वाले किसी अन्य वेब वर्कर) में सिर्फ़ एक बार लोड और कंपाइल किया जा सकता है. इसके बाद, इसे सीपीयू पर ज़्यादा काम करने वाले टास्क के लिए ज़िम्मेदार वेब वर्कर को ट्रांसफ़र किया जा सकता है. नीचे दिया गया कोड, इस फ़्लो को दिखाता है.

/* 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,
  });
});

वेब वर्कर के लिए, WebAssembly.Module ऑब्जेक्ट को निकालना और उसे इंस्टैंशिएट करना बाकी है. WebAssembly.Module वाले मैसेज को स्ट्रीम नहीं किया जाता है. इसलिए, वेब वर्कर्स में मौजूद कोड अब instantiateStreaming() वैरिएंट के बजाय, WebAssembly.instantiate() का इस्तेमाल करता है. इंस्टैंशिएट किए गए मॉड्यूल को वैरिएबल में कैश मेमोरी में सेव किया जाता है. इसलिए, वेब वर्कर्स को स्पिन अप करने के बाद, इंस्टैंशिएट करने की प्रोसेस सिर्फ़ एक बार की जाती है.

/* 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 });
});

बेहतरीन: टास्क, इनलाइन वेब वर्कर्स में चलता है और सिर्फ़ एक बार लोड और कंपाइल होता है

यहां तक कि एचटीटीपी कैशिंग के साथ, (आम तौर पर) कैश मेमोरी में सेव किया गया वेब वर्कर कोड पाना और नेटवर्क पर हिट करना महंगा पड़ सकता है. परफ़ॉर्मेंस को बेहतर बनाने के लिए, वेब वर्कर्स को इनलाइन करना और उसे 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,
  });
});

वेब वर्कर्स को लाज़ी या ईगर बनाने का तरीका

अब तक, सभी कोड सैंपल में वेब वर्कर्स को मांग के हिसाब से धीरे-धीरे शुरू किया गया है. इसका मतलब है कि बटन दबाने पर वेब वर्कर्स शुरू होते हैं. आपके ऐप्लिकेशन के हिसाब से, वेब वर्कर्स को जल्दी से बनाने का फ़ायदा हो सकता है. उदाहरण के लिए, जब ऐप्लिकेशन इस्तेमाल में न हो या ऐप्लिकेशन की बूटस्ट्रैपिंग प्रोसेस के हिस्से के तौर पर. इसलिए, वेब वर्कर्स बनाने का कोड, बटन के इवेंट लिसनर से बाहर ले जाएं.

const worker = new Worker(blobURL);

// Listen for incoming messages and display the result.
worker.addEventListener('message', (e) => {
  output.textContent = e.result;
});

वेब वर्कर को आस-पास रखें या न रखें

आपको खुद से यह सवाल पूछना चाहिए कि आपको वेब वर्कर्स को हमेशा चालू रखना चाहिए या ज़रूरत पड़ने पर ही फिर से बनाना चाहिए. दोनों ही तरीके इस्तेमाल किए जा सकते हैं. इनके अपने फ़ायदे और नुकसान हैं. उदाहरण के लिए, किसी वेब वर्कर्स को हमेशा चालू रखने से, आपके ऐप्लिकेशन की मेमोरी का फ़ुटप्रिंट बढ़ सकता है. साथ ही, एक साथ कई टास्क को मैनेज करना मुश्किल हो सकता है. ऐसा इसलिए, क्योंकि आपको वेब वर्कर्स से मिले नतीजों को अनुरोधों पर वापस मैप करना होगा. दूसरी ओर, हो सकता है कि आपके वेब वर्कर्स का बूटस्ट्रैपिंग कोड काफ़ी जटिल हो. इसलिए, हर बार नया कोड बनाने पर, बहुत ज़्यादा ओवरहेड हो सकता है. अच्छी बात यह है कि User Timing API की मदद से, इस बात का आकलन किया जा सकता है.

अब तक के कोड सैंपल में, एक वेब वर्कर्स को हमेशा चालू रखा गया है. यहां दिया गया कोड सैंपल, ज़रूरत पड़ने पर एक नया वेब वर्कर्स बनाता है. ध्यान दें कि आपको खुद ही वेब वर्कर्स को बंद करने का ट्रैक रखना होगा. (कोड स्निपेट, गड़बड़ी को मैनेज करने की प्रोसेस को छोड़ देता है. हालांकि, अगर कुछ गलत होता है, तो सभी मामलों में इसे बंद करना न भूलें, चाहे वह सफल हो या असफल.)

/* 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 DevTools खोलकर कंसोल देखने पर, आपको User Timing API के लॉग दिख सकते हैं. इनसे, बटन पर क्लिक करने से लेकर स्क्रीन पर दिखने वाले नतीजे तक लगने वाले समय का पता चलता है. नेटवर्क टैब में, blob: यूआरएल के अनुरोध दिखते हैं. इस उदाहरण में, ऐड-हॉक और हमेशा के लिए सेट किए गए विज्ञापन के बीच, समय में करीब तीन गुना का फ़र्क़ है. हालांकि, व्यावहारिक तौर पर, इस मामले में दोनों में कोई फ़र्क़ नहीं दिखता. आपके असल ऐप्लिकेशन के लिए, नतीजे अलग-अलग हो सकते हैं.

Ad hoc Worker के साथ Factorial Wasm डेमो ऐप्लिकेशन. Chrome DevTools खुले हैं. नेटवर्क टैब में दो ब्लॉब हैं: यूआरएल के अनुरोध और Console, गिनती के लिए दो समय दिखाता है.

फ़ैक्टोरियल Wasm डेमो ऐप्लिकेशन, जिसमें हमेशा चालू रहने वाला Worker है. Chrome DevTools खुला हो. नेटवर्क टैब में सिर्फ़ एक ब्लॉब है: यूआरएल अनुरोध. साथ ही, Console में गिनती के चार समय दिखते हैं.

मीटिंग में सामने आए नतीजे

इस पोस्ट में Wasm के साथ काम करने के लिए, परफ़ॉर्मेंस के कुछ पैटर्न के बारे में बताया गया है.

  • सामान्य नियम के तौर पर, नॉन-स्ट्रीमिंग वर्शन (WebAssembly.compile() और WebAssembly.instantiate()) के बजाय, स्ट्रीमिंग के तरीकों (WebAssembly.compileStreaming() और WebAssembly.instantiateStreaming()) को प्राथमिकता दें.
  • अगर हो सके, तो वेब वर्कर में भारी मात्रा में काम करने वाले टास्क को आउटसोर्स कर सकते हैं और वेब वर्कर के बाहर सिर्फ़ एक बार Wasm के काम को लोड और कंपाइल कर सकते हैं. इस तरह, वेब वर्कर को सिर्फ़ WebAssembly.instantiate() के साथ लोड और कंपाइल होने वाले मुख्य थ्रेड से मिलने वाले Wasm मॉड्यूल को इंस्टैंशिएट करना ज़रूरी होता है. इसका मतलब है कि अगर वेब वर्कर को स्थायी रूप से रखा जाता है, तो इंस्टेंस को कैश मेमोरी में सेव किया जा सकता है.
  • ध्यान से मेज़र करें कि हमेशा के लिए एक वेब वर्कर्स को चालू रखना सही है या ज़रूरत पड़ने पर, वेब वर्कर्स को कभी भी बनाया जा सकता है. साथ ही इस बारे में भी सोचें कि वेब वर्कर को बनाने का सबसे सही समय कौनसा है. इन बातों पर ध्यान देना ज़रूरी है: मेमोरी का इस्तेमाल, वेब वर्कर इंस्टैंशिएशन की अवधि, लेकिन एक साथ किए जाने वाले अनुरोधों से निपटने में आने वाली मुश्किलें.

अगर इन पैटर्न को ध्यान में रखा जाए, तो आप बेहतर परफ़ॉर्मेंस के लिए सही राह पर हैं.

धन्यवाद

इस गाइड की समीक्षा, एंड्रियास हास, जैकब कुम्मेरो, दीप्ति गंडलुरी, अलोन ज़काई, फ़्रांसिस मैकबे, फ़्रांस्वा बोफ़ोर, और रेचल एंड्रयू ने की है.