少し前に Flutterからネイティブを呼ぶ3つの構成を比べてみた という回を書きました。MethodChannel・Pigeon・dart:ffi+C++ の3つで同じ仕事をさせて、往復時間を測った回です。

そのとき分かったのは「橋の通行料は安いが、渡った先の仕事は変わらない」ということでした。ただ、あの回のFFIはC++で書いていました

その後 Rustをはじめてみた組み込みRustの現在地 でRustを触ってきたので、今回はいよいよ気になっていたことを試します。同じ橋を、C++からRustに架け替えたらどうなるのか

先に結論を言うと、速度はまったく変わりませんでした。0.0089µs 対 0.0091µs で、3回測っても動きません。拍子抜けするくらいです😅

でも、そこから先が面白いことになりました。速さ以外のところで、はっきり差が出たのです。しかもWindowsでは、そもそもビルドが通らないという壁にぶつかりました。

同じ橋を、材質だけ変えてみる

スポンサーリンク

今回作ったのは、Flutterのアプリから4つの仕事をネイティブ側に頼む、という小さな橋です。仕事の中身は前回と揃えました。

名前やることこれで何が見たいか
ping数を1つ渡して、1足して返すだけ橋そのものの通行料
battery_level電池の残量をOSに聞く渡った先の仕事の重さ
device_info端末の情報を文字列で返す持ち主が境界をまたぐとき
parse_config文字列を数値にする。失敗もするエラーが境界をまたぐとき

これを3通りで実装しました。C++で書いたものRustで書いたもの、そしてflutter_rust_bridgeというツールに任せたものです。

ここがこの記事のしかけ
C++版とRust版は、関数の名前まで同じにしてあります(bridge_ping など)。だからFlutter側のコードは1行も変えずに、読み込むファイルを差し替えるだけで実装が入れ替わります。条件を揃えるにはこれがいちばん確実でした。

用語集

今回は耳慣れない言葉が続けて出てくるので、先に並べておきます。

用語平たく言うとなぜそれが効くのか
FFI(外部関数インターフェース)ある言語から、別の言語で書かれた関数を直接呼ぶ仕組み間に通訳を挟まないので速い。そのかわり作法は自分で守る必要がある
C ABI「関数をどう呼ぶか」の取り決め。引数をどこに置くか、戻り値をどこで受け取るか、といった約束事ほぼすべての言語がこの約束に合わせられるので、言語をまたぐときの共通語になっている
dylib / dllコンパイル済みの関数が詰まったファイル。実行時に読み込んで使うアプリ本体と別々にビルドできる。今回はこれを差し替えて実装を入れ替えた
所有権(Rust)「このメモリは誰の持ち物か」を言語が追跡する仕組み。持ち主がいなくなったら自動で片付くRustの安全性の中心。ただし境界の外までは追跡できない(後述)
flutter_rust_bridgeRustの関数を書くと、Dartから呼ぶためのコードを自動生成してくれるツール。以下「frb」面倒な受け渡しを全部やってくれる。そのかわり中で何が起きているかは見えにくくなる
ビルドスクリプト(Rust)ライブラリをビルドする前に走る、小さなプログラム。環境を調べたりコードを生成したりする今回Windowsで詰まった原因がこれ。その場でコンパイルされて実行される
Smart App Control(Windows 11)「信用できないプログラムは実行させない」というWindows標準の防御機能署名のないプログラムを止める。自分でビルドしたものも例外ではない

3つの橋のかたち

3通りの構造を並べるとこうなります。

3つの橋のかたち Flutter(Dart)から、その端末のネイティブコードへ行く道 A Dart のコード lib/ の中 手書き163行 dart:ffi DynamicLibrary C ABI C++ の実装 手書きの extern "C" OS の API B Dart のコード lib/ の中 手書き163行 dart:ffi DynamicLibrary C ABI Rust の実装 手書きの #[no_mangle] OS の API C Dart のコード lib/ の中 生成された束縛 自動生成・36行 C ABI Rust の実装 flutter_rust_bridge が生成 OS の API ★AとBは、Dart側のコードがまったく同じ。読み込むファイルを差し替えるだけで入れ替わる。 ★Cだけは「Dartの束縛」も「解放や詰め替えの手順」も自動生成される。
3通りの橋の構造。AとBはDart側のコードが同じで、読み込むファイルだけが違う

AとBは、Dart側から見ると本当に区別がつきません。dart:ffi にとっては、どちらも「Cの関数が並んだファイル」でしかないからです。

Cだけが違います。Dart側のコードも、値の詰め替えも、メモリの片付けも、全部生成されたコードの中にあります。

測り方:前回と条件を揃える

前回と比べられるように、測り方はそのまま踏襲しました。ウォームアップを300回してから、2000回の平均を2周とって2周目を採用します。

ただ今回は差がとても小さいところを見にいくので、それとは別に200,000回の平均も取りました。本文の表はこちらの数字です。

それと、前回はFlutterのアプリの中で測ったのですが、今回はコマンドラインから走る計測用のプログラムに移しました。画面を触らずに走るので、Windows側でも同じものをそのまま回せます。

$ dart run tool/bench_cli.dart --out results.json
✓ rust を読み込みました(申告: rust)
✓ cpp を読み込みました(申告: cpp)

もちろん、Flutterのアプリの中でも同じことをしています。こちらは実際の画面です。

Flutterアプリの画面。Rust版・C++版・flutter_rust_bridge版の3つの計測結果が並んでいる

★橋の通行料は、材質で変わらなかった

まず、いちばん知りたかったところです。ping(行って戻るだけ)の時間を並べます。

橋を1往復するのにかかる時間 macOS / Apple M4・引数を1つ渡して返すだけの呼び出し・200,000回の平均(横軸は対数) 0.01 µs 0.1 µs 1 µs 10 µs Rust(生のC ABI) 0.0089 µs C++(生のC ABI) 0.0091 µs flutter_rust_bridge(同期) 0.1939 µs flutter_rust_bridge(既定=非同期) 12.83 µs ★上の2本はほぼ同じ長さ。いちばん下だけが約1,442倍で、これは橋の材質ではなく「別のスレッドへ渡して戻る」ぶん。
1往復にかかる時間(macOS・横軸は対数)

上の2本を見てください。Rustが0.0089µs、C++が0.0091µs。3回測ったときのばらつきはこうです。

Rust(生のC ABI)C++(生のC ABI)
1回目0.0092 µs0.0091 µs
2回目0.0086 µs0.0091 µs
3回目0.0092 µs0.0093 µs

ばらつきの幅の中に、両者の差がすっぽり収まっています。差があるとは言えません

考えてみれば当たり前でした。C ABIまで降りてしまえば、そこにあるのは「関数を1回呼ぶ」という機械語の手続きだけです。RustもC++も、そこから先は同じ命令列になります。「Rustにすると速い/遅い」という話は、少なくとも橋の部分には存在しません

ただし1つだけ差が出た
parse_config(文字列を数値にする)は Rustが0.0119µs、C++が0.0188µsで1.6倍の差がつきました。これは橋の差ではなく実装の差です。C++の std::stoi は例外を投げられるようになっていて、地域ごとの数字の書き方も見にいくぶん重い。Rustの parse はそこまでしません。
「Rustが速い」ではなく「この関数の作りが違う」と読むのが正確です。

★flutter_rust_bridgeは「同期かどうか」で桁が3つ変わる

グラフの下2本がfrbです。こちらは話が別でした。

呼び方1往復生のFFIと比べて
Rust(生のC ABI)0.0089 µs
frb・#[frb(sync)] を付けた場合0.1939 µs約22倍
frb・何も付けない場合(既定)12.83 µs約1,442倍

22倍のほうは、型を守って値を詰め替えるぶんの手数料です。それでも0.2µsなので、普通の使い方なら気になりません。

問題は既定のほうです。frbは何も指定しないと「別のスレッドに渡して、終わったら戻ってくる」形になります。重い処理を画面から切り離すための、ありがたい既定です。ただし軽い処理にこれをやると、往復の手間だけが残ります

// 何も付けない → 別スレッド経由。Dart側は Future<int> になる
pub fn ping_async(value: i32) -> i32 { value + 1 }

// sync を付ける → 呼び出し元のスレッドで走る。Dart側は int
#[flutter_rust_bridge::frb(sync)]
pub fn ping_sync(value: i32) -> i32 { value + 1 }

この1行の有無で、1往復が0.19µsと12.83µsに分かれます。桁が3つ違うので、細かい呼び出しを何度もするなら知っておく価値があります。

逆に言えば、1回で終わる重い処理なら既定のままが正解です。12µsは、画面が固まらないことへの対価としては安い。

境界で何を書かされるか①:文字列と所有権

ここからが、速度より面白かったところです。

数値は境界をそのまま渡れます。32ビットの整数は、どの言語でも32ビットの整数だからです。ところが文字列はそうはいきません。どこかがメモリを確保して、使い終わったら誰かが片付けないといけない。

C++だとこうなります。

// C++:確保して返す
char* bridge_device_info() {
  std::string s = device_info_impl();
  char* out = (char*)malloc(s.size() + 1);
  memcpy(out, s.c_str(), s.size() + 1);
  return out;              // ここから先は呼び出し側の持ち物
}

// 相方。呼ぶ側が必ず1回だけ呼ぶ
void bridge_free_string(char* ptr) {
  if (ptr) free(ptr);
}

Rustだとこうです。

// Rust:確保して返す
#[no_mangle]
pub extern "C" fn bridge_device_info() -> *mut c_char {
    let info = platform::device_info();
    match CString::new(info) {
        Ok(s) => s.into_raw(),   // ★所有権をここで手放す
        Err(_) => CString::new("").unwrap().into_raw(),
    }
}

#[no_mangle]
pub extern "C" fn bridge_free_string(ptr: *mut c_char) {
    if ptr.is_null() { return; }
    unsafe { let _ = CString::from_raw(ptr); }  // ★所有権を引き取って捨てる
}

並べてみて気づいたことがあります。

Rustのほうが、やっていることが名前に出ていますinto_raw は「生のポインタにして手放す」、from_raw は「生のポインタから引き取る」。持ち主が移動したことがコードに書いてあるので、後から読む人に伝わります。

でも、ここは正直に書いておきたいところです。

⚠ Rustにしても、解放を忘れたら漏れます
Rustの所有権はコンパイラが追いかけられる範囲でしか働きませんinto_raw() を呼んだ瞬間に、そのメモリはRustの管理から外れます。あとはC++と同じで、人間が覚えておくしかない
「Rustだからメモリ安全」は、境界の内側の話です。境界そのものは、どちらの言語でも同じだけ危ういままでした。

だから呼ぶ側では、確保から解放までを1つのまとまりにしておきます。

String deviceInfo() {
  final p = _deviceInfoRaw();
  if (p == nullptr) return 'unknown';
  try {
    return p.toDartString();     // Dart側にコピー
  } finally {
    _freeString(p);              // ★必ずネイティブに返す
  }
}

そしてfrbだと、この章の内容がまるごと消えます

// Rust側。これだけ
#[flutter_rust_bridge::frb(sync)]
pub fn device_info_sync() -> String {
    platform::device_info()
}
// Dart側(自動生成される)
String deviceInfoSync() => RustLib.instance.api.crateApiBridgeDeviceInfoSync();

解放の関数はどこにもありません。生成されたコードの中に隠れています。「書かなくていい」というより「間違えようがない」のが効きます😊

境界で何を書かされるか②:エラー

もうひとつが失敗の伝え方です。

C ABIには、例外もRustの Result も乗りません。だから生のFFIでは、「戻り値を成否のコードにして、本当の答えは別の引数で受け取る」という古い形に開くことになります。

// 0 = 成功(out_value に答え) / -1 = 入力が変 / -2 = 数値でない / -3 = 範囲外
#[no_mangle]
pub extern "C" fn bridge_parse_config(input: *const c_char, out_value: *mut c_int) -> c_int {
    if input.is_null() || out_value.is_null() { return -1; }
    let text = match unsafe { CStr::from_ptr(input) }.to_str() {
        Ok(t) => t, Err(_) => return -1,
    };
    let n: i32 = match text.trim().parse() { Ok(n) => n, Err(_) => return -2 };
    if !(0..=100).contains(&n) { return -3; }
    unsafe { *out_value = n };
    0
}

Rustらしい Result を書けるのに、境界に出すためにわざわざ番号に潰しているのが分かると思います。C++版もまったく同じ形です。

呼ぶ側もこうなります。

[rust] parse("42")   = (0, 42)
[rust] parse("abc")  = (-2, 0)
[rust] parse("999")  = (-3, 0)
[rust] parse("12ab") = (-2, 0)

いっぽうfrbでは、こう書けます。

pub enum ConfigError {
    NotANumber,
    OutOfRange(i32),
}

#[flutter_rust_bridge::frb(sync)]
pub fn parse_config_sync(input: String) -> Result<i32, ConfigError> {
    let n: i32 = input.trim().parse().map_err(|_| ConfigError::NotANumber)?;
    if !(0..=100).contains(&n) { return Err(ConfigError::OutOfRange(n)); }
    Ok(n)
}

そしてDart側で受け取ると、こうなりました。

[frb] parse("42")   = 42
[frb] parse("abc")  → 例外 ConfigError_NotANumber: ConfigError.notANumber()
[frb] parse("999")  → 例外 ConfigError_OutOfRange: ConfigError.outOfRange(field0: 999)
[frb] parse("12ab") → 例外 ConfigError_NotANumber: ConfigError.notANumber()

ここは素直に感心しました😳 Rustで定義したエラーの種類が、そのままDartの例外として飛んできます。しかも outOfRange(field0: 999) のように、「何が範囲外だったのか」という値まで運ばれています

番号に潰す形だと、「−3=範囲外」までしか伝わりません。エラーメッセージに具体的な値を出したければ、そのための引数をもう1つ増やすことになります。この差は、実際にアプリを書くと効いてきそうです

パニックと例外は境界を越えるのか

気になったので試しました。Rustのパニック(想定外で処理を打ち切ること)が、境界の外まで飛んでいったらどうなるのか。

結論から言うと、受け止めれば大丈夫、受け止めなければプロセスごと落ちます。これはC++の例外でもまったく同じでした。

#[no_mangle]
pub extern "C" fn bridge_battery_level() -> c_int {
    // 境界の外へパニックを出さない
    catch_unwind(platform::battery_level).unwrap_or(-1)
}

ただ、1つ発見がありました。

thread '<unnamed>' (11125641) panicked at src/lib.rs:102:42:
境界の内側で起きたパニック
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
[rust] panic(受け止める) = -1

受け止めて −1 を返しているのに、パニックのメッセージ自体は標準エラーに出ています。握りつぶしたつもりでも痕跡は残るということです。ログを見て「落ちてないのに、なぜかパニックが出ている」と混乱しないよう、覚えておくとよさそうです。

★Windowsで予想が外れた:「渡った先が支配的」はmacOSだけの話だった

ここまでMacの話でした。同じものをWindows機(RTX 3060の載ったデスクトップ・Windows 11 Pro)でも測っています。

Windows機に向かう前、eightはこう見込んでいました。「橋の通行料が同じなら、Windowsでも同じ結論になるだろう」と。

通行料についてはその通りでした。ping はRust 0.0102µs、C++ 0.0090µs。Macとほぼ同じ値です。

外れたのは、前回いちばん気に入っていた結論のほうでした。

「橋の通行料」と「渡った先の仕事」 電池の残量を1回読む処理を分解する。同じ仕事でもOSでAPIの作りが違う macOS(IOKit の電源情報) 橋だけ(ping) 0.0089 µs OSのAPIまで行く 30.44 µs 橋の3,420倍 Windows(GetSystemPowerStatus) 橋だけ(ping) 0.0102 µs OSのAPIまで行く 0.2690 µs 橋の26倍 ★macOSでは橋が1/3,400に沈むので「橋は誤差」と言い切れる。   ところがWindowsでは同じ仕事が0.27µsしかかからず、橋は1/26までしか沈まない。
橋の通行料と、渡った先の仕事の重さ

前回eightは「橋の通行料は安いが、渡った先の仕事は変わらない」と書きました。macOSではその通りで、電池を1回読むのに30.44µsかかります。橋の0.0089µsは1/3,400に沈むので、「橋の差は誤差」と言い切れました。

ところがWindowsでは、同じ「電池を読む」が0.27µsしかかかりません。113倍の違いです。

橋だけ(ping)OSのAPIまで橋は何分の1に沈むか
macOS0.0089 µs30.44 µs1/3,420
Windows0.0102 µs0.27 µs1/26

理由は、同じ「電池の残量」でもOSの取り方がまるで違うからでした。

  • macOSIOPSCopyPowerSourcesInfo で電源の情報をひとかたまり複製し、そこから配列と辞書を作って、キーを引いて値を取り出す
  • WindowsGetSystemPowerStatus に構造体を1つ渡すと、その場で埋めて返ってくる

片方は辞書を作って引き、片方は箱を1つ埋めるだけ。これで113倍になります。

★ここが今回の学び
「橋は誤差だから気にしなくていい」と言い切れるかどうかは、渡った先のAPIの作りで決まります。同じ処理・同じ橋でも、OSが変われば比率が130倍ぶん動きました。
1つの環境で出した「誤差」は、別の環境では誤差ではないかもしれないということです。

★★Windowsでは、そもそもビルドが通らなかった

そしてWindowsでは、もっと根本的なことが起きました。

flutter_rust_bridgeは、ビルドすらできませんでした。

error: failed to run custom build command for `futures-channel v0.3.29`
caused by: os error 4551 = An Application Control policy has blocked this file

止めていたのはSmart App Controlでした。Windows 11に標準で入っている、「信用できないプログラムは実行させない」という防御機能です。Windowsのイベントログにも記録が残っていました。

Code Integrity determined that a process
  (C:\Users\…\.rustup\toolchains\stable-x86_64-pc-windows-msvc\bin\cargo.exe)
attempted to load
  …\build\futures-core-…\build-script-build.exe
that did not meet the Enterprise signing level requirements
or violated code integrity policy

Rustのライブラリの中には、ビルドする前に小さなプログラム(ビルドスクリプト)を走らせるものがあります。そのプログラムは、その場でコンパイルされた署名なしの実行ファイルです。Smart App Controlから見れば「素性の分からないプログラムが動こうとしている」ので、止まりました。

面白いのは、止まったのがそこだけだったことです。

やったこと結果
生のC ABIのRustをビルド(ビルドスクリプトを持つ依存がない)✅ 通った
C++をVisual Studioでビルド✅ 通った
自分でビルドした署名なしのDLLを読み込む✅ 通った
frbのビルド(依存の中にビルドスクリプトがある)止められた

つまり止められているのは「その場でコンパイルした実行ファイルを、実行すること」だけ。DLLを読み込むのは通るし、ビルドスクリプトを持たないライブラリなら通ります。

★依存の数は、サイズやビルド時間だけの話ではなかった
frbが引き連れてくる依存は多く、Windows側で数えるとビルドスクリプトを持つものが28個ありました(macOSで解決すると依存61個のうち16個)。数が多いほど、こういう防御機能に引っかかる面が増えます。
生のC ABI版は依存が1個で、ビルドスクリプトを持つものはゼロ。だから何事もなく通りました。依存の少なさが、そのまま「通りやすさ」になっていたわけです。

ちなみにこのブログでSmart App Controlに止められたのは2度目です。推論エンジン対決 のときも、音声認識のライブラリが同じように弾かれました。自分でビルドしたものを動かす開発では、わりと出会う壁なのかもしれません。

なお、これは設定を切れば回避できるものです。ただMicrosoftの説明では、一度切ると原則としてWindowsのリセットか再インストールをしないと戻せません(きれいな状態でしか有効にできない仕組みのため)。開発用の機械とはいえ、この記事のために切るのは割に合わないと判断して、今回は「止められた」という事実をそのまま結果として記録しました

楽をした分は、どこかに乗る

3つの構成を、規模の面から並べてみます。

楽をした分は、どこかに乗る 同じ4つの仕事を実装したときの規模(macOS・release ビルド) C++ 生FFI Rust 生FFI frb 呼ぶ側の手書き(行) 163 163 0 依存クレート(個) 0 1 61 ライブラリ(KB) 36.6 378.5 843.3 クリーンビルド(秒) 0.28 0.29 7.72 ★呼ぶ側の手書きを163行から0行にする代わりに、依存が61個・サイズが23倍・ビルドが27倍になる。
同じ4つの仕事を実装したときの規模
呼ぶ側の手書き橋の実装依存ライブラリクリーンビルド
C++(生のC ABI)163行185行36.6 KB0.28秒
Rust(生のC ABI)163行(同じもの)118行1個378.5 KB0.29秒
frb0行(36行が自動生成)55行61個843.3 KB7.72秒

これは 組み込みRustの現在地 で見たことの繰り返しでした。あのときは「短く書けるほど、焼かれるサイズは大きくなる」と書きました。35行のプログラムが134バイト、14行のプログラムが2,636バイトという話です。

今回もまったく同じ形をしています。呼ぶ側の手書きを163行から0行にする代わりに、依存が61個、サイズが23倍、ビルドが27倍になりました。そして今回はそこに、「Windowsでビルドが通らない」というもう1つの代償が乗りました。

誤解のないように書いておくと、これはfrbが悪いという話ではありません。163行の手書きコードは、書けば必ずどこかを間違えます。解放を1か所忘れれば漏れるし、エラーコードの取り違えは型では防げません。その163行を消せることには、はっきりした価値があります

つまずき集

今回もそれなりに転びました😅

★① ターミナルでは通るのに、Xcode経由のビルドだけが落ちる

いちばん時間を取られたのがこれです。cargo build は普通に通るのに、flutter build macos だけが失敗します。

SEVERE: External Command: rustup "run" "stable" "cargo" "build" ...
SEVERE: Returned Exit Code: 101
SEVERE: error: could not execute process `rustc -vV` (never executed)

「rustupは動いているのに、その先のrustcが見つからない」という妙なエラーです。原因はRustの入れ方にありました。

  • Homebrewで rustup を入れると、パスが通るのは rustup だけcargorustc の入口は別のフォルダにあり、そこは既定でパスに入りません
  • 自分のターミナルでは、そのフォルダを自分でパスに足しているので気づきません。Xcodeから走るビルドにはその設定が届かない
  • rustup run stable cargo は、子プロセスのパスに ~/.cargo/bin を足します。ところがHomebrew版はそこに何も置かないので、cargoが rustc を見つけられない

1つずつ戻して確かめました。

~/.cargo/bin の中身結果
could not execute process rustc -vV
rustup だけ置く❌ 同じエラー
rustc を足すビルドが通った

分かれ目は rustc の入口が ~/.cargo/bin にあるかどうかでした。正しい直し方は、公式サイト(rustup.rs)のインストーラで入れ直すことです。そちらなら ~/.cargo/bin に一式が置かれます。

★② Windows向けの型チェックは、Macに居るうちにできる

これは知っておくと得をします。

$ rustup target add x86_64-pc-windows-msvc
$ cargo check --target x86_64-pc-windows-msvc
    Checking rust_bridge v0.1.0
    Finished `dev` profile

実行はできませんが、Windows専用のAPIの使い方が間違っていないかは、Macの上で確かめられます。今回これのおかげで、Windows機に持っていってから直す往復を1回減らせました。

⚠ ただしfrb側はこの方法が使えません。ビルドスクリプトがWindows用のツールを要求するので、チェックの段階で止まります。

③ 同じ構造体の同じ項目なのに、書き方が変わる

WindowsでCPUの種類を調べる SYSTEM_INFO という構造体があります。同じ項目を読むのに、こう変わりました。

// C++
info.wProcessorArchitecture

// Rust(windows-sys)
info.Anonymous.Anonymous.wProcessorArchitecture

C++には「名前のない共用体」という書き方があって、中身をそのまま外から触れます。Rustにはそれがないので、名前を付けて階層をたどることになります。動くものは同じなのに、見た目だけが変わる例です。

④ frbの導入は、既存の main.dart を書き換える

導入コマンドを走らせたら、それまで書いていた lib/main.dart が丸ごとコメントアウトされて、デモ用のコードに差し替えられていました😳

元に戻せるようコメントとして残してはくれるのですが、驚きます。実行する前に lib/ を退避しておくのが安全です。

⑤ エラーを返す関数を書いた瞬間、別のツールを要求される

Result を返す関数を1つ書いたら、コード生成がここで止まりました。

Error: MissingDep: Please add freezed to your dev_dependencies. (version >=1.0.0)

エラーの種類をDart側で表現するために、別のツールが要ります。エラーを返す関数を書くまでは要らないので、途中で急に出てきて戸惑いました。

結局どれを選ぶか

測り終えてみると、選び方は「速さ」ではありませんでした。

こういうとき選ぶもの理由
すでに動いているC/C++の資産を使いたい生のC ABI(C++)書き直す理由がない。橋の速度は同じ
ネイティブ側を新しく書く。関数が数個生のC ABI(Rust)依存1個・ビルド0.3秒。持ち主の移動がコードに出るぶん読みやすい
ネイティブ側を新しく書く。やりとりする型が多いfrb163行の手書きが消える。文字列とエラーの扱いを間違えようがなくなる
細かい呼び出しを1フレームに何度もする生のC ABI、またはfrbに sync既定のfrbは1往復12.8µs。60回呼べば0.77ミリ秒
重い処理を1回だけ呼ぶfrbの既定のまま別スレッドに逃がしてくれるので画面が止まらない
Windowsに配る予定がある生のC ABIを検討依存が多いと、防御機能でビルドが止まることがある

そして、どれを選んでも変わらないことが1つあります。渡った先の仕事は、橋を替えても軽くなりません。macOSで電池を読むのに30µsかかるなら、どの橋を渡っても30µsです。速くしたいなら、橋ではなく回数を減らすほうを考えるべきでした。

まとめ

  • ★★橋の材質は速度に関係なかった。生のC ABIならRust 0.0089µs・C++ 0.0091µsで、3回測っても差が出ない。C ABIまで降りれば、どちらも「関数を1回呼ぶ」だけになる
  • flutter_rust_bridgeは sync の1行で桁が3つ変わる(0.194µs 対 12.83µs=1,442倍)。既定は別スレッド経由なので、軽い呼び出しを何度もするなら要注意
  • parse_configだけRustが1.6倍速い。これは橋ではなく実装の差で、std::stoistr::parse より重いというだけの話
  • ★★「渡った先が支配的」はmacOSだけの話だった。電池を1回読むのにmacOSは30.44µs、Windowsは0.27µsで113倍違う。橋が沈む比率は1/3,420と1/26で、結論の言い切り方が変わる
  • ★★WindowsではfrbがSmart App Controlに止められ、ビルドできなかった。止まるのは「その場でコンパイルした実行ファイルを走らせること」だけで、署名なしDLLの読み込みは通る
  • frbの依存にはビルドスクリプトを持つものが28個あった(Windows側で解決した場合)。依存の数は、サイズやビルド時間だけでなく「そもそも通るか」に効いた
  • Rustにしても、境界を越えたメモリは自分で片付けるinto_raw() の時点で所有権はRustの手を離れる。「Rustだから安全」は境界の内側の話
  • frbならRustのエラーがDartの例外として飛んでくるoutOfRange(field0: 999) のように値まで運ばれるのが、番号に潰す形との大きな違い
  • パニックを受け止めても、メッセージは標準エラーに出る。落ちていないのにログにパニックが残る
  • Homebrewのrustupだと flutter build macos だけが落ちる~/.cargo/binrustc の入口が無いため。公式のインストーラで入れ直すのが正解
  • Windows向けの型チェックはMacでできるcargo check --target x86_64-pc-windows-msvc)。持っていってから直す往復を減らせる

始める前は「Rustにしたら速くなるのか、遅くなるのか」を知りたいと思っていました。測ってみたらまったく同じで、その問いが最初から的外れだったと分かりました。橋の材質で変わるのは速さではなく、境界で何を書かされるかと、そのビルドがちゃんと通るかのほうでした。

特に最後のひとつは予想していませんでした。「依存が多い」は今まで、サイズとビルド時間の話だと思っていたのです。それが「Windowsでは動かせない」という形で返ってくるとは思いませんでした。数えられるものが、数えられない形で効いてくるのが面白いところです😊

今回作ったものは、Dart側のコードを共通にしたまま3つの実装を差し替えられる形にしてあります。同じ仕組みで手元のライブラリを比べれば、同じ表が作れるはずです。

それでは、今回はここまで。最後までありがとうございました😊