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팀에서는 향후 이 모듈을 더 작게 만들기 위해 노력하고 있습니다.

Emscripten은 웹사이트의 안내에 따르거나 Homebrew를 사용하여 다운로드할 수 있습니다. 저처럼 Docker화된 명령어를 즐겨 사용하며 단지 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 (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()를 자바스크립트로 노출해 보겠습니다.

    #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 콘솔의 스크린샷

자바스크립트에서 Wasm으로 이미지 가져오기

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

우리가 대답해야 할 첫 번째 질문은 '이미지를 Wasm 땅으로 옮기려면 어떻게 해야 하는가?'입니다. libwebp의 인코딩 API를 보면 RGB, RGBA, BGR 또는 BGRA 형식의 바이트 배열이 예상됩니다. 다행히 캔버스 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()에서 반환하는 포인터는 버퍼의 첫 번째 메모리 셀 주소입니다. 포인터가 자바스크립트 위치로 돌아오면 그 포인터는 숫자로 취급됩니다. cwrap를 사용하여 함수를 자바스크립트에 노출한 후 해당 숫자를 사용하여 버퍼의 시작 부분을 찾고 이미지 데이터를 복사할 수 있습니다.

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 land에서 이미지를 사용할 수 있습니다. 이제 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이 매우 유용한 도구가 될 수 있습니다.

보너스 콘텐츠: 간단한 일을 어렵게 하기

생성된 자바스크립트 파일을 피하고 싶다면 그렇게 할 수 있습니다. 다시 피보나치의 예로 돌아가겠습니다. 직접 로드하고 실행하려면 다음 단계를 따르세요.

<!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 모듈은 imports 객체 내의 모든 항목에 액세스할 수 있지만 객체 외부의 다른 항목에는 액세스할 수 없습니다. 규칙에 따라 Emscripting으로 컴파일된 모듈은 JavaScript 환경을 로드할 때 몇 가지 사항을 예상합니다.

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