Emscripten의 엠바인드

JS를 Wasm에 결합합니다.

지난 Wasm 기사에서는 웹에서 사용할 수 있도록 C 라이브러리를 wasm으로 컴파일하는 방법에 대해 설명했습니다. 저와 많은 독자들에게 눈에 띄는 한 가지는 사용 중인 wasm 모듈의 기능을 수동으로 선언해야 하는 조잡하고 약간 어색한 방식입니다. 다음은 제가 소개해 드릴 코드 스니펫입니다.

const api = {
    version: Module.cwrap('version', 'number', []),
    create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
    destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};

여기서는 EMSCRIPTEN_KEEPALIVE로 표시한 함수의 이름, 반환 유형, 인수 유형을 선언합니다. 그런 다음 api 객체의 메서드를 사용하여 이러한 함수를 호출할 수 있습니다. 그러나 이 방법으로 wasm을 사용하면 문자열을 지원하지 않으며 메모리 청크를 수동으로 이동해야 하므로 많은 라이브러리 API를 사용하기가 매우 지루하게 됩니다. 더 좋은 방법이 없을까? '예'인 이유는 무엇인가요? 그렇지 않다면 이 글이 무엇에 관한 내용일까요?

C++ 이름 맹글링

개발자 환경은 이러한 결합을 지원하는 도구를 빌드하기에 충분한 이유가 될 수 있지만, 실제로 C 또는 C++ 코드를 컴파일할 때 각 파일이 별도로 컴파일된다는 보다 긴급한 이유가 있습니다. 그런 다음 링커는 소위 개체 파일을 모두 하나로 모아 Wasm 파일로 변환합니다. C를 사용하면 링커가 사용할 객체 파일에서 함수 이름을 계속 사용할 수 있습니다. C 함수를 호출할 수 있으려면 cwrap()에 문자열로 제공되는 이름만 있으면 됩니다.

반면 C++는 함수 오버로드를 지원합니다. 즉, 서명이 다르다면 동일한 함수를 여러 번 구현할 수 있습니다 (예: 유형이 다른 매개변수). 컴파일러 수준에서 add와 같은 좋은 이름은 링커의 함수 이름에 서명을 인코딩하는 것으로 손상될 수 있습니다. 따라서 더 이상 해당 이름으로 함수를 조회할 수 없습니다.

embind 입력

embind는 Emscripten 도구 모음의 일부이며 C++ 코드에 주석을 달 수 있는 다양한 C++ 매크로를 제공합니다. JavaScript에서 사용할 함수, enum, 클래스 또는 값 유형을 선언할 수 있습니다. 몇 가지 일반 함수로 간단하게 시작해 보겠습니다.

#include <emscripten/bind.h>

using namespace emscripten;

double add(double a, double b) {
    return a + b;
}

std::string exclaim(std::string message) {
    return message + "!";
}

EMSCRIPTEN_BINDINGS(my_module) {
    function("add", &add);
    function("exclaim", &exclaim);
}

이전 자료와 달리 더 이상 함수에 EMSCRIPTEN_KEEPALIVE 주석을 달 필요가 없으므로 emscripten.h는 더 이상 포함하지 않습니다. 대신 자바스크립트에 함수를 노출할 이름을 나열하는 EMSCRIPTEN_BINDINGS 섹션이 있습니다.

이 파일을 컴파일할 때는 이전 문서와 동일한 설정 (또는 원하는 경우 동일한 Docker 이미지)을 사용할 수 있습니다. embind를 사용하기 위해 --bind 플래그를 추가합니다.

$ emcc --bind -O3 add.cpp

이제 남은 작업은 새로 만든 wasm 모듈을 로드하는 HTML 파일을 만드는 것입니다.

<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
    console.log(Module.add(1, 2.3));
    console.log(Module.exclaim("hello world"));
};
</script>

보시다시피 더 이상 cwrap()를 사용하지 않습니다. 이것은 즉시 작동합니다. 그러나 더 중요한 것은 문자열이 작동하도록 메모리 청크를 수동으로 복사하는 작업에 대해 걱정할 필요가 없다는 것입니다. embind를 사용하면 유형 검사와 함께 이러한 작업이 무료로 제공됩니다.

인수의 개수가 잘못된 함수를 호출하거나 인수의 유형이 잘못된 경우 DevTools에서 오류가 발생합니다.

이는 간혹 다루기 힘든 Wasm 오류를 처리하는 대신 일부 오류를 조기에 포착할 수 있으므로 매우 유용합니다.

객체

많은 JavaScript 생성자 및 함수가 옵션 객체를 사용합니다. JavaScript에서 좋은 패턴이지만 wasm에서 수동으로 실현하는 것은 매우 지루합니다. embind도 여기서도 도움이 될 수 있습니다.

예를 들어 문자열을 처리하는 매우 유용한 C++ 함수가 생각났는데 급히 이 함수를 웹에서 사용해 보겠습니다. 그 방법은 다음과 같습니다.

#include <emscripten/bind.h>
#include <algorithm>

using namespace emscripten;

struct ProcessMessageOpts {
    bool reverse;
    bool exclaim;
    int repeat;
};

std::string processMessage(std::string message, ProcessMessageOpts opts) {
    std::string copy = std::string(message);
    if(opts.reverse) {
    std::reverse(copy.begin(), copy.end());
    }
    if(opts.exclaim) {
    copy += "!";
    }
    std::string acc = std::string("");
    for(int i = 0; i < opts.repeat; i++) {
    acc += copy;
    }
    return acc;
}

EMSCRIPTEN_BINDINGS(my_module) {
    value_object<ProcessMessageOpts>("ProcessMessageOpts")
    .field("reverse", &ProcessMessageOpts::reverse)
    .field("exclaim", &ProcessMessageOpts::exclaim)
    .field("repeat", &ProcessMessageOpts::repeat);

    function("processMessage", &processMessage);
}

processMessage() 함수의 옵션에 관한 구조체를 정의하고 있습니다. EMSCRIPTEN_BINDINGS 블록에서 value_object를 사용하여 JavaScript가 이 C++ 값을 객체로 인식하도록 할 수 있습니다. 이 C++ 값을 배열로 사용하려면 value_array를 사용할 수도 있습니다. processMessage() 함수도 바인딩하면 나머지는 매직으로 임베딩됩니다. 이제 상용구 코드 없이 JavaScript에서 processMessage() 함수를 호출할 수 있습니다.

console.log(Module.processMessage(
    "hello world",
    {
    reverse: false,
    exclaim: true,
    repeat: 3
    }
)); // Prints "hello world!hello world!hello world!"

클래스

완전성을 위해 embind를 통해 전체 클래스를 노출하여 ES6 클래스와 함께 많은 시너지 효과를 가져오는 방법도 보여드리겠습니다. 이제부터 패턴이 표시되기 시작할 것입니다.

#include <emscripten/bind.h>
#include <algorithm>

using namespace emscripten;

class Counter {
public:
    int counter;

    Counter(int init) :
    counter(init) {
    }

    void increase() {
    counter++;
    }

    int squareCounter() {
    return counter * counter;
    }
};

EMSCRIPTEN_BINDINGS(my_module) {
    class_<Counter>("Counter")
    .constructor<int>()
    .function("increase", &Counter::increase)
    .function("squareCounter", &Counter::squareCounter)
    .property("counter", &Counter::counter);
}

JavaScript 측면에서는 거의 네이티브 클래스와 같습니다.

<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
    const c = new Module.Counter(22);
    console.log(c.counter); // prints 22
    c.increase();
    console.log(c.counter); // prints 23
    console.log(c.squareCounter()); // prints 529
};
</script>

C의 경우는 어떨까요?

embind는 C++용으로 작성되었으며 C++ 파일에서만 사용할 수 있지만 C 파일에 링크할 수 없다는 의미는 아닙니다. C와 C++를 함께 사용하려면 입력 파일을 C용 그룹과 C++ 파일용의 두 그룹으로 구분하고 다음과 같이 emcc용 CLI 플래그를 보강하면 됩니다.

$ emcc --bind -O3 --std=c++11 a_c_file.c another_c_file.c -x c++ your_cpp_file.cpp

결론

embind를 사용하면 Wasm 및 C/C++로 작업할 때 개발자 환경을 크게 개선할 수 있습니다. 이 문서에서는 embind에서 제공하는 모든 옵션을 다루지 않습니다. 관심이 있으시면 embind 문서를 계속 참고하시기 바랍니다. embind를 사용하면 gzip으로 압축했을 때 wasm 모듈과 JavaScript 글루 코드를 최대 11,000개까지(특히 작은 모듈에서) 커질 수 있습니다. Wasm 노출 영역이 매우 작으면 프로덕션 환경에서 embind에 비해 비용이 많이 들 수 있습니다. 어쨌든 꼭 한 번 사용해 보아야 합니다.