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++. Thông thường, đây là lúc bạn bỏ cuộc. Giờ đây, chúng ta đã có Emscripten và WebAssembly (hoặc Wasm)!

Chuỗi công cụ

Tôi đặt mục tiêu tìm hiểu cách biên dịch một số mã C hiện có sang Wasm. Có một số thông tin về phần phụ trợ Wasm của LLVM, vì vậy, tôi bắt đầu tìm hiểu về phần phụ trợ đó. 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 ngay 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 vấn đề. Điều này giúp tôi rút ra bài học quan trọng:

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

Mặc dù điều đó có vẻ như bạn phải lo lắng về tình trạng phình to (tôi chắc chắn lo lắng), nhưng trình biên dịch Emscripten sẽ loại bỏ 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à chúng chứa, đồng thời các 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ể tải 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 bạn là người hâm mộ các lệnh được docker hoá như tôi và không muốn cài đặt mọi thứ trên hệ thống của mình chỉ để chơi với WebAssembly, thì có một hình ảnh Docker được duy trì tốt mà bạn có thể sử dụng:

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

Đang biên soạn nội dung đơn giản

Hãy lấy ví dụ chuẩn mực về việc viết một hàm bằng C để tính số fibonacci thứ nth:

    #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 ngôn ngữ C, thì bản thân hàm này không có gì quá xa lạ. Ngay cả khi không biết C nhưng biết JavaScript, bạn vẫn có thể hiểu được những gì đang diễn ra ở đây.

emscripten.h là tệp tiêu đề do Emscripten cung cấp. Chúng ta chỉ cần thư viện này để có thể truy cập vào macro EMSCRIPTEN_KEEPALIVE, nhưng thư viện này cung cấp nhiều chức năng hơn thế. 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 được sử dụng. Nếu chúng ta bỏ qua macro đó, trình biên dịch sẽ tối ưu hoá hàm — rốt cuộc thì không ai sử dụng hàm đó.

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

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

Hãy cùng 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 một 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 giữ lại hàm cwrap() trong tệp JavaScript. Chúng ta sẽ tìm hiểu thêm về hàm này sau. -O3 yêu cầu trình biên dịch tối ưu hoá mạnh mẽ. Bạn có thể chọn số thấp hơn để giảm thời gian tạo bản dựng, nhưng điều đó cũng sẽ khiến các gói kết quả 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ẽ có một tệp JavaScript tên là a.out.js và một tệp WebAssembly tên là a.out.wasm. Tệp Wasm (hoặc "mô-đun") chứa mã C đã biên dịch và phải có kích thước 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 hiệu quả hơn. Nếu cần, lớp 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 có kích thước lớn hơn một chút, nặng 19 KB (khoảng 5 KB khi nén bằng gzip).

Chạy một nội dung đơ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 biến toàn cục Module để sử dụng. 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 hàm thân thiện với C và gọi hàm được gói. 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ự 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.

Mục tiêu tối thượng: Biên dịch thư viện C

Cho đến nay, mã C mà chúng ta đã viết được viết bằng Wasm. Tuy nhiên, một trường hợp sử dụng cốt lõi cho WebAssembly là lấy hệ sinh thái hiện có của các thư viện C và cho phép 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 thứ khác. Emscripten cung cấp hầu hết các tính năng này, mặc dù có một số hạn chế.

Hãy quay lại mục tiêu ban đầu của tôi: biên dịch bộ mã hoá cho WebP sang 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 API mở rộng. Đó là một điểm 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 sang 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 tra xem chúng ta có thể biên dịch mã nguồn của libwebp hay không, vì chúng ta không cần 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 cho trình biên dịch tất cả các tệp C của libwebp mà trình biên dịch cần. Thành thật mà nói, tôi chỉ cung cấp cho nó tất 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. Có vẻ như cách này 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

Bây giờ, chúng ta chỉ cần một số HTML và JavaScript để tải mô-đun mới:

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

Và chúng ta sẽ thấy số phiên bản chính xác trong kết quả:

Ả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 số phiên bản chính xác.

Đưa hình ảnh từ JavaScript vào Wasm

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

Câu hỏi đầu tiên chúng ta phải trả lời là: Làm cách nào để đưa hình ảnh vào vùng đất Wasm? Khi xem xét API mã hoá của libwebp, API này cần một mảng byte ở định dạng 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);
}

Giờ đây, vấn đề "duy nhất" là sao chép dữ liệu từ JavaScript sang Wasm. Để làm được điều đó, chúng ta cần hiển thị thêm hai hàm. Một hàm phân bổ bộ nhớ cho hình ảnh bên trong vùng Wasm và một hàm giải phóng bộ nhớ:

    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 cho mỗi pixel. Con trỏ do malloc() trả về là địa chỉ của ô nhớ đầu tiên của vùng đệm đó. Khi con trỏ được trả về JavaScript, con trỏ đó được coi là một số. Sau khi hiển thị hàm cho JavaScript bằng cwrap, chúng ta có thể sử 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);

Bước cuối cùng: Mã hoá hình ảnh

Hình ảnh hiện đã có trong vùng Wasm. Đã đến lúc gọi bộ mã hoá WebP để thực hiện công việc của nó! Khi xem tài liệu về WebP, WebPEncodeRGBA có vẻ là lựa chọn phù hợp nhất. Hàm này lấy một con trỏ đến hình ảnh đầu vào và kích thước của hình ảnh đó, cũng như một tuỳ chọn chất lượng từ 0 đến 100. Hàm này cũng phân bổ một vùng đệm đầu ra cho chúng ta, mà chúng ta cần giải phóng bằng WebPFree() sau khi hoàn tất với hình ảnh WebP.

Kết quả của thao tác 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 làm kiểu dữ liệu trả về (trừ phi chúng ta phân bổ bộ nhớ một cách linh động), nên tôi đã sử dụng một mảng tĩnh toàn cục. Tôi biết, không phải C sạch (trên thực tế, nó dựa vào thực tế là con trỏ Wasm rộng 32 bit), nhưng để mọi thứ đơn giản, tôi nghĩ đâ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];
    }

Bây giờ, khi đã có 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, đưa vào vùng đệm JavaScript của riêng mình và giải phóng tất cả vùng đệm Wasm mà chúng ta đã 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 của hình ảnh, bạn có thể gặp lỗi khi Wasm không thể tăng đủ bộ nhớ để chứa 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 một lỗi.

May mắn là 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 rằng việc này đã thành công, chúng ta có thể biến vùng đệm kết quả thành một blob và sử dụng blob đó trên một 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);

Hãy chiêm ngưỡng vẻ đẹp của hình ảnh WebP mới!

Bảng điều khiển mạng của DevTools và hình ảnh tạo sinh.

Kết luận

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

WebAssembly mở ra nhiều khả năng mới trên web để 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 có thể áp dụng cho mọi trường hợp, nhưng khi bạn gặp phải một trong những điểm tắc nghẽn đó, Wasm có thể là một công cụ cực kỳ hữu ích.

Nội dung bổ sung: Chạy một nội dung đơn giản theo cách phức tạp

Nếu muốn, bạn có thể thử và tránh tệp JavaScript đã tạo. 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ớ để hoạt động trừ phi bạn cung cấp bộ nhớ cho các mô-đun đó. Cách bạn cung cấp mô-đun Wasm với bất kỳ nội dung nào là 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, nhưng không có nội dung nào khác bên ngoài đối tượng đó. Theo quy ước, các mô-đun được biên dịch bằng Emscripting sẽ yêu cầu một số điều từ môi trường JavaScript đang tải:

  • Đầu tiên là env.memory. Mô-đun Wasm không biết về 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. Đối tượng này đại diện cho một phần bộ nhớ tuyến tính (có thể tăng lên). Các tham số định cỡ được tính theo "đơn vị trang WebAssembly", nghĩa là mã ở trên phân bổ 1 trang bộ nhớ, trong đó mỗi trang có kích thước 64 KiB. Nếu không cung cấp tuỳ chọn maximum, thì về mặt lý thuyết, bộ nhớ sẽ tăng lên không giới hạn (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 đặt giá trị tối đa.
  • env.STACKTOP xác định vị trí mà ngăn xếp sẽ bắt đầu phát triển. Ngăn xếp là 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ỏ, nên chúng ta có thể sử dụng toàn bộ bộ nhớ làm ngăn xếp, do đó STACKTOP = 0.