🧪

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人を超える開発者が解答した、世界最大級の開発コミュニティによる調査です。
https://survey.stackoverflow.co/2023/

この調査では、Rustは最も賞賛されている言語であり、使用している開発者の80%以上が来年も使用したいと回答しています。

Rustには言語仕様やパフォーマンスの高さなど、様々な賞賛ポイントがありますが、特筆すべき1つのポイントとして、非常に多くのプラットフォームで利用可能なコンパイラを公開していることが挙げられます。

それらの機能を活かすサードパーティ製のライブラリも充実しており、近年ではFlutterでも比較的簡単にRust製の機能を活かせるようになりました。

とはいえ、素直にDartで実装するよりも、実際にRustで実装するのはパフォーマンス上のメリットとなるのでしょうか?

この疑問には、実際に実装して計測してみないと答えることができません。
したがって、今回はRustが本当に早いのか。実際にFlutterからの呼び出しを含めて測定し、実用に耐えうるかを検証していきます。

今回選んだ題材はMarkdown parser

もちろん処理が重ければ重いほどRust有利かと思いますが、ある程度カジュアルな実装にもRustを利用できるのかを検証したかったため、今回の題材はMarkdown Parserとしました。

実装がカンタンなことも大きな理由です。
Dart, RustそれぞれのライブラリからmarkdownToHtml()のような関数を呼び出すだけでテスト可能でラクなのが良き。

Dart製のmarkdown parserを選ぶ

https://pub.dev/packages/markdown

FlutterでMarkdownを表示する際に最も有名なpackageはflutter_markdownだと思われます。このpackageの内部で呼び出されているmarkdownの内部実装がdartのため、Rust製のparserと比較するのにちょうど良さそうです。

 flutter pub add markdown

今回はこのmarkdownをdart側の選手としてエントリーしましょう。

Rust製markdown parserを選ぶ

https://crates.io/crates/pulldown-cmark
Rust製もたくさんありますが、pulldown-cmarkが一番利用されているようなので、こちらをつかいます。

FlutterからRustを呼び出して実行する

https://pub.dev/packages/flutter_rust_bridge
RustとFlutterを繋げてくれる便利なpackageがあるので、利用していきます。
正直、これがなければこの検証をやろうとすら思いませんでした。ってくらい、便利な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の場合

https://cjycode.com/flutter_rust_bridge/integrate/desktop.html
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の場合

https://cjycode.com/flutter_rust_bridge/integrate/android_tasks.html
マニュアルにしたがって、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
        }
    }
}

マニュアルに大きく書いてあるコードは現在動かないので、マニュアル下部のリンクを参照してコピペしました。

https://cjycode.com/flutter_rust_bridge/tutorial/alternative_ndk.html

さらに、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の場合

以下の公式マニュアルを参考にセットアップします。
https://cjycode.com/flutter_rust_bridge/integrate/ios_proj.html
https://zenn.dev/mytooyo_dev/articles/ee3c3fcbf69426#flutter_rust_bridgeの利用
日本語で非常に丁寧に記事を書いてくれている方もいらっしゃるので、そちらも非常に参考になります。同じマニュアルを参考にしているようなので、そのまま実装すれば齟齬なく動くはずです。

コンパイルした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にはBERTLightGBMなどの機械学習系のクレートや、Polarsなどの表計算クレート、Bayardなどの全文検索用クレートなどもあり、現状Flutterのpackageを使うにはちょっと微妙な領域にもチャレンジできる可能性があります。

バック/フロントどちらでやるべきかという問題は残りますが、選択肢が増えることで解決できる問題は増えることでしょう。

ぜひ、面白い活用方法を見つけた方はシェアしてくださいね!

おまけ

https://twitter.com/hagakun_yakuzai

本記事はPublication初投稿!
7月から、株式会社マインディアにてFlutterエンジニアとしてジョインさせて頂きました!

ValueのFail and Learnは本当で、どんどんチャレンジできるのが素晴らしい。

弊社ではFlutterやRailsな仲間を引き続き募集しておりますので、興味があったらお気軽にカジュアルにお話だけでもしてみませんか?

引き続き、Flutter周りの気になることを調査したり、ジュニアなエンジニアが思うことも記事にしていきますので、良かったらZennTwitterのフォローをお願いします!

株式会社マインディア テックブログ

Discussion