📓

csbindgenを使ったら「C#からネイティブコード呼び出し」が高速で実現できて処理時間も高速になった話

2023/11/18に公開

※C#からC++を呼ぶ方法も現在執筆中です。ちょっと難航していて執筆が遅れています。
2023/12/25 ようやくC++呼び出しができたので追記。文章だけだと難しいと思うので検証で使ったサンプルコードを公開しました。
https://github.com/DogFortune/CSharpBindingRust

概要

よく高速化の手法として「重い処理をネイティブコードに書き換えて外部から呼び出す」という手法がありますが、私も昔C#からC++を呼び出すとかやっていました。そして数年後、またネイティブコードを呼び出す案件を受ける事になりました。
C#からC++呼び出すのって結構大変(というか色々下準備が面倒)なので、「あれから結構年数経ってるし、もう少し簡単に呼び出せないものか?」って調べた所、面白そうなライブラリを見つけたので試してみる事にしました。

https://github.com/Cysharp/csbindgen

今回はこのライブラリを使って「C#からRust呼び出し」と「C#からC呼び出し」をやってみました。C呼び出しで結構苦戦したので注意点も含めて紹介します。

C#からRustを呼ぶ

まずは簡単なコードで試してみようと思い、大きな配列に値を入れていくサンプルをやってみます。

Program.cs

class Program
{
    static void Main(string[] args)
    {
        CalcPoint(10000000);
    }
    
    static void CalcPoint(int points)
    {
        var src = new byte[points * 3];
        var index = 0;

        for (var i = 0; i < points; i++)
        {
            src[index++] = 1;
            src[index++] = 2;
            src[index++] = 3;
        }

        foreach (var i in Enumerable.Range(0, 9))
        {
            Console.WriteLine(src[i]);
        }
    }
}

これをReleaseビルドして時間を計測してみました。

-> % time ./CsbindingDotnetConsoleApp
1
2
3
1
2
3
1
2
3
./CsbindingDotnetConsoleApp  0.07s user 0.02s system 90% cpu 0.094 total

だいたいこれくらい。これがRustになるとどれくらい早くなるのかをやってみます。
まずは上記のループ部分のメソッドをRustで書きます。外部から呼び出せるようにします。

lib.rs
#[no_mangle]
pub extern "C" fn calcpoint(points: usize)
{
    let mut src: Vec<u8> = vec![0; points * 3];
    let mut elm = 0;

    for _i in 0..points {
        src[elm] = 1;
        src[elm+1] = 2;
        src[elm+2] = 3;
        elm += 3;
    }

    // srcの最初の10個の要素を表示して確認する
    for i in 0..9 {
        println!("{}", src[i]);
    }
}
Cargo.toml
[package]
name = "calcpoint"
version = "0.1.0"
edition = "2021"

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

[build-dependencies]
csbindgen = "1.8.3"

Cargo.tomlとディレクトリにbuild.rsを作り、csbindgenによるC#自動生成を行います。

build.rs
fn main() {
    // C#→Rust
    // Rustで書いたコードをC#から呼び出せるコードを生成
    csbindgen::Builder::default()
        .input_extern_file("src/lib.rs")
        .csharp_dll_name("calc")
        .generate_csharp_file("../NativeMethods.cs")
        .unwrap();
}

これでcargo build --releaseを実行すると、C#のコードとRustコードをコンパイルしたライブラリが生成されます。target/releaseフォルダにあるdll or dylibが成果物です。
あとは以下のようにC#からバイナリを呼び出すようにします。

Program.cs
using CsBindgen;

namespace dotnet_console;

class Program
{
    static void Main(string[] args)
    {
	Console.WriteLine("通常のC#");
        CalcPoint(10000000);
        Console.WriteLine("Rust呼び出し");
        NativeMethods.calcpoint(10000000);
    }
}

これをリリースビルドして時間を計測してみました。

-> % time ./CsbindingDotnetConsoleApp
1
2
3
1
2
3
1
2
3
./CsbindingDotnetConsoleApp  0.03s user 0.02s system 77% cpu 0.068 total

0.07s → 0.03という事でちょっとですが高速化できました。誤差かもしれないので数回試してみましたが、ほとんど同じ値だったので、きちんと速くなっています。

C#からC(C++)を呼ぶ

過去のC++資産があるとかC++のSDKしかないとかでこっちの方が需要があると思います。
これはcsbindgenだけではなく他のクレートと連携する事で実現されています。

  • cc crateやcmake crateによりCコードをビルド。Rustコードと一緒にライブラリとして出力
  • rust-bindgenにより.hからC(及び一部のC++)ライブラリへのFFIバインディング(Rustコード)を自動生成
  • 自動生成されたFFIバインディング(Rustコード)をcsbindgenが解析してC#コードを自動生成

という感じの流れになります。
まずはccとbindgenをCargo.tomlに追加してインストールします。

Cargo.toml
[build-dependencies]
csbindgen = "1.8.3"
# 以下2つ追加。バージョンは最新でOK。
bindgen = "0.69.1"
cc = "1.0.83"

呼び出し対象のCコードを用意します。フィボナッチ数列を出力するコードです。サンプルコードのdotnet-console/native/rust/c/fibonacci.cです。
これをコンパイル→FFI生成→C#連携コード生成を一気に行います。以下のように設定を追記します。

build.rs
fn main() {
    // C#→C
    // fibonacciのヘッダーファイルを解析してFFIバインディングを生成
    bindgen::Builder::default()
        .header("c/fibonacci.h")
        .generate()
        .unwrap()
        .write_to_file("src/fibo_bindgen.rs")
        .unwrap();

    // fibonacciのコードをccクレートでコンパイル
    cc::Build::new()
        .warnings(true)
        .file("c/fibonacci.c")
        .compile("fibo");

    //出力されたFFIバインディングからC#連携コード生成
    csbindgen::Builder::new()
        .input_bindgen_file("src/fibo_bindgen.rs")
        // rust_method_prefixを設定しないとcsbindgen_になる。
        .rust_method_prefix("fibo_")
        .rust_file_header("use super::fibo_bindgen::*;")
        .csharp_class_name("Libfibo")
        // ただしこっちは指定しないと何もつかない。
        .csharp_entry_point_prefix("fibo_")
        // ここはcargo.tomlのpackageと同じにしないといけない。
        .csharp_dll_name("calc")
        .generate_to_file("src/fibo_ffi.rs", "../fibo_bindgen.cs")
        .unwrap();
}

ここのcsbindgenの設定がうまく行かず、執筆が遅れてしまった原因なのでハマったポイントを説明していきます。

.rust_method_prefixの挙動

  • .rust_method_prefixは設定は任意ですが設定しない場合csbindgen_になります。
  • .csharp_entry_point_prefixは指定しないと空白になりますので、もし.rust_method_prefixを指定しなかったら、メソッド名にcsbindgen_が入ってしまうのでエントリーポイントが見つからないというエラーになります。
  • なので、.rust_method_prefix.csharp_entry_point_prefixは必ず設定した方がいいです。値はどちらも同じにする。

.csharp_dll_nameの解釈

  • これは出力されるdllの名前ではなく、参照したいライブラリの名前を入れる。
  • Cargo.toml[package]のnameと同じ名前でライブラリが生成されるのでここと同じにする。

最後にlib.rsに生成されたモジュールをインポートする文を追記します。

lib.rs
#[allow(dead_code)]
#[allow(non_snake_case)]
#[allow(non_camel_case_types)]
#[allow(non_upper_case_globals)]
mod fibo;

#[allow(dead_code)]
#[allow(non_snake_case)]
#[allow(non_camel_case_types)]
mod libfibo_ffi;

これでOKです。carbo build --releaseを実行するとRustコードと一緒にCもコンパイルされてライブラリ化します。target/releaseにpackage名にlibを追加したdllまたはdylibが出力されているはずです。
あとはこれをC#連携コードfibo_bindgen.csを使ってC#から呼び出せばOKです。サンプルコードのdotnet-console/Program.csを参照して下さい。Libfibo.fibo(20);が呼び出し部分です。

dotnet buildするとdotnet-console/bin/Debug/net7.0/CsbindingDotnetConsoleAppという実行アプリが作成されますので、同じディレクトリにRustのビルドで出力されたdll or dylibを配置して実行します。

-> % dotnet-console/bin/Debug/net7.0/CsbindingDotnetConsoleApp
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, %  

以上で呼び出し完了です。

まとめ

ネイティブコード呼び出しのハードルが随分下がったと思いますが、可能であれば書かずに済ませるのが正解です。色々面倒だし。
ただそれでもパフォーマンス重視の案件でC#だと太刀打ちできないとかでネイティブコード使う時もあります。そんな時にこのcsbindgenが使えそうです。

Discussion