10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

長い前置き

のんたんと申します。趣味開発ではモデリングツールを制作しています。

知っていた方は「まだやってるのか...」とお思いになるかもしれません。知らなかった方はじき完成するツールのリリースに期待していてくれると嬉しいです:grinning:

ところで、この記事のタグはご覧になりましたか?

  • rust
  • Emscripten
  • WebAssembly
  • asm.js
  • TypeScript

とてもバズワード的キャッチーな響きをしていますね。

僕はモデリングツールを実装するにあたってこれらの技術を利用しています。もともとはネイティブアプリとして開発していたのですが、Webアプリ開発の方が圧倒的に楽1であることに気付かされこっちにお引越ししてきました。

世の中には、「RustとEmscriptenで(HelloWorld|fib|マンデルブロ集合|移植)やってみた!」といった記事がゴロゴロ落ちていて、またとても参考になりますが、ゴリゴリ書いたという話はあまり無いように思います。様々なWeb技術を連携させつつゴリゴリ書いた2経験を元に知見の共有ができれば良いかなと思ってこの記事を書きました。特にEEIC向けというわけではありませんが、EEICの皆様も是非。

また、僕自身はこれらについてとても理解が深い人間ではありません。誤りを見つけた場合や「こっちのほうが良いよ!」という場合はツッコミを入れてもらえると非常に(自分の)ためになります。よろしくお願いします。

概要

タグの通り、rustEmscriptenWebAssemblyasm.jsTypeScriptについて、特に順序を気にすること無く、落とし穴、知見、awesome的何か等を徒然なるままにコード片を交えつつ書きます。ハードな話はありません。逆引き辞典的になれば良いなと思っています。非常に雑多です。

長くなりそうなので2回に分けて書こうと思います。WebGL絡みの話を次回に書こうと思います。

前提知識

The bookrustnomiconを読んでおくと色々はかどります。

セットアップ

公式資料を元にインストールします。基本的にこれに従ってインストールすれば良いのですが注意点があるのでそれも合わせて記述します。

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設定例

次のようなプログラムがあるとします。

src/main.rs
fn main() {
}

#[no_mangle]
pub fn hoge() -> i32 {
  1
}

Emscriptenを用いてビルドするためemccに対してコンパイルオプションを渡す必要がありますが、設定はcargo/.configに次のように書くと良いです。オプションについては基本的にEmscripten 公式が参考になりますが、Undocumentedな部分が大きいので該当するソースコードを生で読むと良いでしょう。

cargo/.config
[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",
]
exported_functions.json
[
    "_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_derivebincodeです。
bincodeはSerializeを実装していればVec<u8>に、逆にDeserializeを実装していれば`&[u8]`から元に復元できます。

as_ptrでポインタを取得してしまえばコールバックを使ってやり取りできるようになります。

終わりに

長くなってきたので、その1はこのへんにしようと思います。

明日は@potato_omomさんの「趣味の話」です。ニューラルネットワークの話でしょうか?気になりますね。

  1. 良いコードが書けるとは言っていない

  2. ゴリゴリと言ってもまだ20KLOCくらいしかなかったりする

10
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?