Emscripting a C library to Wasm

گاهی اوقات می‌خواهید از کتابخانه‌ای استفاده کنید که فقط به‌صورت کد C یا C++‎ دردسترس است. به‌طور سنتی، اینجاست که تسلیم می‌شوید. خب، دیگر این‌طور نیست، چون حالا Emscripten و WebAssembly (یا Wasm) را داریم!

زنجیره ابزار

I set myself the goal of working out how to compile some existing C code to Wasm. سروصداهایی درباره پس‌زمینه Wasm LLVM وجود دارد، بنابراین شروع به کاوش در آن کردم. درحالی‌که می‌توانید برنامه‌های ساده را برای کامپایل کردن از این طریق دریافت کنید، به‌محض اینکه بخواهید از کتابخانه استاندارد C استفاده کنید یا حتی چندین فایل را کامپایل کنید، احتمالاً با مشکل مواجه خواهید شد. این موضوع من را به درس مهمی که یاد گرفتم رساند:

درحالی‌که Emscripten قبلاً یک کامپایلر C-to-asm.js بود، از آن زمان به بعد برای هدف قرار دادن Wasm تکامل یافته است و در فرایند تغییر به باطن رسمی LLVM به‌صورت داخلی است. Emscripten همچنین یک پیاده‌سازی سازگار با Wasm از کتابخانه استاندارد C ارائه می‌دهد. از Emscripten استفاده کنید. این ابزار کارهای پنهان زیادی انجام می‌دهد، سیستم فایل را شبیه‌سازی می‌کند، مدیریت حافظه را ارائه می‌دهد، OpenGL را با WebGL پوشش می‌دهد — کارهای زیادی که واقعاً نیازی نیست خودتان در توسعه تجربه کنید.

اگرچه ممکن است به‌نظر برسد که باید نگران تورم باشید — من قطعاً نگران بودم — کامپایلر Emscripten هر چیزی را که لازم نیست حذف می‌کند. در آزمایش‌های من، اندازه ماژول‌های Wasm حاصل برای منطقی که دربردارند مناسب است و تیم‌های Emscripten و WebAssembly در تلاشند تا در آینده آن‌ها را حتی کوچک‌تر کنند.

می‌توانید Emscripten را با دنبال کردن دستورالعمل‌های موجود در وب‌سایت آن یا بااستفاده از Homebrew دریافت کنید. If you are a fan of dockerized commands like me and don't want to install things on your system just to have a play with WebAssembly, there is a well-maintained Docker image that you can use instead:

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

درحال گردآوری چیزی ساده

بیایید مثال تقریباً متعارف نوشتن تابعی در C را درنظر بگیریم که عدد فیبوناچی nth را محاسبه می‌کند:

    #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 را نمی‌دانید اما با جاوا اسکریپت آشنایی دارید، امیدواریم بتوانید آنچه را که در اینجا اتفاق می‌افتد درک کنید.

‫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"]' به کامپایلر می‌گوید که تابع cwrap() را در فایل جاوا اسکریپت دردسترس بگذارد — بعداً درباره این تابع بیشتر توضیح می‌دهیم. ‫-O3 به مترجم می‌گوید که به‌طور تهاجمی بهینه‌سازی کند. می‌توانید اعداد کوچک‌تر را انتخاب کنید تا زمان ساخت کاهش یابد، اما این کار باعث می‌شود بسته‌های حاصل بزرگ‌تر شوند زیرا ممکن است کامپایلر کدهای استفاده‌نشده را حذف نکند.

پس‌از اجرای فرمان، باید یک فایل جاوااسکریپت به نام a.out.js و یک فایل WebAssembly به نام a.out.wasm داشته باشید. فایل Wasm (یا «ماژول») حاوی کد C کامپایل‌شده ما است و باید نسبتاً کوچک باشد. فایل جاوا اسکریپت مسئولیت بار کردن و مقداردهی اولیه ماژول Wasm و ارائه یک API بهتر را برعهده دارد. درصورت نیاز، راه‌اندازی پشته، توده، و دیگر عملکردهایی را که معمولاً انتظار می‌رود سیستم‌عامل هنگام نوشتن کد C ارائه دهد نیز انجام می‌دهد. به همین دلیل، فایل جاوا اسکریپت کمی بزرگ‌تر است و حجم آن ۱۹ کیلوبایت (حدود ۵ کیلوبایت gzip) است.

اجرای چیزی ساده

ساده‌ترین راه برای بار کردن و اجرای واحدتان استفاده از فایل JavaScript تولیدشده است. وقتی آن فایل را بار کنید، Module سراسری در اختیار خواهید داشت. از cwrap برای ایجاد تابع بومی جاوا اسکریپت استفاده کنید که تبدیل پارامترها به چیزی سازگار با C و فراخوانی تابع پیچیده را انجام می‌دهد. cwrap نام تابع، نوع پاسخ، و انواع متغیر مستقل را به‌ترتیب به‌عنوان متغیر مستقل می‌گیرد:

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

اگر این کد را اجرا کنید، باید «۱۴۴» را در کنسول ببینید که دوازدهمین عدد فیبوناچی است.

جام مقدس: کامپایل کردن کتابخانه C

تاکنون، کد C که نوشته‌ایم با درنظر گرفتن Wasm نوشته شده است. بااین‌حال، یک مورد استفاده اصلی برای WebAssembly این است که اکوسیستم موجود کتابخانه‌های C را بگیرد و به توسعه‌دهندگان اجازه دهد از آن‌ها در وب استفاده کنند. این کتابخانه‌ها اغلب به کتابخانه استاندارد C، سیستم‌عامل، سیستم فایل، و چیزهای دیگر متکی هستند. Emscripten بیشتر این ویژگی‌ها را ارائه می‌دهد، اگرچه برخی محدودیت‌ها وجود دارد.

بیایید به هدف اصلی من برگردیم: کامپایل کردن یک کدبند برای WebP به Wasm. منبع کدک WebP به زبان C نوشته شده است و در GitHub و همچنین برخی از اسناد API گسترده دردسترس است. این نقطه شروع بسیار خوبی است.

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

برای شروع ساده، بیایید با نوشتن فایل C به‌نام webp.c، WebPGetEncoderVersion() را از encode.h در جاوااسکریپت نمایش دهیم:

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

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

This is a good simple program to test if we can get the source code of libwebp to compile, as we don't require any parameters or complex data structures to invoke this function.

برای کامپایل کردن این برنامه، باید بااستفاده از پرچم -I به کامپایلر بگوییم که فایل‌های سرایند libwebp را کجا می‌تواند پیدا کند و همچنین همه فایل‌های C مربوط به libwebp را که نیاز دارد به آن ارسال کنیم. صادقانه بگویم: من فقط همه فایل‌های 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

اکنون فقط به مقداری HTML و جاوا اسکریپت نیاز داریم تا واحد جدید و درخشان خود را بار کنیم:

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

و شماره نسخه صحیح را در خروجی خواهیم دید:

نماگرفت کنسول DevTools که شماره نسخه صحیح را نشان می‌دهد.

دریافت تصویر از جاوا اسکریپت در Wasm

دریافت شماره نسخه کدبند عالی است، اما کدبندی یک تصویر واقعی تأثیرگذارتر خواهد بود، درست است؟ پس بیایید این کار را انجام دهیم.

اولین سؤالی که باید به آن پاسخ دهیم این است: چگونه تصویر را وارد سرزمین Wasm کنیم؟ با نگاه کردن به میانای برنامه‌سازی کاربردی کدبندی libwebp، می‌بینیم که آرایه‌ای از بایت‌ها را در RGB،‏ RGBA،‏ BGR، یا BGRA انتظار دارد. خوشبختانه Canvas API دارای getImageData() است که به ما یک Uint8ClampedArray می‌دهد که حاوی داده‌های تصویر در RGBA است:

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

Now it's "only" a matter of copying the data from JavaScript land into Wasm land. برای این کار، باید دو تابع دیگر را نمایش دهیم. یکی که حافظه را برای تصویر داخل سرزمین 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 اختصاص می‌دهد — بنابراین ۴ بایت برای هر پیکسل. اشاره‌گر برگردانده‌شده توسط malloc() نشانی اولین سلول حافظه آن میان‌گیر است. وقتی اشاره‌گر به قلمرو جاوا اسکریپت برمی‌گردد، با آن فقط به‌عنوان یک عدد رفتار می‌شود. پس‌از افشای تابع در جاوا اسکریپت بااستفاده از cwrap، می‌توانیم از آن عدد برای پیدا کردن شروع میان‌گیرمان و کپی کردن داده‌های تصویر استفاده کنیم.

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 land دردسترس است. وقت آن است که رمزگذار WebP را فراخوانی کنیم تا کارش را انجام دهد! با نگاهی به اسناد WebP، WebPEncodeRGBA به‌نظر می‌رسد که کاملاً مناسب باشد. این تابع اشاره‌گری به تصویر ورودی و ابعاد آن، و همچنین گزینه کیفیت بین ۰ تا ۱۰۰ را می‌گیرد. همچنین یک میان‌گیر خروجی برای ما اختصاص می‌دهد که باید پس‌از اتمام کار با تصویر WebP، آن را بااستفاده از WebPFree() آزاد کنیم.

نتیجه عملیات کدبندی، میان‌گیر خروجی و طول آن است. Because functions in C can't have arrays as return types (unless we allocate memory dynamically), I resorted to a static global array. I know, not clean C (in fact, it relies on the fact that Wasm pointers are 32bit wide), but to keep things simple I think this is a fair shortcut.

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

Now with all of that in place, we can call the encoding function, grab the pointer and image size, put it in a JavaScript-land buffer of our own, and release all the Wasm-land buffers we have allocated in the process.

    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 را به فرمان کامپایل اضافه کنیم.

و این شما و این AgentSpace! ما یک کدبند WebP را کامپایل کردیم و یک تصویر JPEG را به WebP تراتبدیل کردیم. برای اثبات اینکه کار می‌کند، می‌توانیم میان‌گیر نتیجه را به یک blob تبدیل کنیم و از آن در عنصر <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 می‌تواند ابزار بسیار مفیدی باشد.

محتوای پاداش: اجرای چیزی ساده به روشی سخت

اگر می‌خواهید از فایل جاوا اسکریپت تولیدشده اجتناب کنید، ممکن است بتوانید این کار را انجام دهید. بیایید به مثال فیبوناچی برگردیم. برای بار کردن و اجرای آن توسط خودمان، می‌توانیم کارهای زیر را انجام دهیم:

<!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>

ماژول‌های WebAssembly که توسط Emscripten ایجاد شده‌اند، حافظه‌ای برای کار ندارند، مگر اینکه حافظه را برای آن‌ها فراهم کنید. روشی که برای ارائه ماژول Wasm با هر چیزی استفاده می‌کنید، استفاده از شیء imports است — پارامتر دوم تابع instantiateStreaming. واحد Wasm می‌تواند به همه چیز در داخل شیء واردات دسترسی داشته باشد، اما به هیچ چیز دیگری خارج از آن دسترسی ندارد. طبق قرارداد، ماژول‌های کامپایل‌شده توسط Emscripting چند چیز را از محیط بارگیری جاوا اسکریپت انتظار دارند:

  • اول از همه، env.memory وجود دارد. ماژول Wasm از دنیای بیرون بی‌خبر است، بنابراین باید مقداری حافظه برای کار کردن دریافت کند. WebAssembly.Memory را وارد کنید. این نوع نشان‌دهنده یک قطعه حافظه خطی (با قابلیت رشد اختیاری) است. پارامترهای اندازه‌گذاری «در واحدهای صفحه‌های WebAssembly» هستند، یعنی کد بالا ۱ صفحه حافظه اختصاص می‌دهد و هر صفحه ۶۴ KiB اندازه دارد. بدون ارائه گزینه maximum ، حافظه ازنظر تئوری رشد نامحدودی دارد (Chrome درحال‌حاضر محدودیت سخت ۲ گیگابایتی دارد). اکثر ماژول‌های WebAssembly نباید نیازی به تنظیم حداکثر داشته باشند.
  • env.STACKTOP مشخص می‌کند که پشته از کجا باید شروع به رشد کند. پشته برای فراخوانی تابع و اختصاص حافظه برای متغیرهای محلی لازم است. Since we don't do any dynamic memory management shenanigans in our little Fibonacci program, we can just use the entire memory as a stack, hence STACKTOP = 0.