Dart와 Wasm 실험하기

Dart와 Wasm 실험하기

Dart를 Wasm으로 컴파일하고, Dart에서 Wasm 모듈 호출하기

작성: Liam Appelbe & Michael Thomsen

출처: Experimenting with Dart and Wasm

본문

WebAssembly(흔히 Wasm으로 줄여 부름)는 "스택 기반 가상 머신을 위한 바이너리 명령 형식"이에요. Wasm은 원래 웹에서 네이티브 코드를 실행하기 위해 설계됐지만, 이후 여러 플랫폼에서 컴파일된 코드를 실행하는 일반 기술로 발전했어요. Dart는 이미 이식성이 아주 높고 다중 플랫폼을 지원하는 언어라서, 우리는 Wasm이 어떻게 Dart의 이런 성질을 확장할 수 있을지에 관심이 많아요.

왜 Wasm을 실험할까?

Wasm은 Chrome, Edge, Firefox, WebKit 같은 브라우저 벤더들의 광범위한 지원을 받고 있어요. 이 때문에 Wasm은 브라우저에서 바이너리 코드를 실행하기에 아주 매력적인 대상이에요. 하지만 원래 Wasm은 Dart나 Java/Kotlin 같은 가비지 컬렉션(GC)이 있는 프로그래밍 언어를 위해 설계된 게 아니에요. 그래서 GC 기반 언어를 Wasm으로 효율적으로 컴파일하기 어려웠죠. 우리는 최근 Wasm 프로젝트의 **GC 제안(GC proposal)**에 참여함으로써, 그 제안에 기술적 피드백을 제공하고, Wasm 코드를 통해 Dart 기반 웹 앱을 실행해서 얻을 수 있는 이점이 무엇인지 더 배우길 기대하고 있어요.

Wasm의 두 번째 장점은 바이너리 Wasm 모듈이 플랫폼 독립적이라는 거예요. 이는 기존 코드와의 상호운용성을 더 실용적으로 만들어줄 가능성이 있어요. 그 기존 코드가 Wasm으로 컴파일될 수 있다면, 모든 플랫폼의 Dart 앱이 하나의 공유된 바이너리 Wasm 모듈에 의존할 수 있으니까요.

이 글의 나머지에서는 Wasm과 Dart에 대한 우리 실험을 두 가지 형태로 논의할게요:

  • Dart → Wasm 컴파일: AOT 컴파일러를 확장해서 Dart 소스 코드를 Wasm 바이너리 코드로 컴파일하는 것을 지원 (이슈 32894).
  • Dart ↔ Wasm 상호운용(interop): Dart 코드에서 컴파일된 Wasm 모듈을 호출하는 것을 지원 (이슈 37355 & 37882).

Dart를 Wasm으로 컴파일하는 것과, Dart VM 런타임을 통한 Dart↔Wasm 상호운용을 비교하는 다이어그램 Dart와 함께 Wasm을 쓰는 두 가지 잠재적 용도의 예시

Dart를 Wasm으로 컴파일하기

언급했듯이 Wasm은 웹에서 네이티브 코드를 실행하는 방법으로 시작됐어요. 웹은 전통적으로 JavaScript 코드로 구동되는데, 그 코드는 웹 앱이 실행되는 동안 JavaScript 코드를 네이티브 코드로 JIT 컴파일하는 가상 머신(VM)에서 실행돼요. Flutter web 같은 현재의 웹 대상 Dart 프레임워크에서는 Dart 앱 코드가 배포를 위해 최적화된 JavaScript로 컴파일되고, 그 JavaScript는 앱이 실행될 때 웹 플랫폼에 의해 네이티브 코드로 JIT 컴파일돼요.

우리는 Dart 코드를 직접 Wasm 네이티브 코드로 컴파일해서, 웹에서 네이티브 코드를 실행하는 더 직접적인 경로를 얻을 수 있는지 조사하고 있어요. Wasm 어셈블리 형식은 저수준으로, JavaScript보다 기계어의 추상화 수준에 더 가까워요. 그 덕분에 시작 시간이 개선되고 전반적으로 더 예측 가능한 효율성을 얻을 수 있어요.

Dart의 Wasm 컴파일 지원은 초기 단계의 조사이고 컴파일러도 불완전하지만, 우리는 배우기 위해 실험하고 있어요. 우리는 항상 Dart의 컴파일 대상으로서 Wasm에 관심을 가져왔어요. 하지만 Wasm의 원래 형태는 가비지 컬렉션이 있는 언어에 잘 맞지 않아요. Wasm에는 내장 가비지 컬렉션 지원이 없어서, Dart 같은 언어는 컴파일된 Wasm 모듈 안에 가비지 컬렉션 구현을 포함해야 해요. GC 구현을 포함하면 매우 복잡해지고, 컴파일된 Wasm 코드의 크기를 부풀리며 시작 시간을 해치고, 브라우저 시스템의 나머지와 객체 수준의 상호운용에도 별로 좋지 않아요.

다행히도 Wasm GC라고 알려진 WebAssembly 커뮤니티의 진행 중인 노력이, 가비지 컬렉션 언어를 직접적이고 성능 좋게 지원하도록 Wasm을 확장하는 가능성을 탐구하고 있어요. Wasm에 대한 우리의 오랜 관심을 고려해, 우리는 커뮤니티에 참여하고 Dart를 Wasm GC로 변환하는 컴파일러를 작성함으로써 실제 경험을 제공할 기회를 봤어요.

이게 어디로 이어질지는 예측하기 너무 이르지만, 초기 프로토타입은 매우 긍정적인 결과를 보여주고 있어요. 초기 벤치마크에서 첫 프레임까지의 시간이 빨라지고 평균 프레임 시간/처리량도 빨라지는 것을 확인했죠. 프로젝트에 대해 더 알고 싶다면 wasm_prototype 소스 코드를 살펴보세요.

Wasm 코드와의 상호운용 (package:wasm)

Wasm으로 컴파일하는 것 외에도, 우리는 Wasm이 더 크로스 플랫폼인 방식으로 기존 코드와 통합하는 데 쓰일 수 있는지도 조사하는 데 관심이 있어요. 여러 언어가 C 호출 규약을 따르는 모듈로 컴파일하는 것을 지원하고, Dart FFI로 이런 모듈과 상호운용할 수 있어요. Dart FFI는 코드를 Dart로 다시 구현하는 대신 기존 소스 코드와 라이브러리를 활용하는 훌륭한 방법이 될 수 있어요.

하지만 C 모듈은 플랫폼 특정적이기 때문에, 네이티브 C 모듈이 있는 공유 패키지를 배포하는 건 복잡해요. 범용 빌드 시스템이 필요하거나, 각 원하는 플랫폼에 대한 여러 바이너리 모듈을 배포해야 하거든요. 만약 하나의 Wasm 바이너리 어셈블리 형식을 모든 플랫폼에서 쓸 수 있다면 배포가 훨씬 쉬워져요. 그러면 라이브러리를 대상 플랫폼마다 플랫폼 특정 바이너리 코드로 컴파일하는 대신, 한 번 Wasm 바이너리 모듈로 컴파일해서 어디서든 실행할 수 있죠. 이는 네이티브 코드를 포함한 패키지를 pub.dev에 쉽게 배포하는 길을 열어줄 수도 있어요.

우리는 package:wasm이라는 새 패키지에서 Wasm 상호운용 지원을 실험하고 있어요. 이 프로토타입은 Wasmer 런타임 위에 구축되고, OS 상호작용을 위해 WASI를 지원해요. 참고로 현재 프로토타입은 불완전하고 데스크톱 플랫폼(Windows, Linux, macOS)만 지원해요.

예제: Brotli 압축 라이브러리 호출하기

package:wasm을 써서 Wasm 모듈로 컴파일된 Brotli 압축 라이브러리를 활용하는 예제를 살펴볼게요. 이 예제에서는 입력 파일을 읽고, 압축하고, 압축 비율을 보고하고, 다시 압축 해제해서 입력을 그대로 되돌려받는지 검증할 거예요. 전체 샘플 소스 코드는 GitHub 저장소에 있어요. package:wasmdart:ffi 위에 구축되어 있으니, FFI 경험이 있다면 단계가 익숙하게 느껴질 거예요.

C 코드를 Wasm으로 컴파일하는 방법은 몇 가지 있는데, 이 경우에는 wasienv를 사용했어요. 전체 세부 사항은 README에서 확인할 수 있어요.

이 예제에서는 데이터를 압축하고 압축 해제하는 다음 Brotli 함수들을 호출해볼게요:

int BrotliEncoderCompress(
  int quality, int lgwin, int mode, size_t input_size,
  const uint8_t* input_buffer, size_t* output_size,
  uint8_t* output_buffer);

int BrotliDecoderDecompress(
  size_t encoded_size, const uint8_t* encoded_buffer,
  size_t* output_size, uint8_t* output_buffer);

quality, lgwin, mode 인자는 인코더의 튜닝 파라미터예요. 세부 사항은 예제와 무관하니 이 값들에는 기본값을 사용할게요. 또 하나 주의할 점은 output_sizein-out 파라미터라는 거예요. 이 함수들을 호출할 때 output_size는 우리가 할당한 output_buffer의 크기로 초기화되어야 하고, 호출 후에는 실제로 사용된 버퍼의 양으로 설정돼요.

첫 번째 단계는 컴파일된 Wasm 바이너리로 WasmModule 객체를 만드는 거예요. 바이너리 데이터는 Uint8List여야 하는데, file.readAsBytesSync()로 파일에서 읽어서 얻을 수 있어요.

var brotliPath = Platform.script.resolve('libbrotli.wasm');
var moduleData = File(brotliPath.path).readAsBytesSync();
var module = WasmModule(moduleData);

Wasm 모듈이 우리가 기대한 API를 갖고 있는지 확인하는 데 아주 유용한 디버깅 도구는 module.describe()예요. 이 메서드는 모듈의 모든 임포트와 익스포트를 나열한 문자열을 돌려줘요.

print(module.describe());

Brotli 라이브러리의 경우 이게 출력이에요:

import function: int32 wasi_unstable::fd_close(int32)
import function: int32 wasi_unstable::fd_write(int32, int32, int32, int32)
import function: int32 wasi_unstable::fd_fdstat_get(int32, int32)
import function: int32 wasi_unstable::fd_seek(int32, int64, int32, int32)
import function: void wasi_unstable::proc_exit(int32)

export memory: memory
export function: int32 BrotliDecoderSetParameter(int32, int32, int32)
export function: int32 BrotliDecoderCreateInstance(int32, int32, int32)
export function: void BrotliDecoderDestroyInstance(int32)
export function: int32 BrotliDecoderDecompress(int32, int32, int32, int32)
…
export function: int32 BrotliEncoderSetParameter(int32, int32, int32)
export function: int32 BrotliEncoderCreateInstance(int32, int32, int32)
export function: void BrotliEncoderDestroyInstance(int32)
export function: int32 BrotliEncoderMaxCompressedSize(int32)
export function: int32 BrotliEncoderCompress(int32, int32, int32, int32, int32, int32, int32)

…

모듈이 몇 가지 WASI 함수를 임포트하고, 메모리와 여러 Brotli 함수를 익스포트하는 걸 볼 수 있어요. 우리가 관심 있는 두 함수는 익스포트되어 있지만, 시그니처가 조금 달라 보여요. 그 이유는 Wasm이 32비트와 64비트 정수와 부동소수점만 지원하기 때문이에요. 포인터가 익스포트된 메모리로의 int32 인덱스가 되어버렸죠.

다음 단계는 모듈을 인스턴스화하는 거예요. 인스턴스화 중에는 모듈이 기대하는 모든 임포트를 채워야 해요. 인스턴스화는 빌더 패턴 (module.instantiate(). initialization… .build())을 사용해요. 우리 라이브러리는 WASI 함수만 임포트하니 enableWasi()만 호출하면 돼요:

var instance = module.instantiate().enableWasi().build();

추가로 비-WASI 함수 임포트가 있다면 addFunction()으로 Dart 함수를 wasm 라이브러리로 임포트할 수 있어요.

이제 WasmInstance가 있으니, 익스포트된 함수를 조회하거나 메모리를 검사할 수 있어요:

var memory = instance.memory;
var compress = instance.lookupFunction("BrotliEncoderCompress");
var decompress = instance.lookupFunction("BrotliDecoderDecompress");

다음으로 입력 파일에 compress/decompress 함수를 사용하고 싶어요. 하지만 데이터를 이 함수들에 직접 전달할 수는 없어요. C 함수는 데이터에 대한 uint8_t 포인터를 받는데, Wasm 코드에서는 그 포인터가 인스턴스 메모리로의 int32 인덱스가 되거든요. Brotli는 또한 size_t 포인터로 압축/압축해제 데이터의 크기를 보고하는데, 이것도 int32가 돼요.

그래서 데이터를 함수에 전달하려면 그 데이터를 인스턴스 메모리로 복사하고, 함수에 그 인덱스를 전달해야 해요. 메모리 영역 5개가 필요해요: 입력 데이터, 압축 데이터, 압축 크기, 압축 해제 데이터, 압축 해제 크기. 단순하게 하기 위해 사용하지 않는 메모리 영역 몇 개를 가져올 건데, 라이브러리에서 malloc()free()를 익스포트해도 돼요.

데이터를 사용하지 않는 메모리에 넣도록 하기 위해, 인스턴스 메모리를 키우고 그 새 영역을 데이터에 쓸게요:

var inputPtr = memory.lengthInBytes;
memory.grow((3 * inputData.length /
    WasmMemory.kPageSizeInBytes).ceil());
var memoryView = memory.view;
var outputPtr = inputPtr + inputData.length;
var outSizePtr = outputPtr + inputData.length;
var decodedPtr = outSizePtr + 4;
var decSizePtr = decodedPtr + inputData.length;

메모리 영역은 이렇게 생겼어요:

[initial instance memory][input][output][output size][decoded][decoded size]

다음으로 입력 데이터를 메모리에 로드하고 압축 함수를 호출해요:

memoryView.setRange(
    inputPtr, inputPtr + inputData.length, inputData);

var status = compress(kDefaultQuality, kDefaultWindow, kDefaultMode,
    inputData.length, inputPtr, outSizePtr, outputPtr);

나머지 예제도 비슷하게 동작해요. 결과는 이래요:

Loading lipsum.txt
Input size: 3210 bytes

Compressing…
Compression status: 1
Compressed size: 1198 bytes
Space saving: 62.68%

Decompressing…
Decompression status: 1
Decompressed size: 3210 bytes

Verifying decompression…
Decompression succeeded :)

package:wasm 시도해보기

Wasm 상호운용을 시도해보고 싶다면, package:wasm README에서 안내를 확인해보세요.

로드맵(Roadmap)

Wasm 컴파일과 Wasm 상호운용 둘 다 실험이에요. 이 실험들이 유의미한 결과를 내면, 계속 발전시켜 결국 안정적이고 지원되는 버전으로 만들 계획이에요. 하지만 무언가 의도대로 작동하지 않는다는 걸 알게 되거나 관심이 없다는 걸 보게 되면, 실험을 중단할 거예요.

우리는 배우기 위한 목적으로 이 실험들을 진행하며, 두 가지 주요 구성 요소가 있어요. 첫째, Wasm을 기술적으로 지원하는 **실현 가능성(feasibility)**과 그 지원의 특성이 무엇일지 배우고 싶어요. Dart 코드를 더 빠르게, 더 작게, 더 예측 가능하게 만들 수 있을까요? 둘째, Wasm이 잠금 해제할 새로운 기술적 역량과 그것이 Dart 개발자에게 가능하게 할 새 사용 사례를 탐구하는 데 관심이 있어요. 네이티브 코드와의 상호운용을 더 이식 가능하게 만들 수 있을까요?

Wasm이 여러분의 요구에 어떻게 적용될 수 있다고 생각하나요? 어떤 용도로 사용할 것 같나요? 여러분의 생각을 듣고 싶어요. Dart misc 토론 그룹에서 알려주세요.

더 알아보기