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

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

Toolchain

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

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

เรียกใช้อะไรง่ายๆ

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

    <script src="a.out.js"></script>
    <script>
      Module.onRuntimeInitialized = _ => {
        const fib = Module.cwrap('fib', 'number', ['number']);
        console.log(fib(12));
      };
    </script>

หากคุณ รันโค้ดนี้ คุณควรจะเห็น "144" ในคอนโซล ซึ่งเป็นหมายเลข Fibonacci หมายเลข 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 หรือไม่ เนื่องจากเราไม่ต้องใช้พารามิเตอร์ หรือโครงสร้างข้อมูลที่ซับซ้อน เรียกใช้ฟังก์ชันนี้

ในการคอมไพล์โปรแกรมนี้ เราต้องบอกโปรแกรมว่าโปรแกรมจะค้นหาตำแหน่งใด ไฟล์ส่วนหัวของ 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);

รอบชิงชนะเลิศ: เข้ารหัสรูปภาพ

รูปภาพนี้มีอยู่ใน Wasm land แล้ว ได้เวลาเรียกใช้โปรแกรมเปลี่ยนไฟล์ 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 ที่สร้างขึ้น คุณอาจ เป็น ลองย้อนกลับไปที่ตัวอย่างของ 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 แต่ในทางทฤษฎี หน่วยความจำนั้นไม่มีขอบเขตในการเติบโต (ตอนนี้ จำกัดอยู่ที่ 2 GB) โมดูล WebAssembly ส่วนใหญ่ไม่ควรต้องตั้งค่า สูงสุด
  • env.STACKTOP จะเป็นตัวกำหนดว่าสแต็กควรเริ่มเติบโตที่ใด กลุ่ม ที่จำเป็นต่อการเรียกใช้ฟังก์ชันและจัดสรรหน่วยความจำสำหรับตัวแปรภายในเครื่อง เนื่องจากเราไม่มีโซลูชันการจัดการหน่วยความจำแบบไดนามิกใน โปรแกรม Fibonacci เราสามารถใช้หน่วยความจำทั้งหมดเป็นกองซ้อนกันอยู่ ดังนั้น STACKTOP = 0