Wasm에 C 라이브러리 첨가

C 또는 C++ 코드로만 제공되는 라이브러리를 사용해야 하는 경우가 있습니다. 일반적으로 여기서 포기합니다. 하지만 이제 EmscriptenWebAssembly(또는 Wasm)가 있으므로 더 이상 그렇지 않습니다.

도구 모음

기존 C 코드를 Wasm으로 컴파일하는 방법을 알아내는 것을 목표로 삼았습니다. LLVM의 Wasm 백엔드에 관한 소문이 있어 이를 살펴보기 시작했습니다. 이 방법으로 간단한 프로그램을 컴파일할 수 있지만 C의 표준 라이브러리를 사용하거나 여러 파일을 컴파일하려고 하면 문제가 발생할 수 있습니다. 이로 인해 제가 배운 중요한 교훈은 다음과 같습니다.

Emscripten은 이전에는 C-to-asm.js 컴파일러였지만 이후 Wasm을 타겟팅하도록 발전했으며 내부적으로 공식 LLVM 백엔드로 전환하는 과정에 있습니다. Emscripten은 C의 표준 라이브러리에 대한 Wasm 호환 구현도 제공합니다. Emscripten 사용 숨겨진 작업이 많고 파일 시스템을 에뮬레이션하고, 메모리 관리를 제공하고, OpenGL을 WebGL로 래핑하는 등 직접 개발할 필요가 없는 많은 작업을 수행합니다.

블로트가 걱정될 수도 있지만 Emscripten 컴파일러는 필요하지 않은 모든 것을 삭제합니다. 실험에서 결과 Wasm 모듈은 포함된 로직에 맞게 적절한 크기이며 Emscripten 및 WebAssembly팀은 향후 더 작게 만들기 위해 노력하고 있습니다.

웹사이트의 안내를 따르거나 Homebrew를 사용하여 Emscripten을 가져올 수 있습니다. 저처럼 도커화된 명령어를 좋아하고 WebAssembly를 사용하기 위해 시스템에 항목을 설치하고 싶지 않다면 대신 사용할 수 있는 잘 관리된 Docker 이미지가 있습니다.

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

간단한 항목 컴파일

n번째 피보나치 수를 계산하는 C 함수를 작성하는 거의 표준적인 예를 살펴보겠습니다.

    #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에 asm.js 파일 대신 Wasm 파일을 제공하도록 지시합니다. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]'는 컴파일러에 JavaScript 파일에서 cwrap() 함수를 사용할 수 있도록 남겨두라고 지시합니다. 이 함수에 관한 자세한 내용은 나중에 설명합니다. -O3는 컴파일러에 적극적으로 최적화하도록 지시합니다. 빌드 시간을 줄이기 위해 더 낮은 숫자를 선택할 수 있지만, 컴파일러가 사용하지 않는 코드를 삭제하지 않을 수 있으므로 결과 번들이 더 커집니다.

명령어를 실행하면 a.out.js라는 JavaScript 파일과 a.out.wasm라는 WebAssembly 파일이 생성됩니다. Wasm 파일('모듈')에는 컴파일된 C 코드가 포함되어 있으며 크기가 상당히 작아야 합니다. JavaScript 파일은 Wasm 모듈을 로드하고 초기화하며 더 나은 API를 제공합니다. 필요한 경우 C 코드를 작성할 때 운영체제에서 제공할 것으로 예상되는 스택, 힙, 기타 기능도 설정합니다. 따라서 JavaScript 파일이 약간 더 커서 19KB (~5KB gzip)입니다.

간단한 작업 실행

생성된 JavaScript 파일을 사용하면 모듈을 가장 쉽게 로드하고 실행할 수 있습니다. 이 파일을 로드하면 Module 전역을 사용할 수 있습니다. cwrap를 사용하여 매개변수를 C 친화적인 것으로 변환하고 래핑된 함수를 호출하는 JavaScript 네이티브 함수를 만듭니다. cwrap는 함수 이름, 반환 유형, 인수 유형을 순서대로 인수로 사용합니다.

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

이 코드를 실행하면 콘솔에 12번째 피보나치 수인 '144'가 표시됩니다.

성배: C 라이브러리 컴파일

지금까지 작성한 C 코드는 Wasm을 염두에 두고 작성되었습니다. 하지만 WebAssembly의 핵심 사용 사례는 기존 C 라이브러리 생태계를 가져와 개발자가 웹에서 사용할 수 있도록 하는 것입니다. 이러한 라이브러리는 C의 표준 라이브러리, 운영체제, 파일 시스템 등을 사용하는 경우가 많습니다. Emscripten은 이러한 기능을 대부분 제공하지만 일부 제한사항이 있습니다.

원래 목표인 WebP를 Wasm으로 인코더를 컴파일하는 것으로 돌아가 보겠습니다. WebP 코덱의 소스는 C로 작성되었으며 GitHub에서 제공됩니다. 또한 광범위한 API 문서도 제공됩니다. 이 정도면 시작하기에 충분합니다.

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

간단하게 시작하기 위해 encode.h에서 WebPGetEncoderVersion()를 JavaScript에 노출해 보겠습니다. webp.c라는 C 파일을 작성하면 됩니다.

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

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

이 함수를 호출하는 데 매개변수나 복잡한 데이터 구조가 필요하지 않으므로 libwebp의 소스 코드를 컴파일할 수 있는지 테스트하는 데 적합한 간단한 프로그램입니다.

이 프로그램을 컴파일하려면 -I 플래그를 사용하여 컴파일러에 libwebp의 헤더 파일을 찾을 수 있는 위치를 알려야 하며 필요한 libwebp의 모든 C 파일을 전달해야 합니다. 솔직히 말해 찾을 수 있는 모든 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 영역으로 가져오는 방법입니다. libwebp의 인코딩 API를 살펴보면 RGB, RGBA, BGR 또는 BGRA의 바이트 배열이 필요합니다. 다행히 Canvas API에는 RGBA의 이미지 데이터를 포함하는 Uint8ClampedArray를 제공하는 getImageData()가 있습니다.

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 영역으로 데이터를 복사하기만 하면 됩니다. 이를 위해 두 가지 함수를 추가로 노출해야 합니다. 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 영역으로 반환되면 숫자일 뿐인 것으로 취급됩니다. cwrap를 사용하여 JavaScript에 함수를 노출한 후 이 숫자를 사용하여 버퍼의 시작을 찾고 이미지 데이터를 복사할 수 있습니다.

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 사이의 품질 옵션을 사용합니다. 또한 WebP 이미지를 완료한 후 WebPFree()를 사용하여 해제해야 하는 출력 버퍼를 할당합니다.

인코딩 작업의 결과는 출력 버퍼와 그 길이입니다. 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이 입력 이미지와 출력 이미지를 모두 수용할 만큼 메모리를 늘릴 수 없는 오류가 발생할 수 있습니다.

오류를 보여주는 DevTools 콘솔의 스크린샷

다행히 이 문제의 해결책은 오류 메시지에 나와 있습니다. 컴파일 명령어에 -s ALLOW_MEMORY_GROWTH=1를 추가하기만 하면 됩니다.

이제 Cloud 함수가 완성되었네요. 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 파일을 사용하지 않으려면 그렇게 할 수 있습니다. 피보나치 예로 돌아가 보겠습니다. 직접 로드하고 실행하려면 다음을 실행하면 됩니다.

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

Emscripten으로 생성된 WebAssembly 모듈에는 메모리가 제공되지 않는 한 작업할 메모리가 없습니다. 무언가를 사용하여 Wasm 모듈을 제공하는 방법은 instantiateStreaming 함수의 두 번째 매개변수인 imports 객체를 사용하는 것입니다. Wasm 모듈은 가져오기 객체 내부의 모든 항목에 액세스할 수 있지만 그 외 항목에는 액세스할 수 없습니다. 일반적으로 Emscripting으로 컴파일된 모듈은 로드 JavaScript 환경에서 다음과 같은 몇 가지 사항을 기대합니다.

  • 첫 번째는 env.memory입니다. Wasm 모듈은 외부 세계를 알지 못하므로 작동하려면 메모리가 필요합니다. WebAssembly.Memory을 입력합니다. 선형 메모리의 (선택적으로 확장 가능한) 부분을 나타냅니다. 크기 조정 매개변수는 'WebAssembly 페이지 단위'로 되어 있습니다. 즉, 위의 코드는 메모리 1페이지를 할당하며 각 페이지의 크기는 64KiB입니다. maximum 옵션을 제공하지 않으면 메모리 증가가 이론적으로 무제한입니다 (Chrome에는 현재 2GB의 하드 제한이 있음). 대부분의 WebAssembly 모듈은 최대값을 설정할 필요가 없습니다.
  • env.STACKTOP는 스택이 성장하기 시작해야 하는 위치를 정의합니다. 스택은 함수를 호출하고 로컬 변수의 메모리를 할당하는 데 필요합니다. 작은 피보나치 프로그램에서는 동적 메모리 관리 속임수를 사용하지 않으므로 전체 메모리를 스택으로 사용할 수 있습니다. 따라서 STACKTOP = 0입니다.