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

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

Toolchain

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

แม้ว่าก่อนหน้านี้ Emscripten จะใช้คอมไพเลอร์ C เป็น asm.js แต่ปัจจุบันได้พัฒนาให้อยู่ในกระบวนการเปลี่ยนไปใช้แบ็กเอนด์ LLVM อย่างเป็นทางการภายในแล้ว เพื่อกำหนดเป้าหมายเป็น Wasm นอกจากนี้ Emscripten ยังมีการใช้งานไลบรารีมาตรฐานของ C ที่เข้ากันได้กับ Wasm ใช้ 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 บอกคอมไพเลอร์ให้เพิ่มประสิทธิภาพอย่างเต็มที่ คุณเลือกตัวเลขที่ต่ำกว่า เพื่อลดเวลาบิลด์ได้ แต่จะทำให้บันเดิลที่ได้ มีขนาดใหญ่ขึ้นด้วย เนื่องจากคอมไพเลอร์อาจไม่นำโค้ดที่ไม่ได้ใช้ออก

หลังจากเรียกใช้คำสั่งแล้ว คุณควรมีไฟล์ JavaScript ชื่อ a.out.js และไฟล์ WebAssembly ชื่อ a.out.wasm ไฟล์ Wasm (หรือ "โมดูล") มีโค้ด C ที่คอมไพล์แล้วและควรมีขนาดเล็ก ไฟล์ JavaScript จะจัดการการโหลดและการเริ่มต้นโมดูล Wasm ของเรา รวมถึง ให้ API ที่ดีขึ้น หากจำเป็น ก็จะดูแลการตั้งค่า สแต็ก ฮีป และฟังก์ชันอื่นๆ ที่มักคาดหวังว่าจะได้รับจาก ระบบปฏิบัติการเมื่อเขียนโค้ด C ด้วย ดังนั้นไฟล์ JavaScript จึงมีขนาดใหญ่ขึ้นเล็กน้อย โดยมีขนาด 19 KB (~5 KB เมื่อบีบอัดด้วย 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

The holy grail: Compiling a C library

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

แผงเครือข่ายของเครื่องมือสำหรับนักพัฒนาเว็บและรูปภาพที่สร้างขึ้น

บทสรุป

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

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