Wasm에 C 라이브러리 첨가

C 또는 C++ 코드로만 제공되는 라이브러리를 사용해야 하는 경우가 있습니다. 기존에는 이 단계에서 포기하게 됩니다. 이제 Emscripten과 WebAssembly(또는 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을 가져올 수 있습니다. 저처럼 Docker화된 명령어를 좋아하고 WebAssembly를 사용하기 위해 시스템에 항목을 설치하고 싶지 않다면 대신 사용할 수 있는 잘 관리되는 Docker 이미지가 있습니다.

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

간단한 항목 컴파일

nth 피보나치 수를 계산하는 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 코드를 작성할 때 일반적으로 운영체제에서 제공할 것으로 예상되는 스택, 힙, 기타 기능도 설정합니다. 따라서 자바스크립트 파일이 약간 더 크며, 크기는 19KB (gzip 압축 시 약 5KB)입니다.

간단한 작업 실행

모듈을 로드하고 실행하는 가장 쉬운 방법은 생성된 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

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

    #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과 자바스크립트만 있으면 됩니다.

<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-land 버퍼에 넣고 프로세스에서 할당한 모든 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이 입력 이미지와 출력 이미지를 모두 수용할 만큼 메모리를 늘릴 수 없다는 오류가 발생할 수 있습니다.

오류를 보여주는 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 모듈에 무엇이든 제공하는 방법은 imports 객체(instantiateStreaming 함수의 두 번째 매개변수)를 사용하는 것입니다. Wasm 모듈은 가져오기 객체 내부의 모든 항목에 액세스할 수 있지만 그 외의 항목에는 액세스할 수 없습니다. 일반적으로 Emscripting으로 컴파일된 모듈은 로드하는 JavaScript 환경에서 몇 가지를 예상합니다.

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