PolyTypeとserde、シリアライザの話

先日MessagePack-CSharpのv4案が提出され、そこでまあ...一悶着ありました。(あえてリンクは貼らないので、気になる方は自分で探してください)

あまりに本筋から離れた議論に見かねて外野から私もかなり口出ししてしまっていて、ちょっと反省してます。とはいえこの件や近年の.NETランタイム、特にUnsafe Evolution周りに関して思うことがないと言えば嘘になるんですが、とりあえず終わったことではあるので、これ以上この話はしません。はい。

というわけでMessagePack-CSharpは@neueccさんを中心に更なるパフォーマンス追求の道へ進んでいて個人的にはとても楽しみなんですが、それとは別にMessagePack-CSharpでv2/v3のメンテナーを行っていた@AArnottさんは現在、MessagePack-CSharpの開発からは距離を置きつつあり、新しいC#向けMessagePackシリアライザであるNerdbank.MessagePackに取り組んでおられるようです。

AArnott / Nerdbank.MessagePackA feature-packed .NET MessagePack serialization library with great performance and simplicity. msgpack.io[C#]17216C#

これはPolyTypeというライブラリの抽象化層の上に実装されたシリアライザになっています。これに関して、v4のPRでneueccさんが以下のような懸念を表明しています。

A cross-library abstraction layer like PolyType imposes the same performance ceiling on every serializer built on top of it. Performance lost at the foundation cannot be recovered by the layers above. So frankly, I am concerned about it spreading as a foundational layer for serialization in the .NET ecosystem.

翻訳: PolyTypeのようなライブラリ横断的な抽象化レイヤーは、その上で構築されるすべてのシリアライザーに対し、共通の性能上の上限を課すことになります。基盤部分で失われた性能は、上位のレイヤーで取り戻すことはできません。そのため、率直に言って、.NETエコシステムにおけるシリアライゼーションの基盤レイヤーとしてこれが普及することには懸念を抱いています。

RustとC#の両方のコミュニティを眺めてる身としてはなかなか興味深い話です。せっかくなのでもう少し深掘りしてみましょう。

PolyType

eiriktsarpalis / PolyTypePractical generic programming for .NET28020C#

改めてPolyTypeの話をしましょう。PolyTypeは厳密に型付けされた型のShapeとそれに対するVisitorを提供するライブラリです。[GenerateShape]属性を対象の型に付与することで、必要なコードがSource Generatorによって生成されます。(Reflection経由のProviderも一応提供はされていますが、NativeAOT対応を考えるとSource Generatorの利用は前提となるでしょう)

[GenerateShape] // IShapeable<Person>の実装を生成する
partial record Person(string name, int age, List<Person> children);

これにより以下のように型情報にアクセスできるようになります。

ITypeShape<Person> shape = TypeShapeResolver.Resolve<Person>();

// プロパティを列挙
foreach (var property in shape.Properties)
{
    Console.WriteLine(property.Name);
}

Reflectionと似ていますが、事前にコード生成が行われるため、こちらの方がパフォーマンスは遥かに上です。そこそこのパフォーマンスで気軽に型情報を使ったライブラリが作れる、というのは魅力的かもしれません。実際手軽に使えていい感じな雰囲気はあります。

今のところPolyTypeが.NET界隈でそこまでメジャーなライブラリか?と言われると全くそんなことはない(知らない人の方が多いでしょう)と思いますが、これからどうなるのかはわかりません。

serde

そしてRustaceanたる我々は似たようなものを知っています。皆様ご存知、serdeですね。

serde-rs / serdeSerialization framework for Rust10,758929Rust
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize, Debug)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let point = Point { x: 1, y: 2 };

    // Convert the Point to a JSON string.
    let serialized = serde_json::to_string(&point).unwrap();

    // Prints serialized = {"x":1,"y":2}
    println!("serialized = {}", serialized);

    // Convert the JSON string back to a Point.
    let deserialized: Point = serde_json::from_str(&serialized).unwrap();

    // Prints deserialized = Point { x: 1, y: 2 }
    println!("deserialized = {:?}", deserialized);
}

serdeといえばシリアライズの時に出てくるもの、というイメージが強いですが、それ自体は特定のフォーマットに対するシリアライズの機構を持ちません。あくまで本体は型情報とVisitorの提供のみで、具体的なシリアライズ処理はserde_jsonなどの個別の実装が存在する形です。

serdeは現在Rustにおけるシリアライザ基盤のデファクトスタンダードとして君臨していますが、批判がないわけではありません。

Blog post: Making Slow Rust Code FastI wrote a blog post on performance benchmarking and tuning in Rust using Criterion.rs and flamegraphs, which is based on my recent experience optimizing the mongodb crate. Check it out if interested in learning some techniques for speeding up a Rust codebase!The Rust Programming Language Forum

Forumのこのスレッドでは、serdeの抽象化層が引き起こすパフォーマンスの問題について議論されています。serdeの生成するコードはかなり複雑で、コンパイル時間やバイナリサイズに少なくない悪影響をもたらします。Rustの抽象化は基本的に静的ディスパッチであるため、実行時パフォーマンスへの影響は少ないですが、ただでさえコンパイルが遅いRustですから、コンパイル時間の低下は無視できないファクターでしょう。

とはいえ、実行時パフォーマンスへの影響がないわけではありません。

【Rust】zerompk - Rust向けの最速MessagePack実装とその最適化手法Zenn

私は自作の高速なRust製MessagePackシリアライザを開発・メンテナンスしており、その時の解説記事にも書きましたが、serdeを使わないことで少なくないパフォーマンスの改善が見込めます。細かい説明はここでは省略しますが、serdeのVisitorを経由せずに直接バイナリを読み書きしてシリアライズ・デシリアライズするコードを生成した方が高速になる、というのは直感的に理解できるでしょう。

シリアライザはアプリケーションのパイプラインに組み込まれるものであり、通信のたびに実行されることから、パフォーマンス要件が極めて高いライブラリと言えます。serdeは確かに便利ですが、パフォーマンスに関してはやや疑問があり、これがデファクトスタンダード化しているのはどうなのかな...と思わなくもない、ということが多少なりとも伝わったでしょうか。

改めてPolyTypeのこと

ここまではserdeの話でしたが、C#の場合はもう少し事情が異なります。C#でもstructとジェネリクスを活用したゼロコスト抽象化は可能ですが、型システムの表現力がRustのそれほど高くないので、使いどころは限られます。

そして当然(?)PolyTypeはinterfaceを介した抽象化になっているので、実行時パフォーマンスにかなりの影響を与えます。近年の.NETのJITはそれなりに優秀ですが、すべての仮想メソッドを脱仮想化(devirtualization)できるわけではありません。これがシリアライザの基盤としてエコシステムに広まることを考えると...懸念を表明したくなる気持ちもわかるでしょう。

正直なところ、私もPolyTypeをシリアライザの基盤として使うことに対しては否定的です。有用な場面がないわけではないですが、これが広まってほしくないという気持ちもかなりあります。PolyTypeやNerdbank.MessagePackの実装は悪くないですが、この辺りの認識が甘いのはユーザーとしてもどうなのかな...と。

結局話の着地点が見えないまま雑に書いてきましたが、まあこういう話もあるんだな、くらいの雰囲気で読んでもらえれば幸いです。個人的にはMessagePack-CSharp派の人間なので、v4もいい感じになるといいですね。