他言語がメインの場合のRustの活用法 - csbindgenによるC# x Rust FFI実践事例

Rust.Tokyo 2023というRustのカンファレンスで、「他言語がメインの場合のRustの活用法 - csbindgenによるC# x Rust FFI実践事例」と題してcsbindgen周りの話をしてきました。

タイトルが若干かなり回りっくどい雰囲気になってしまいましたが、Rustのカンファレンスということで、あまりC#に寄り過ぎないように、という意識があったのですが、どうでしょう……?

会場での質問含めて何点かフォローアップを。

FFIとパフォーマンス

Rustは速い!FFIは速い!ということが常に当てはまるわけでもなく、例えばGoのcgoはかなり遅いという話があったりします。Why cgo is slow @ CapitalGo 2018。このことは直近のRustのasyncの話Why async Rust?でも触れられていて、cgoの遅さはGoroutine(Green Thread)が影響を及ぼしているところもある、とされています。.NET でもGreen Threadを実験的に実装してみたというレポートがついこないだ出ていたのですが、FFIの問題とか、まぁ諸々あってasync/awaitでいいじゃろ、という結論になっています。技術はなんでもトレードオフなので、過剰にGreen Threadを持ち上げるのもどうかな、とは思いますね。

で、C#のFFI速度ですが、こちらのTesting FFI Hot Loop Overhead - Java, C#, PHP, and Goという記事での比較ではFFIにおいては圧勝ということになっているので、まぁ、実際C#のFFIは速いほうということでいいんじゃないでしょーか(昔からWin32 APIを何かと叩く必要があったりとかいう事情もありますし)。

とはいえ、原則Pure C#実装のほうがいいなあ、という気持ちはめっちゃあります。パフォーマンスのためのネイティブライブラリ採用というのは、本当に限定的な局面だけではありますね。そんなわけで、その限定的な局面であるところのコンプレッションライブラリを鋭意開発中です、来月に乞うご期待。

Zig, C++

FFI目的でunsafeなRust中心になるぐらいならZigのほうがいいんじゃない?というのは一理ある。というか最初はそう思ってZigを試したんですが、今回は見送らせていただきます、と。一理ある部分に関しては一理あるんですが、それ以外のところではRustのほうが上だという判断で、総合的にはRustを採用すべきだと至りました。

具体的には資料の中のRustの利点、これは資料中ではC++との比較という体にしていますが、Zigとの比較という意味もあります。標準公式のパッケージマネージャーがないし、開発環境の乏しさは、たとえZigが言語的にRustよりイージーだとしても、体感は正直言ってRustよりもハードでした。コンパイルエラーもRustは圧倒的にわかりやすいんですが、Zigはめちゃくちゃ厳しい……。Rustはイージーとは言わないですが、開発環境の助けやcargoコマンドのシンプルさ、技術情報(本・ブログ・FAQ)の多さによって、入り口は意外と大変ではない、むしろ入りやすい部類とすら言える感じです。

また、ZigはZigでありC/C++ではない。これはRustも同じでRustはRustでC/C++ではない、つまりCとZig(Rust)を連動させるにはbindgenのようなものが必要なのですが、Zigのそれの安定性がかなり低い、パースできない.hが普通にチラホラある。rust-bindgenのIssue見ていると本当に色々なケースに対応させる努力を延々と続けていて、それがbindgenの信頼性(と実用性)に繋がっているわけで、Zigはまだまだその域には達していないな、と。

Cはまだいいとしても、C++のエコシステムを使うという点では、ZigもRustも難しい。セッションの中ではPhysX 5を例に出しましたが、物理エンジンはOSSどころだとBullet PhysicsJolt Physicsも、SDKそのままそのものはC++だけなんですよね。これをC++以外の言語に持ち込むのは非常に骨の折れる仕事が必要になってきます。Rustに関してはEmbark Studiosがphysx-rsを作ってくれたのである程度現実的ではありますが、何れにせよ大仕事が必要で、そのままでは持ち込めないというのが現実です。

physx-rsではC++のPhysXをRustで動かすために、まずC APIのPhysXを自動生成してそれ経由でRustから呼び出す、という話をしましたが、ZigもC++のものを呼び出すには、概ね同様のアプローチを取る必要があり、例えばZigでJolt Physicsを動かすzphysicsというプロジェクトでは、C++のJoltに対して、C APIで公開するJoltCという部分を作って、それ経由でZigから呼び出すという手法を取っています。

この辺のことはMagicPhysXを作る時にめちゃくちゃ迷走して色々作りかけてたので痛感しています。そう、最初はZigでBullet Physicsを動かしてC#から呼び出すMagicBulletというプロジェクトだったこともあったのだ……。

最後発C++後継系言語であるところのCarbon Languageは、C++におけるTypeScriptというのを標榜しているので、そうしたC++との連携を最優先に考えた言語になっているんじゃないかなー、と思います(触ってないので知らんですけど!)。C++の後継はRust(やZig)があるからいらんやろー、とはならない、C++の資産を活かしながらもモダンな言語仕様を使えるようにする、という絶妙な立ち位置を狙っているんじゃないかなー、と。どのぐらい盛り上がっていくのかわかりませんが……!

C++/CLI

C++/CLIは使わないんですか?という質問がありました。.NETとC++ライブラリの連携という点で、C++/CLIはたしかに良いソリューションで、C++のライブラリをC#のために公開するブリッジとしては最高に使いやすい代物でした。.NET Frameworkの時代までは。

C++/CLIの問題は「.NET Core の C++/CLI サポートは Windows のみ」ということで、特にライブラリがLinuxサポートしないというのはありえないので、.NET Core以降にC++/CLIを新規採用するのは基本ありえない、といった状態になっています。こういった問題があるので、 .NET Framework時代に作られていたC++ライブラリをC#で使える系ライブラリはほとんど使えなくなりました。例えばPhysX 4の.NETバインディングであるPhysX.NETは、C++/CLIでバインディングが作られているため、.NET 5対応はしていますが、サポートプラットフォームはWindowsのみです。

csbindgenは、そうした.NET / C連携での空白地帯にちょうどうまくはまったライブラリなのではないかと思います。C++連携については頑張るしかないですが、そこはしょうがないね……! ただ、Rustはエコシステムがうまく動いているので、Pure Rustライブラリであったり、RustでC++バインディングが作られているものを経由してC#バインディングを作る、といった手法でうまく回せる場合も多いんじゃないかなあー、というところがいいところです。それと、近年のライブラリ事情でいうと、物理エンジンみたいな老舗系はC++で作られていますが、例えば暗号通貨系のライブラリなんかは最初からRust実装だったりするものも多いので、RustからC#への持ち込み、のほうが今後の実用性としても高いんじゃないかと踏んでいます。

UTF8文字列生成を最適化するライブラリ Utf8StringInterpolation を公開しました

Utf8StringInterpolationという新しいライブラリを公開しました!UTF8文字列の生成と書き込みに特化していて、動作をカスタマイズした文字列補間式によるC#コンパイラの機能を活用した生成と、StringBuilder的な連続的な書き込みの両方をサポートします。

基本的な流れはこんな感じで、Stringを生成するのと同じように、UTF8を生成/書き込みできます。

using Utf8StringInterpolation;

// Create UTF8 encoded string directly(without encoding).
byte[] utf8 = Utf8String.Format($"Hello, {name}, Your id is {id}!");

// write to IBufferWriter<byte>(for example ASP.NET HttpResponse.BodyWriter)
Utf8String.Format(bufferWriter, $"Today is {DateTime.Now:yyyy-MM-dd}"); // support format

// like a StringBuilder
var writer = Utf8String.CreateWriter(bufferWriter);
writer.Append("My Name...");
writer.AppendFormat($"is...? {name}");
writer.AppendLine();
writer.Flush();

// Join, Concat methods
var seq = Enumerable.Range(1, 10);
byte[] utf8seq = Utf8String.Join(", ", seq);

Cysharpから公開している ZString と非常に近いのですが、ZStringがString(UTF16), UTF8をサポートしていたのに対して、UTF8側のみを取り出して強化したようなイメージになります。何が強化なのかというと、C# 10.0からImproved Interpolated Stringsとして、文字列補間式($"foo{bar}baz")のパフォーマンスが大きく向上しました。具体的には、コンパイラが文字列補間式の構造を分解して、値が埋め込まれている箇所はGenericsのまま渡すようになりボクシングが消滅しました。つまりZStringでやっていたことではあるのですが、ZStringはC# 10.0以前のものですからね……!逆に言えば、これによってZStringは半分は不要となったわけです。

もう半分、UTF8側に関しては依然として標準のサポートは薄い、というかほぼない状態です。しかし、Improved Interpolated Strings は文字列補間式での挙動を自由にカスタマイズできるという性質も追加されています。というわけで、文字列補間式を利用してUTF8を組み立てられるようにすればいいのではないか、というのがUtf8StringInterpolationのコンセプトであり、正しくZLoggerの後継として位置づけていることでもあります。

// こういう文字列補間式を渡すと:
// Utf8String.Format(ref Utf8StringWriter format)
Utf8String.Format($"Hello, {name}, Your id is {id}!");
// コンパイラが「コンパイル時」にこのような形に展開します。
var writer = new Utf8StringWriter(literalLength: 20, formattedCount: 2);
writer.AppendLiteral("Hello, ");
writer.AppendFormatted<string>(name);
writer.AppendLiteral(", You id is ");
writer.AppendFormatted<int>(id);
writer.AppendLiteral("!");

コンパイル時に展開してくれるというのは性能上非常に重要で、つまりString.Formatのように実行時に文字列式のパースをしないで済む、わけです。また、ボクシングなしに全ての値を書き込みに呼んでくれます。

[InterpolatedStringHandler]を付与しているref Utf8StringWriter$"{}"を渡すと、自動的に展開してくれるという仕様になっています。そのUtf8StringWriterは以下のような実装になっています。

// internal struct writer write value to utf8 directly without boxing.
[InterpolatedStringHandler]
public ref struct Utf8StringWriter<TBufferWriter> where TBufferWriter : IBufferWriter<byte>
{
    TBufferWriter bufferWriter; // when buffer is full, advance and get more buffer
    Span<byte> buffer;          // current write buffer

    public void AppendLiteral(string value)
    {
        // encode string literal to Utf8 buffer directly
        var bytesWritten = Encoding.UTF8.GetBytes(value, buffer);
        buffer = buffer.Slice(bytesWritten);
    }

    public void AppendFormatted<T>(T value, int alignment = 0, string? format = null)
        where T : IUtf8SpanFormattable
    {
        // write value to Utf8 buffer directly
        while (!value.TryFormat(buffer, out bytesWritten, format))
        {
            Grow();
        }
        buffer = buffer.Slice(bytesWritten);
    }
}

.NET 8 の場合は、値が IUtf8SpanFormattable という.NET 8から追加されたインターフェイスを実装している場合(intなど標準のプリミティブはほぼ実装されています)、直接TryFormatによりUTF8としてSpanに書き込みます。

さすがに .NET 8 にしか対応していません!というのはエクストリームすぎるので、 .NET Standard 2.1, .NET 6(.NET 7)では Utf8Formatter.TryFormat を使うことで、同様の性能を担保しています。

Builder vs Writer

ZStringのときはStringBuilderに引っ張られすぎていて、Builderとして内部でバッファを抱えるようにしていたのですが、ちょっとUTF8的な利用ではイマイチだということが徐々に分かってきました。今の .NET の基本は IBufferWriter<byte> である。というのはついこないだのCEDEC 2023での発表 モダンハイパフォーマンスC# 2023 Edition でかなり語らせていただいたのですが

BuilderというよりもWriterとして構築すべきだな、ということに至りました。そこで Utf8StringWriter は基本的にIBufferWriter<byte>を受け取ってそれに書き込むという仕様となりました。

public ref partial struct Utf8StringWriter<TBufferWriter>
    where TBufferWriter : IBufferWriter<byte>
{
    Span<byte> destination;
    TBufferWriter bufferWriter;
    int currentWritten;

    public Utf8StringWriter(TBufferWriter bufferWriter)
    {
        this.bufferWriter = bufferWriter;
        this.destination = bufferWriter.GetSpan();
    }

    public void Flush()
    {
        if (currentWritten != 0)
        {
            bufferWriter.Advance(currentWritten);
            currentWritten = 0;
        }
    }

バッファが足りなくなったときは拡大するのではなくて、Advanceして新たにGetSpanを呼んで新しいバッファを確保しにいくという形を取りました。StringBuilderと違ってFlushの概念が必要になってしまいましたが、パフォーマンス的には大きな向上を果たしています。

Flushが必要ということを除けば、StringBuilderのように扱うことができます。

var writer = Utf8String.CreateWriter(bufferWriter);

// call each append methods.
writer.Append("foo");
writer.AppendFormat($"bar {Guid.NewGuid()}");
writer.AppendLine();

// finally call Flush(or Dispose)
writer.Flush();

また、ちょっとStringBuilder的に使いたいだけの時に IBufferWriter<byte> を用意するのは面倒くさい!という場合のために、内部でプーリングを行っているバッファを使えるオーバーロードも用意しています。戻り値がバッファのコントローラーになっていて、ToArrayや他のIBufferWriter<byte>にコピーしたりReadOnlySpan<byte>の取得ができます。

// buffer must Dispose after used(recommend to use using)
using var buffer = Utf8String.CreateWriter(out var writer);

// call each append methods.
writer.Append("foo");
writer.AppendFormat($"bar {Guid.NewGuid()}");
writer.AppendLine();

// finally call Flush(no need to call Dispose for writer)
writer.Flush();

// copy to written byte[]
var bytes = buffer.ToArray();

// or copy to other IBufferWriter<byte>, get ReadOnlySpan<byte>
buffer.CopyTo(otherBufferWriter);
var writtenData = buffer.WrittenSpan;

その他、Format, Join, Concat メソッドなども IBufferWriter<byte> を受け取るオーバーロードと byte[]を返すオーバーロードの2種を用意しています。

.NET 8 と StandardFormat

値のフォーマット書式は、特にDateTimeでよく使うと思いますが、数値型などでも多くの書式が用意されています。.NET の数値、日付、列挙、その他の型の書式を設定する方法 や各種カスタム書式指定文字列は非常に便利です。

しかし、UTF8に値を直接書き込む手段として従来用意されていたUtf8Formatter.TryFormatでは、その標準的な書式指定文字列は使えませんでした!代わりに用意されたのがStandardFormatなのですが、恐ろしく限定的なことしかできず(例えば'G', 'D', or 'X'のような一文字charの指定しかできない)、使い物にならないといっても過言ではないぐらいでした。

ところが .NET 8 から追加された IUtf8SpanFormattable.TryFormat では、通常の書式指定文字列が帰ってきました!

// Utf8Formatter.TryFormat
static bool TryFormat (int value, Span<byte> destination, out int bytesWritten, System.Buffers.StandardFormat format = default);

// .NET 8 IUtf8SpanFormattable.TryFormat
bool TryFormat (Span<byte> utf8Destination, out int bytesWritten, ReadOnlySpan<char> format, IFormatProvider? provider);

パラメーターは非常に似ていますが、formatを文字列で受け取るようになっています。実際に比較してみるとこんな感じです。

Span<byte> dest = stackalloc byte[16];
int written = 0;

// ParseできなくてExceptionがthrowされるので表現できない
Utf8Formatter.TryFormat(123.456789, dest, out written, StandardFormat.Parse(".###"));

// 123.456
123.456123.TryFormat(dest, out written, ".###");


// カスタム書式文字列は指定できないので例外!サポートしてるのは `G`, `R`, `l`, `O` だけ!
Utf8Formatter.TryFormat(DateTime.Now, dest, out written, StandardFormat.Parse("yyyy-MM-dd"));

// もちろんちゃんと動作する
DateTime.Now.TryFormat(dest, out written, "yyyy-MM-dd");

Console.WriteLine(Encoding.UTF8.GetString(dest.Slice(0, written)));

良かった、やと普通の世界が到達した……!これは ZString や、それを内部に使っていた ZLoggerで最もフラストレーションを感じていた点です。

Utf8StringInterpolationは .NET 8 では全て IUtf8SpanFormattable で変換するようにしています。しかし、 .NET Standard 2.1, .NET 6, .NET 7では残念ながらUtf8Formatter利用となっているので、書式指定に関しては制限があります。数値に関してはターゲットプラットフォームによって動作したりしなかったりが発生します。

// .NET 8 supports all numeric custom format string but .NET Standard 2.1, .NET 6(.NET 7) does not.
Utf8String.Format($"Double value is {123.456789:.###}");

ただし、 DateTime, DateTimeOffset, TimeSpan に関しては Utf8Formatter を使わない処理をしているため、全てのターゲットプラットフォームでカスタム書式指定が利用可能です!

// DateTime, DateTimeOffset, TimeSpan support custom format string on all target plaftorms.
// https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings
Utf8String.Format($"Today is {DateTime.Now:yyyy-MM-dd}");

とにかくDateTimeの書式指定がまともに出来ないのはZString/ZLoggerで一番辛かったところなので、それを改善できてとても良かった……。ただしこの対応により、DateTimeの変換性能が落ちているため、性能が最大限引き出せるのは .NET 8 となります。

Unity

Unity対応はありません!いや、可能な限り私は .NET と Unityの両対応のライブラリを作りたいと思っていて、実際今までもそうしてきているわけですが、今回ばかりはどうにもならないのです。そもそもImproved Interpolated Stringsが C# 10.0 からで、Unityの現在のC#のバージョンは C# 9.0……!さすがにそれはどうにもならない。

C# 9.0で止まってから結構長いんですよね。別にランタイムのバージョンは上げなくてもいいから、コンパイラのバージョンだけ上げて欲しいと切実に思いますが、まぁC# 10.0にしたらDefaultInterpolatedStringHandlerがなくて動作しないじゃんとかなるだろうから、結局はランタイムのバージョンアップもセットでやらなければならない……。

Unityが C# 10.0 に対応したらすぐに対応させるつもりではあります!待ってます!

Next

さて、とはいえ、UTF8文字列を直接扱わなければならないケースというのは、別にそんなに多くはないでしょう。実際、私も本命はZLoggerの大型バージョンアップでの利用を考えています。ZLoggerは今まではZStringベースでしたが、根本からデザインをやり直した新しいものを開発中です。その中の文字列化にUtf8StringInterpolationを使っています。

といったように、アプリケーションの基盤レイヤーに差し込んであげると有効に機能するシチュエーションは色々あると思います。もちろん、直接使ってもらってもいいのですが……!?

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive