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

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

‫toolchain

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

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

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

כדי להוריד את Emscripten, פועלים לפי ההוראות באתר או משתמשים ב-Homebrew. אם אתם אוהבים פקודות Dockerized כמוני, ולא רוצים להתקין דברים במערכת רק כדי לשחק עם WebAssembly, יש קובץ אימג' של Docker שמתוחזק היטב ואפשר להשתמש בו במקום:

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

אחרי הרצת הפקודה, אמורים להופיע קובץ 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. קוד המקור של קודק 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>

מספר הגרסה הנכון יופיע בפלט:

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

העברת תמונה מ-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, ואחת שמשחררת אותו שוב:

    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!

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

סיכום

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

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