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

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

שרשרת הכלים

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

אומנם Emscripten השתמש כדי מהדר (compiler) ב-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 שמחשבת את מספר הפיבונאצ'י ה-:

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

גם מספר גרסת התיקון נראה בפלט:

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

אחזור תמונה מ-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-land שהקצינו בתהליך.

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