Rustは本当に早いのか。Flutterにおける速度をMarkdownParserで比較してみた。
まとめ
- Rust製parserは、Dart製parserの2倍程度の速度が期待できる
- Rustの呼び出しコストとして、1.2ms程度のボトルネックが存在する
- n回のRust呼び出しは、場合によってはパフォーマンス低下につながる
- RustとFlutterのアダプタは自動生成できるので実装は非常にカンタン
- Rustコンパイル用の設定は1回すればずっと使えるが、プラットフォームごとにしなければいけないので結構面倒。
どんな人向けの記事?
- FlutterからRustの関数を呼び出す方法を知りたい方
- Rustの関数をわざわざ呼び出すべきか、判断基準のヒントを得たい方
- Dart vs Rustのスピードテストの実測値をみたい方
Rustは本当に早いのか。Flutterで実際に呼び出してDart実装と比較する
つい先日、Stack Overflow Developer Survey 2023が公開されました。
90,000人を超える開発者が解答した、世界最大級の開発コミュニティによる調査です。
この調査では、Rustは最も賞賛されている言語であり、使用している開発者の80%以上が来年も使用したいと回答しています。
Rustには言語仕様やパフォーマンスの高さなど、様々な賞賛ポイントがありますが、特筆すべき1つのポイントとして、非常に多くのプラットフォームで利用可能なコンパイラを公開していることが挙げられます。
それらの機能を活かすサードパーティ製のライブラリも充実しており、近年ではFlutterでも比較的簡単にRust製の機能を活かせるようになりました。
とはいえ、素直にDartで実装するよりも、実際にRustで実装するのはパフォーマンス上のメリットとなるのでしょうか?
この疑問には、実際に実装して計測してみないと答えることができません。
したがって、今回はRustが本当に早いのか。実際にFlutterからの呼び出しを含めて測定し、実用に耐えうるかを検証していきます。
今回選んだ題材はMarkdown parser
もちろん処理が重ければ重いほどRust有利かと思いますが、ある程度カジュアルな実装にもRustを利用できるのかを検証したかったため、今回の題材はMarkdown Parserとしました。
実装がカンタンなことも大きな理由です。
Dart, RustそれぞれのライブラリからmarkdownToHtml()
のような関数を呼び出すだけでテスト可能でラクなのが良き。
Dart製のmarkdown parserを選ぶ
FlutterでMarkdownを表示する際に最も有名なpackageはflutter_markdownだと思われます。このpackageの内部で呼び出されているmarkdownの内部実装がdartのため、Rust製のparserと比較するのにちょうど良さそうです。
flutter pub add markdown
今回はこのmarkdownをdart側の選手としてエントリーしましょう。
Rust製markdown parserを選ぶ
pulldown-cmarkが一番利用されているようなので、こちらをつかいます。
Rust製もたくさんありますが、FlutterからRustを呼び出して実行する
正直、これがなければこの検証をやろうとすら思いませんでした。ってくらい、便利なpackageです。
Flutter側とRust側の両方でセットアップ必要なので、以下に示します。
flutter_rust_bridge_codegenのインストール
cargo install flutter_rust_bridge_codegen
// macの場合以下もインストールしておく
cargo-xcode
以下のように正常なインストールを確認しておきましょう。
バージョンが返ってくればOK
flutter_rust_bridge_codegen --version
LLVMのインストール
ffigen
がLLVMに依存しているため、以下の要領でインストールします。
// Windowsの場合
winget install -e --id LLVM.LLVM
// Macの場合
brew install llvm
Rustプロジェクトの作成
Flutterで空のプロジェクトを準備した状態で、./rust
にrustプロジェクトを準備します。
// 以下のようにコマンドでやっても、VSCodeなどを使ってGUIでやってもかまいません
flutter create app
// アプリのホームディレクトリに移動
cd app
// CargoでRustのプロジェクトを作成
cargo new --lib native
さらに、rust/cargo.toml
に以下のように追記します
[lib]
crate-type = ["lib", "cdylib", "staticlib"]
[dependencies]
flutter_rust_bridge = "1"
pulldown-cmark = "0.9.3"
Rust側のコードを書く
今回は、native/src/api.rs
にRustのコードを書いていきます。
のちほど、Rust製のHello WorldをFlutter側から呼び出す実装を紹介します。
RustのHello WorldとMarkdown変換用コードを書く
native/src/api.rs
に以下のコードを書きます。
pub fn helloWorld() -> String {
String::from("Hello from Rust! 🦀")
}
// markdown変換用のfunction
pub fn markdown_to_html(markdown : String) -> String {
let parser = pulldown_cmark::Parser::new(&markdown);
let mut html_output = String::new();
pulldown_cmark::html::push_html(&mut html_output, parser);
html_output
}
native/src/lib.rs
の一番上に以下のように追記します。
mod api;
Flutter側の準備
flutter pub add --dev ffigen
flutter pub add ffi
flutter pub add flutter_rust_bridge
flutter pub add -d build_runner
flutter pub add -d freezed
flutter pub add freezed_annotation
flutter pub add meta
現在、ffigen < 9.0.0
を求められたので、私はffigen: ^8.0.0
のようにして対応しました。
FlutterとRustの接続用コードを自動生成する
そして、以下実行してDartとRustの接続用コードを自動生成します。
flutter_rust_bridge_codegen --rust-input native/src/api.rs --dart-output lib/bridge_generated.dart --dart-decl-output lib/bridge_definitions.dart
コンソール上で以下のように表示されればOK
2023/07/16 09:34:58 [INFO] Success!
2023/07/16 09:34:58 [INFO] Now go and use it :)
生成されたファイルは以下の通りです
-
rust/src
bridge_generated.io.rs
bridge_generated.ts
-
/lib
bridge_definitions.dart
bridge_generated.dart
プラットフォームごとに設定を行う
Rustで生成されるライブラリはプラットフォームごとに異なるため、プラットフォームごとに対応が必要です。
Windowsの場合
マニュアルにしたがってwindows/runner/CmakeLists.txt
に以下のように追加します。
# Generated plugin build rules, which manage building the plugins and adding
# them to the application. include(flutter/generated_plugins.cmake)
# ここを追加
include(./rust.cmake)
# === Installation ===
# Support files are copied into place next to the executable, so that it can
すると、flutter run
が走るごとにRustのコードがコンパイルされます。
Flutterとのアダプタを自動生成するflutter_rust_bridge_codegen
は別途Rustのコードを更新するたびに実行が必要ですので、お忘れなく。
Androidの場合
マニュアルにしたがって、android/app/build.gradle
の一番下に以下のコードを追加します。
[
Debug: null,
Profile: '--release',
Release: '--release'
].each {
def taskPostfix = it.key
def profileMode = it.value
tasks.whenTaskAdded { task ->
if (task.name == "javaPreCompile$taskPostfix") {
task.dependsOn "cargoBuild$taskPostfix"
}
}
tasks.register("cargoBuild$taskPostfix", Exec) {
workingDir "../../native"
environment ANDROID_NDK_HOME: "$ANDROID_NDK"
commandLine 'cargo', 'ndk',
// the 2 ABIs below are used by real Android devices
'-t', 'armeabi-v7a',
'-t', 'arm64-v8a',
// the below 2 ABIs are usually used for Android simulators,
// add or remove these ABIs as needed.
'-t', 'x86',
'-t', 'x86_64',
'-o', '../android/app/src/main/jniLibs', 'build'
if (profileMode != null) {
args profileMode
}
}
}
マニュアルに大きく書いてあるコードは現在動かないので、マニュアル下部のリンクを参照してコピペしました。
さらに、NDKのインストールおよび設定の確認もマニュアルを参照しながら行いましょう。
Android Studio > SDK Manager > SDK Tools > NDK (Side by side)
上記にてNDKのインストールが終わったら、cargo install cargo-ndk
を行います。
最後にインストールしたandroid-ndkへのパスをandroid\gradle.properties
に設定する必要があります
// Windowsの場合、以下のようなパスになります。
ANDROID_NDK=C:/Users/{userName}/AppData/Local/Android/Sdk/ndk/25.2.9519653
Mac/iOSの場合
以下の公式マニュアルを参考にセットアップします。
日本語で非常に丁寧に記事を書いてくれている方もいらっしゃるので、そちらも非常に参考になります。同じマニュアルを参考にしているようなので、そのまま実装すれば齟齬なく動くはずです。コンパイルしたRust製ライブラリをプラットフォームごとに切り替える
上記手順によって、Rust製ライブラリがビルドされますが、プラットフォームごとに呼び出すべきファイルが異なります。
したがって、下記ファイルにて動的に切り替えられるよう設定します。
lib/rust.dart
import 'dart:ffi';
import 'dart:io' as io;
// flutter_rust_bridge_codegenで生成された、bridge_generated.dartをimport
import 'package:rust_flutter_markdown_parser/bridge_generated.dart';
const _base = 'native';
final _dylib = io.Platform.isWindows ? '$_base.dll' : 'lib$_base.so';
// RustのAPIを呼び出すためのインスタンスを生成
// RustImplはbridge_generated.dartで生成されたクラス
final api = RustImpl(io.Platform.isIOS || io.Platform.isMacOS
? DynamicLibrary.executable()
: DynamicLibrary.open(_dylib));
FlutterからRustの関数を呼び出す
rust.dart
で書いたapiを呼び出すだけでOK。
型なども自動でついてくれるので、オートコンプリートも効いてくれて非常に体験が良いです。
注意するべきなのは、呼び出したapiはFuture
になることと、Rust側でfunctionに引数をつけた場合、named parameter
になることです。まぁこのへんもIDEの指示に従っておけばなんなく実装できるはずです。
import 'package:rust_flutter_markdown_parser/rust.dart';
FutureBuilder(
future: api.helloWorld(), // ここでRustのAPIを呼び出す
builder: (context, AsyncSnapshot<String> data) {
if (data.hasData) {
return Text(data.data!); // The string to display
}
return const Center(
child: CircularProgressIndicator(),
);
},
),
無事呼び出せました!
DartとRustの速度をMarkdownの処理で比較する
長かったですが、ようやく本題です!
今回は単純に実行時間をもってパフォーマンスを測定していきます。
また、markdown textを2つ用意しました。
1つ目は、重めのテキストとして、awesome-flutterのREADME.md(77.2 KB)です。実践的なmarkdown text として、ある程度分量のあるものをparseしたときのパフォーマンスを想定しています。
2つ目は軽めのテキストとして、Flutterプロジェクトを作成したときに自動生成されるREADME.md(0.57kb) を使用します。
Rustの方がparseのパフォーマンスが高いことが想定されますが、比較的小さなtextでもRustを呼び出すことが適当かを判断するために2つのテキストを用いて検証します。
測定結果
- 調査環境:Androidエミュレータ(Pixel3a)
- 重めのテキスト:awesome-flutterのREADME.md(77.2 KB)
- 中くらいのテキスト:この記事(15kb)
- 軽めのテキスト:
flutter create
で生成されるReadme.md(0.57kb) - Hot Reloadをしてから1回目は実行速度が遅くなるので、2回目以降の実行時間を計測
使用したmarkdown | size(kb) | 言語 | 試行回数 | avg | max | min |
---|---|---|---|---|---|---|
重め | 77.2 | Dart | 100 | 18.0ms | 40.5ms | 15.2ms |
重め | 77.2 | Rust | 100 | 10.3ms | 13.2ms | 9.0ms |
重め | 77.2 | Dart | 1000 | 16.7ms | 37.0ms | 14.5ms |
重め | 77.2 | Rust | 1000 | 9.7ms | 16.2ms | 8.6ms |
中 | 15.0 | Dart | 100 | 3.9ms | 6.4ms | 3.4ms |
中 | 15.0 | Rust | 100 | 2.4ms | 5.0ms | 1.7ms |
中 | 15.0 | Dart | 1000 | 3.8ms | 6.5ms | 3.3ms |
中 | 15.0 | Rust | 1000 | 2.4ms | 4.8ms | 1.7ms |
軽め | 0.57 | Dart | 100 | 0.2ms | 0.9ms | 0.1ms |
軽め | 0.57 | Rust | 100 | 1.2ms | 2.9ms | 0.5ms |
軽め | 0.57 | Dart | 1000 | 0.2ms | 1.8ms | 0.1ms |
軽め | 0.57 | Rust | 1000 | 1.2ms | 4.1ms | 0.5ms |
測定結果は上記の通りです。
処理量が一定以上になるとRustのパフォーマンスの高さが活かせることが分かります。
RustはDartのおよそ2倍の速度を出すことができています。
一方FFIを利用したRustの呼び出しにはボトルネックが存在し、
平均1.2ms程度の時間がかかってしまうようです。
Dart製のMarkdown parserも十分早いので、今回使った軽めのテキスト(0.57kb)では、
Dartの方が6倍程早い結果になりました。
RustがDartのパフォーマンスを上回るポイントは、およそ6kb程度からと考えられます(荒い測定ですし、Markdownの内容にもよるので、ご参考まで)
したがって、基本的には次のような方針に従うべきだと考えられます。
- 処理自体の量が多い時は、Rustで処理する
- 呼び出しの回数が多い場合は、Dartで処理する
また、parserの選択は以下のようなパターンが考えられます。
- Rustの呼び出しコストを考慮し、テキストの量に応じて利用するparserを使い分ける
- 1.2msの呼び出し時間を許容し、すべてRustで処理する
- 軽いテキストしか扱わない場合は、すべてDartで処理する
結論、RustをFlutterから呼び出すのは正解なのか
実際に実装してみて、以下のように感じました。
- Rustをプラットフォームごとに呼び出し可能な状態にもっていくのは結構大変
- Dart側からRustを呼び出すこと自体は非常にカンタンで体験も良い
- Rustの強力なクレートをDartから利用できることは、めちゃくちゃ強力
今回のテーマはMarkdown parserでした。Markdownな記事を手軽にHTMLに変換したいなどの要求の場合、1000文字から多くても1万字程度の量が想定されるので、実際にはDart製parserを使えば十分と言えるでしょう。
しかし、一気に大量のテキストを変換したい場合、最初に文字列を全て結合してからRustで処理して分割するなどの手順でパフォーマンスの向上に寄与する可能性は十分考えられます。
また、RustにはBERT
やLightGBM
などの機械学習系のクレートや、Polars
などの表計算クレート、Bayard
などの全文検索用クレートなどもあり、現状Flutterのpackageを使うにはちょっと微妙な領域にもチャレンジできる可能性があります。
バック/フロントどちらでやるべきかという問題は残りますが、選択肢が増えることで解決できる問題は増えることでしょう。
ぜひ、面白い活用方法を見つけた方はシェアしてくださいね!
おまけ
本記事はPublication初投稿!
7月から、株式会社マインディアにてFlutterエンジニアとしてジョインさせて頂きました!
ValueのFail and Learnは本当で、どんどんチャレンジできるのが素晴らしい。
弊社ではFlutterやRailsな仲間を引き続き募集しておりますので、興味があったらお気軽にカジュアルにお話だけでもしてみませんか?
引き続き、Flutter周りの気になることを調査したり、ジュニアなエンジニアが思うことも記事にしていきますので、良かったらZennやTwitterのフォローをお願いします!
Discussion