Zstandard(zstd)のデコーダーをWASMでビルドする

通信データ量を削減するため、圧縮したデータをダウンロードしてブラウザ側で解凍するという場面は結構あるかと思います。
はやりのZstandardのWASMでブラウザの解凍処理を試そうとしたところ、ビルドする際に色々はまったので作業ログを残しておきます。

:::note warn
この記事では解凍(decoder)だけを扱います
:::

## 要約
Zstandardのビルド時に`emcc`を`-s MALLOC=dlmalloc or 指定なし`でビルドすればOK
[試行結果をGitHubに置いておきます。](https://github.com/wagadao-y/zstddec-wasm)

## WASMのビルドが通らない
先人たちがZstandardをWASMでビルドした例を公開してくれています。ありがたいですね。
https://github.com/donmccurdy/zstddec-wasm

しかし、2025年1月現在ではこのコマンドでビルドが通らなくなっているためコマンドを見直します。
```
$ git clone https://github.com/facebook/zstd.git
$ cd zstd/build/single_file_libs/
$ ./combine.sh -r ../../lib -o zstddeclib.c zstddeclib-in.c     # ここまではOK

# Emscriptenのビルドが通らない・・・
$ emcc zstddeclib.c -Oz \
    -s EXPORTED_FUNCTIONS="['_ZSTD_decompress', '_ZSTD_findDecompressedSize', '_ZSTD_isError', '_malloc', '_free']" \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s MALLOC=emmalloc \
    -o zstddec.wasm
wasm-ld: error: /home/hogehoge/work/wasm/emsdk/upstream/emscripten/cache/sysroot/lib/wasm32-emscripten/libstandalonewasm-nocatch-memgrow.a(__main_void.o): undefined symbol: main
emcc: error: '/home/hogehoge/work/wasm/emsdk/upstream/bin/wasm-ld -o zstddec.wasm /tmp/emscripten_temp_z46po2ux/zstddeclib_0.o -L/home/hogehoge/work/wasm/emsdk/upstream/emscripten/cache/sysroot/lib/wasm32-emscripten /home/hogehoge/work/wasm/emsdk/upstream/emscripten/cache/sysroot/lib/wasm32-emscripten/crt1.o -lGL-getprocaddr -lal -lhtml5 -lc_optz -lstandalonewasm-nocatch-memgrow -lstubs -lc -lemmalloc -lcompiler_rt -lc++-noexcept -lc++abi-noexcept -lsockets -mllvm -combiner-global-alias-analysis=false -mllvm -enable-emscripten-sjlj -mllvm -disable-lsr /tmp/tmp13wdvq7blibemscripten_js_symbols.so --strip-debug --export=ZSTD_decompress --export=ZSTD_findDecompressedSize --export=ZSTD_isError --export=malloc --export=free --export=emscripten_stack_get_current --export=_emscripten_stack_restore --export-if-defined=__start_em_asm --export-if-defined=__stop_em_asm --export-if-defined=__start_em_lib_deps --export-if-defined=__stop_em_lib_deps --export-if-defined=__start_em_js --export-if-defined=__stop_em_js --export-table -z stack-size=65536 --max-memory=2147483648 --initial-heap=16777216 --table-base=1 --global-base=1024' failed (returned 1)
```

main関数がないというエラーなので`--no-entry`を追加します。
```
$ emcc zstddeclib.c -Oz \
    -s EXPORTED_FUNCTIONS="['_ZSTD_decompress', '_ZSTD_findDecompressedSize', '_ZSTD_isError', '_malloc', '_free']" \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s MALLOC=emmalloc \
    --no-entry \
    -o zstddec.wasm
$ base64 -w 0 zstddec.wasm > zstddec.txt
```
無事にビルドできましたのでJavaScript側から利用するためにBase64に変換しておきます。

## ビルドしたWASMでZstandardのテストが通らない
上記のリポジトリにはZstandardのWASMで正常に解凍できているか確認するテストコードがありますので、まずはgit cloneしたままの内容でテストが通ることを確認します。

```
$ git clone https://github.com/donmccurdy/zstddec-wasm.git
$ cd zstddec-wasm/
$ npm install
$ npm run dist
$ npm run test

> zstddec@0.1.0 test
> npm run test:node && npm run test:browser


> zstddec@0.1.0 test:node
> tape *.test.cjs | tap-spec


  zstddec

    ✔ decodes text


  total:     1
  passing:   1
  duration:  37ms
```
無事にテストが通りました。次は本命のビルドしたWASMで試してみましょう。
WASMをBASE64に変換した`zstddec.txt`の内容を`zstddec-wasm/zstddec.ts`の`const wasm = '...'`に上書きして、再度テストを実行します。
```
$ npm run dist
$ npm run test

> zstddec@0.1.0 test
> npm run test:node && npm run test:browser


> zstddec@0.1.0 test:node
> tape *.test.cjs | tap-spec


  zstddec


    ✖ RuntimeError: memory access out of bounds
    --------------------------------------------
      operator: error
      stack: |-
  RuntimeError: memory access out of bounds
  at wasm://wasm/00031c36:wasm-function[43]:0xbb14
  at wasm://wasm/00031c36:wasm-function[42]:0xb991
  at wasm://wasm/00031c36:wasm-function[44]:0xbc60
  at ZSTDDecoder.decode (/home/hoge/work/wasm/temp/zstddec-wasm/dist/zstddec.cjs:47:44)
  at Test.<anonymous> (/home/hoge/work/wasm/temp/zstddec-wasm/zstddec.test.cjs:17:20)




  Failed Tests: There was 1 failure

    zstddec

      ✖ RuntimeError: memory access out of bounds


  total:     1
  passing:   0
  failing:   1
  duration:  35ms
```
テストが失敗しました。`✖ RuntimeError: memory access out of bounds`のエラーですが、`dist/zstddec.cjs`の47行目のmallocが問題のようです。
```
// dist/zstddec.cjs
    var compressedPtr = instance.exports.malloc(compressedSize);   # このmallocはエラーにならない
    heap.set(array, compressedPtr);
    // Decompress into WASM memory.
    uncompressedSize = uncompressedSize || Number(instance.exports.ZSTD_findDecompressedSize(compressedPtr, compressedSize));
        var uncompressedPtr = instance.exports.malloc(uncompressedSize);  # 47行目 このmallocでエラー
    var actualSize = instance.exports.ZSTD_decompress(uncompressedPtr, uncompressedSize, compressedPtr, compressedSize);
```

ビルド時の`MALLOC`の指定を`emmalloc`からデフォルト値の`dlmalloc`に変えてみます。
```
$ emcc zstddeclib.c -Oz \
    -s EXPORTED_FUNCTIONS="['_ZSTD_decompress', '_ZSTD_findDecompressedSize', '_ZSTD_isError', '_malloc', '_free']" \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s MALLOC=dlmalloc \
    --no-entry \
    -o zstddec.wasm
$ base64 -w 0 zstddec.wasm > zstddec.txt
```
再度`zstddec.txt`の`const wasm = '...'`に上書きしてテスト実行したところ、テストが通りました。とりあえず動いたのですが、なんで`emmalloc`だと動かないんでしょうね…
```
$ npm run dist
$ npm run test

> zstddec@0.1.0 test
> npm run test:node && npm run test:browser


> zstddec@0.1.0 test:node
> tape *.test.cjs | tap-spec


  zstddec

    ✔ decodes text


  total:     1
  passing:   1
  duration:  39ms
```