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>

간단한 코드 컴파일

C로 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;
    }

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

먼저 webp.c라는 C 파일을 작성하여 encode.h에서 JavaScript로 WebPGetEncoderVersion()를 노출해 보겠습니다.

    #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으로 이미지 가져오기

인코더의 버전 번호를 확인하는 것도 좋지만, 실제 이미지를 인코딩하면 더 인상적이겠죠? 그럼 해 봅시다.

첫 번째 질문은 '이미지를 와즘 랜드로 가져오는 방법'입니다. 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-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 모듈에 무엇이든 제공하는 방법은 instantiateStreaming 함수의 두 번째 매개변수인 imports 객체를 사용하는 것입니다. Wasm 모듈은 import 객체 내부의 모든 항목에 액세스할 수 있지만 그 밖의 다른 접근에는 액세스할 수 없습니다. 규칙에 따라 Emscripting으로 컴파일되는 모듈은 자바스크립트 로드 환경에서 몇 가지 사항을 예상합니다.

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