EmscriptenでビルドしたZstandardをブラウザ環境で動かす

はじめに

前回の投稿で、C言語実装のHelloWorldをNode.js環境で動作させました。 今回はデータ圧縮アルゴリズムZstandardをEmscriptenでビルドしてブラウザ環境で動作させてみます。

データの圧縮/伸張だけだと挙動がわかりにくいので、Zstandard圧縮したファイルをドロップすると データを伸張して画面に表示するクライアントアプリケーションを作成しました。

本記事ではZstandardのブラウザ向けのビルド方法と、クライアントアプリケーションへの組み込み方法を確認したいと思います。

emscripten-example-logo

検証環境

  • macOS Sierra 10.12.6
  • Emscripten 1.37.16
  • Zstandard 1.3.1
  • Google Chrome 61.0.3163.91 (64ビット)
  • webpack 3.5.5
  • React 15.6.1

Zstandardをビルド

Emscripten SDK導入済みの前提で説明を進めます。 導入手順については、前回の投稿を参照してください。

ソースコードを入手

Zstandardのリポジトリからソースコードを入手します。

$ wget https://github.com/facebook/zstd/archive/v1.3.1.tar.gz
$ tar xvf v1.3.1.tar.gz
$ mv zstd-1.3.1{,-emscripten}
$ cd zstd-1.3.1-emscripten

Emscriptenでブラウザ向けにビルド

ZstandardのライブラリをEmscriptenでビルドします。

$ # -- (1) Zstandardをビルド
$ emmake make lib-release

$ # -- (2) ファイルの内容を確認
$ file lib/libzstd.dylib
lib/libzstd.dylib: LLVM IR bitcode

$ # -- (3) 拡張子変更
$ mv lib/libzstd.{dylib,bc}

$ # -- (4) エクスポート用のコードを作成
$ echo 'module.exports = Module;' | cat > export_module.js

$ # -- (5) JavaScriptに変換
$ emcc -o zstd.js -O0 --memory-init-file 0 --post-js export_module.js -s EXPORTED_FUNCTIONS="['_ZSTD_isError', '_ZSTD_getFrameContentSize', '_ZSTD_decompress']" lib/libzstd.bc
  • (1) emmakeコマンドでEmscriptenのコンパイラ/リンカを使うように設定してビルドします
  • (2) fileコマンドでビルド結果を確認します。ネイティブビルドの場合は共有ライブラリとなりますが、Emscriptenビルドの場合はLLVMビットコードになります
  • (3) emccコマンドにLLVMビットコードを認識させるため、ファイルの拡張子を.bcに変更します
  • (4) Moduleオブジェクトをエクスポートするためのコードを用意します。理由は後述します
  • (5) LLVMビットコードをJavaScriptに変換します。以下オプションの内容です
    • -o 出力ファイルの指定
    • -O0 最適化レベルの指定。-Oに続く数字が最適化レベル、0の場合は最適化なし 1
    • --memory-init-file 0 メモリ初期化ファイルのサイズ指定、0にすると使用しなくなる
    • --post-js 変換結果の末尾に追加するJavaScriptソースの指定
    • -s EXPORTED_FUNCTIONS エクスポート対象関数名の指定

操作(5)まで完了すると、zstd.jsが生成されます。テキストエディタで中身を確認してみましょう。

'exports'で検索すると、Moduleオブジェクトのエクスポート処理が見つかりますが、実行環境がNode.jsの場合に限られるため、ブラウザ環境ではエクスポートされません。 そのままだと外部からModuleオブジェクトを利用できないので、操作(4)で強制的にエクスポートするコードを用意して、操作(5)の--post-jsオプションでzstd.jsにコードを追加しています。

今回使用したオプションの他にも様々な機能が用意されています。詳細は公式ドキュメントのBuilding Projectsを確認してください。

クライアントアプリケーションへの組み込み

EmscriptenでビルドしたZstandardを、Reactとwebpackで作成したクライアントアプリケーションに組み込みます。 今回はZstandardで圧縮したデータを伸長するため、以下のAPIを使用します。

/* エラー判定 */
ZSTDLIB_API unsigned    ZSTD_isError(size_t code);

/* 伸長後データ長取得 */
ZSTDLIB_API unsigned long long ZSTD_getFrameContentSize(const void *src, size_t srcSize);

/* 伸長処理 */
ZSTDLIB_API size_t ZSTD_decompress( void* dst, size_t dstCapacity,
                              const void* src, size_t compressedSize);

EmscriptenでビルドしたC言語の関数は、cwrapを使うとJavaScriptの関数から直接呼び出せるようになります。 今回使用するAPIについて、cwrapで指定する型の対応を下表にまとめました。

区分 C言語の型 JavaScriptの型 データ種別
入力 unsigned number 数値
入力 unsigned long long number 数値
入力 size_t number 数値
入力 const void* array ポインタ
出力 void* number ポインタ

ポインタを指定する場合は注意が必要です。

JavaScriptからC言語にポインタを渡す場合は原則numberでアドレスを指定します。 cwrapで関数をラップして引数の型にarrayを指定した場合のみ、JavaScriptの配列(ArrayおよびTypedArray)を指定できるようになります。

ただし、arrayを指定する場合はJavaScriptの配列の内容をC言語のスタック領域(一時的なメモリ)にコピーするので、 C言語の関数の処理結果を受け取ることができません。今回は使用しませんが、stringも同様です。

ZSTD_decompress関数に指定するvoid* dstのように、処理結果をメモリに出力する場合は_mallocでC言語のヒープ領域を確保して使用します。 C言語のヒープ領域に出力した伸張データは以下の手順で取得できます。

  1. C言語のヒープ領域のデータをJavaScriptの配列にコピーする
  2. _freeでヒープ領域を解放する

JavaScriptの配列にコピーする前にC言語のヒープ領域を解放すると、処理結果を取得できなくなるので注意しましょう。

データ伸張処理の実装

公式ドキュメントのInteracting with Codeを参考に、Zstandard APIの呼び出しをラップしたコーデッククラスを作成します。 下記サンプルではES6の構文を使用しています。

class ZstdCodec {
    constructor() {
        // cwrapでJavaScriptから呼び出せるようにする
        this.zstd = require('./zstd.js');
        this.ZSTD_isError = this.zstd.cwrap('ZSTD_isError', 'number', ['number']);
        this.ZSTD_getFrameContentSize = this.zstd.cwrap('ZSTD_getFrameContentSize', 'number', ['array', 'number']);
        this.ZSTD_decompress = this.zstd.cwrap('ZSTD_decompress', 'number', ['number', 'number', 'array', 'number']);
    }

    isError(zstd_rc) {
        return this.ZSTD_isError(zstd_rc);
    }

    getFrameContentSize(zstd_bytes) {
        // 伸長後サイズ(単位:バイト)を取得する
        const content_size = this.ZSTD_getFrameContentSize(zstd_bytes, zstd_bytes.length);

        // Emscriptenの割り当てメモリはデフォルトで16MBなので、
        // サンプルアプリケーションでは1MBを超える場合はエラー扱いにする

        // NOTE: ZSTD_getFrameContentSizeのエラー値はnumber型で表現できない。
        // 厳密にエラーチェックする場合は、C言語のレイヤで判定するラッパー関数を用意すること
        const content_size_limit = 1 * 1024 * 1024;
        return content_size <= content_size_limit ? content_size : null;
    }

    decompress(zstd_bytes) {
        // 伸張後のデータ長を取得
        const content_size = this.getFrameContentSize(zstd_bytes);
        if (!content_size) return null;

        // (a) ヒープ領域を確保、伸張データの出力先として使用する
        const heap = this.zstd._malloc(content_size);
        try {
            // (b) 圧縮データを伸長する
            const decompress_rc = this.ZSTD_decompress(heap, content_size, zstd_bytes, zstd_bytes.length);
            if (this.isError(decompress_rc) || decompress_rc != content_size) return null;

            // (c) 伸長データをJavaScriptの配列にコピーする
            return new Uint8Array(this.zstd.HEAPU8.buffer, heap, content_size);
        } finally {
            // (d) 例外発生時に解放漏れしないようにする
            this.zstd._free(heap);
        }
    }
}

decompressメソッドは、引数で受け取ったZstandard圧縮データを伸張します。 下図は処理開始時点のメモリの状態です。

memory-image-01-init-adj

(a)の_mallocで伸張データの配置領域を確保します。Emscriptenでビルドした場合、_mallocはヒープ領域先頭からのオフセット値を返します。

memory-image-02-malloc-adj

(b)のZSTD_decompressで、確保したヒープ領域に圧縮データを伸張します。 そのままだとJavaScriptの配列として使用できないので、(c)でUint8Arrayに変換しています。

ここまでの処理で、伸張したデータをJavaScriptの配列として参照できるようになりました。 (d)でヒープ領域を解放すると、ヒープ領域は1個目の図の状態に戻ります。

webpackの設定

Zstandardのライブラリ内でFSモジュールを使用していますが、今回は不要なのでCan't resolve 'fs' when bundle with webpack #447を参考に無効化します。

module.exports = {
    // ... 長いので省略

    // 追加
    node: {
        fs: "empty"
    }
}

以上で組み込み準備完了です。

クライアントアプリケーションの動作確認

zstdコマンドの準備

先ほどダウンロードしたZstandardのソースコードを使用して、CLIコマンドをビルドしましょう。

$ tar xvf v1.3.1.tar.gz
$ mv zstd-1.3.1{,-native}
$ cd zstd-1.3.1-native
$ make

テストデータの作成

zstd.hファイルを圧縮してテストデータを用意しましょう。

$ # ZstandardのCLIコマンドで圧縮
$ ./zstd lib/zstd.h
lib/zstd.h           : 26.51%   ( 69076 =>  18311 bytes, lib/zstd.h.zst)

$ # ファイルサイズを確認
$ ls -lh zstd.*
-rw-r--r--  1 yoshihitoh  staff    67K  8 21 04:34 zstd.h
-rw-r--r--  1 yoshihitoh  staff    18K  8 21 04:34 zstd.h.zst

$ # 16進数でダンプ、ファイルの内容を確認
$ od -x -N 32 zstd.h
0000000      2a2f    200a    202a    6f43    7970    6972    6867    2074
0000020      6328    2029    3032    3631    702d    6572    6573    746e
$ od -x -N 32 zstd.h.zst
0000000      b528    fd2f    d4a4    010d    bd00    023b    dc9a    50d1
0000020      a035    a888    136a    8ef2    4443    d232    d2bd    1265

動作確認

zstd.h.zstをテキストエディタで開くと文字化けして中身を読めなくなっていますが、 サンプルアプリケーションにドロップすると、中身を表示することができました!

emscripten-example-zstd-h

おまけ

Emscriptenでビルドするときに、 -s WASM=1を指定すると、WebAssembly版をビルドできます。 サンプルアプリケーションの場合、以下の対応で動作させることができました。

  • zstd.jsを更新してリビルド
  • zstd.wasmをpublicフォルダに配置
$ # WebAssembly版をビルド
$ cd <path/to/zstd-1.3.1-emscripten>
$ emcc -o zstd.js -O0 --memory-init-file 0 --post-js export_module.js -s WASM=1 -s EXPORTED_FUNCTIONS="['_ZSTD_isError', '_ZSTD_getFrameContentSize', '_ZSTD_decompress']" lib/libzstd.bc

$ # zstd.jsを更新
$ cp zstd.js <path/to/sample_app/src>

$ # zstd.wasmをHTTPアクセスできる場所にコピー
$ cp zstd.wasm <path/to/public_dir>

$ # リビルド
$ cd <path/to/sample_app>
$ yarn run webpack

$ # HTTPサーバ起動
$ cd public
$ python -m SimpleHTTPServer

おわりに

C言語実装のZstandardライブラリをブラウザ環境で動作させることができました。

今回は特にはまることなくEmscriptenでビルドできましたが、プログラムの内容や ライブラリの構成によっては移植作業が必要になることもあると思います。

そのまま適用できるケースは限られそうですが、うまく活用してブラウザ環境でも 既存の資産や他言語のライブラリを活用していきたいですね!

ソースコード

サンプルアプリケーションのソースコードは下記リポジトリで公開しています。

zstd-emscripten-example

参考


  1. 'exports'を検索できるようにするため最適化なしにしています。-O3で最適化した場合もサンプルアプリケーションが問題なく動作することを確認しています。