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

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

שרשרת הכלים

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

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

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

כדי לקבל את Emscripten, צריך לפעול לפי ההוראות באתר או באמצעות Homebrew. אם אתם אוהדים פקודות עגינה כמו שלי, ואני לא רוצה להתקין דברים במערכת שלך, לשחק עם WebAssembly, יש מודל תמונת Docer שבה אפשר להשתמש במקום זאת:

    $ 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 אבל יודעים JavaScript, אני מקווה שתוכלו להבין כדי להבין מה קורה פה.

emscripten.h הוא קובץ כותרת שסופק על ידי Emscripten. אנחנו צריכים אותו רק כדי יש גישה למאקרו EMSCRIPTEN_KEEPALIVE, אבל מספק פונקציונליות רבה יותר. המאקרו הזה מורה למהדר לא להסיר פונקציה גם אם היא מופיעה לא בשימוש. אם לא תשמיט את המאקרו הזה, המהדר יבצע אופטימיזציה של הפונקציה - אף אחד עדיין לא משתמש בו.

נשמור את כל זה בקובץ בשם fib.c. כדי להפוך אותו לקובץ .wasm, אנחנו צריכים צריך לעבור לפקודת המהדר (compiler) של 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 (או "Module") מכיל את קוד C שלנו, והוא צריך להיות קטן יחסית. הקובץ JavaScript מטפל בטעינה ובאתחול של מודול Wasm. לספק API טוב יותר. במקרה הצורך, הכלי יעזור גם להגדיר את את הערימה (heap) ופונקציות נוספות שיסופקו בדרך כלל על ידי של מערכת ההפעלה בזמן כתיבת קוד C. לכן, קובץ ה-JavaScript גדול יותר, במשקל 19KB (כ-5KB gzip).

הרצה של משהו פשוט

הדרך הקלה ביותר לטעון ולהפעיל את המודול היא להשתמש ב-JavaScript שנוצר חדש. לאחר טעינת הקובץ הזה, יהיה לך Module בכל העולם לעמוד לרשותכם. כדאי להשתמש cwrap כדי ליצור פונקציית נייטיב של JavaScript שמטפלת בפרמטרים של המרות לתוכנה ידידותית ל-C שמפעילה את הפונקציה הארוזה. cwrap תיקח את השעה function name, סוג החזרה וסוגי ארגומנטים כארגומנטים, בסדר הזה:

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

    $ 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? מסתכלים על encoding 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 land to 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);

Grand Finale: קידוד התמונה

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

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