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

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

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

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

เรียกใช้การดำเนินการง่ายๆ

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

แผงเครือข่ายของ DevTools และรูปภาพที่สร้างขึ้น

บทสรุป

การใช้ไลบรารี C ในเบราว์เซอร์ไม่ใช่เรื่องง่าย แต่เมื่อคุณเข้าใจกระบวนการโดยรวมและวิธีการทำงานของการไหลของข้อมูลแล้ว ทุกอย่างจะง่ายขึ้นและผลลัพธ์ที่ได้อาจน่าทึ่ง

WebAssembly เปิดโอกาสใหม่ๆ มากมายบนเว็บสำหรับการประมวลผล การคํานวณตัวเลข และเกม โปรดทราบว่า Wasm ไม่ใช่ยาครอบจักรวาลที่ควรนำไปใช้กับทุกสิ่ง แต่ 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 จะคาดหวังสิ่งต่อไปนี้จากสภาพแวดล้อม JavaScript ที่โหลด

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