長い前置き
のんたんと申します。趣味開発ではモデリングツールを制作しています。
知っていた方は「まだやってるのか...」とお思いになるかもしれません。知らなかった方はじき完成するツールのリリースに期待していてくれると嬉しいです
ところで、この記事のタグはご覧になりましたか?
rust
Emscripten
WebAssembly
asm.js
TypeScript
とてもバズワード的キャッチーな響きをしていますね。
僕はモデリングツールを実装するにあたってこれらの技術を利用しています。もともとはネイティブアプリとして開発していたのですが、Webアプリ開発の方が圧倒的に楽1であることに気付かされこっちにお引越ししてきました。
世の中には、「RustとEmscriptenで(HelloWorld|fib|マンデルブロ集合|移植)やってみた!」といった記事がゴロゴロ落ちていて、またとても参考になりますが、ゴリゴリ書いたという話はあまり無いように思います。様々なWeb技術を連携させつつゴリゴリ書いた2経験を元に知見の共有ができれば良いかなと思ってこの記事を書きました。特にEEIC向けというわけではありませんが、EEICの皆様も是非。
また、僕自身はこれらについてとても理解が深い人間ではありません。誤りを見つけた場合や「こっちのほうが良いよ!」という場合はツッコミを入れてもらえると非常に(自分の)ためになります。よろしくお願いします。
概要
タグの通り、rust
、Emscripten
、WebAssembly
、asm.js
、TypeScript
について、特に順序を気にすること無く、落とし穴、知見、awesome的何か等を徒然なるままにコード片を交えつつ書きます。ハードな話はありません。逆引き辞典的になれば良いなと思っています。非常に雑多です。
長くなりそうなので2回に分けて書こうと思います。WebGL絡みの話を次回に書こうと思います。
前提知識
The bookとrustnomiconを読んでおくと色々はかどります。
セットアップ
公式資料を元にインストールします。基本的にこれに従ってインストールすれば良いのですが注意点があるのでそれも合わせて記述します。
rustup
$ curl -L https://sh.rustup.rs | sh -s -- -y --default-toolchain=stable
$ source ~/.cargo/env
$ rustup target add asmjs-unknown-emscripten
$ rustup target add wasm32-unknown-emscripten
公式ではnightlyツールチェインでやっていますが、stableでも問題がなさそうです。僕はstableで利用しています。
emsdk
$ curl -O https://s3.amazonaws.com/mozilla-games/emscripten/releases/emsdk-portable.tar.gz
$ tar -xzf emsdk-portable.tar.gz
$ source emsdk_portable/emsdk_env.sh
$ emsdk update
$ emsdk install clang-incoming-64bit
$ emsdk install emscripten-incoming-64bit
$ emsdk install binaryen-master-64bit
$ emsdk activate clang-incoming-64bit
$ emsdk activate emscripten-incoming-64bit
$ emsdk activate binaryen-master-64bit
昔はincoming版を利用しなければビルドできないとされていたため、これを利用しています。ソースからビルドする必要があるため非常にビルドに時間がかかります。現在はバイナリパッケージでも利用可能になっている可能性があります。
全て一括でインストールできるsdk-*-64bit
というパッケージがありますが、これを使うとemsdkの用意したnodeがインストールされ、emsdk環境を起動したときにemsdkのnodeにパスが通るようになります。アプリ全体のビルドに独自でnodeを使う(webpack, yarn, gulp等を利用する)場合には複数バージョンのnodeが存在することが都合が悪いのでこのようにしました。node 8系を利用していますが問題に遭遇したことはありません。
Cargo設定例
次のようなプログラムがあるとします。
fn main() {
}
#[no_mangle]
pub fn hoge() -> i32 {
1
}
Emscriptenを用いてビルドするためemccに対してコンパイルオプションを渡す必要がありますが、設定はcargo/.config
に次のように書くと良いです。オプションについては基本的にEmscripten 公式が参考になりますが、Undocumentedな部分が大きいので該当するソースコードを生で読むと良いでしょう。
[target.asmjs-unknown-emscripten]
rustflags = [
"-Clink-args= -O3 -s EXPORTED_FUNCTIONS=@exported_functions.json -s RESERVED_FUNCTION_POINTERS=1000 -s TOTAL_MEMORY=1073741824",
]
[target.wasm32-unknown-emscripten]
rustflags = [
"-Clink-args= -O3 -s EXPORTED_FUNCTIONS=@exported_functions.json -s RESERVED_FUNCTION_POINTERS=1000 -s TOTAL_MEMORY=16777216 -s ALLOW_MEMORY_GROWTH=1",
]
[
"_hoge"
]
link-args
-Clink-args= -O3
でEmscripten側の最適化を有効にしています 参考
EXPORTED_FUNCTIONS
Emscripten FAQにあるように公開する関数はEXPORTED_FUNCTIONS
としてオプションから渡してやる必要があります。
emcc --help
によれば
Options that are modified or new in *emcc* are listed below:
...skipping...
-s DEAD_FUNCTIONS=@/path/to/file
-s DEAD_FUNCTIONS=@/path/to/file
Note: * In this case the file might contain a JSON-formatted list
of
functions: "["_func1", "func2"]".
* The specified file path must be absolute, not relative.
とのことなので、リストをJSONとして別ファイルにまとめておいてcurlの様に@
を用いてemccに渡しています。The specified file path must be absolute
とありますが、Cargo.toml
起点の相対パスを渡せています(?)cargo/.config
起点ではないことに注意。
RESERVED_FUNCTION_POINTERS
コールバック関数の登録可能最大数を指定します 参考
コールバック関数は開放することができるので、適当に大きめで切りが良い数にしています。
TOTAL_MEMORY
使用する全メモリサイズを指定します。
TypeScript
Emscripten型定義
@types/emscriptenが利用可能です。
グローバルな空間にdeclare namespace Module
されています。
webpackでimportする方式は厳しいかもしれません。それは良いとしても、
HEAP
と言った無い(場合がある)メンバが登録されていて実行時エラーになったり、JSのstringとWebAssembly側でのバイナリ表現との間を変換する関数(stringToUTF8
, UTF8ToString
)が登録されていなかったり、と若干の不満点があります。
WebGL2型定義
@types/webgl2が利用可能です。
こちらは特に不満点なし。
TypeScriptからEmscripten呼び出し
interface FunctionsType {
createContext: (contextConfig: number) => number;
hoge: (context: number) => number;
piyo: (context: number, objectHandle: number, objSource: number) => number;
...
}
let functions: FunctionsType = null;
export function isFunctionsInitialized() {
return functions !== null;
}
export function initializeFunctions() {
functions = {
createContext: Module.cwrap('create_context', 'number', ['number']),
hoge: Module.cwrap('hoge', 'number', ['number']),
piyo: Module.cwrap('piyo', 'number', ['number', 'number', 'number']),
...
};
}
バインド用のコードは型定義と実際にバインドするコードをあわせてこのような感じ。この例ではobjSource
は文字列の引数ですが、cwrapの引数指定に'string'
を用いていません。これを使うとスタック領域に文字列がコピーされるため、大きな文字列を渡すと簡単にスタックが溢れるからです。そこで次のようなユーティリティ関数を用意しています。
export function withStringPtr<T>(s: string, proc: (stringPtr: number) => T): T {
const dstLength: number = (<any> Module).lengthBytesUTF8(s) + 1;
const dstPointer = Module._malloc(dstLength);
const dstView = new Uint8Array(Module.HEAPU8.buffer, dstPointer, dstLength);
(<any> Module).stringToUTF8(s, dstPointer, dstLength);
let result: T;
try {
dstView[dstLength - 1] = 0;
result = proc(dstPointer);
} catch (e) {
throw e; // Re-throw
} finally {
Module._free(dstPointer);
}
return result;
}
この関数はModule._malloc
を用いてヒープにメモリを確保しUTF8でコピーした後、そのポインタを引数に渡されたproc
を呼び出します。出るときには必ず開放するようにしています。こいつは便利だと思ってます。
EmscriptenからTypeScript呼び出し
Module.Runtime.addFunctionでJavascriptの関数をEmscripten側に登録し関数ポインタをえることができます。
次のようにして一括登録しています。
const callbacks = {
foo: (ctx: number) => {
...
},
bar: (ctx: number) => {
...
},
baz: (ctx: number) => {
...
},
...
};
let callbackPtrs: { [key: string]: number } = null;
export function isCallbacksInitialized() {
return callbackPtrs !== null;
}
export function initializeCallbacks() {
callbackPtrs = mapValues(callbacks, (value) => Module.Runtime.addFunction(value));
}
そして、(自分でRustで実装した)コンテキストを作成するときに、次のようにしてごっそりと渡します。
const pointerSize = 4;
const contextConfigData = [
0,
callbackPtrs.foo,
callbackPtrs.bar,
callbackPtrs.baz,
];
const numElements = contextConfigData.length;
const contextConfigPtr = Module._malloc(pointerSize * numElements);
for (let i = 0; i < numElements; i++) {
Module.setValue(contextConfigPtr + pointerSize * i, contextConfigData[i], "*");
}
this._contextPtr = functions.createContext(contextConfigPtr);
Rust側ではContextConfigは次のようになっています。
use libc::c_void;
#[repr(C)]
#[derive(Clone)]
pub struct ContextConfig {
ctx: *mut c_void,
foo: extern fn (*mut c_void) -> i32,
bar: extern fn (*mut c_void) -> i32,
baz: extern fn (*mut c_void) -> i32,
}
#[no_mangle]
pub unsafe fn create_context(config_ptr: *const ContextConfig) {
let config = &*config_ptr;
config.foo(config.ctx);
...
}
これで、コールバックの設定をまとめて行うことができました。
また、文字列をRustからTypeScriptに返す際にも、コールバックを用いて実現しています。
export function withStringWriteFuncPtr(proc: (writeFuncPtr: number) => any): string {
let result = '';
const writeFunc = (pointer: number, length: number) => {
const str = (<any> Module).UTF8ToString(pointer);
result += str;
};
const writeFuncPtr = Module.Runtime.addFunction(writeFunc);
try {
proc(writeFuncPtr);
} catch (e) {
throw e; // Re-throw
} finally {
Module.Runtime.removeFunction(writeFuncPtr);
}
return result;
}
use std::io;
use std::ffi::CString;
use libc::{ c_char, c_void };
pub type WriteFunc = extern fn (*const c_char, usize) -> c_void;
pub struct CWrite {
write_func: WriteFunc,
}
impl CWrite {
pub fn new(write_func: WriteFunc) -> CWrite {
CWrite { write_func: write_func }
}
}
impl io::Write for CWrite {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match CString::new(buf) {
Ok(cstr) => {
let ptr = cstr.as_ptr() as *const c_char;
let len = buf.len() as usize;
unsafe {
(self.write_func)(ptr, len);
}
Ok(len)
},
Err(_) => {
Err(io::Error::from(io::ErrorKind::InvalidData))
},
}
}
fn flush(&mut self) -> io::Result<()> {
// Do nothing
Ok(())
}
}
#[no_mangle]
pub fn return_string(write_func: WriteFunc) {
let writer = CWrite::new(write_func);
...
}
このようにすることでWrite
トレイトを用いて文字列を返すことができるようになって便利です。文字列を+=
で連結するこのに不満があるならばロープ的な仕組みに改造すると良いと思います。
これでJS側とEmscripten側で文字列を自由にやり取りできるようになったのでserde_jsonとかと組み合わせて、情報交換がはかどります。
メモリの制約
ここまで、ちまちましたノウハウについて話をしてきましたが少し趣向を変えた話をします。こっからはポエティックです。
Emscripten、WebAssembly、asm.jsのメモリに関する制約には次のようなものがあります
- WebAssembly, asm.js上で実行されるコードはEmsciptenによって用意されたメモリ領域以外触ることができない
- Emscriptenでasm.jsのコードを吐き出した場合("almost asm"を回避する場合)最初に確保したメモリ領域を広げることができない
というわけで、頻繁に使う高速に書き換えたい情報はEmscriptenのメモリ領域に残し、しばらく使わない情報はEmscriptenのメモリ領域から外に出しておくのがベターかと思っています。
そんなときに便利なのがserde+serde_deriveとbincodeです。
bincodeはSerialize
を実装していればVec<u8>
に、逆にDeserialize
を実装していれば`&[u8]`から元に復元できます。
as_ptr
でポインタを取得してしまえばコールバックを使ってやり取りできるようになります。
終わりに
長くなってきたので、その1はこのへんにしようと思います。
明日は@potato_omomさんの「趣味の話」です。ニューラルネットワークの話でしょうか?気になりますね。