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

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

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

למרות ש-Emscripten השתמש בתור מהדר C-to-asm.js, הוא התבגר מאז ולמקד ל-Wasm והוא בתהליך מעבר לקצה העורפי הרשמי של 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, אנחנו צריכים להפוך לפקודת המהדר של Emscripten emcc:

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

כדי להדר את התוכנה הזו, צריך ליידע את המהדר (compiler) איפה הוא יכול למצוא את קובצי הכותרת של 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 אדמה, וכזאת שמשחררת אותה שוב:

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

התוצאה של פעולת הקידוד היא מאגר פלט ואורכו. מכיוון שפונקציות ב-C לא יכולות לכלול מערכים כסוגי החזרה (אלא אם אנחנו מקצים זיכרון באופן דינמי), השתמשתי במערך גלובלי סטטי. אני יודע, זה לא C נקי (למעשה, הוא מסתמך על העובדה ש-Wasm pointers הם ברוחב 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];
    }

עכשיו, אחרי שכל זה מוכן, אנחנו יכולים להפעיל את פונקציית הקידוד, לאחוז ב-pointer ובגודל התמונה, להעביר אותם למאגר (buffer) משלהם ב-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!

חלונית הרשת של 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>

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

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