Emscripten 및 npm

WebAssembly를 이 설정에 어떻게 통합하나요? 이 문서에서는 C/C++ 및 Emscripten을 예로 들어 이 문제를 해결할 것입니다.

WebAssembly (Wasm)는 성능 프리미티브 또는 웹에서 기존 C++ 코드베이스를 실행하는 방법으로 프레이밍하는 경우가 많습니다. Google은 squoosh.app을 통해 Wasm에 적어도 세 번째 관점이 있다는 사실을 보여주고 싶었습니다. 바로 다른 프로그래밍 언어의 거대한 생태계를 활용하는 것입니다. Emscripten을 사용하면 C/C++ 코드를 사용할 수 있고, Rust에는 wasm 지원이 내장되어 있으며, Go팀에서도 이를 해결하기 위해 노력하고 있습니다. 다른 언어들도 많이 지원될 것입니다.

이 시나리오에서 Wasm은 앱의 중심이 아니라 퍼즐 조각, 또 다른 모듈입니다. 앱에는 이미 JavaScript, CSS, 이미지 애셋, 웹 중심 빌드 시스템과 React 같은 프레임워크가 있을 수 있습니다. 이 설정에 WebAssembly를 어떻게 통합하나요? 이 문서에서는 C/C++ 및 Emscripten을 예로 들어 이를 수행해 보겠습니다.

Docker

저는 Emscripten을 사용할 때 Docker가 매우 중요하다는 것을 알게 되었습니다. C/C++ 라이브러리는 빌드된 운영체제에서 작동하도록 작성되는 경우가 많습니다. 일관된 환경을 유지하는 것은 매우 유용합니다. Docker를 사용하면 이미 Emscripten과 호환되도록 설정되고 모든 도구와 종속 항목이 설치된 가상화된 Linux 시스템을 사용할 수 있습니다. 누락된 항목이 있으면 자체 머신이나 다른 프로젝트에 미치는 영향을 걱정할 필요 없이 바로 설치할 수 있습니다. 문제가 발생하면 컨테이너를 버리고 다시 시작하세요. 이 방법이 한 번 작동하면 계속 작동하고 동일한 결과를 생성할 수 있습니다.

Docker Registry에는 광범위하게 사용하고 있는 trzeciEmscripten 이미지가 있습니다.

npm과 통합

대부분의 경우 웹 프로젝트의 진입점은 npm의 package.json입니다. 규칙에 따라 대부분의 프로젝트는 npm install && npm run build로 빌드할 수 있습니다.

일반적으로 Emscripten에 의해 생성된 빌드 아티팩트 (.js.wasm 파일)는 그저 또 다른 JavaScript 모듈과 또 다른 애셋으로 취급되어야 합니다. JavaScript 파일은 webpack 또는 롤업과 같은 번들러에서 처리할 수 있으며, wasm 파일은 이미지와 같이 더 큰 바이너리 애셋처럼 취급해야 합니다.

따라서 '일반' 빌드 프로세스가 시작되기 전에 Emscripten 빌드 아티팩트를 빌드해야 합니다.

{
    "name": "my-worldchanging-project",
    "scripts": {
    "build:emscripten": "docker run --rm -v $(pwd):/src trzeci/emscripten
./build.sh",
    "build:app": "<the old build command>",
    "build": "npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

새로운 build:emscripten 작업은 Emscripten을 직접 호출할 수 있지만 앞서 언급했듯이 Docker를 사용하여 빌드 환경의 일관성을 유지하는 것이 좋습니다.

docker run ... trzeci/emscripten ./build.sh는 Docker에 trzeci/emscripten 이미지를 사용하여 새 컨테이너를 가동하고 ./build.sh 명령어를 실행하도록 지시합니다. build.sh는 다음에 작성할 셸 스크립트입니다. --rm는 Docker에게 실행이 완료되면 컨테이너를 삭제하도록 지시합니다. 이렇게 하면 시간이 지나도 오래된 머신 이미지 모음이 쌓이지 않습니다. -v $(pwd):/src는 Docker가 현재 디렉터리 ($(pwd))를 컨테이너 내부의 /src에 '미러링'하도록 하려는 것을 의미합니다. 컨테이너 내 /src 디렉터리에 있는 파일을 변경하면 실제 프로젝트에 미러링됩니다. 이 미러링된 디렉터리를 '바인드 마운트'라고 합니다.

build.sh를 살펴보겠습니다.

#!/bin/bash

set -e

export OPTIMIZE="-Os"
export LDFLAGS="${OPTIMIZE}"
export CFLAGS="${OPTIMIZE}"
export CXXFLAGS="${OPTIMIZE}"

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    src/my-module.cpp

    # Create output folder
    mkdir -p dist
    # Move artifacts
    mv my-module.{js,wasm} dist
)
echo "============================================="
echo "Compiling wasm bindings done"
echo "============================================="

분석해야 할 게 많아요!

set -e는 셸을 'fail fast' 모드로 설정합니다. 스크립트의 명령어가 오류를 반환하면 전체 스크립트가 즉시 취소됩니다. 스크립트의 마지막 출력은 항상 성공 메시지이거나 빌드 실패를 야기한 오류가 되므로 이는 매우 유용할 수 있습니다.

export 문을 사용하여 몇 가지 환경 변수의 값을 정의합니다. 이를 통해 추가 명령줄 매개변수를 C 컴파일러 (CFLAGS), C++ 컴파일러 (CXXFLAGS), 링커 (LDFLAGS)에 전달할 수 있습니다. 모두 OPTIMIZE를 통해 옵티마이저 설정을 수신하여 모든 것이 동일한 방식으로 최적화되도록 합니다. OPTIMIZE 변수에는 몇 가지 가능한 값이 있습니다.

  • -O0: 최적화를 수행하지 않습니다. 데드 코드가 제거되지 않으며 Emscripten은 생성되는 JavaScript 코드도 축소하지 않습니다. 디버깅에 적합합니다.
  • -O3: 실적을 높이기 위해 적극적으로 최적화합니다.
  • -Os: 성능과 크기를 보조 기준으로 적극적으로 최적화합니다.
  • -Oz: 크기를 적극적으로 최적화하고 필요한 경우 성능을 희생합니다.

웹에는 주로 -Os을 사용하는 것이 좋습니다.

emcc 명령어에는 자체 옵션이 무수히 많습니다. emcc는 'GCC 또는 clang과 같은 컴파일러의 드롭인 대체'로 간주됩니다. 따라서 GCC에서 알 수 있는 모든 플래그는 emcc로도 구현될 가능성이 높습니다. -s 플래그는 Emscripten을 구체적으로 구성할 수 있다는 점에서 특별합니다. 사용 가능한 모든 옵션은 Emscripten의 settings.js에서 확인할 수 있지만 이 파일은 상당히 복잡할 수 있습니다. 다음은 웹 개발자에게 가장 중요한 Emscripten 플래그 목록입니다.

  • --bind엠바인드를 사용 설정합니다.
  • -s STRICT=1는 지원 중단된 모든 빌드 옵션에 대한 지원을 중단합니다. 이렇게 하면 코드가 향후 버전과 호환되는 방식으로 빌드됩니다.
  • -s ALLOW_MEMORY_GROWTH=1를 사용하면 필요한 경우 메모리를 자동으로 늘릴 수 있습니다. 이 문서 작성 시점을 기준으로 Emscripten은 메모리 16MB를 초기에 할당합니다. 코드가 메모리 청크를 할당할 때 이 옵션은 메모리가 소진될 때 이러한 작업으로 전체 wasm 모듈이 실패하게 할지 아니면 글루 코드가 할당을 수용하기 위해 총 메모리를 확장하도록 허용할지 결정합니다.
  • -s MALLOC=...는 사용할 malloc() 구현을 선택합니다. emmalloc는 Emscripten을 위해 특별히 제작된 빠르고 작은 malloc() 구현입니다. 대안은 완전한 malloc() 구현인 dlmalloc입니다. 많은 작은 객체를 자주 할당하거나 스레딩을 사용하려는 경우에만 dlmalloc로 전환해야 합니다.
  • -s EXPORT_ES6=1는 JavaScript 코드를 모든 번들러에서 작동하는 기본 내보내기가 있는 ES6 모듈로 변환합니다. 또한 -s MODULARIZE=1를 설정해야 합니다.

다음 플래그는 항상 필요하지 않거나 디버깅 목적으로만 유용한 것은 아닙니다.

  • -s FILESYSTEM=0는 Emscripten과 관련된 플래그이며 C/C++ 코드에서 파일 시스템 작업을 사용할 때 파일 시스템을 에뮬레이션할 수 있는 기능입니다. 컴파일한 코드를 일부 분석하여 글루 코드에 파일 시스템 에뮬레이션을 포함할지 여부를 결정합니다. 그러나 이 분석으로 인해 잘못된 결과가 나올 수 있으며 필요하지 않을 수도 있는 파일 시스템 에뮬레이션을 위한 추가 글루 코드에 70KB의 비용이 많이 들 수 있습니다. -s FILESYSTEM=0를 사용하면 Emscripten에 이 코드가 포함되지 않도록 강제할 수 있습니다.
  • -g4는 Emscripten이 .wasm에 디버깅 정보를 포함하도록 하고 wasm 모듈의 소스 맵 파일도 내보냅니다. Emscripten을 사용한 디버깅에 관한 자세한 내용은 디버깅 섹션을 참고하세요.

이제 됐습니다. 이 설정을 테스트하기 위해 작은 my-module.cpp를 만들어 보겠습니다.

    #include <emscripten/bind.h>

    using namespace emscripten;

    int say_hello() {
      printf("Hello from your wasm module\n");
      return 0;
    }

    EMSCRIPTEN_BINDINGS(my_module) {
      function("sayHello", &say_hello);
    }

index.html:

    <!doctype html>
    <title>Emscripten + npm example</title>
    Open the console to see the output from the wasm module.
    <script type="module">
    import wasmModule from "./my-module.js";

    const instance = wasmModule({
      onRuntimeInitialized() {
        instance.sayHello();
      }
    });
    </script>

(여기에 모든 파일이 포함된 gist가 나와 있습니다.)

모든 것을 빌드하려면 다음을 실행합니다.

$ npm install
$ npm run build
$ npm run serve

localhost:8080으로 이동하면 DevTools 콘솔에 다음과 같은 출력이 표시됩니다.

C++ 및 Emscripten을 통해 출력된 메시지를 보여주는 DevTools

C/C++ 코드를 종속 항목으로 추가

웹 앱용 C/C++ 라이브러리를 빌드하려면 코드가 프로젝트에 포함되어야 합니다. 프로젝트의 저장소에 코드를 수동으로 추가하거나 npm을 사용하여 이러한 종류의 종속 항목을 관리할 수도 있습니다. 내 웹 앱에서 libvpx를 사용하려고 한다고 가정해 보겠습니다. libvpx는 .webm 파일에서 사용되는 코덱인 VP8로 이미지를 인코딩하는 C++ 라이브러리입니다. 그러나 libvpx는 npm에 없고 package.json도 없으므로 npm을 직접 사용하여 설치할 수는 없습니다.

이 문제를 해결할 수 있는 napa가 있습니다. napa를 사용하면 모든 Git 저장소 URL을 node_modules 폴더에 종속 항목으로 설치할 수 있습니다.

napa를 종속 항목으로 설치합니다.

$ npm install --save napa

그런 다음 napa를 설치 스크립트로 실행해야 합니다.

{
// ...
"scripts": {
    "install": "napa",
    // ...
},
"napa": {
    "libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}

npm install를 실행하면 napa가 libvpx GitHub 저장소를 libvpx라는 이름의 node_modules에 클론합니다.

이제 빌드 스크립트를 확장하여 libvpx를 빌드할 수 있습니다. libvpx는 configuremake를 사용하여 빌드합니다. 다행히 Emscripten은 configuremake가 Emscripten의 컴파일러를 사용하도록 하는 데 도움을 줄 수 있습니다. 이를 위해 래퍼 명령어 emconfigureemmake가 있습니다.

# ... above is unchanged ...
echo "============================================="
echo "Compiling libvpx"
echo "============================================="
(
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
# ... below is unchanged ...

C/C++ 라이브러리는 두 부분으로 나뉩니다. 일반적으로 라이브러리가 노출하는 데이터 구조, 클래스, 상수 등을 정의하는 헤더 (일반적으로 .h 또는 .hpp 파일)와 실제 라이브러리 (기존에는 .so 또는 .a 파일)입니다. 코드에서 라이브러리의 VPX_CODEC_ABI_VERSION 상수를 사용하려면 #include 문을 사용하여 라이브러리의 헤더 파일을 포함해야 합니다.

#include "vpxenc.h"
#include <emscripten/bind.h>

int say_hello() {
    printf("Hello from your wasm module with libvpx %d\n", VPX_CODEC_ABI_VERSION);
    return 0;
}

문제는 컴파일러가 vpxenc.h를 찾을 위치를 모른다는 것입니다. -I 플래그의 용도입니다. 헤더 파일을 확인할 디렉터리를 컴파일러에 알려줍니다. 또한 컴파일러에 실제 라이브러리 파일도 제공해야 합니다.

# ... above is unchanged ...
echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s ASSERTIONS=0 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    -I ./node_modules/libvpx \
    src/my-module.cpp \
    build-vpx/libvpx.a

# ... below is unchanged ...

지금 npm run build를 실행하면 프로세스가 새 .js 및 새 .wasm 파일을 빌드하고, 데모 페이지에서 실제로 상수를 출력하는 것을 확인할 수 있습니다.

emscripten을 통해 출력된 libvpx의 ABI 버전을 보여주는 DevTools

빌드 프로세스에 시간이 오래 걸리는 것도 알 수 있습니다. 빌드 시간이 긴 이유는 다양할 수 있습니다. libvpx의 경우 소스 파일이 변경되지 않았더라도 빌드 명령어를 실행할 때마다 VP8과 VP9의 인코더와 디코더를 모두 컴파일하므로 시간이 오래 걸립니다. my-module.cpp를 조금만 변경해도 빌드하는 데 시간이 오래 걸립니다. libvpx의 빌드 아티팩트를 처음 빌드한 이후에 유지하는 것이 매우 유용합니다.

이를 위한 한 가지 방법은 환경 변수를 사용하는 것입니다.

# ... above is unchanged ...
eval $@

echo "============================================="
echo "Compiling libvpx"
echo "============================================="
test -n "$SKIP_LIBVPX" || (
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="
# ... below is unchanged ...

다음은 모든 파일이 포함된 gist입니다.

eval 명령어를 사용하면 빌드 스크립트에 매개변수를 전달하여 환경 변수를 설정할 수 있습니다. $SKIP_LIBVPX이 임의의 값으로 설정된 경우 test 명령어가 libvpx 빌드를 건너뜁니다.

이제 모듈을 컴파일하되 libvpx 재빌드는 건너뜁니다.

$ npm run build:emscripten -- SKIP_LIBVPX=1

빌드 환경 맞춤설정

라이브러리에서 빌드할 추가 도구를 사용하는 경우도 있습니다. Docker 이미지가 제공하는 빌드 환경에서 이러한 종속 항목이 누락된 경우 직접 추가해야 합니다. 예를 들어 doxygen을 사용하여 libvpx 문서도 빌드한다고 가정해 보겠습니다. Doxygen은 Docker 컨테이너 내부에서 사용할 수 없지만 apt를 사용하여 설치할 수 있습니다.

build.sh에서 이 작업을 하려면 라이브러리를 빌드할 때마다 doxygen을 다시 다운로드하고 재설치해야 합니다. 그러면 낭비일 뿐만 아니라 오프라인에서 프로젝트 작업을 할 수 없게 됩니다.

여기에서는 자체 Docker 이미지를 빌드하는 것이 좋습니다. Docker 이미지는 빌드 단계를 설명하는 Dockerfile를 작성하여 빌드됩니다. Dockerfile은 매우 강력하며 많은 명령어가 있지만 대부분의 경우 FROM, RUN, ADD만 사용하면 됩니다. 이 경우에는 다음과 같습니다.

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen

FROM를 사용하면 시작점으로 사용할 Docker 이미지를 선언할 수 있습니다. 저는 개발자가 지금까지 사용해 온 이미지인 trzeci/emscripten를 기반으로 선택했습니다. RUN를 사용하면 컨테이너 내에서 셸 명령어를 실행하도록 Docker에 지시할 수 있습니다. 이러한 명령어에서 컨테이너를 변경하면 이제 Docker 이미지의 일부가 됩니다. build.sh를 실행하기 전에 Docker 이미지가 빌드되었고 사용 가능한지 확인하려면 package.json를 약간 조정해야 합니다.

{
    // ...
    "scripts": {
    "build:dockerimage": "docker image inspect -f '.' mydockerimage || docker build -t mydockerimage .",
    "build:emscripten": "docker run --rm -v $(pwd):/src mydockerimage ./build.sh",
    "build": "npm run build:dockerimage && npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

다음은 모든 파일이 포함된 gist입니다.

이렇게 하면 Docker 이미지가 빌드되지만 아직 빌드되지 않은 경우에만 빌드됩니다. 그러면 모든 것이 이전과 동일하게 실행되지만 이제 빌드 환경에서 doxygen 명령어를 사용할 수 있으므로 libvpx 문서도 빌드됩니다.

결론

C/C++ 코드와 npm이 적당하지 않은 것은 당연하지만, Docker가 제공하는 몇 가지 추가 도구와 격리를 통해 편안하게 사용할 수 있습니다. 이 설정이 모든 프로젝트에 적용되는 것은 아니지만, 필요에 맞게 조정할 수 있는 시작점입니다. 개선이 필요한 사항이 있다면 공유해 주세요.

부록: Docker 이미지 레이어 사용하기

또 다른 솔루션은 이러한 문제를 Docker 및 Docker의 스마트한 캐싱 접근 방식으로 캡슐화하는 것입니다. Docker는 Dockerfile을 단계별로 실행하고 각 단계의 결과에 자체 이미지를 할당합니다. 이러한 중간 이미지를 종종 '레이어'라고 합니다 Dockerfile의 명령어가 변경되지 않은 경우 Dockerfile을 다시 빌드할 때 Docker는 실제로 해당 단계를 다시 실행하지 않습니다. 대신 이미지가 마지막으로 빌드되었을 때의 레이어를 재사용합니다.

이전에는 앱을 빌드할 때마다 libvpx를 다시 빌드하지 않도록 약간의 노력을 기울였습니다. 대신 libvpx의 빌드 안내를 build.sh에서 Dockerfile로 이동하여 Docker의 캐싱 메커니즘을 활용할 수 있습니다.

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen git && \
    mkdir -p /opt/libvpx/build && \
    git clone https://github.com/webmproject/libvpx /opt/libvpx/src
RUN cd /opt/libvpx/build && \
    emconfigure ../src/configure --target=generic-gnu && \
    emmake make

다음은 모든 파일이 포함된 gist입니다.

docker build를 실행할 때 바인드 마운트가 없으므로 git을 수동으로 설치하고 libvpx를 클론해야 합니다. 부작용으로 더 이상 Napa가 필요하지 않습니다.