การเขียนคลัง C ลงใน Wasm

บางครั้งคุณต้องการใช้ไลบรารีที่มีให้เป็นโค้ด C หรือ C++ เท่านั้น แต่เดิมมาจุดนี้ก็คือจุดที่คุณยอมแพ้ แต่ตอนนี้เรามี Emscripten และ WebAssembly (หรือ Wasm) แล้ว

Toolchain

ผมกำหนดเป้าหมายของตัวเองในการหาวิธีคอมไพล์ C Code ที่มีอยู่ให้เป็น Wasm มีเสียงรบกวนรอบๆ แบ็กเอนด์ Wasm ของ LLVM ฉันเลยเริ่มดูข้อมูลดังกล่าวแล้ว แม้ว่าคุณสามารถให้โปรแกรมง่ายๆ เพื่อคอมไพล์ด้วยวิธีนี้ ส่วนที่สองที่คุณต้องการใช้ไลบรารีมาตรฐานของ C หรือคอมไพล์ไฟล์หลายไฟล์ คุณก็อาจประสบปัญหาได้ สิ่งเหล่านี้นำมาสู่ บทเรียนสำคัญที่ได้เรียนรู้ นั่นคือ

แม้ว่า Emscripten จะใช้เป็นคอมไพเลอร์ C-to-asm.js แต่ก็ได้มีการพัฒนาเพื่อกำหนดเป้าหมาย Wasm และอยู่ในขั้นตอนที่จะเปลี่ยนไปใช้แบ็กเอนด์ LLVM อย่างเป็นทางการ นอกจากนี้ Emscripten ยังมีการใช้งานไลบรารีมาตรฐานของ C ที่ใช้ร่วมกับ Wasm ได้ ใช้ Emscripten โดยมีงานที่ซ่อนไว้จำนวนมาก จำลองระบบไฟล์ จัดการหน่วยความจำ รวม OpenGL ด้วย WebGL ซึ่งเป็นสิ่งต่างๆ มากมายที่คุณไม่จำเป็นต้องประสบกับการพัฒนาด้วยตนเอง

คุณอาจไม่ต้องกังวลเรื่องการเพิ่มขนาด แต่ฉันก็กังวลอยู่เหมือนกัน เนื่องจากคอมไพเลอร์ Emscripten จะนำทุกสิ่งที่ไม่จำเป็นออก ในการทดสอบของฉัน โมดูล Wasm ที่ได้จะมีการปรับขนาดให้เหมาะสมกับตรรกะที่มี และทีม Emscripten และ WebAssembly กำลังดำเนินการให้ขนาดเล็กลงอีกในอนาคต

คุณรับ Emscripten ได้โดยทำตามวิธีการในเว็บไซต์หรือใช้ Homeสำหรบ ถ้าคุณชอบคำสั่งที่เป็นเอกสารเหมือนฉัน และไม่อยากติดตั้งสิ่งต่างๆ ในระบบ ก็แค่อยากลองเล่นกับ WebAssembly คุณสามารถใช้อิมเมจ Docker ที่ได้รับการดูแลอย่างดีซึ่งคุณสามารถใช้ รูปภาพ Docker แทน

    $ docker pull trzeci/emscripten
    $ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>

รวบรวมวิดีโอง่ายๆ

ลองมาดูตัวอย่างที่เกือบจะเป็น Canonical ของการเขียนฟังก์ชันใน C ซึ่งคำนวณเลข Fibonacci ที่ กัน ดังนี้

    #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 จึงมีขนาดใหญ่ขึ้นเล็กน้อย โดยมีขนาด 19 KB (ประมาณ 5 KB gzip'd)

ทำสิ่งง่ายๆ

วิธีที่ง่ายที่สุดในการโหลดและเรียกใช้โมดูลคือการใช้ไฟล์ 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 Land ได้อย่างไร เมื่อพิจารณา 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 Land ด้วยเหตุนี้ เราจึงต้องเปิดเผยฟังก์ชันเพิ่มเติม 2 รายการ ไอคอนที่จัดสรรหน่วยความจำสำหรับรูปภาพภายใน 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);

Grand Finale: เข้ารหัสรูปภาพ

ตอนนี้รูปภาพดังกล่าวอยู่ใน Wasm land แล้ว ถึงเวลาเรียกโปรแกรมเปลี่ยนไฟล์ WebP มาทำงานแล้ว! เมื่อดูจากเอกสารประกอบของ WebP WebPEncodeRGBA ดูเหมือนจะเหมาะสมที่สุด ฟังก์ชันนี้จะใช้ตัวชี้ไปยังรูปภาพอินพุตและขนาด รวมถึงตัวเลือกคุณภาพที่อยู่ระหว่าง 0 ถึง 100 และยังจัดสรรบัฟเฟอร์เอาต์พุตให้กับเรา ซึ่งเราต้องเพิ่มพื้นที่ว่างโดยใช้ WebPFree() เมื่อดำเนินการกับอิมเมจ WebP เสร็จแล้ว

ผลของการดำเนินการเข้ารหัสคือบัฟเฟอร์เอาต์พุตและความยาว เนื่องจากฟังก์ชันใน C จะมีอาร์เรย์เป็นประเภทผลลัพธ์ไม่ได้ (เว้นแต่เราจัดสรรหน่วยความจำแบบไดนามิก) ผมจึงหันมาใช้อาร์เรย์ส่วนกลางแบบคงที่ ฉันรู้ว่าไม่ใช่ C9 เลย (เพราะจริงๆ แล้วเคอร์เซอร์ 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 ไม่ใช่สัญลักษณ์ Silver ที่ควรใช้กับทุกสิ่ง แต่เมื่อคุณเจอจุดคอขวด Wasm จะเป็นเครื่องมือที่มีประโยชน์อย่างยิ่ง

เนื้อหาโบนัส: ทำทุกอย่างให้เป็นเรื่องง่าย

หากต้องการพยายามหลีกเลี่ยงไฟล์ JavaScript ที่สร้างขึ้น คุณอาจทำได้ กลับไปดูตัวอย่างของ Fibonacci กัน ในการโหลดและเรียกใช้ด้วยตนเอง เราสามารถ ทำสิ่งต่อไปนี้

<!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 ซึ่งเป็นพารามิเตอร์ที่ 2 ของฟังก์ชัน instantiateStreaming โมดูล Wasm สามารถเข้าถึงทุกอย่างภายในอ็อบเจ็กต์การนำเข้า แต่เข้าถึงทุกอย่างภายนอกออบเจ็กต์นั้นไม่ได้ ตามปกติแล้ว โมดูลที่คอมไพล์โดย Emscripting คาดหวังว่าจะมีสิ่ง 2 อย่างจากสภาพแวดล้อม JavaScript ในการโหลด ได้แก่

  • อย่างแรกคือ env.memory โมดูล Wasm ไม่รู้จักโลกภายนอก ดังนั้นจึงต้องต้องใช้เวลาหน่วยความจำในการทำงานด้วย ป้อน WebAssembly.Memory ซึ่งเป็นหน่วยความจำเชิงเส้น (สามารถเลือกเพิ่มได้) พารามิเตอร์การปรับขนาดจะอยู่ใน "หน่วยของหน้า WebAssembly" ซึ่งหมายถึงโค้ดข้างต้นจะจัดสรรหน่วยความจำ 1 หน้า โดยแต่ละหน้ามีขนาด 64 KiB หากไม่มีตัวเลือก maximum ทางทฤษฎีของหน่วยความจำจะไม่มีข้อจำกัดในการเติบโต (ปัจจุบัน Chrome มีขีดจำกัดอย่างมากอยู่ที่ 2 GB) โมดูล WebAssembly ส่วนใหญ่ไม่จำเป็นต้องตั้งค่าสูงสุด
  • env.STACKTOP กำหนดตำแหน่งที่สแต็กควรเริ่มเติบโต ซึ่งจำเป็นต้องมีสแต็กนี้เพื่อเรียกใช้ฟังก์ชันและจัดสรรหน่วยความจำสำหรับตัวแปรภายใน เนื่องจากเราไม่ได้จัดการหน่วยความจำแบบไดนามิกใดๆ ในโปรแกรมFibonacci ตัวเล็กๆ ของเรา เราจึงใช้หน่วยความจำทั้งหมดเป็นกลุ่มได้ ดังนั้น STACKTOP = 0