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

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

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

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

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

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

इन पैटर्न को ध्यान में रखकर, Wasm की परफ़ॉर्मेंस को बेहतर बनाया जा सकता है.

आभार

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