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 (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.hWebPGetEncoderVersion()를 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과 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입니다.