ZeroFormatterと謎RPCについて発表してきました。

歌舞伎座.tech#12「メッセージフォーマット/RPC勉強会」で話してきました。前半はZeroFormatterについて、後半は謎の何かについて、です。

ZeroFormatter/MagicOnion - Fastest C# Serializer/gRPC based C# RPC from Yoshifumi Kawai

ZeroFormatterは良くも悪くもというか、あんま良くないんですがリリース頻度がすごくて、1.0出してから既に16回もアップデートを重ねていて最新は1.5.2です。1.0とは何だったのか……。いやまあ一応、自称、さすがに安定してきたとは思っています。思っています。思っています。常にこれで完成だ!って思ってはいます(反省)。

なんでこんなに変わったかというと、社内での置き換えで200クラス以上は書き換えてったんですが(とぅらい……)、わりと重箱の隅を突っつくような使い方をしてるところがあったりなかったりで、ビミョーに引っかかりまくったせいだ、という。ようは詰めが甘いってことなんですが、かなり色々なケースで鍛え上げられたという言い方はできます。それならちゃんと社内で叩き上げてから公開しろよって気がしなくもないんですが、公開後に皆さんから頂いたフィードバックはものすごく役立ったので、大変助かりました。お陰で当初よりも、更により良いものになったと思っています。

今回のセッションで省略した、C#の実装面でシリアライザのパフォーマンスを稼いでいく話については、12月1日に赤坂のbitFlyerさんで行われる【bitFlyer TechNight★ vol.2 C#LT Meetup!】でお話したいと思っていますので、良ければそちらへの参加もどうぞ。

Union Again

Union(1.5からDynamicUnionという動的にUnionを作る機能も入れています)は、成功時と失敗時(汎用のstring messageだけじゃなくて特化した何かを返したい)みたいな表現にも使えます。エラー表現が複数種類ある場合は、IsSuccessをenumに変えて、Union属性のtypeofに複数書いてもらえればOKって感じにサクッと拡張していけます。

[Union(typeof(Success), typeof(Error))]
public abstract class MyServiceResponse
{
    [UnionKey]
    public abstract bool IsSuccess { get; }

    [ZeroFormattable]
    public class Success : MyServiceResponse
    {
        public override bool IsSuccess => true;

        [Index(0)]
        public virtual int Foo { get; set; }
        [Index(1)]
        public virtual string Bar { get; set; }
    }

    [ZeroFormattable]
    public class Error : MyServiceResponse
    {
        public override bool IsSuccess => false;

        [Index(0)]
        public virtual int ErrorCode { get; set; }
        [Index(1)]
        public virtual int Sender { get; set; }
        [Index(2)]
        public virtual int Receiver { get; set; }
        [Index(3)]
        public virtual string Message { get; set; }
    }
}

よくある2つだけのケースの時に一々定義するのが面倒!ジェネリックなEitherが欲しい!って感じになるかもですが(なりますねぇ)、現状の素のUnion, DynamicUnionは継承を前提にした作りになっているので、ジェネリックなEitherは作れないです。ただバイナリ仕様的にはOKなので、そこはF#サポートエクステンションでEither対応させればいいんじゃないでしょうか!ちょっとIL書くだけです(自分ではやらない)。あと、継承前提とかだっせ、F#の判別共用体なら……とかってのも、結局、判別共用体の実態は(ILレベルでは)継承したクラスになってるんですからね!(ぶっちけ実行効率的には富豪過ぎるのでは……)

今回の勉強会では、Unionの話題いっぱい出ました、こんなにUnionの話が聞ける機会があるなんて……!Thriftのunion、GraphQLのUnion、ProtobufのOneof。いいですねいいですねー。

クロスプラットフォーム

Ruby実装Swift実装を作っていただいています!わーい、ありがとうございます!会場のQ&Aにあったのですが、まぁ今回IDLを全体的に嫌った(実際、好きじゃない)内容を話していたのに、他言語で使うのにC#をIDL代わりにするという二重の苦痛なのいいの?ってことですが、そもそも、他言語で使うのにIDL自体が必須ではない、という認識に立ってます。

例えばJSONを使うのに、MsgPackを使うのにIDLは必須ではないでしょう。言語を超えなければ(単一言語内で完結している)、あるいはドキュメントベースでのやり取りをするならば、この場合だとRubyネイティブやSwiftネイティブの表現でZeroFormatterのシリアライズ/デシリアライズは達成できるはずだし、それでいい、それがいいと考えています。

ただ、言語を超えたやり取りをする時に、共通の語彙がないと面倒くさいよね、JSONならデータ自体がある程度自己記述的で、目で見てなんとかなるみたいな側面も実際あるけれど(あるいはデータから型を起こすことができる)、ZeroFormatterのバイナリはそうではないよね。という点で、共通のIDLはあったほうがしかりだし、そこで、まぁC#の表現をスキーマ代わりに使うという話になってきます。そこからジェネレータも兼務するかは別問題として。

なのでLTで発表されていたScalaによるサーバーとUnityによるクライアントをThriftのIDLのリポジトリ置いてやり取りするは、クライアント-サーバーで別言語な状態でコミュニケーションしていくにあたっては良いやり方だなあ、と思いましたし、IDLが存在する強みとも思いました(MsgPackが(実質的に)標準のIDLがないのはこういうところで地味に痛そうですね)。私のアプローチは、サーバーとクライアントを両方C#にすることによって超えていく、ということなのですが、それはそれで共通であることの大変さも存在するので(世界に銀の弾丸は存在しない!大事なのは大変さをどう超えていくか、ですね)、それぞれ環境にあった良いやり方を探っていきたいし、色々知りたいなあというところです。いやほんと、今回の勉強会は私もとても勉強になりました!

懇親会で聞いた、Protobufコードジェネレータがplugin形式になっててAST渡されて、自由に拡張できるってのは、良いですね。現在もC# -> C#書き出しのzfcは、ある程度コード解析してから出してるので、もう少しまとめてプラガブルにするとか、あとは、そのデータを標準入出力経由でやり取りすることでC#でのプラグインではなくてどの言語でも書けるようにする(zfcはZeroFormatterとしてのC#スキーマの解析だけを担ってあげる)、というのは良いなぁ、って感じなのでロードマップには入れたいですが、とにかくやることが無限大に膨らんでいくので、一旦は程々にしておきます。無限大に時間が捻出できれば……!!!

RPC Revisited

3年前から、LightNodeというアンチREST主義なフレームワーク(HTTP1上のRPC風味なRESTフレームワーク)を作っていたので、最近のファッキンRESTな風潮は時代が追いついた……、とか悦に浸ってたりなかったりするのですが、まぁ実際、RPCっすよね、って本当に思ってます。本当に本当に。一貫して。

gRPCはそんなRPC戦国時代の中でも、頭一つ抜けているし、今後デファクトスタンダードとなっていくと思っています。なので、まず一つはgRPCにベットします。そんな中で、C#の人間としてどのようなアプローチを取っていくかの、私からのアンサーがMagicOnionというマ・ニ・ア・ワ・ナ・カ・ッ・タ、フレームワークになっているんですが、まぁ間に合わなかったんであんまり語ることはありません。スライド中では、コンセプトの入り口ぐらいしか紹介できていなくて、もっと深い意味合いが存在しているんですが、その辺を語るのは出来上がってからにしましょう。その辺の間に合わなさから、C# Everywhereという「いつものところ」に話を落とすしかなかったんですが、いやー、本当のところはもう少し大層で高尚なビジョンがあるんです、はい。

ZeroFormatter 1.3 - 機能強化とstructの超高速性能とFAQと。

ほとんど昨日の今日な状態で1.3って、バージョン1.0とは何だったのか、というかそれってベータだったということなのでは?という、あまりにいい加減なバージョン番号付けなのですけれど、そんなわけで1.3です。これが本当の1.0だ……。

基本的な概要は初出での記事 ZeroFormatter - C#の最速かつ無限大高速な .NET, .NET Core, Unity用シリアライザーを読んでいただければと思うのですが、では何が変わったかというと、ReadMeを全部書いた!いや地味に面倒なんですよ、分量あるし。英語だし。

というのもあるんですが、方向性を若干変えました。なんというか、反響が思ったよりも良すぎた。あまりの良さにビビッた(GitHub Starも私的最高伸び速度最大をマークした)、のと、だいぶ気を良くしたので、ユースケースを変えたベンチマークを他にとってみたりして、改めて考えた結果「汎用的に全方位に使える最強シリアライザ」にすることにした。というのが大きな方針転換。

汎用シリアライザとして

ビルトインでサポートしてる型を大幅に増やしました。具体的には

All primitives, All enums, TimeSpan, DateTime, DateTimeOffset,
Tuple<,...>, KeyValuePair<,>, KeyTuple<,...>,
Array, List<>, HashSet<>, Dictionary<,>, ReadOnlyCollection<>, ReadOnlyDictionary<,>,
IEnumerable<>, ICollection<>, IList<>, ISet<,>,
IReadOnlyCollection<>, IReadOnlyList<>, IReadOnlyDictionary<,>, ILookup<,>
and inherited ICollection<> with paramterless constructor

です。まぁようするに、普通に生活してて(?)出てくるほとんど全部の型がそのまま使えます。特にコレクション系を、普通に使ってても一切躓かないようにしました。1.0では実はIList/IDictionaryしかサポートしていなかったのです!もともとの発端がFlatBuffersのような内部にバイト配列を抱えてデシリアライズしないから無限大に速い(ツッコミどころの多いこの表現ですが、これはCap'n Protoから引用してます。Cap'n Protoは日本での知名度はゼロに近いですが、私は最初見た時かなり衝撃を受けました。ちなみに他にもタイムトラベルRPCとか、カッコイイ用語が目白押しなのもCap'n Protoは素敵です)、という点を強く意識していたので、具象型(ListとかArray)だと、それが実現できないんですよね。なので却下にしてたのですけれど、「汎用シリアライザ」として使わせたいんだったらサポートしたほうがいいかな、と。シリアライズ/デシリアライズ速度が他を圧倒して超高速だったというのも決断を後押ししてます。まぁこれだけ速いんだから全然いいだろ、みたいな。

structが超速い

というか、これに関しては他が遅すぎるといったほうが正しいぐらい。

image

intだけとかVector3とかそれの配列とか、HTMLぐらいを想定した大きめ文字列とかの結果です。文字列は結局UTF-8でエンコード/デコードするのはみんな変わらないのでそんなもんかってところですが、他が絶望的に違いすぎる。アホみたいに差が開いてるんですが、これは事実なんだなぁ。

これは、小さいデータに関しての考慮が全然ないから、というのがめっちゃ大きい。int(1)を書くってのは、つまり最速は BitConverter.GetBytes(1) なんですよ、で、もはやそこからどれだけ「遅くするか」の勝負ですらある。他のシリアライザは、やってることがあまりにも多い、だから際限なく、最速から遠くなる。ZeroFormatterは限界まで無駄がない(実際、これ以上縮めようがない)ので、もんのすごく差が開きます。どうせ小さいデータだから一個一個は差がデカいといっても小さいとも言えるんですが、頻度が高いと馬鹿にならない差になります。というかさすがにここまで違うと全然違うでしょう。

小さいデータのやり取りって、ないようで結構あるんですよ。ウェブだったら、例えばMemcachedやRedisなどKVSへのアクセスでintだけ格納したりとかって普通によくある。ゲームだったら座標データ(Vector3)のやり取りとかね。なのでまぁ、ZeroFormatterはかなり価値あるかなー、と。

Union型の追加

なにそれ、というと、一個の型の表明で複数の型を返せるようになります。どちらかというとポリモーフィズムのほうが近いですかねー、実際C#でのデシリアライズ結果はポリモーフィズムとしての表現に落としているので。ド直球に言うとFlatBuffersにあるやつです。

// こんなんで判別したいとして
public enum CharacterType
{
    Human, Monster
}

// こんなふーにabstract classとUnionAttributeに子クラスを並べて、UnionKeyで識別するものを指します
[Union(typeof(Human), typeof(Monster))]
public abstract class Character
{
    [UnionKey]
    public abstract CharacterType Type { get; }
}

// あとは延々と並べる。
[ZeroFormattable]
public class Human : Character
{
    // UnionKeyはintでもstringでもなんでもいいんですが、かならず同じ値が帰ってくるようにする必要がある
    public override CharacterType Type => CharacterType.Human;

    [Index(0)]
    public virtual string Name { get; set; }

    [Index(1)]
    public virtual DateTime Birth { get; set; }

    [Index(2)]
    public virtual int Age { get; set; }

    [Index(3)]
    public virtual int Faith { get; set; }
}

[ZeroFormattable]
public class Monster : Character
{
    public override CharacterType Type => CharacterType.Monster;

    [Index(0)]
    public virtual string Race { get; set; }

    [Index(1)]
    public virtual int Power { get; set; }

    [Index(2)]
    public virtual int Magic { get; set; }
}
// で、こう使う。
var demon = new Monster { Race = "Demon", Power = 9999, Magic = 1000 };

// Union型を指定してシリアライズする(そうしないと子を直接シリアライズしてしまうので)
var data = ZeroFormatterSerializer.Serialize<Character>(demon);

var union = ZeroFormatterSerializer.Deserialize<Character>(data);

// 結局みんな大好きswitchですが何か。
switch (union.Type)
{
    case CharacterType.Monster:
        var demon2 = (Monster)union;
        demon2.Race...
        demon2.Power..
        demon2.Magic...
        break;
    case CharacterType.Human:
        var human2 = (Human)union;
        human2.Name...
        human2.Birth...
        human2.Age..
        human2.Faith...
        break;
    default:
        Assert.Fail("invalid");
        break;
}

最終的にswitchなのがダサいといえばダサいんですが(C#でやる表現上の限界かな!)、まぁ悪くない落とし所なのではないかな、と。で、これ、便利ですよ。マジで。うーん、結構あるんですよね、状況に応じて複数データ返したいときって。で、愚直にやるとこうなるわけです。

public class Hoge
{
    public 何か1の時の型 Nanika1 { get; set;}
    public 何か2の時の型 Nanika2 { get; set;}
    public 何か3の時の型 Nanika3 { get; set;}
}

いやー、色々無駄だし型の表現としてもアレだしちょっと、ねー、っていう。

Unionをシリアライザで記述するという点では、ZeroFormatterのやり方はかなり上手い感じで(自分で言う)、書きやすさと安全性(完全ではないけれど、意識しやすさが高いのでそこそこはある)をいい塩梅に両立させれたんじゃないかなー、と。特に書きやすさはかなりあると思います。というかぶっちけ他のシリアライザでこの手のポリモーフィズムやるのは凄まじく大変なので、革命的に便利になったといっても過言ではない。

バイナリ仕様の整理と多言語対応

諸々の追加や事情も踏まえて、バイナリ仕様を整理しました。

まず、言語中立にしました。いやまぁ、もともと、C#依存度の高いものは外して移植しようと思えばできるように、みたいな感じに作ってはいたのですけれど、より明確に中立を意識して整理しました。元々かなり頭悪く単純に作ってあるので(ZeroFormatterの速さは賢くないバイナリ仕様をC#実装力でねじ伏せる、というところがかなりあって、逆に言えば実装Firstで作られているので、言語実装で最速になるように寄り添って仕様が固まったとも言える)

というのと、↑のように遅延実行ではないコレクションのサポートを正式に入れるということで、Sequence Formatというのを正式に用意して遅延ではないDictionaryなどのレイアウトはここに属する、という形にしました。Objectも、ObjectとStruct という分けかたで定義して、KeyTupleはStructに属してますよ、みたいに割とそこそこちゃんと汎用的感な分類になってるんじゃあなかろうか。結構あーでもないこーでもないと弄ってたんですが、うーん、なるほど、こういうのは結果はあっさりしてるけど過程はとても大変……。

と、いうわけで、言語がC#のみってのはさすがに普通に欠点なんですが、整備してみたんで多言語サポートよろしくお願いします、みたいな(?)。やりたい気持ちはあるんですが、如何せんちょっとC#以外は手が回らないのデスデス。社内ではサーバーもC#で完動するようになってるので、あんまり強い外圧が働かなくて。そして実際手が回らないので。仕様作る!実装する!社内のプロジェクトのデータの移植もする!更にこれを使った次の何かも作る!あわあわわわわあわ、本当に手が回ってないヤヴァイ。

スキーマはあるよ

スキーマはあります。見えないだけで。どういうことかというとこういうことです。

namespace /* Namespace */
{
    // Fomrat Schemna
    [ZeroFormattable]
    public class /* FormatName */
    {
        [Index(/* Index Number */)]
        public virtual /* FormatType */ Name { get; set; }
    }

    // UnionSchema
    [Union(typeof(/* Union Subtypes */))]
    public abstract class UnionSchema
    {
        [UnionKey]
        public abstract /* UnionKey Type */ Key { get; }
    }
}

C#自体がスキーマなのです。それの利点はかなりあって、「パーサーを作らなくて済む(C#のコンパイラは既にC#で実装されていて、それのパーサーが使える)」「入力補完/コードフォーマット/シンタックスハイライト/アナライザー拡張などIDE(Visual Studio)の恩恵をフルに使える」ってのが、まずは良い。実際、zfc.exe(ZeroFormatterCompiler)という実行ファイルによって、C#というスキーマをもとにコード生成をしています。現在はAOTのためのC#コード生成ですが、別に出力を変えれば、他の言語のコードでも全然吐けます(ランタイムがないから無理だけど!)

デメリットは「機能が制限されてないので容易に制限からはみだせるので言語中立にしづらい」「現行のC#の言語機能に制限される(例えば非nullなStringは定義できない)」ってとこですね。特に前者がビミョーなんですが紳士協定の範囲内(C#としてコンパイル可能でもZeroFormatterとして解析不能だっていうエラーを放り投げちゃえばSyntaxErrorなコードと変わらない)に収めることはなんとか可能なんじゃあないかなあ、とか。ってのは夢見てます。

そして最大の利点がスキーマが生成を介さなくてもシェアできる、ということ。「プロジェクト参照」や「DLL参照」という形で、スキーマと生成コード(実際は実行時動的生成するんですが)をコード生成なしで複数プロジェクト間で共有できます。シームレスに。これは非常に大きくて、まぁ前の記事でも書いたんですがコード生成はやればやるほど複雑化していくんで、ないに越したことはないんですよね。んで、C# as Schemaだと、ゼロにできる。これはワークフローにとってはインパクトが相当大きいことです。

私は、コード生成や自動化って「したくない」ことの筆頭候補に挙げてます。自動化はミクロでは楽になっても、その積み重ねがマクロでは害悪になるケースが往々にして多い。なので、やるべきことは「自動化をしなくてすむ」ようにすることです。そのために脳みそを動かしたい。結果、脳みそが追いついてなくてそこら中が止まることも往々にしてある。shoganai。

まとめ

redddit/r/csharp/ZeroFormatterでAsk Me Anythingやってます(とは)。Fastestとかぶち撒けたせいでシリアライザ戦争が勃発している(恐ろしい)。なるほどWire、シランカッタ。コード的には基本的にZeroFormatterのほうが速そーなので、トータルで色々なケース作れば勝つと思うんだけど、弱点を突くと負けるケースは出てくるのかなぁ。とはいえ普通に私の手元で図ったら圧勝した、ふむ。(最終的に相手のベンチマークにZeroFormatter足して計測→結果 圧倒的な圧勝ということで、まぁしょうがない、相手が悪い。確かにWireは二位なので、惜しかったで賞というところ)

というわけで、真面目に、C#でサッと今使ってるシリアライザをそのまま置き換えられるものにしました。つまり、あらゆるところで使ってください、と言ってます。実際、小さなところから大きなところまで効果あると思います。小さなところは↑でstructを例にしましたが、大きなところでは、例えばバッチ処理の連鎖とかで、延々と巨大なデータを送っているのだけれど、一つ一つはその一部しか使わないんだよねー、みたいな場合。に、ものすごく効くんじゃない?って意見貰いました。その通りで、実際そういうケースでは正しくめっちゃ効きますねー。

とかとかって感なので、是非是非試してみてくださいな。あとクドい告知ですが11/27開催の歌舞伎座.tech#12「メッセージフォーマット/RPC勉強会」でもお話します&クロスプラットフォーム(Unity, Windows, Mac, Linux)で使える通信用のフレームワークをリリースします(!)のもします(ホントに!)

ZeroFormatter - C#の最速かつ無限大高速な .NET, .NET Core, Unity用シリアライザー

(現状は)C#専用の、新しいシリアライズフォーマットを作りました。アセットストアには置いてないんですが、GitHubで公開しています。ReadMeが超書きかけですが明日ぐらいには全部書き終わってるはず……。

特徴はデシリアライズ速度がゼロなので、真の意味で爆速です。そう、無限大高速。

image

嘘くせー、って話なんですが、実のところこれは類似品があって、Googleの出してるFlatBuffersと基本的な考えは同じです(他にCap'n Protoというのもあります、こっちも元Googleの人ですね)。デシリアライズ「しない」から速い。つまるところ必要になるときまでパースを先送りするってことです。これは、アプリケーションの作りにもよりますが非常に効果があって、例えばデカいマスタデータをドバッと取得するなんてときに、その場で必要なデータってその巨大データのごく一部だったりするんですよね。全部パースしてデシリアライズなんかしてると遅くって。そういった問題をFlatBuffersなら一挙に解決できます(多分)。ってことは同種のZeroFormatterでも大解決できます。

なら、じゃあFlatBuffers使えばいいじゃんって話になると思うんですが、なんとFlatBuffersはAPIがエクストリームすぎて実用はマジ不可能。最速という噂のFlatbuffersの速度のヒミツと、導入方法の紹介という記事にもありますが、まぁfbsという専用IDLでスキーマ書いてジェネレートまでは許せても、その後のオブジェクトの生成をバイナリ弄っているかのように生でビルドさせるのは正気の沙汰ではない……。さすがにこれをふっつーに使うのは無理でしょ、無理。それでもピンとこない?このFlatBuffersの公式サンプルはどうでしょう?new Monster { Hp = ... Name =... WEapon... }ってだけのはずのコードが凄いことになってますけれど、まぁ、つまるところそういうことです。厳しい。絶対厳しい。(しかもそんだけやってもそこまで速くないという)。

というわけで、C#で「ちゃんと使える」というのを念頭において、シンプルなAPI(Serialize<T>とDeserialize<T>だけ!)で使えるようにデザインしました。

また、社内的事情で、IDictionaryやILookup(MultiDictionary)へのゼロ速度デシリアライズが欲しかったので(Dictionaryを作るのに配列を全部パースしてC#コードで構築、なんてやってると結局全部パースしててパースの先送りができないわ、Dictionary構築にかなり時間喰っちゃうわで全然ダメ)、ネイティブフォーマットの中にDictionaryやILookupを加えています。これにより爆速でDictionaryのデシリアライズが終わります。Dictionaryをまんま保存できるので、簡易データベース、インメモリKVSとなります。ただのシリアライズフォーマットより少し賢くて、SQLiteのようなデータベースほどは賢くない、けれど、Dictionaryそのものなので絶妙な使いやすさがある。結構、そういうのがマッチするシチュエーションって多いんじゃないかと思います(MySQLをメインに使っててもRedisも最高だよね、みたいな)

シリアライズ速度もまた、並み居る強豪を抑え(protobuf-net, MsgPack-Cli, UnityだとネイティブJsonUtilityなど)最速をマークしています。パフォーマンス系は痛い思い出があるので(性能ガン無視したゴテゴテした何かで構築すると、困ったときに性能を取り戻すのは非常に難しく、始まる前から技術的負債となる……)、とにかくパフォーマンス超優先、絶対落とさん、というぐらいにギチギチに突き詰めました。実際、今後C#でZeroFormatterを超える速度を叩き出すのは不可能でしょう。いやマジで。というぐらいにC#の最適化技法が詰め込んであります。

Unityサポートを最初から組んでいるシリアライザも珍しくて(まぁふつーは.NETでシリアライザ書くとふつーの.NETが対象になって対応が後手に回るので)、使えるっていうだけではなくて、ちゃんとiOS/IL2CPPでも最速が維持できるように組んであります。結果実際、ネイティブ実装なはずのJsonUtilityよりも速い。C#が遅いなんて誰が言ったヲイ。ちゃんと書けば速いのだ(まぁJSONじゃないからってアドバンテージはあるんだけど)。この辺はUniRxの実装で散々IL2CPPと格闘した経験がちゃんと活きてます。

メインターゲットは Server - Unity 間での通信のためですが、Server - ServerのRPC/Microservices的シナリオや、Unityでのファイルセーブなどのシナリオでも有意義に使うことは可能でしょう。難点はネットワーク通信に使うとサーバーもC#で実装しなきゃいけないってことですね!それはいいことですね!この際なのでC#で実装しましょう!そのために .NET Coreにも対応させたのでLinuxでも動かせますよ!

まぁ、この辺は来月ぐらいというか、今月末ぐらいには、更にクロスプラットフォーム(Unity, Windows, Mac, Linux)で使える通信用のフレームワークをリリースします(!)ので、そこはそれを待っていただければきっと活用の幅が広がるはずです……。詳しくは11/27開催の歌舞伎座.tech#12「メッセージフォーマット/RPC勉強会」でお話するつもりなので、是非来てくださいな。

使い方

DLLはNuGetに転がってます。

.NET用。

Unity用。Interfacesは.NET 3.5プロジェクトとUnityで共用できるのでクラスの共通化に使えます。Unityの場合はreleasesからバイナリをダウンロードしてもらったほうがいいかもしれません。

Visual Studio 2015用のAnalyzer。

クラスを定義して、ZeroFormatterSerializer.Serializeでbyte[], DeserializeでTが取れるというのが基本APIになります。

[ZeroFormattable]
public class MyClass
{
    [Index(0)]
    public virtual int Age { get; set; }

    [Index(1)]
    public virtual string FirstName { get; set; }

    [Index(2)]
    public virtual string LastName { get; set; }

    [IgnoreFormat]
    public string FullName { get { return FirstName + LastName; } }

    [Index(3)]
    public virtual IList<int> List { get; set; }
}

class Program
{
    static void Main(string[] args)
    {
        var mc = new MyClass
        {
            Age = 99,
            FirstName = "hoge",
            LastName = "huga",
            List = new List<int> { 1, 10, 100 }
        };

        var bytes = ZeroFormatterSerializer.Serialize(mc);
        var mc2 = ZeroFormatterSerializer.Deserialize<MyClass>(bytes);

        // ZeroFormatter.DynamicObjectSegments.MyClass
        Console.WriteLine(mc2.GetType().FullName);
    }
}

ZeroFormatterSerializerの使い方自体は超単純なんですが、対象となるクラスには幾つか制限があります。「ZeroFormattable」で「Indexで番号のついた」「virtualな」プロパティが必要です。更にコレクションはIList<T>で、ディクショナリはIDicitionary<TKey,TValue>で宣言しておく必要があります。おー、なんか面倒くさそうですね!そこでVisual Studioの環境ならAnalyzerが用意されていて、エディット時にリアルタイムで警告/修正してもらえます。

zeroformatteranalyzer

structにも対応していて、その場合は「Indexで0から欠番なしの連番がついたpublicなフィールドかプロパティ」と「その順番どおりの引数を持つコンストラクタ」が要求されます。これもAnalyzerが警告します。詳しいルールはReadMeで!

IDL経由で書くよりマシだし(普通のC#ですからね)、そこまで面倒くさくはないかなあ、というギリギリラインにしているつもりです。virtual強要のダルさとかルールの多さはVisual Studio Analyzerでカバーするという、今風の作りになってます。今風といっても、一つのライブラリにAnalyzerをセットでがっつし組み込むような作りしてる人は私以外見た覚えないけれど……。一昔前だとvirtual強要とか無理ゲーと思ってましたが、Analyzer以降の世代のC#ならこういう作りをしてもアリだなって思えているので、APIの見せ方の幅が広がると思うんで、もう少し増えてもいいんじゃないかなーとは思いますね。

デシリアライズと再シリアライズ

グラフ意味ないレベルなんですが、デシリアライズ速度。特に大きい配列とか、サイズがデカければデカいほど無限大に差は開きます。

image

なんでかといえば、裏にbyte[]を持って、ラップするクラスに包んでいるだけだからです。クラス定義で virtual を強要しているのは、デシリアライズ後のオブジェクトの実態は、裏で動的に作り変えてあり、それを継承したクラスにするためです。FlatBuffersと同等のパフォーマンスでありながら、極力自然なC#のシリアライズ/デシリアライズのAPIに載せるための手段です。

所詮はパースの先送りなので、全部の要素を使う場合はそこまで差は開きません(但し、ふつーのbyte[]から実体化するという点でのデシリアライズもかなり高速なので、仮に全プロパティを舐めても他のシリアライザよりも速度的には高速になってます、現状の実装だと)。まぁ、モノによってはすんごく効果的というのは分かってもらえるかと。実際うちの(開発中の)ゲームでは効果大(になる見込み)です。

そうして作り込んだオブジェクトの再シリアライズも強烈な速度です、というか、こちらも再シリアライズも無限大高速です。というのも裏で持ってるbyte[]をBuffer.BlockCopyでコピーするだけだから。シリアライズしないから無限大速い。これはひどぅぃ。

再シリアライズするシナリオっていうのは、サーバー側だとMicroservices的な分散環境でかなり効果あると思ってます。オブジェクトを左から右に流すだけって、それなりにあるんですよね。そういう時に生のbyte[]でやりくりするとかじゃなくて、ちゃんとオブジェクトとしての実体を持ちつつ(API的に嬉しい)、パフォーマンスも両立(左から右に流すだけなのでデシリアライズもしなければシリアライズもしない!)することが達成できます。

また、触らないというだけじゃなくて、触ることもできます。オブジェクトはミュータブルで、ちゃんとふつーのクラスのように扱って値も変えられます。FlatBuffersは制限付きで一部だけ可能なんですが、ZeroFormatterは全てを変更可能にしてます(イミュータブルにしたい場合はセッターをprotectedにしたりIListのかわりにIReadOnlyListで宣言したりすることでイミュータブルにできるので安心してくだしあ)。この場合、もし固定長の値(intとかfloatとか)を変更した場合は、裏のbyte[]に直接書き込むので、再シリアライズの高速性は維持されます。可変長の値(stringとかオブジェクトとか)を変更した場合は、そこの部分だけシリアライズが必要な差分としてマークされます。それ以外の箇所はbyte[]をBlockCopyするので、可能な限りの高速性を維持しつつも自由な編集を可能にしています。

これはFlatBuffersでは出来ないし、当然他のフォーマットでもできません。

シリアライズパフォーマンス

シリアライズはデシリアライズの時のようなチートは出来ないんで、ZeroFormatterの実装も正攻法で真正面から競ってます。で、ちゃんと速いというか十分以上に速いですというか.NET最速です、いやほんと。

image

image

せっかくなので比較対象は沢山用意していて、まぁprotobuf-netを基準として見るといいと思います。protobuf-netは実際、.NET最速シリアライザで、良いパフォーマンス出してます。でもZeroFormatterはその2倍以上速いんだなぁ。FsPicklerは個性的で面白いし、機能の豊富さを考えると十分よくやってる感じでいいですね。FlatBuffersは気合が足りないですね、あんだけ奇怪なAPIでこれかよ、みたいな。

Google.Protobufが凄く良好でビックリした。これはGoogle公式のProto3実装で、gRPCとか最近のGoogle公式でProtocol Buffersを多用するものはこれを使っていますね。ただ、汎用シリアライザじゃなくて、protoから生成してやらないと一歩も動けないタイプなので使いづらいとは思います。gRPCとかで完全にIDLのシステムが固まっている場合でなら、速度的に不安にならなくて良いという点で良いですねぇ。というかなんでこんな速いんだろ、いや、速いのはいいんですけど、コード的に、だったらZeroFormatterはもう少しいけるはずなはず、うーん、事前csコード生成で普通にコンパイルかけたほうが動的生成よりイケてるってのはあるにはあるんですが、とはいえとはいえ。むー。

というわけでZeroFormatterは実際超速い、んですが速度の秘訣は、沢山あります!幾つか紹介すると、一つは自分でコントロールできない実装を一切通してないから。ひたすらrefでbyte[]を回して、それに対して書き込むだけって感じになっていて、Streamすら使っていません。(Memory)Streamはあんまり通さないほうがいいですね、基本的にはパフォーマンスのネックになります。実際Google.Protobufやprotobuf-netは内部で書き込み用のbyte[]バッファを持ってて、溢れた時のFlushのタイミングでだけ嫌々(?)MemoryStreamに書きに行ってます。ZeroFormatterは更に徹底して、byte[]だけをひたすら引き回す。

次に、整数(など)が可変長じゃないから。Protocol BuffersやMsgPackはint(4バイト)をシリアライズするにあたって、4バイト使いません。というか使わない場合があります。それぞれのエンコード方式を使って、例えばよく使われる数字なんかは1バイトとか2バイトでシリアライズできたりします。これによってバイナリサイズが縮みます。素晴らしい。が、これはエンコードの一種と考えられるので、そのまんまintの4バイトを突っ込むのに比べてエンコードのコストがかかってます。ZeroFormatterは固定長です(これは別にパフォーマンス稼ぎたいわけじゃなくて、ランダムアクセス・ミュータブルなデシリアライズのために必要だからそうなってるだけなのですけれど)

文字列の取扱いもそこそこ工夫があります。まず、 Encoding.GetBytes(string) でbyte[]取ってストリームにWrite、なんてのはビミョー。そのbyte[]無駄じゃんって話で。GetBytesにはbyte[]を受け取ってそいつに書き込んでくれるオーバーロードがあるので、それを使います。じゃあ単純にbyte[]投げればいいのかっていうとそうでもなくて、byte[]の長さが足りない時に伸ばしてくれたりしないので、事前にちゃんと余裕もった長さにしてあげる必要があります。つまりエンコード後のサイズを知っておく必要がある。ここで Encoding.GetByteCount を大抵の実装は使うんですが、長さ分かるってことは実質エンコードしたようなものじゃん、と。というわけで、ここは Encoding.GetMaxByteCount で確保します。こっちのほうがずっと軽い。そして、別にちょっと大きめに取るのはそんな問題ないんですよ、後続がシリアライズするのに使うかもしれないし、そもそも既に大きめに確保されているかもしれない。

長さが分かっている場合(intしか返さない場合とかVector2しか返さないとか、何気にあるはず)は、返すbyte[]をきっちりそのサイズでしか確保しないという最適化が入っています。余計なバッファなし。これは↑のstringも同様で(stringだけ返すというのは非常によくある!)、その場合だけ大きめに確保はせず、ジャストサイズで返します。この辺をきっちりやってる実装は、ないですね(Streamが根底に入ってるとそもそも出来ないので、ZeroFormatterがbyte[]しか引き回さない戦略取ってるからこそ出来る芸当とも言える)

オブジェクトへのシリアライザは初回に一度だけDynamicAssemblyで型を動的に生成するわけですが、コード生成の外からループのヘルパーを通したりせずに、全てのコードを埋め込んでます。というわけで長めのil.Emitが延々と続いてるんですが、これは手間かけるだけの効果ありますね、最初はExpressionTreeでプロパティ単位でのシリアライザを用意して回してたりしたんですが、全部埋め込みにしたら劇的に良くなりました。こう差が出ると、あんまExpressionTreeで書いたほうがいいよねー、なんて気はなくなりました。

そうして生成したシリアライザのキャッシュにDictionaryは使いません。辞書のルックアップはオーバーヘッドです。.NETで最速の型をキーにした取り出しは、適当な<T>のクラスのスタティック変数から取り出すことです。特に静的コンストラクタはスレッドセーフが保証されているので、lockもいりません。つまりどうすればいいかというと、静的コンストラクタの中でifを書きまくることが絶対の正解です。if連打とか気持ち悪い?いやいや、いいんですよ、こんなんで、むしろこういうのがいいんですよ。

それやると一つの型につき一つのシリアライザしか登録できないのでコンフィグが出来ない!って話になってしまうんですが、今のとこZeroFormatterはそもそもノー・コンフィグなので問題ない(酷い)。というのはともかく、オプション毎に型を作って<TOption, T>という形で登録するっていう手法があります。その場合はオプションの全組み合わせを一つ一つの型として用意するということになります。んなのアホかって思うかもですが、実際にJilはそういう実装になっていて、真面目に現実的な手法です。

Enumの取扱いはかなり厄介で、そもそもToStringは遅くてヤバい。ので、ZeroFormatterは値でしかシリアライズしません。ToStringのキャッシュってのもありますが、じゃあそのキャッシュはどこに置くのって話になってきて(Dictionaryに突っ込むと取ってくるコストかかるので、やるなら専用シリアライザを動的に作ってIL内に文字列埋め込みが最速でしょうね)、やらなくていいならやらないにこしたことはない!

さて、というだけじゃなくて、そもそも実はEnumのUnderlyingTypeへのキャストも汎用的にやろうとするとかなり大変だったり。つまりTEnumをInt32に変換するって奴で、これ、正攻法でうまく(速く)やる手段はないです。そうなると結局動的コード生成するしかないってことになりそうで、その場合ExpressionTreeでサクッと作るのが正攻法なんですが、今回私はCreateDelegateのハックでやりました。例えば、通常は変換できないFunc<int,int>はFunc<T,int>に変換できます。TがEnumの場合、かつCreateDelegate経由の場合のみ。実装バグが、まぁベンリだしいいんじゃね?って感じで仕様として(?)残ったって感じなんですが、まぁ実際ベンリなので良きかな良きかな。ちなみに、これでExpressionTreeとか動的生成が効かないUnityでも行けるぜ!とか思ったら、そもそもUnityだと(古いmonoのコンパイラだと?)エディター上ですら動かなかった……。のでUnityではこのテクニックは使ってなくて、普通にEnumはクラスと同じように事前コードジェネレートの対象に含めてます。

あとは本当にボクシングが絶対に発生しないように書いてあります。アタリマエと思いきや意外と普通にこの辺が甘いコードは少なくなくて、protobuf-netですら秘孔を突くってほどじゃなく普通にボクシング行きのコードパス通せたりします。このボクシング殺すべしはUnityでも徹底していて、一切ボクシングなコードは通りません。どうしても必要そうな場合でもコードジェネレートでシリアライザを徹底的に事前生成させることで完全に回避してます。MsgPack-CliのUnity用コードが、コレクションをobjectで取り出すようにしてたり(汎用的なAOT対策としては、正解なのですが……)なので、Unityで徹頭徹尾やってるものも珍しい部類に入るんじゃないかと思います。

また、そもそもbyte[]を確保しない(外から渡せて縮小もしない)NoAlloc系のAPIも用意してます。外側でBufferPoolとか用意しといてもらえれば、ゴミを全く発生させないシリアライザになります。内部ではヘルパーオブジェクトの生成も全くしてない(最初から最後までbyte[]を引き回すだけでなんとかしてる)ですしね。これはリアルタイム通信書いてる時に、こんなにバンバン通信してる = シリアライザが動きまくってるのに byte[] を使い捨てまくり嫌すぎる、と思ってどうしても用意したかったのでした。まぁさすがにバンバン通信といったってUpdateループのような毎フレとかじゃあないんで、神経質になりすぎっちゃあなりすぎかもですが。

Unityでのパフォーマンス

ZeroFormatter, MsgPack-Cli, JsonUtilityでの計測です。ループ回数は500回でiPhone 6s Plus/IL2CPPで動かした結果です。ZeroFormatter, MsgPack-Cliはコードジェネレート済み、JsonUtilityはstringの後にEncoding.GetBytesでbyte[]を取る/byte[]からの復元を時間に含めてます(この手の使い方だと通常最終的にbyte[]に落とすはずなので)

image

デシリアライズは例によってチートなので見なくていいんですが、シリアライズもきっちり爆速です。というかJsonUtilityよりも速い。配列がMsgPack-Cliの10倍速い……(MsgPack-Cliの配列のデシリアライズ速度は正直結構厳しい結果ですね、うーん、なんでそうなのかは分からなくもなくはないんですが……)。

ZeroFormatterをUnityで使うには zfc.exe というコンソールアプリケーションを使ってシリアライザを事前生成します。今のところzfcはWindowsでしか動きません(本当は.NET Coreで実装してLinuxやMacでも動かせるようにしたかったんですが、コード解析に使っているRoslynのプロジェクト解析部分がWindows用しかまともに動かせないという鬼門があり、解決策は今のところない。もう少し.NET Coreが成熟すればいい感じになれるはず、まだ実は細かいところがイケてないのだ……)。生成物自体はどのプラットフォームでもいけます。

Unityでパフォーマンスが有利になる点といえば、ZeroFormatterでのデシリアライズ後のDictionaryは普通に書いたDictionaryよりも良い場合があります。何かというと、Unityの場合、Enumがキーの場合のDictionaryはパフォーマンスに不利です。というのも、参照の度に裏でボクシングが発生しているから(これはmonoの古いバージョンのEqualityComparer.Defaultの実装に問題があって、というかふつーの.NETのほうも4.5辺りまで微妙な実装でした)。で、解決策は専用のEqualityComparerを作ってセットしてあげること、です。面倒くさくてやってられないし実際それ分かっててもやれ(て)ないんですが、zfcで生成したDictionaryには専用のEqualityComparerが最初から自動でセットされてます。ので、その問題は起こりません。

ZeroFormatterはUnityのVector3とかはそのまんまだとシリアライズできないんですが、ZeroFormattableなstructに見せかけてzfcを通すと、Vector3用のシリアライザとかを作ってくれてベンリです。例えば

#if INCLUDE_ONLY_CODE_GENERATION

using ZeroFormatter;

namespace UnityEngine
{
    [ZeroFormattable]
    public struct Vector2
    {
        [Index(0)]
        public float x;
        [Index(1)]
        public float y;

        public Vector2(float x, float y)
        {
            this.x = x;
            this.y = y;
        }
    }
}

#endif

のようなコードを用意しておくと、「INCLUDE_ONLY_CODE_GENERATION」が特別なシンボルになっていて、zfcのみで解析対象になってVector2用のシリアライザが生成されます。サーバー側でも受けたいとかって場合は、普通に↑のものをそのまま使えばそれはそれでOKです。ラップした何かに置き換える、なんてのは当然オーバーヘッドなわけなので、structがそのまま使えるんならそれにこしたことはないですからねえ。

zfcの解析対象はソースコードです。昔はコンパイル済みのDLLバイナリを解析するコードをよく書いていたのですが、CIでのビルドと相性が悪すぎて(ビルド順序の依存がある・互いが生成しあって、その生成物を参照しているような場合だとCI上でビルド不能になったりする)イマイチでした。というわけで、今後はコードジェネレートはソースコード解析によるものを主軸にしていこうと思っています。まぁ、そもそもコード生成なんてしないにこしたことはないんですけどね、ILでもなんでもいいから極力実行時動的生成にして、UnityのIL2CPP用だとか、特別な理由がある時だけ「しょうがないから」ソースコード生成する、ぐらいがいいと思ってます。全然、コード生成なんてほんといいもんでもなんでもないし、少なくするにこしたことはない。だから私はIDLを定義して生成するってのは嫌いで、C#そのものがIDLにならなきゃならないと思ってます。言語中立にしたいなら、その場合だけ「しょうがないから」IDLをジェネレートすればいい。そうですねぇ、仮にZeroFormatterを言語中立に拡大していくのだとしたら、C#をIDLの代わりにします。csx(C# Script)で直接コンパイルできるような。結構面白いと思うんだよね。

バイナリサイズ

バイナリサイズはMsgPackやProtocol Buffersに比べて「大きい」です。さすがにJSONよりは小さくなるんですが、まぁFlatBuffersとは同じぐらいですね。別にバイナリだから小さいなんてことはなくて、そう、FlatBuffersも結構大きいですよ。なんで大きいのかっていうと、ランダムアクセスするためのヘッダ領域が必要なので、その分が純粋にオーバーヘッドになってます。これはねえ、しゃーない。デシリアライズ先送りのための必要経費です。銀の弾丸なんてこの世にはなくて、トレードオフなんです、トレードオフ。gzipとかLZ4とかで圧縮しちゃうんなら結構縮められるので、もとよりMsgPack+gzipとかってやってるんなら、そんなにサイズに差は出てこないでしょう。せっかくの速度がウリのフォーマットなので、圧縮する場合はLZ4がお薦めです。結局、デシリアライズが速いといってもネットワーク転送量が多くなってしまえば、ネットワーク通信がボトルネックになってトータル処理時間では負けた!みたいなことだって普通に起こるわけなんで、全然、LZ4で圧縮ってのは良い選択です。ていうか私も(モノによってやるやらないの判断は入れますけれど)やります。

また、パフォーマンスのところで有利になると書いた可変長整数「ではない」ことは、バイナリサイズには当然響いてきます。固定長なのはパフォーマンスのためじゃなくてミュータブルにするためだったり、固定長配列の長さを真に固定するために必要だったりするのでしょうがないんですけれどね(FlatBuffersも勿論同様の話で、固定長で整数のサイズが大きくなってしまうのも必要経費でバイナリ仕様的にしょーがない)、どちらかというとパフォーマンスのほうが副産物で。

ところで突然ちなみにBinaryFormatterは更にもっとサイズでかいです、なんでかっていうとかなりリッチめに型情報が入ってるからなんですねえ。シリアライズ/デシリアライズも遅いんで、アレは使わないほうがいいですよ。

他のシリアライザにはないZeroFormatterだけのお薦め機能として、IDictionaryやILookup(MultiDictionary)へのゼロ速度デシリアライズというのを持っているんですが、なんと、それを使うとバイナリサイズが飛躍的に増大します!(ついでにシリアライズ速度も大きく低下する)。なんでかっていうと、中のハッシュテーブルを丸ごとシリアライズしてるので純粋にKey, Valueだけのシリアライズに比べて、結構に大きくなっちゃいます。なのでデフォルトでは有効になってなくて(?)、IDictionaryのかわりにILazyDictionary, ILookupのかわりにILazyLookupという形で型を宣言すると、そっちのモードでシリアライズします。これはトレードオフはトレードオフでも、ちゃんと理解した上で選択しないと危なっかしいので、デフォのIDictionaryは初回アクセス時に丸ごと構築するという、全然遅延してないじゃんモードになってます。

拡張性

ZeroFormatterはバイナリ生成のためのフレームワーク、ぐらいの気持ちで設計してあって、割とサクッと拡張して俺々バイナリを統合して流し込めるようになってます。というのも、ゲーム用に使うというのも主眼に入れてるので、一部の型は汎用ではなくて、特化したバイナリを流したいって局面は全然あるでしょう。拡張のコードの例として、Guidはデフォでサポートしてないんでプロパティの型に使うと怒られるんですが、

// こんな風にFormatter<T>を継承したクラスを作って
public class GuidFormatter : Formatter<Guid>
{
    // もしバイナリが固定サイズなら数字を、そうじゃないならnullを返す
    public override int? GetLength()
    {
        return 16;
    }

    // あとはbyte[]に対して書き込む/読み込む
    // BinaryUtilが汎用的に使えるヘルパーになっている他、Formatter<T>.Defaultを呼べば子シリアライザを使える
    public override int Serialize(ref byte[] bytes, int offset, Guid value)
    { 
        return BinaryUtil.WriteBytes(ref bytes, offset, value.ToByteArray());
    }

    public override Guid Deserialize(ref byte[] bytes, int offset, DirtyTracker tracker, out int byteSize)
    {
        byteSize = 16;
        var guidBytes = BinaryUtil.ReadBytes(ref bytes, offset, 16);
        return new Guid(guidBytes);
    }
}

// どっか起動時に↓のコードを呼んでおけば、Guidに対するデシリアライズが必要な時には↑のコードが呼ばれるようになる
ZeroFormatter.Formatters.Formatter<Guid>.Register(new GuidFormatter());

という風にすれば、どんな型でも対応させられます。ジェネリクス対応や動的に変動させたい、とかって場合のための登録の口も用意されているので(詳しくはReadMeを読んでね!)基本的にはどんな状況でもいけます。社内からはF#の判別共用体へのシリアライズを対応させるって話もありましたが果たして実装してもらえるのであろうか……。

この辺、protobufとかだとバイナリ仕様決まってるので、あんまり手をいれるのは気が引ける、って感じになりますが、新興フォーマットなだけに、別に自由にやっていいんじゃよ、って気になれます。MsgPackにも仕様の中にExtension typeありますけれど、如何せんZeroFormatterはオプションがない直線番長なので、考えることもまったくなく、とにかくbyte[]に書きたいように書けばそれでOK、問題なくちゃんと動きますよ、っていうのが嬉しい話です。

他言語サポート

ないです!私自身はちょっと出来ないので、気になる人がいれば、やっていただける人をゆるぼです。基本的なのは実のところかなり単純で、そこまでC#特化の何かを入れているわけでもなかったりします(というか、一応は汎用的なものを意識しているのでC#特化のものは極力入れてません)。独自のデータ構造が必要になる遅延Dictionaryとかが厳しいんですが(あと、あれはフォーマット的にも内部構造をベタシリアライズしているので、実装しづらさがかなりある)。一応、仕様サポートのステージは考えていて

  • Stage1: 全てが先行評価される(無限大高速なほうの仕様は満たさない)、Decimal, LazyDictionary/LazyMultiDictionaryは非サポート
  • Stage2: リスト、クラスが遅延評価される(無限大高速なデシリアライズ)、Decimal, LazyDictionary/LazyMultiDictionaryは非サポート
  • Stage3: Decimalをサポートする、LazyDictionary/LazyMultiDictionaryは非サポート
  • Stage4: 全フォーマットをサポートする

みたいな感じです。もし、やろう!という方がいれば、まずはStage1から試みてもらえるとどうでしょうかー。バイナリ仕様はGitHubのReadMeにあります。

まとめ

デシリアライズ先送りが魅力なのは勿論なのですが、先送りしないようなものであっても、他より高いパフォーマンスが出るので、ほぼ全方位に有効なものになってるんじゃないかと思います。比較対象としてやたらFlatBuffersに関して言及しましたが、実際のところ本当にあれ実用で使うのは無理なので(あんなんで普通に使えてる人いるのかな……)、まともに使える代物としては唯一無二な価値はあるんじゃないかな、と。

なんで作ろうかって思ったというと、絶賛開発中のゲームで手詰まったからなんですね、とほほ。巨大なDictionary/MultiDictionaryをデータベース代わりに起動時に構築する、というアプローチだったんですが、かなり破綻してて(起動時間は遅いし、そのための対応のせいでただでさえ未熟なワークフローが更にグチャグチャに)、gdgdループの根底にいたのが其奴なのであった。といっても、今更もう作りは変えられないんで、なんというか、なんとかするしかないわけで、ウルトラC的なアプローチに走ったのであった。そりゃ私だって別にこのレイヤーで俺々フォーマット作りたいなんて思わないですよ、んなもん常識的に考えて悪手に決まってるじゃん。他人がやるって言ったら全力で止めるわ。まぁ結果オーライで最終的にはZeroFormatterを活かした爆速仕様になる(予定)んで、いいってことよってことですかね。

元々はそうした無限大高速なデシリアライズと、ボトルネックにならない程度に普通に高速なシリアライズ、ぐらいに思っていたんですが、シリアライズの計測結果がかなり良かったので欲張って、いっそもうやるなら世界最速だろうとガッチガッチに実装し始めると性能は確かに伸びる。やればやるほど伸びる。が、実装時間も伸びる。やればやるほど。なるほど。とはいえ、実は告知してないだけでGitHub上ではpublicにしていたので、社外でも何人かの方には公開を伝えていて、ベータテスターじゃないけれど、様々なフィードバックなどなどを頂きました。それがなければ、全然もっと出来は悪かったと思うので、非常に感謝です。過去に制作した30のライブラリから見るC#コーディングテクニックと個人OSSの原理原則で偉そうに言いましたけれど、自分一人の限界を超えていけるのもいいことですね。外に出すってことで外圧も感じられるし:)MsgPack-Cliの藤原さんがセッションで言ってました気がしましたが、シリアライザーなんて作るのは奇特で、確かにちょっともう次はやりたくない、しんどいー。ILも一生分書いた気がする。しかし、例によって様々な既存シリアライザーの仕様から、それぞれのC#版の実装のコードを大量に読んだので、シリアライザーに関しては更に相当詳しくなりました、ううむ。シリアライザーとは結構ブログでことあるごとに記事書いてたりと、なんか長い付き合いなんですよねえ、最終的にまさか自分で作ることになるとは……。

と、そんなわけなので、是非是非使ってみてください。実用品なのかどうかで言ったら、会社で使う気満々というかそのための代物なので、その辺の耐久性はあります、まぁまだリリースされてないので(!)、耐久性はどんどん上がっていきます、ぐらいで。バグあればどんどん直すというのと、(UniRxで聞かれたことがあるのですが)社内用と社外用に分けてたりもないので、ちゃんとpublicなところでメンテナンスは続いていきます。

現状どうしても通信用フォーマットとしてはC#オンリーなので、サーバー側で送り出せなくて使いにくい、ということも絶対あるとは思うんですが、そのための解決策としてクロスプラットフォーム(Unity, Windows, Mac, Linux)で使えるC#製の通信用フレームワークをリリースする、という計画も控えているので、その辺も含めて注視していただければですね。繰り返しますが、その辺のところは11/27開催の歌舞伎座.tech#12「メッセージフォーマット/RPC勉強会」でお話するつもりなので、是非来てくださいな。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(.NET)
April 2011
|
July 2025

X:@neuecc GitHub:neuecc

Archive