Emscripten으로 WebAssembly와 JavaScript 코드 크기 줄이기

Emscripten은 asm.js 및 WebAssembly를 위한 컴파일러 툴체인으로 웹에서 C/C++를 네이티브에 가까운 속도로 실행할 수 있게 해줍니다.

Emscripten 출력물의 크기는 최근에 굉장히 작아졌습니다(특히 작은 프로그램에서 더). 다음은 예제 C 코드입니다:

#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE
int add(int x, int y) {
  return x + y;
}

이 코드는 두 수의 합을 내보내는 순수 연산의 “hello world” 입니다. -Os -s WASM=1(크기 최적화, wasm으로 빌드) 옵션으로 컴파일하면 WebAssembly 바이너리는 42 바이트에 불과합니다. 디어셈블링하면 이는 정확히 여러분이 기대했던 것을 포함하고 있습니다:

(module
 (type $0 (func (param i32 i32) (result i32)))
 (export "_add" (func $0))
 (func $0 (; 0 ;) (type $0) (param $var$0 i32) (param $var$1 i32) (result i32)
  (i32.add
   (get_local $var$1)
   (get_local $var$0)
  )
 )
)

아주 좋습니다! 실제로, Emscripten이 Javascript 파일을 생성하여 로드되기는 하지만 아시다시피 크기가 아주 작으며, 특정 런타임 지원에 의존하지 않으므로 자기 자신만의 로딩 코드를 쉽게 작성할 수 있습니다.

비교해보자면 Emscripten 1.37.22는 위의 코드 샘플에서 10,837 바이트의 WebAssembly 바이너리를 제거하였고, 따라서 아주 극적인 42 바이트라는 결과를 얻게되었습니다. 더 큰 프로그램에서는 어떨까요? 마찬가지로 크게 향상되었습니다: Emscripten 1.37.22와 1.37.29에서 printf를 사용한 C hello world 프로그램을 비교해보면, WebAssembly 바이너리는 11,745 바이트에서 2,438 바이트로, 5배정도 작아졌습니다. –closure-compiler 1 옵션으로 emcc를 실행해 Closure Compiler(강력 권장!)를 실행하고, 방출된 Javascript 파일을 보면, 최근에 향상된 Emscripten는 이를 23,707 바이트에서 11,690 바이트로, 2배 이상 축소하였습니다.(이 숫자에 대해선 나중에 더 설명합니다.)

무엇이 바뀌었나요?

Emscripten은 존재하는 C/C++ 코드를 쉽게 포팅하는 것에 초점을 맞추었습니다. 이는 다양한 POSIX API, 파일 시스템 에뮬레이팅, 그리고 아직은 WebAssembly에서 지원되지 않는 longjmp와 C++ 예외(exception)같은 특별한 처리를 지원한다는 것을 의미합니다. 또한 다양한 JavaScript API(ccall, 등등.)를 제공하여, JavaScript에서 컴파일된 코드를 사용하기 쉽게 만들었습니다. 그리고 이 모든 것은 OpenGLSDL과 같은 유용한 API를 좀 더 실용적으로 웹으로 포팅할 수 있게 해주었습니다. 이러한 능력은 Emscripten의 런타임 및 라이브러리에 달려있으며, 두 가지 주된 이유로, 우리는 여러분이 실제로 필요한 것 보다 더 많은 것들을 포함(include)시켰었습니다.

먼저, 우리는 기본적으로 많은 것들을 내보냈었습니다. 즉, 여러분이 사용할지도모르는 많은 것들을 출력물에 포함시켰었습니다. 최근에는 이런 기본적인 것을 좀 더 합리적인 무언가로 변경하는것에 중점을 두고 있습니다.

두 번째 이유는 훨씬 더 흥미롭습니다. Emscripten은 WebAssembly와 JavaScript의 결합물을 방출하며, 개념적으론 다음과 같습니다:

Emscripten emits a combination of WebAssembly & JavaScript (a conceptual diagram)

원은 함수를 나타내고 화살표는 콜을 나타냅니다. 이 함수들의 일부는 root(계속 살아 있도록 유지해야하는 것들)일 수 있고, 우리는 데드 코드 제거(Dead Code Elimination, DCE)를 수행해 root로부터 도달할 수 없는 모든 것들을 제거하려고합니다. 하지만 우리가 JavaScript 또는 WebAssembly의 한 면만 보면서 이런 작업을 수행한다면 root로 접근할 수 있는 다른 모든 것들을 고려해야 하며, 따라서 우리는 체인 상단의 마지막 두 부분과 하단의 전체 사이클과 같은 것들을 제거할 수 없게됩니다.

우리는 이 두 도메인간의 몇 가지 연결을 고려했었기 때문에, 사실 이전에도 큰 프로그램을 위한 적절한 작업을 수행하기에 충분할 정도로 그렇게 나쁘진 않았습니다(예를들면, WebGL 지원이 필요 없는 경우, 필수 JS 라이브러리 코드만 포함할 수 있습니다). 그러나 우리는 여러분이 사용하지 않는 코어 런타임 컴포넌트를 제거하는데 실패하였으며, 이는 작은 프로그램에서 아주 뚜렷하게 나타납니다.

이를 위한 해결책을 우리는 (더 나은 이름이 없어)meta-DCE라고 부릅니다. 이는 WebAssembly와 JavaScript의 결합된 그래프를 전체적으로 확인합니다. 실제로, meta-DCE는 JavaScript 사이드를 스캐닝하고 그 정보를 Binaryen의 wasm-metadce 도구로 전달하여, 큰 그림을 보고 제거할만한 것이 있는지 확인할 수 있게됩니다. 불필요한 WebAssembly 코드를 제거하고, 모듈을 최적화하며(불필요 코드 제거를 통해 최적화 기회가 새롭게 발생), JavaScript에서 제거(Emscripten JavaScript 최적화기가 종료되고, Closure Compiler가 나머지 모든 것들을 정리합니다)할 수 있는 것들을 보고합니다.

프로젝트가 JavaScript와 WebAssembly를 함께 포함하며 이 둘 사이에 흥미로운 연결을 허용하고 있다면, 무조건 둘을 함께 DCE 해야합니다. 이런 어플리케이션들은 더 많아질것이므로 이 문제는 Emscripten에서만 중요한것이 아닙니다. 예를들면, Binaryen의 wasm-metadce 도구가 JavaScript 모듈 번들러의 옵션으로 통합될수도 있습니다. 그러면 이 방법으로 여러분이 WebAssembly 라이브러리를 포함하고 실제로 사용하지 않는 부분이 있을 때 그 부분은 자동으로 제거될 수 있습니다.

코드 크기에 대해 좀 더 살펴보기

C hello world로 돌아가봅시다. 최적화의 중요성을 강조하기 위해, 여러분이 -s WASM=1(wasm으로 빌드, 최적화 미지정) 옵션으로 컴파일한다고 하면, 여러분은 44,954 바이트의 WebAssembly와 100,462 바이트의 JavaScript 코드를 얻게됩니다. 최적화가 없으면 컴파일러는 코드 크기를 줄일 필요가 없으므로 출력물은 주석과 공백 및 불필요한 코드를 포함하게 됩니다. 크기 최적화를 위해 -Os –closure 1 옵션을 추가하면, 이 글의 앞에서 언급한것처럼 2,438 바이트의 WebAssembly와 11,690 바이트의 JavaScript 코드를 얻게됩니다. 실제로 최적화되지 않은 빌드보다 10배 이상 작습니다. 훨씬 낫습니다만, 왜 더 작아질 수 없을까요? 왜 그냥 console.log(“hello, world”)만 출력하지 않을까요?

C hello world는 libc(Emscripten 내의 musl)에서 구현된 printf를 사용합니다. printf는 console에 단순히 출력하는 것을 포함해 파일과 같은 임의의 기기를 다룰 수 있을 정도로 충분히 범용적인 libc 스트림 코드를 사용하며, 버퍼링과 에러 처리등을 수행합니다. 최적화기가 모든 복잡성을 제거하도록 기대하는 것은 무리입니다. 이 문제는 우리가 console에 출력하기 위해선 printf보다 더 간단한 API를 사용해야 한다는 것과 같습니다.

console에 출력만 하기 위한 한 가지 옵션은 emscripten_log를 사용하는 것이지만, 이 또한 많은 옵션(스택 추적 출력, 포매팅 등)을 지원하기 때문에 코드 크기를 크게 줄이는데는 별 도움은 되지 않습니다. 정말 console.log만 사용하길 원한다면 임의의 JavaScript를 호출하기위한 방법인 EM_ASM을 사용하면 됩니다:

#include <emscripten.h>

int main() {
  EM_ASM({
    console.log("hello, world!");
  });
}

(파라미터를 전달하고 결과를 반환할수도 있으므로 이 방법으로 자신만의 미니멀한 로깅 메소드를 구현할수 있습니다.) 이 파일은 206 바이트의 WebAssembly와 10,272 바이트의 JavaScript로 컴파일됩니다. 우리가 원하는 것에 거의 도달했지만, 왜 여전히 JavaScript가 작지 않을까요? 왜냐하면 Emscripten의 JavaScript 출력물이 많은 것들을 지원하기 때문입니다:

  • 출력물은 Web, Node.js 및 다양한 JavaScript VM 쉘에서 실행이 가능합니다. 우리는 이들 사이의 차이를 부드럽게 극복하기 위한 많은 코드를 포함시켰습니다.
  • WebAssembly 로딩 코드는 스트리밍 사용(가능할 때)과 같은 많은 옵션들을 지원합니다.
  • Hook은 프로그램 실행중에 다양한 지점(예를들면, main()바로 전에)에서 코드를 실행할 수 있게해줍니다. Webassembly의 시작이 비동기이므로 유용합니다.

이러한 것들은 모두 매우 중요하므로 그냥 빼버리기가 힘듭니다. 하지만 아마도 미래에는 이를 옵션으로 두거나 더 적은 코드로 이를 수행할 수 있는 방법을 찾을 수 있을겁니다.

앞으로는

meta-DCE를 사용하여 우리는 코드 크기를 위한 대부분의 최적화 인프라를 갖게되었습니다. 하지만 마지막 섹션의 끝에서 언급한 JavaScript 개선을 포함해 아직 해야할 것이 많이 남아있습니다. 참여하시겠습니까? 아래의 이슈들중에 여러분이 조사해볼만한 것이 있는지 확인해보세요.

이 글은 Alon ZakaiShrinking WebAssembly and JavaScript code sizes in Emscripten의 한국어 번역입니다.

작성자: Seul Gi Choi

Seul Gi Choi가 작성한 문서들…


댓글이 없습니다.

댓글 쓰기