Rust の他言語バインディング生成ライブラリ UniFFI を試す
Mozilla の Firefox Application Services (旧 Nimbus SDK) で使われている、他言語向けバインディングのライブラリ UniFFI を試してみる。今回は Swift 向けバインディングを生成するのが目的。
- This Week in Glean: Cross-Platform Language Binding Generation with Rust and “uniffi” – Data@Mozilla
- Overview - The UniFFI user guide
User Guide の内容は若干古いようなので、適宜他のリソースも当たりながら進めていく。
対象 lib を cdylib
としてビルドする必要がある。Cargo.toml
に以下を追加する。
[lib]
crate-type = ["cdylib"]
--crate-type=cdylib
,#![crate_type = "cdylib"]
- A dynamic system library will be produced. This is used when compiling a dynamic library to be loaded from another language. This output type will create*.so
files on Linux,*.dylib
files on macOS, and*.dll
files on Windows.
技術的には静的リンクも可能なはずだが、けっこう面倒
Note: You don't technically need to make a dynamic library (
cdylib
) for your Rust code to be callable from other languages. You can always use static linking with astaticlib
, however that can be a bit more annoying to set up because you need to remember to link in a bunch of other things that the Rust standard library uses (mainlylibc
and the C runtime).With a dynamic library all the work for dependency resolution is handled by the loader when your program gets loaded into memory on startup. Meaning things should Just Work.
UniFFI では、外部に公開するインターフェースを UDL という WebIDL 由来の言語で記述する。まだ何も公開するものがないので、とりあえずチュートリアル通りの add
関数を lib.rs
に追加しておく。
fn add(a: u32, b: u32) -> u32 {
a + b
}
src/lib.udl
もチュートリアル通り。
namespace math {
u32 add(u32 a, u32 b);
};
Rust 側の boilerplate コードを生成するために、依存ライブラリに uniffi
と uniffi_macros
および uniffi_build
を追加する。
[dependencies]
uniffi = "0.21.0"
uniffi_macros = "0.21.0"
[build-dependencies]
uniffi_build = "0.21.0"
ビルドスクリプト build.rs
も追加
fn main() {
uniffi_build::generate_scaffolding("./src/lib.udl").unwrap();
}
lib.rs
の先頭に次を追加
use uniffi_macros;
uniffi_macros::include_scaffolding!("lib");
Swift 側の boilerplace コードを生成するために uniffi-bindgen
をインストールする。
cargo install uniffi_bindgen
注意しなくてはならないのは、依存ライブラリで追加した uniffi
と同じバージョンの uniffi-bindgen
をインストールする必要があるということ。そうしないと、実行時にエラーが発生する可能性がある。
当然それでは、バージョン違いのライブラリを開発するときや複数人で開発するときに困ってしまう。回避策としては、システムワイドにインストールした uniffi-bindgen
を使うのではなく、依存ライブラリで追加した uniffi
と同じバージョンの uniffi-bindgen
をプロジェクト単位でインストールして使う方法がある。
まず、uniffi_build
で builtin-bindgen
feature を有効にして、プロジェクトローカルの uniffi-bindgen
を利用するように設定する。
[build-dependencies]
uniffi_build = {version = "0.21.0", features = [ "builtin-bindgen" ]}
これで、ビルドスクリプトで呼び出す uniffi_build::generate_scaffolding
では、プロジェクトローカルの uniffi-bindgen
が使われるようになるので、Rust 側の boilerplate コード生成では問題が解決した。
問題は Swift 側の boilerplace コード生成時にプロジェクトローカルの uniffi-bindgen
を使うようにする方法だが、いくつかの方法がある。
- ワークスペースを利用し、専用のパッケージで独自の
uniffi-bindgen
を作る - 同一パッケージに
bin
クレートを追加する - 同一パッケージのビルドスクリプトで
uniffi-bindgen
を呼び出す。
1 の方法が一番汎用的だが、少々管理が面倒くさい。2 の方法だと uniffi-bindgen
に直接依存することになるし、crate.io に公開する時も困りそうだ。ここでは 3 を試してみたい。
ビルドスクリプトで uniffi-bindgen
を実行する。
use camino::Utf8Path;
use std::env;
use std::path::Path;
fn main() {
let out_dir = env::var_os("OUT_DIR").unwrap();
uniffi_build::generate_scaffolding("./src/lib.udl").unwrap();
uniffi_bindgen::generate_bindings(
Utf8Path::new("./src/lib.udl"),
None,
vec!["swift"],
Utf8Path::from_path(Path::new(&out_dir)),
None,
false,
)
.unwrap();
}
成果物は OUT_DIR
環境変数が指し示すディレクトリに生成されるのだが、このディレクトリの場所を取得するのは簡単ではない。cargo を cargo build --message-format=json
で実行することで、JSON 形式のメッセージとして出力することはできる。
$ cargo build --message-format=json | jq .
...
{
"reason": "build-script-executed",
"package_id": "tokenizers-ffi 0.1.0 (path+file:///Users/takanori.ishikawa/Developer/Workspace/tokenizers-swift/ffi)",
"linked_libs": [],
"linked_paths": [],
"cfgs": [],
"env": [],
"out_dir": "/Users/takanori.ishikawa/Developer/Workspace/tokenizers-swift/ffi/target/debug/build/tokenizers-ffi-684fc4c089349484/out"
}
ここまでで必要な成果物は揃ったので、Swift 用のモジュールとライブラリを生成する。Rust の OUT_DIR
を毎回指定するのは面倒くさいので、必要なファイルをあらかじめ別のディレクトリにコピーしておこう。
mkdir -p .build
cp -r ./target/debug/build/tokenizers-ffi-684fc4c089349484/out/*.{h,swift,modulemap} .build/
ここでは .build
ディレクトリを作成し、必要な成果物をコピーするようにした。
これで、Swift モジュールを生成できる。-module-link-name
を指定することで Auto linking も有効にする。
$ swiftc -parse-as-library \
-emit-module -emit-module-path .build -module-name math -module-link-name math \
-emit-library -o .build/libmath.dylib \
-L ./target/debug -ltokenizers_ffi \
-I .build \
-Xcc -fmodule-map-file=.build/mathFFI.modulemap \
.build/math.swift
.build
ディレクトリにダイナミックライブラリとモジュールが生成されている。
$ tree build
build
├── libmath.dylib
└── math.swiftmodule
REPL で実行することができる。
$ swift repl -I./build -L./build -Xcc -fmodule-map-file=.build/mathFFI.modulemap
Welcome to Apple Swift version 5.7 (swiftlang-5.7.0.127.4 clang-1400.0.29.50).
Type :help for assistance.
1> import math
2> math.add(a: 10, b: 20)
$R0: UInt32 = 30
できれば、-Xcc -fmodule-map-file=
も不要にしたいのだが、方法が分からない。
cargo
小技。Package name を取得
$ cargo metadata --no-deps --format-version 1 | jq ".packages[0].name"
"tokenizers-ffi"
cargo check
の出力から out_dir
を取得
$ cargo check --message-format json | jq -r 'if .reason == "build-script-executed" and (.package_id | contains("tokenizers")) then .out_dir else empty end'
static method を定義することができない。
interface Tokenizer {
static Tokenizer from_pretrained([ByRef] string identifier);
};
これをコンパイルすると
Caused by:
method modifiers are not supported', build.rs:8:57
コンストラクタとして宣言することはできる。
interface Tokenizer {
[Name=from_pretrained]
constructor([ByRef] string identifier);
};
Swift 側で呼び出すときは static method になる。
2> Tokenizer.fromPretrained(identifier: "bert-base-cased")
$R0: tokenizers.Tokenizer = {
pointer = 0x600000004040
}
optional な引数も使える。
pub struct Tokenizer {}
impl Tokenizer {
pub fn from_pretrained(identifier: &str, revision: String, auth_token: Option<String>) -> Self {
Self {}
}
}
UDL
interface Tokenizer {
[Name=from_pretrained]
constructor(
[ByRef] string identifier,
optional string revision = "main",
optional string? auth_token = null);
};
Swift
Tokenizer.fromPretrained(identifier: "bert-base-cased", revision: "main2")
Tokenizer.fromPretrained(identifier: "bert-base-cased", revision: "main2", authToken: "hasdkfk")
HuggigFace の Tokenizers を移植したいので、雑に実装してみる。
use std::sync::Arc;
use tokenizers as tk;
pub struct Tokenizer {
tokenizer: Arc<tk::tokenizer::Tokenizer>,
}
impl Tokenizer {
pub fn from_pretrained(identifier: &str, revision: String, auth_token: Option<String>) -> Self {
let params = tk::FromPretrainedParameters {
revision,
auth_token,
user_agent: [("bindings", "Swift"), ("version", crate::VERSION)]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
};
let tokenizer =
tk::tokenizer::Tokenizer::from_pretrained(identifier, Some(params)).unwrap();
Self {
tokenizer: Arc::new(tokenizer),
}
}
}
ちゃんと動いた :+1:
1> import tokenizers
2> let tokenizer = Tokenizer.fromPretrained(identifier: "bert-base-cased")
Downloaded 425.58KiB in 0s
tokenizer: tokenizers.Tokenizer = {
pointer = 0x600000202790
}
- 引数のラベルを一部だけ省略することはできない?
-
omit_argument_labels
で全て省略はできるみたいだが... - できれば、先頭の引数はラベルを省略したい。
-
- Generics や Traits には未対応
- Tuple もない
- Associated function も未対応
- メソッドや関数から参照を返すことはできない
- 自動生成されたファイルをコンパイルするだけなので、extension として実装したり、ラッパークラスを追加することができそう
- できた
できれば、
-Xcc -fmodule-map-file=
も不要にしたいのだが、方法が分からない。
これを Configuration で generate_module_map
を false にしたが、これは設定項目の名前通り、.modulemap
を生成しなくなるだけだった。
UniFFI で生成した Swift バインディングを Swift Packge にする。まずは以下のような構成でファイルを構成する(ライブラリ名は Tokenizers
)。Swift Package に関係ないファイルは割愛した。
.
├── Package.swift
├── Sources
│ ├── Tokenizers
│ │ └── Tokenizers.swift
│ └── TokenizersFFI
│ ├── TokenizersFFI.c
│ └── include
│ └── TokenizersFFI.h
-
Tokenizers.swift
とTokenizersFFI.h
が UniFFI の生成するファイル -
TokenizersFFI.c
は中身が空のダミーファイル。Swift Package Manager で C Language Target を作るためには最低でもひとつの.c
ファイルが必要みたい -
.modulemap
は Swift Package Manager が自動生成してくれるため不要
Package.swift
は以下
// swift-tools-version: 5.7
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "Tokenizers",
products: [
.library(
name: "Tokenizers",
targets: ["Tokenizers"])
],
dependencies: [],
targets: [
.target(
name: "Tokenizers",
dependencies: ["TokenizersFFI"],
linkerSettings: [
.linkedLibrary("tokenizers")
]),
.target(
name: "TokenizersFFI",
dependencies: []),
.testTarget(
name: "TokenizersTests",
dependencies: ["Tokenizers"]),
]
)
リンクしている動的ライブラリ libtokenizers.dylib
は Rust が生成したもの。実際にこのパッケージを利用する側で必要になる。
$ swift run -Xlinker -L../../target/debug
Building for debugging...
ld: warning: dylib (../../target/debug/libtokenizers.dylib) was built for newer macOS version (12.0) than being linked (11.0)
[1/1] Linking Example
Build complete! (0.19s)
また、このパッケージ自体のディレクトリで REPL 実行もできる。
$ swift run --repl -Xlinker="-Ltarget/debug"
UDL で Option<&str>
を書く方法が分からない。
[ByRef] string?
は &Option<String>
として解釈される。
([ByRef] string)?
は Union 型と解釈されるのか no support for union types yet
と怒られる。
文法をざっと眺めた感じ無理っぽい?
Enum の定義にも制限がある。値を持たせる場合、
- 拡張属性が使えない。そのため、
&str
とかは使えない - 他のオブジェクトを使えない
Union type のようなものを模倣するのは難しい。