Wasm के लिए C लाइब्रेरी को एम्स्क्रिप्ट करना

कभी-कभी आपको ऐसी लाइब्रेरी का इस्तेमाल करना होता है जो सिर्फ़ C या C++ कोड के तौर पर उपलब्ध होती है. आम तौर पर, इस जगह पर लोग हार मान लेते हैं. हालांकि, अब ऐसा नहीं है. इसकी वजह यह है कि अब हमारे पास Emscripten और WebAssembly (या Wasm) है!

टूलचेन

मैंने यह लक्ष्य तय किया है कि मुझे कुछ मौजूदा C कोड को Wasm में कंपाइल करना है. LLVM के Wasm बैकएंड के बारे में कुछ समय से चर्चा हो रही है. इसलिए, मैंने इस पर काम करना शुरू कर दिया. हालांकि, इस तरीके से आपको कंपाइल करने के लिए आसान प्रोग्राम मिल सकते हैं, लेकिन जैसे ही आपको C की स्टैंडर्ड लाइब्रेरी का इस्तेमाल करना होगा या एक से ज़्यादा फ़ाइलों को कंपाइल करना होगा, आपको शायद समस्याओं का सामना करना पड़ेगा. इससे मुझे यह अहम सबक मिला:

Emscripten, पहले C-to-asm.js कंपाइलर था. हालांकि, अब यह Wasm को टारगेट करता है. साथ ही, यह इंटरनल तौर पर LLVM के आधिकारिक बैकएंड पर स्विच करने की प्रोसेस में है. Emscripten, C की स्टैंडर्ड लाइब्रेरी का Wasm के साथ काम करने वाला वर्शन भी उपलब्ध कराता है. Emscripten का इस्तेमाल करें. इसमें कई छिपे हुए काम होते हैं, यह फ़ाइल सिस्टम की तरह काम करता है, मेमोरी मैनेजमेंट की सुविधा देता है, और OpenGL को WebGL के साथ रैप करता है. ये ऐसी कई चीज़ें हैं जिन्हें आपको खुद डेवलप करने की ज़रूरत नहीं है.

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

Emscripten को उनकी वेबसाइट पर दिए गए निर्देशों का पालन करके या Homebrew का इस्तेमाल करके पाया जा सकता है. अगर आपको मेरी तरह डॉकर कमांड पसंद हैं और आपको WebAssembly का इस्तेमाल करने के लिए, अपने सिस्टम पर चीज़ें इंस्टॉल नहीं करनी हैं, तो आपके पास Docker इमेज का इस्तेमाल करने का विकल्प है. इसे अच्छी तरह से मैनेज किया जाता है:

    $ docker pull trzeci/emscripten
    $ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>

कोई सामान्य चीज़ कंपाइल करना

आइए, C में फ़ंक्शन लिखने का एक सामान्य उदाहरण लेते हैं. यह फ़ंक्शन, nवीं फ़िबोनाची संख्या का हिसाब लगाता है:

    #include <emscripten.h>

    EMSCRIPTEN_KEEPALIVE
    int fib(int n) {
      if(n <= 0){
        return 0;
      }
      int i, t, a = 0, b = 1;
      for (i = 1; i < n; i++) {
        t = a + b;
        a = b;
        b = t;
      }
      return b;
    }

अगर आपको C के बारे में पता है, तो आपको इस फ़ंक्शन के बारे में ज़्यादा हैरानी नहीं होनी चाहिए. अगर आपको C के बारे में जानकारी नहीं है, लेकिन JavaScript के बारे में जानकारी है, तो उम्मीद है कि आपको यह समझ आ जाएगा कि यहाँ क्या हो रहा है.

emscripten.h, Emscripten की ओर से उपलब्ध कराई गई हेडर फ़ाइल है. हमें सिर्फ़ EMSCRIPTEN_KEEPALIVE मैक्रो को ऐक्सेस करने के लिए इसकी ज़रूरत होती है. हालांकि, इससे कई और सुविधाएं मिलती हैं. यह मैक्रो, कंपाइलर को यह निर्देश देता है कि किसी फ़ंक्शन को न हटाएं, भले ही वह इस्तेमाल न किया गया हो. अगर हमने उस मैक्रो को हटा दिया, तो कंपाइलर फ़ंक्शन को ऑप्टिमाइज़ कर देगा — आखिर कोई भी इसका इस्तेमाल नहीं कर रहा है.

आइए, इस जानकारी को fib.c नाम की फ़ाइल में सेव करें. इसे .wasm फ़ाइल में बदलने के लिए, हमें Emscripten के कंपाइलर कमांड emcc का इस्तेमाल करना होगा:

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c

आइए, इस कमांड के बारे में विस्तार से जानते हैं. emcc, Emscripten का कंपाइलर है. fib.c हमारी C फ़ाइल है. अब तक सब ठीक है. -s WASM=1, Emscripten को asm.js फ़ाइल के बजाय Wasm फ़ाइल देने के लिए कहता है. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' कंपाइलर को बताता है कि JavaScript फ़ाइल में cwrap() फ़ंक्शन उपलब्ध है. इस फ़ंक्शन के बारे में बाद में ज़्यादा जानकारी दी जाएगी. -O3 कंपाइलर को ज़्यादा से ज़्यादा ऑप्टिमाइज़ करने के लिए कहता है. बिल्ड प्रोसेस में लगने वाला समय कम करने के लिए, कम संख्याएं चुनी जा सकती हैं. हालांकि, इससे नतीजे के तौर पर मिलने वाले बंडल भी बड़े हो जाएंगे, क्योंकि कंपाइलर शायद इस्तेमाल न किए गए कोड को न हटाए.

कमांड चलाने के बाद, आपको a.out.js नाम की JavaScript फ़ाइल और a.out.wasm नाम की WebAssembly फ़ाइल मिलनी चाहिए. Wasm फ़ाइल (या "मॉड्यूल") में, हमारा कंपाइल किया गया C कोड होता है. इसका साइज़ काफ़ी छोटा होना चाहिए. JavaScript फ़ाइल, हमारे Wasm मॉड्यूल को लोड और शुरू करने के साथ-साथ बेहतर एपीआई उपलब्ध कराने का काम करती है. ज़रूरत पड़ने पर, यह स्टैक, हीप, और अन्य फ़ंक्शन को भी सेट अप करेगा. आम तौर पर, C कोड लिखते समय ऑपरेटिंग सिस्टम से इन फ़ंक्शन के उपलब्ध होने की उम्मीद की जाती है. इसलिए, JavaScript फ़ाइल का साइज़ थोड़ा बड़ा है. यह 19 केबी की है. हालांकि, gzip फ़ॉर्मैट में यह करीब 5 केबी की हो जाती है.

कोई सामान्य टास्क पूरा करना

जनरेट की गई JavaScript फ़ाइल का इस्तेमाल करके, अपने मॉड्यूल को आसानी से लोड और चलाया जा सकता है. उस फ़ाइल को लोड करने के बाद, आपके पास Module ग्लोबल का ऐक्सेस होगा. cwrap का इस्तेमाल करके, JavaScript का एक नेटिव फ़ंक्शन बनाएं. यह फ़ंक्शन, पैरामीटर को C के साथ काम करने वाले फ़ॉर्मैट में बदलने और रैप किए गए फ़ंक्शन को लागू करने का काम करता है. cwrap फ़ंक्शन, फ़ंक्शन का नाम, रिटर्न टाइप, और आर्ग्युमेंट के टाइप को आर्ग्युमेंट के तौर पर लेता है. इनका क्रम इस तरह होता है:

    <script src="a.out.js"></script>
    <script>
      Module.onRuntimeInitialized = _ => {
        const fib = Module.cwrap('fib', 'number', ['number']);
        console.log(fib(12));
      };
    </script>

अगर इस कोड को चलाया जाता है, तो आपको कंसोल में "144" दिखेगा. यह फ़िबोनैकी क्रम की 12वीं संख्या है.

पवित्र ग्रेल: C लाइब्रेरी को कंपाइल करना

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

आइए, अपने मूल लक्ष्य पर वापस चलते हैं: WebP को Wasm में बदलने के लिए एक एनकोडर बनाना. WebP कोडेक का सोर्स, C में लिखा गया है. यह GitHub पर उपलब्ध है. साथ ही, इसके बारे में ज़्यादा जानकारी एपीआई के दस्तावेज़ में भी दी गई है. यह शुरुआत करने के लिए एक अच्छा आइडिया है.

    $ git clone https://github.com/webmproject/libwebp

शुरुआत में, हम webp.c नाम की एक C फ़ाइल लिखकर, JavaScript को WebPGetEncoderVersion() से encode.h तक ऐक्सेस करने की कोशिश करेंगे:

    #include "emscripten.h"
    #include "src/webp/encode.h"

    EMSCRIPTEN_KEEPALIVE
    int version() {
      return WebPGetEncoderVersion();
    }

यह एक सामान्य प्रोग्राम है. इससे यह जांच की जा सकती है कि libwebp के सोर्स कोड को कंपाइल किया जा सकता है या नहीं. ऐसा इसलिए, क्योंकि इस फ़ंक्शन को शुरू करने के लिए, हमें किसी पैरामीटर या जटिल डेटा स्ट्रक्चर की ज़रूरत नहीं होती.

इस प्रोग्राम को कंपाइल करने के लिए, हमें कंपाइलर को यह बताना होगा कि -I फ़्लैग का इस्तेमाल करके, libwebp की हेडर फ़ाइलें कहां मिल सकती हैं. साथ ही, उसे libwebp की वे सभी C फ़ाइलें पास करनी होंगी जिनकी उसे ज़रूरत है. मैं आपको सच बताऊं, तो मैंने इसे सभी C फ़ाइलें दी हैं और कंपाइलर पर भरोसा किया है कि वह गैर-ज़रूरी चीज़ों को हटा देगा. यह बहुत अच्छा काम करता है!

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
        -I libwebp \
        webp.c \
        libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

अब हमें अपने नए मॉड्यूल को लोड करने के लिए, सिर्फ़ कुछ एचटीएमएल और JavaScript की ज़रूरत है:

<script src="/a.out.js"></script>
<script>
  Module.onRuntimeInitialized = async (_) => {
    const api = {
      version: Module.cwrap('version', 'number', []),
    };
    console.log(api.version());
  };
</script>

इसके बाद, हमें आउटपुट में सही किए गए वर्शन का नंबर दिखेगा:

DevTools कंसोल का स्क्रीनशॉट, जिसमें सही वर्शन नंबर दिख रहा हो.

JavaScript से Wasm में कोई इमेज पाना

एनकोडर का वर्शन नंबर जानना अच्छी बात है. हालांकि, किसी इमेज को एन्कोड करना ज़्यादा दिलचस्प होगा, है न? ठीक है, तो चलिए ऐसा करते हैं.

हमें सबसे पहले इस सवाल का जवाब देना है: हम इमेज को Wasm लैंड में कैसे लाएं? libwebp के एन्कोडिंग एपीआई के मुताबिक, यह RGB, RGBA, BGR या BGRA में बाइट के ऐरे की उम्मीद करता है. खुशी की बात यह है कि Canvas API में getImageData() है. इससे हमें RGBA में इमेज डेटा वाला Uint8ClampedArray मिलता है:

async function loadImage(src) {
  // Load image
  const imgBlob = await fetch(src).then((resp) => resp.blob());
  const img = await createImageBitmap(imgBlob);
  // Make canvas same size as image
  const canvas = document.createElement('canvas');
  canvas.width = img.width;
  canvas.height = img.height;
  // Draw image onto canvas
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);
  return ctx.getImageData(0, 0, img.width, img.height);
}

अब JavaScript लैंड से Wasm लैंड में डेटा कॉपी करना "सिर्फ़" एक काम है. इसके लिए, हमें दो और फ़ंक्शन दिखाने होंगे. एक फ़ंक्शन, Wasm लैंड में इमेज के लिए मेमोरी असाइन करता है और दूसरा फ़ंक्शन, उसे फिर से खाली कर देता है:

    EMSCRIPTEN_KEEPALIVE
    uint8_t* create_buffer(int width, int height) {
      return malloc(width * height * 4 * sizeof(uint8_t));
    }

    EMSCRIPTEN_KEEPALIVE
    void destroy_buffer(uint8_t* p) {
      free(p);
    }

create_buffer RGBA इमेज के लिए बफ़र असाइन करता है. इसलिए, हर पिक्सल के लिए 4 बाइट. malloc() से मिला पॉइंटर, उस बफ़र के पहले मेमोरी सेल का पता होता है. जब पॉइंटर को JavaScript लैंड पर वापस लाया जाता है, तो इसे सिर्फ़ एक संख्या के तौर पर माना जाता है. cwrap का इस्तेमाल करके फ़ंक्शन को JavaScript के लिए उपलब्ध कराने के बाद, हम उस नंबर का इस्तेमाल करके अपने बफ़र की शुरुआत का पता लगा सकते हैं और इमेज डेटा को कॉपी कर सकते हैं.

const api = {
  version: Module.cwrap('version', 'number', []),
  create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
  destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);

ग्रैंड फ़ाइनल: इमेज को कोड में बदलना

अब इमेज Wasm लैंड में उपलब्ध है. अब WebP एन्कोडर को कॉल करने का समय आ गया है, ताकि वह अपना काम कर सके! WebP के दस्तावेज़ देखने पर, WebPEncodeRGBA सही विकल्प लगता है. यह फ़ंक्शन, इनपुट इमेज और उसके डाइमेंशन के साथ-साथ क्वालिटी के विकल्प के तौर पर 0 से 100 तक की वैल्यू लेता है. यह हमारे लिए एक आउटपुट बफ़र भी असाइन करता है. WebP इमेज का इस्तेमाल करने के बाद, हमें WebPFree() का इस्तेमाल करके इसे खाली करना होगा.

एन्कोडिंग ऑपरेशन का नतीजा, आउटपुट बफ़र और उसकी लंबाई होती है. C में फ़ंक्शन, ऐरे को रिटर्न टाइप के तौर पर इस्तेमाल नहीं कर सकते. ऐसा तब तक नहीं किया जा सकता, जब तक हम मेमोरी को डाइनैमिक तरीके से असाइन न करें. इसलिए, मैंने स्टैटिक ग्लोबल ऐरे का इस्तेमाल किया. हमें पता है कि यह C का सही कोड नहीं है. दरअसल, यह इस बात पर निर्भर करता है कि Wasm पॉइंटर 32 बिट के होते हैं. हालांकि, इसे आसान बनाने के लिए, हमें लगता है कि यह एक सही शॉर्टकट है.

    int result[2];
    EMSCRIPTEN_KEEPALIVE
    void encode(uint8_t* img_in, int width, int height, float quality) {
      uint8_t* img_out;
      size_t size;

      size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);

      result[0] = (int)img_out;
      result[1] = size;
    }

    EMSCRIPTEN_KEEPALIVE
    void free_result(uint8_t* result) {
      WebPFree(result);
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_pointer() {
      return result[0];
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_size() {
      return result[1];
    }

अब इन सभी चीज़ों को सेट करने के बाद, हम एन्कोडिंग फ़ंक्शन को कॉल कर सकते हैं. साथ ही, पॉइंटर और इमेज का साइज़ पा सकते हैं. इसके बाद, इसे JavaScript-लैंड के अपने बफ़र में डाल सकते हैं. साथ ही, इस प्रोसेस में हमने Wasm-लैंड के जितने भी बफ़र असाइन किए हैं उन्हें रिलीज़ कर सकते हैं.

    api.encode(p, image.width, image.height, 100);
    const resultPointer = api.get_result_pointer();
    const resultSize = api.get_result_size();
    const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
    const result = new Uint8Array(resultView);
    api.free_result(resultPointer);

इमेज के साइज़ के हिसाब से, आपको यह गड़बड़ी दिख सकती है. इसमें Wasm, इनपुट और आउटपुट इमेज, दोनों को सेव करने के लिए मेमोरी को ज़रूरत के मुताबिक नहीं बढ़ा पाता:

DevTools कंसोल का स्क्रीनशॉट, जिसमें गड़बड़ी दिख रही है.

अच्छी बात यह है कि इस समस्या को हल करने का तरीका, गड़बड़ी के मैसेज में ही दिया गया है! हमें सिर्फ़ अपनी कंपाइलेशन कमांड में -s ALLOW_MEMORY_GROWTH=1 जोड़ना होगा.

बस हो गया! हमने WebP एन्कोडर को कंपाइल किया और JPEG इमेज को WebP में ट्रांसकोड किया. यह साबित करने के लिए कि यह काम करता है, हम अपने नतीजे के बफ़र को एक ब्लोब में बदल सकते हैं और इसका इस्तेमाल <img> एलिमेंट पर कर सकते हैं:

const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);

यह रही नई WebP इमेज!

DevTools का नेटवर्क पैनल और जनरेट की गई इमेज.

नतीजा

ब्राउज़र में C लाइब्रेरी को काम करने के लिए सेट अप करना आसान नहीं है. हालांकि, पूरी प्रोसेस और डेटा फ़्लो के काम करने के तरीके को समझने के बाद, यह आसान हो जाता है. साथ ही, इसके नतीजे शानदार हो सकते हैं.

WebAssembly की मदद से, वेब पर प्रोसेसिंग, नंबर क्रंचिंग, और गेमिंग के लिए कई नई संभावनाएं खुलती हैं. ध्यान रखें कि Wasm हर समस्या का समाधान नहीं है. हालांकि, जब आपको इनमें से किसी समस्या का सामना करना पड़े, तो Wasm एक बहुत ही मददगार टूल साबित हो सकता है.

बोनस कॉन्टेंट: किसी आसान काम को मुश्किल तरीके से करना

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

<!DOCTYPE html>
<script>
  (async function () {
    const imports = {
      env: {
        memory: new WebAssembly.Memory({ initial: 1 }),
        STACKTOP: 0,
      },
    };
    const { instance } = await WebAssembly.instantiateStreaming(
      fetch('/a.out.wasm'),
      imports,
    );
    console.log(instance.exports._fib(12));
  })();
</script>

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

  • सबसे पहले, env.memory है. Wasm मॉड्यूल को बाहरी दुनिया के बारे में कोई जानकारी नहीं होती. इसलिए, इसे काम करने के लिए कुछ मेमोरी की ज़रूरत होती है. WebAssembly.Memory डालें. यह लीनियर मेमोरी का एक हिस्सा है, जिसे ज़रूरत के हिसाब से बढ़ाया जा सकता है. साइज़िंग पैरामीटर "WebAssembly पेजों की यूनिट में" होते हैं. इसका मतलब है कि ऊपर दिया गया कोड, मेमोरी का एक पेज असाइन करता है. हर पेज का साइज़ 64 KiB होता है. maximum विकल्प दिए बिना, मेमोरी में बढ़ोतरी की कोई सीमा नहीं होती. फ़िलहाल, Chrome में 2 जीबी की सीमा तय की गई है. ज़्यादातर WebAssembly मॉड्यूल को ज़्यादा से ज़्यादा वैल्यू सेट करने की ज़रूरत नहीं होती.
  • env.STACKTOP यह तय करता है कि स्टैक को कहां से बढ़ाना है. फ़ंक्शन कॉल करने और लोकल वैरिएबल के लिए मेमोरी असाइन करने के लिए, स्टैक की ज़रूरत होती है. हमारे छोटे से फ़िबोनाची प्रोग्राम में, डाइनैमिक मेमोरी मैनेजमेंट से जुड़ी कोई भी गड़बड़ी नहीं होती. इसलिए, हम पूरी मेमोरी को स्टैक के तौर पर इस्तेमाल कर सकते हैं. इसलिए, STACKTOP = 0.