Open16

Rust の他言語バインディング生成ライブラリ UniFFI を試す

Takanori IshikawaTakanori Ishikawa

Mozilla の Firefox Application Services (旧 Nimbus SDK) で使われている、他言語向けバインディングのライブラリ UniFFI を試してみる。今回は Swift 向けバインディングを生成するのが目的。

User Guide の内容は若干古いようなので、適宜他のリソースも当たりながら進めていく。

Takanori IshikawaTakanori Ishikawa

対象 lib を cdylib としてビルドする必要がある。Cargo.toml に以下を追加する。

[lib]
crate-type = ["cdylib"]

Linkage - The Rust Reference

--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 a staticlib, 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 (mainly libc 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.

Takanori IshikawaTakanori Ishikawa

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);
};
Takanori IshikawaTakanori Ishikawa

Rust 側の boilerplate コードを生成するために、依存ライブラリに uniffiuniffi_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");
Takanori IshikawaTakanori Ishikawa

Swift 側の boilerplace コードを生成するために uniffi-bindgen をインストールする。

cargo install uniffi_bindgen

注意しなくてはならないのは、依存ライブラリで追加した uniffi と同じバージョンの uniffi-bindgen をインストールする必要があるということ。そうしないと、実行時にエラーが発生する可能性がある。

当然それでは、バージョン違いのライブラリを開発するときや複数人で開発するときに困ってしまう。回避策としては、システムワイドにインストールした uniffi-bindgen を使うのではなく、依存ライブラリで追加した uniffi と同じバージョンの uniffi-bindgen をプロジェクト単位でインストールして使う方法がある。

まず、uniffi_buildbuiltin-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 を使うようにする方法だが、いくつかの方法がある。

  1. ワークスペースを利用し、専用のパッケージで独自の uniffi-bindgen を作る
  2. 同一パッケージに bin クレートを追加する
  3. 同一パッケージのビルドスクリプトで uniffi-bindgen を呼び出す。

1 の方法が一番汎用的だが、少々管理が面倒くさい。2 の方法だと uniffi-bindgen に直接依存することになるし、crate.io に公開する時も困りそうだ。ここでは 3 を試してみたい。

Takanori IshikawaTakanori Ishikawa

ビルドスクリプトで 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"
}
Takanori IshikawaTakanori Ishikawa

ここまでで必要な成果物は揃ったので、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= も不要にしたいのだが、方法が分からない。

Takanori IshikawaTakanori Ishikawa

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'
Takanori IshikawaTakanori Ishikawa

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
}
Takanori IshikawaTakanori Ishikawa

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")
Takanori IshikawaTakanori Ishikawa

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
}
Takanori IshikawaTakanori Ishikawa
  • 引数のラベルを一部だけ省略することはできない?
  • Generics や Traits には未対応
  • Tuple もない
  • Associated function も未対応
  • メソッドや関数から参照を返すことはできない
  • 自動生成されたファイルをコンパイルするだけなので、extension として実装したり、ラッパークラスを追加することができそう
    • できた
Takanori IshikawaTakanori Ishikawa

UniFFI で生成した Swift バインディングを Swift Packge にする。まずは以下のような構成でファイルを構成する(ライブラリ名は Tokenizers)。Swift Package に関係ないファイルは割愛した。

.
├── Package.swift
├── Sources
│   ├── Tokenizers
│   │   └── Tokenizers.swift
│   └── TokenizersFFI
│       ├── TokenizersFFI.c
│       └── include
│           └── TokenizersFFI.h
  • Tokenizers.swiftTokenizersFFI.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"
Takanori IshikawaTakanori Ishikawa

Enum の定義にも制限がある。値を持たせる場合、

  • 拡張属性が使えない。そのため、&str とかは使えない
  • 他のオブジェクトを使えない

Union type のようなものを模倣するのは難しい。