Sao chép thư viện C thành wasm

Đôi khi, bạn muốn sử dụng một thư viện chỉ có sẵn dưới dạng mã C hoặc C++. Theo thông lệ, đây là lúc bạn sẽ từ bỏ. Giờ thì chúng ta đã có EmscriptenWebAssembly (hay Wasm)!

Chuỗi công cụ

Tôi đặt ra mục tiêu tìm ra cách biên dịch một số mã C hiện có thành Wasm. Có một số vấn đề gây nhiễu về phần phụ trợ Wasm của LLVM, vì vậy, tôi đã bắt đầu tìm hiểu sâu hơn về vấn đề đó. Mặc dù bạn có thể biên dịch các chương trình đơn giản theo cách này, nhưng khi muốn sử dụng thư viện chuẩn của C hoặc thậm chí biên dịch nhiều tệp, bạn có thể sẽ gặp sự cố. Điều này dẫn tôi đến bài học chính mà tôi học được:

Mặc dù Emscripten sử dụng là trình biên dịch C-to-asm.js, nhưng sau đó, trình biên dịch này đã trưởng thành để nhắm mục tiêu đến Wasm và trong quá trình chuyển đổi sang phần phụ trợ LLVM chính thức trong nội bộ. Emscripten cũng cung cấp cách triển khai tương thích với Wasm đối với thư viện chuẩn của C. Sử dụng Emscripten. Thư viện này mang rất nhiều công việc ẩn, mô phỏng hệ thống tệp, cung cấp khả năng quản lý bộ nhớ, kết hợp OpenGL với WebGL – rất nhiều thứ mà bạn thực sự không cần tự mình trải nghiệm khi phát triển.

Mặc dù điều đó nghe có vẻ như bạn phải lo lắng về tình trạng phình ảnh – tôi chắc chắn lo lắng – trình biên dịch Emscripten sẽ xoá mọi thứ không cần thiết. Trong các thử nghiệm của tôi, các mô-đun Wasm thu được có kích thước phù hợp với logic mà các mô-đun đó chứa, đồng thời nhóm Emscripten và WebAssembly đang nỗ lực làm cho các mô-đun này nhỏ hơn nữa trong tương lai.

Bạn có thể nhận được Emscripten bằng cách làm theo hướng dẫn trên trang web của họ hoặc sử dụng Homebrew. Nếu thích dùng các lệnh được tạo sẵn như tôi và không muốn cài đặt mọi thứ trên hệ thống chỉ để chơi với WebAssembly, thì bạn có thể sử dụng hình ảnh Docker được duy trì tốt:

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

Biên dịch nội dung đơn giản

Hãy lấy ví dụ gần như chính tắc về cách viết một hàm trong C để tính số fibonacci thứ 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;
    }

Nếu bạn biết C, bản thân hàm này không nên quá đáng ngạc nhiên. Ngay cả khi bạn không biết ngôn ngữ C nhưng biết JavaScript, hy vọng bạn có thể hiểu được điều gì đang diễn ra ở đây.

emscripten.h là một tệp tiêu đề do Emscripten cung cấp. Chúng ta chỉ cần thực thể này để có quyền truy cập vào macro EMSCRIPTEN_KEEPALIVE, nhưng nó cung cấp nhiều chức năng hơn nữa. Macro này yêu cầu trình biên dịch không xoá một hàm ngay cả khi hàm đó có vẻ như không sử dụng. Nếu chúng tôi bỏ qua macro đó, trình biên dịch sẽ tối ưu hoá hàm – rốt cuộc sẽ không có ai sử dụng hàm đó.

Hãy lưu tất cả những nội dung đó vào tệp có tên fib.c. Để chuyển tệp này thành tệp .wasm, chúng ta cần chuyển sang lệnh emcc của trình biên dịch Emscripten:

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c

Hãy phân tích lệnh này. emcc là trình biên dịch của Emscripten. fib.c là tệp C của chúng ta. Đến giờ thì mọi thứ vẫn ổn! -s WASM=1 yêu cầu Emscripten cung cấp cho chúng ta tệp Wasm thay vì tệp asm.js. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' yêu cầu trình biên dịch để lại hàm cwrap() có sẵn trong tệp JavaScript – bạn có thể xem thêm thông tin về hàm này ở phần sau. -O3 yêu cầu trình biên dịch tối ưu hoá một cách linh hoạt. Bạn có thể chọn số lượng thấp hơn để giảm thời gian xây dựng, nhưng điều đó cũng sẽ làm cho các gói thu được lớn hơn vì trình biên dịch có thể không xoá mã không dùng đến.

Sau khi chạy lệnh, bạn sẽ thấy một tệp JavaScript có tên a.out.js và một tệp WebAssembly có tên là a.out.wasm. Tệp Wasm (hay "mô-đun") chứa mã C đã biên dịch và phải khá nhỏ. Tệp JavaScript đảm nhận việc tải và khởi chạy mô-đun Wasm, đồng thời cung cấp một API đẹp hơn. Nếu cần, phần phụ thuộc này cũng sẽ đảm nhận việc thiết lập ngăn xếp, vùng nhớ khối xếp và các chức năng khác thường được hệ điều hành cung cấp khi viết mã C. Do đó, tệp JavaScript lớn hơn một chút, có trọng số 19KB (~5KB gzip).

Chạy phương thức đơn giản

Cách dễ nhất để tải và chạy mô-đun là sử dụng tệp JavaScript đã tạo. Sau khi tải tệp đó, bạn sẽ có một Module chung theo ý mình. Sử dụng cwrap để tạo một hàm gốc JavaScript đảm nhiệm việc chuyển đổi các tham số thành một nội dung thân thiện với C và gọi hàm được bao bọc. cwrap lấy tên hàm, kiểu dữ liệu trả về và kiểu đối số làm đối số theo thứ tự như sau:

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

Nếu chạy mã này, bạn sẽ thấy "144" trong bảng điều khiển, đây là số Fibonacci thứ 12.

Chìa khoá bí mật: Biên dịch thư viện C

Cho đến nay, chúng tôi đã viết mã C dựa trên Wasm. Tuy nhiên, một trường hợp sử dụng chính của WebAssembly là tận dụng hệ sinh thái thư viện C hiện có và cho phép các nhà phát triển sử dụng chúng trên web. Các thư viện này thường dựa vào thư viện chuẩn của C, hệ điều hành, hệ thống tệp và những nội dung khác. Mô tả cung cấp hầu hết các tính năng này, mặc dù có một số giới hạn.

Hãy quay lại mục tiêu ban đầu của tôi: biên dịch bộ mã hoá cho WebP thành Wasm. Nguồn cho bộ mã hoá và giải mã WebP được viết bằng C và có trên GitHub cũng như một số tài liệu chuyên sâu về API. Đó là khởi đầu khá tốt.

    $ git clone https://github.com/webmproject/libwebp

Để bắt đầu một cách đơn giản, hãy thử hiển thị WebPGetEncoderVersion() từ encode.h tới JavaScript bằng cách viết một tệp C có tên là webp.c:

    #include "emscripten.h"
    #include "src/webp/encode.h"

    EMSCRIPTEN_KEEPALIVE
    int version() {
      return WebPGetEncoderVersion();
    }

Đây là một chương trình đơn giản và phù hợp để kiểm thử xem chúng tôi có thể lấy mã nguồn của libwebp để biên dịch hay không, vì chúng tôi không yêu cầu bất kỳ tham số hoặc cấu trúc dữ liệu phức tạp nào để gọi hàm này.

Để biên dịch chương trình này, chúng ta cần cho trình biên dịch biết nơi có thể tìm thấy các tệp tiêu đề của libwebp bằng cách sử dụng cờ -I, đồng thời truyền tất cả các tệp C của libwebp mà trình biên dịch cần. Tôi nói trung thực là: Tôi vừa cung cấp tất cả các tệp C mà tôi có thể tìm thấy và dựa vào trình biên dịch để loại bỏ mọi thứ không cần thiết. Nó có vẻ rất hiệu quả!

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
        -I libwebp \
        webp.c \
        libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

Hiện tại, chúng ta chỉ cần một số HTML và JavaScript để tải mô-đun mới của mình:

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

Chúng ta sẽ thấy số phiên bản chỉnh sửa ở đầu ra:

Ảnh chụp màn hình bảng điều khiển Công cụ cho nhà phát triển cho thấy đúng số phiên bản.

Lấy hình ảnh từ JavaScript vào Wasm

Việc nhận được số phiên bản của bộ mã hoá là rất tuyệt vời, nhưng việc mã hoá hình ảnh thực tế sẽ ấn tượng hơn, đúng không? Hãy làm việc đó đi.

Câu hỏi đầu tiên chúng tôi phải trả lời là: Làm cách nào để chúng tôi đưa hình ảnh vào vùng đất Wasm? Nhìn vào API mã hoá của libwebp, bạn có thể thấy một mảng byte theo RGB, RGBA, BGR hoặc BGRA. May mắn thay, Canvas API có getImageData(), cung cấp cho chúng ta một Uint8ClampedArray chứa dữ liệu hình ảnh trong 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);
}

Bây giờ, vấn đề "chỉ" có thể là sao chép dữ liệu từ JavaScript land vào Wasm. Do đó, chúng ta cần hiển thị thêm 2 hàm. Một hệ thống phân bổ bộ nhớ cho hình ảnh bên trong vùng đất Wasm và một bộ nhớ giải phóng bộ nhớ đó lần nữa:

    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 phân bổ một vùng đệm cho hình ảnh RGBA – do đó, có 4 byte trên mỗi pixel. Con trỏ do malloc() trả về là địa chỉ của ô bộ nhớ đầu tiên của vùng đệm đó. Khi con trỏ được trả về vùng đất JavaScript, con trỏ chỉ được coi là một số. Sau khi hiển thị hàm cho JavaScript bằng cwrap, chúng ta có thể dùng số đó để tìm điểm bắt đầu của vùng đệm và sao chép dữ liệu hình ảnh.

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);

Chung kết: Mã hoá hình ảnh

Hình ảnh đó hiện đã có tại vùng đất Wasm. Đã đến lúc gọi bộ mã hoá WebP! Theo tài liệu về WebP, WebPEncodeRGBA có vẻ hoàn toàn phù hợp. Hàm này đưa con trỏ đến hình ảnh đầu vào và kích thước của hình ảnh đó, cũng như tuỳ chọn chất lượng nằm trong khoảng từ 0 đến 100. Hộp cát về quyền riêng tư cũng phân bổ một vùng đệm đầu ra cho chúng ta. Chúng ta cần giải phóng bằng cách sử dụng WebPFree() sau khi xử lý xong hình ảnh WebP.

Kết quả của hoạt động mã hoá là một vùng đệm đầu ra và độ dài của vùng đệm đó. Vì các hàm trong C không thể có mảng ở dạng loại dữ liệu trả về (trừ khi chúng ta phân bổ bộ nhớ một cách linh động), nên tôi đã dùng đến một mảng toàn cục tĩnh. Tôi biết là C không sạch (trên thực tế, công cụ này dựa trên thực tế là con trỏ Wasm có chiều rộng 32 bit), nhưng để đơn giản, tôi cho rằng đây là một lối tắt hợp lý.

    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];
    }

Giờ đây, với tất cả những điều đó, chúng ta có thể gọi hàm mã hoá, lấy con trỏ và kích thước hình ảnh, đặt vào vùng đệm JavaScript-land của riêng mình và phát hành tất cả vùng đệm Wasm-land mà chúng tôi đã phân bổ trong quá trình này.

    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);

Tuỳ thuộc vào kích thước hình ảnh, bạn có thể gặp lỗi trong đó Wasm không thể tăng bộ nhớ đủ để đáp ứng cả hình ảnh đầu vào và đầu ra:

Ảnh chụp màn hình bảng điều khiển Công cụ cho nhà phát triển cho thấy lỗi.

Thật may, giải pháp cho vấn đề này nằm trong thông báo lỗi! Chúng ta chỉ cần thêm -s ALLOW_MEMORY_GROWTH=1 vào lệnh biên dịch.

Vậy là xong! Chúng tôi đã biên dịch một bộ mã hoá WebP và chuyển mã hình ảnh JPEG sang WebP. Để chứng minh cách này hiệu quả, chúng ta có thể chuyển vùng đệm kết quả thành một blob và sử dụng vùng đệm đó trên phần tử <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);

Đây là sự huy hoàng của hình ảnh WebP mới!

Bảng điều khiển mạng của Công cụ cho nhà phát triển và hình ảnh đã tạo.

Kết luận

Không phải chỉ đi vài bước để biến thư viện C hoạt động trong trình duyệt, nhưng sau khi bạn hiểu quy trình tổng thể và cách thức hoạt động của luồng dữ liệu, việc này sẽ trở nên dễ dàng hơn và kết quả thật ấn tượng.

WebAssembly mở ra nhiều khả năng mới trên web cho việc xử lý, tính toán số và chơi trò chơi. Xin lưu ý rằng Wasm không phải là giải pháp toàn diện cho mọi vấn đề, nhưng khi bạn gặp phải một trong những nút thắt cổ chai đó, Wasm có thể là một công cụ cực kỳ hữu ích.

Nội dung tặng thêm: Chạy việc gì đơn giản theo cách khó

Nếu muốn dùng thử và tránh tệp JavaScript được tạo, bạn có thể làm vậy. Hãy quay lại ví dụ về Fibonacci. Để tự tải và chạy, chúng ta có thể làm như sau:

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

Các mô-đun WebAssembly do Emscripten tạo không có bộ nhớ để làm việc trừ khi bạn cung cấp bộ nhớ cho các mô-đun này. Bạn có thể cung cấp bất cứ thứ gì cho mô-đun Wasm bằng cách sử dụng đối tượng imports – tham số thứ hai của hàm instantiateStreaming. Mô-đun Wasm có thể truy cập vào mọi nội dung bên trong đối tượng nhập, ngoại trừ đối tượng khác. Theo quy ước, các mô-đun được biên dịch bằng cách Viết mã cần có một số điều từ môi trường tải JavaScript:

  • Đầu tiên, có env.memory. Mô-đun Wasm không nhận biết được thế giới bên ngoài, vì vậy, mô-đun này cần có một số bộ nhớ để hoạt động. Nhập WebAssembly.Memory. Nó đại diện cho một phần bộ nhớ tuyến tính (không bắt buộc). Các tham số định kích thước được tính bằng "theo đơn vị của trang WebAssembly", tức là mã ở trên sẽ phân bổ 1 trang bộ nhớ, mỗi trang có kích thước là 64 KiB. Nếu không cung cấp tuỳ chọn maximum, thì về mặt lý thuyết, bộ nhớ sẽ không bị giới hạn về mức tăng trưởng (Chrome hiện có giới hạn cứng là 2 GB). Hầu hết các mô-đun WebAssembly không cần phải đặt mức tối đa.
  • env.STACKTOP xác định vị trí ngăn xếp bắt đầu tăng trưởng. Ngăn xếp cần thiết để thực hiện lệnh gọi hàm và phân bổ bộ nhớ cho các biến cục bộ. Vì chúng ta không thực hiện bất kỳ thủ thuật quản lý bộ nhớ động nào trong chương trình Fibonacci nhỏ của mình, nên chúng ta có thể chỉ cần sử dụng toàn bộ bộ nhớ làm ngăn xếp, do đó, STACKTOP = 0.