כתיבת ספריית C ל-Wasm

לפעמים רוצים להשתמש בספרייה שזמינה רק כקוד C או C++‎. בדרך כלל, בשלב הזה מתייאשים. אבל זה כבר לא המצב, כי עכשיו יש לנו את Emscripten ואת WebAssembly (או Wasm)!

ערכת הכלים

הצבתי לעצמי מטרה ללמוד איך לקמפל קוד C קיים ל-Wasm. שמעתי רעשים לגבי קצה העורף של LLVM Wasm, אז התחלתי לחקור את זה. אפשר לקמפל תוכניות פשוטות בדרך הזו, אבל ברגע שתרצו להשתמש בספרייה הרגילה של C או אפילו לקמפל כמה קבצים, סביר להניח שתיתקלו בבעיות. זה הוביל אותי למסקנה העיקרית שלמדתי:

‫Emscripten היה קומפיילר מ-C ל-asm.js, אבל מאז הוא התפתח והפך לקומפיילר שמטרגט Wasm, ובתהליך של מעבר ל-backend הרשמי של LLVM באופן פנימי. ‫Emscripten מספקת גם הטמעה תואמת ל-Wasm של הספרייה הרגילה של C. שימוש ב-Emscripten. הוא מבצע הרבה פעולות שמוסתרות מהמשתמש, מדמה מערכת קבצים, מספק ניהול זיכרון, עוטף את OpenGL ב-WebGL – הרבה דברים שאתם לא צריכים להתנסות בהם בעצמכם.

יכול להיות שזה נשמע כאילו צריך לדאוג לגבי נפח קובץ גדול מדי – אני בהחלט דאגתי – אבל קומפיילר Emscripten מסיר את כל מה שלא נחוץ. בניסויים שלי, הגודל של מודולי ה-Wasm שנוצרו מתאים ללוגיקה שהם מכילים, והצוותים של Emscripten ו-WebAssembly פועלים כדי להקטין אותם עוד יותר בעתיד.

כדי לקבל את Emscripten, פועלים לפי ההוראות באתר או משתמשים ב-Homebrew. אם אתם אוהבים פקודות מבוססות Docker כמוני ולא רוצים להתקין דברים במערכת רק כדי להתנסות ב-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, צריך להשתמש בפקודת הקומפיילר emcc של Emscripten:

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

בואו ננתח את הפקודה הזו. ‫emcc הוא הקומפיילר של Emscripten. ‫fib.c הוא קובץ ה-C שלנו. עד עכשיו, הכול טוב ויפה. ‫-s WASM=1 אומר ל-Emscripten לתת לנו קובץ Wasm במקום קובץ asm.js. ‫-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' אומר למהדר להשאיר את הפונקציה cwrap() זמינה בקובץ JavaScript – מידע נוסף על הפונקציה הזו בהמשך. ‫-O3 אומר לקומפיילר לבצע אופטימיזציה אגרסיבית. אפשר לבחור מספרים נמוכים יותר כדי לקצר את זמן הבנייה, אבל זה גם יגרום לחבילות שיתקבלו להיות גדולות יותר, כי יכול להיות שהקומפיילר לא יסיר קוד שלא נמצא בשימוש.

אחרי שמריצים את הפקודה, אמור להתקבל קובץ JavaScript בשם a.out.js וקובץ WebAssembly בשם a.out.wasm. קובץ ה-Wasm (או 'המודול') מכיל את קוד ה-C שעבר קומפילציה, והוא אמור להיות קטן יחסית. קובץ ה-JavaScript מטפל בטעינה ובהפעלה של מודול ה-Wasm שלנו ומספק API נוח יותר. במקרה הצורך, הוא גם ידאג להגדרת המחסנית, הערימה ופונקציות אחרות שבדרך כלל מצופות ממערכת ההפעלה כשכותבים קוד C. לכן, קובץ ה-JavaScript קצת יותר גדול, והגודל שלו הוא 19KB (‎~5KB אחרי דחיסה ב-gzip).

הפעלת משהו פשוט

הדרך הקלה ביותר לטעון ולהפעיל את המודול היא באמצעות קובץ ה-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. הקוד של רכיב ה-codec של WebP כתוב בשפת C וזמין ב-GitHub. יש גם תיעוד מקיף של ה-API. זו נקודת התחלה טובה.

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

כדי להתחיל בפשטות, ננסה לחשוף את WebPGetEncoderVersion() מ-encode.h ל-JavaScript על ידי כתיבת קובץ C בשם webp.c:

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

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

זו תוכנית פשוטה וטובה לבדיקה אם אפשר להדר את קוד המקור של libwebp, כי לא נדרשים פרמטרים או מבני נתונים מורכבים כדי להפעיל את הפונקציה הזו.

כדי לקמפל את התוכנית הזו, צריך לציין לקומפיילר איפה הוא יכול למצוא את קובצי הכותרת של libwebp באמצעות הדגל -I, וגם להעביר לו את כל קובצי ה-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 ו-JavaScript כדי לטעון את המודול החדש והמבריק שלנו:

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

מספר גרסת התיקון יופיע בפלט:

צילום מסך של מסוף כלי הפיתוח שבו מוצג מספר הגרסה הנכון.

העברת תמונה מ-JavaScript ל-Wasm

קבלת מספר הגרסה של המקודד היא נהדרת, אבל קידוד של תמונה בפועל יהיה מרשים יותר, נכון? אז בוא נתחיל.

השאלה הראשונה שעלינו לענות עליה היא: איך מעבירים את התמונה אל Wasm? כשבודקים את ממשק ה-API של הקידוד של 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);
}

עכשיו נשאר רק להעתיק את הנתונים מ-JavaScript אל Wasm. לשם כך, אנחנו צריכים לחשוף שתי פונקציות נוספות. אחת שמקצה זיכרון לתמונה בתוך Wasm land ואחת שמפנה אותו שוב:

    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, הוא נחשב למספר בלבד. אחרי שחושפים את הפונקציה ל-JavaScript באמצעות 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. הגיע הזמן לקרוא למקודד WebP כדי לבצע את העבודה שלו. אחרי עיון בתיעוד של WebP, WebPEncodeRGBAנראה שזה הפורמט המושלם. הפונקציה מקבלת מצביע לתמונת הקלט ולמידות שלה, וגם אפשרות איכות בין 0 ל-100. היא גם מקצה לנו מאגר פלט, שאנחנו צריכים לשחרר באמצעות WebPFree() אחרי שסיימנו עם תמונת WebP.

התוצאה של פעולת הקידוד היא מאגר פלט והאורך שלו. מכיוון שפונקציות ב-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 לא יכול להגדיל את הזיכרון מספיק כדי להכיל גם את תמונת הקלט וגם את תמונת הפלט:

צילום מסך של מסוף כלי הפיתוח עם שגיאה.

למזלכם, הפתרון לבעיה הזו מופיע בהודעת השגיאה. אנחנו רק צריכים להוסיף את -s ALLOW_MEMORY_GROWTH=1 לפקודת ההידור שלנו.

זהו, אתה מוכן! הידרנו מקודד 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!

חלונית הרשת בכלי הפיתוח ותמונה שנוצרה.

סיכום

לא קל לגרום לספריית 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>

למודולים של WebAssembly שנוצרו על ידי Emscripten אין זיכרון לעבוד איתו, אלא אם מספקים להם זיכרון. כדי לספק מודול Wasm עם משהו, צריך להשתמש באובייקט imports – הפרמטר השני של הפונקציה instantiateStreaming. מודול ה-Wasm יכול לגשת לכל מה שנמצא באובייקט imports, אבל לא לשום דבר אחר מחוץ לו. לפי המוסכמה, מודולים שעברו קומפילציה על ידי Emscripting מצפים לכמה דברים מסביבת JavaScript של הטעינה:

  • קודם כל, יש את env.memory. מודול ה-Wasm לא מודע למה שקורה מחוץ לו, ולכן הוא צריך לקבל זיכרון כדי לפעול. מזינים את הערך WebAssembly.Memory. הוא מייצג חלק (שניתן להרחבה) של זיכרון ליניארי. פרמטרי הגודל הם 'ביחידות של דפי WebAssembly', כלומר הקוד שלמעלה מקצה דף אחד של זיכרון, וכל דף הוא בגודל 64 KiB. בלי לספק אפשרות maximum, הזיכרון גדל באופן בלתי מוגבל (ל-Chrome יש כרגע מגבלה קשיחה של 2GB). ברוב המקרים, לא צריך להגדיר ערך מקסימלי למודולים של WebAssembly.
  • ההגדרה env.STACKTOP קובעת מאיפה אמור להתחיל הגידול במחסנית. ה-stack נדרש כדי לבצע קריאות לפונקציות ולהקצות זיכרון למשתנים מקומיים. מכיוון שאנחנו לא מבצעים שום תמרונים של ניהול זיכרון דינמי בתוכנית הקטנה שלנו של פיבונאצ'י, אנחנו יכולים פשוט להשתמש בזיכרון כולו כמחסנית, ולכן STACKTOP = 0.