Emscripten 및 npm

WebAssembly를 이 설정에 통합하려면 어떻게 해야 하나요? 이 도움말에서는 C/C++ 및 Emscripten을 예로 들어 이 문제를 해결해 보겠습니다.

WebAssembly(wasm)는 성능 프리미티브 또는 웹에서 기존 C++ 코드베이스를 실행하는 방법으로 간주되는 경우가 많습니다. 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 레지스트리에는 제가 광범위하게 사용하고 있는 trzeciEmscripten 이미지가 있습니다.

npm 통합

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

일반적으로 Emscripten에서 생성된 빌드 아티팩트 (.js.wasm 파일)는 또 다른 JavaScript 모듈이자 또 다른 애셋으로 취급되어야 합니다. JavaScript 파일은 웹팩 또는 롤업과 같은 번들러에서 처리할 수 있으며 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는 셸을 '빠른 실패' 모드로 전환합니다. 스크립트의 명령어 중 하나라도 오류를 반환하면 전체 스크립트가 즉시 중단됩니다. 스크립트의 마지막 출력은 항상 성공 메시지 또는 빌드 실패의 원인이 된 오류이므로 이는 매우 유용할 수 있습니다.

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 플래그 목록입니다.

  • --bindembind를 사용 설정합니다.
  • -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라는 이름의 node_modules에 libvpx GitHub 저장소를 클론합니다.

이제 빌드 스크립트를 확장하여 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",
    // ...
    },
    // ...
}

(여기에 모든 파일이 포함된 기기스트가 있습니다.)

이렇게 하면 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

(여기에 모든 파일이 포함된 기기스트가 있습니다.)

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