C#の高速なMySQLのドライバを書こうかという話、或いはパフォーマンス向上のためのアプローチについて

割とずっと公式のC# MySQL Driverは性能的にビミョいのではと思っていて、それがSQL Serverと比較してもパフォーマンス面で足を引っ張るなー、と思っていたんですが、いよいよもって最近はシリアライザも延々と書いてたりで、その手の処理に自信もあるし、いっちょやったるかと思い至ったのであった。つまり、データベースドライバをシリアライゼーションの問題として捉えたわけです。あと会社のプログラム(黒騎士と白の魔王)のサーバー側の性能的にもう少し飛躍させたくて、ボトルネックはいっぱいあるんですが、根本から変えれればそれなりにコスパもいいのでは、みたいな。

image

中間結果としては、コスパがいいというには微妙な感じというか、Mean下がってなくてダメじゃんという形になって、割と想定と外れてしまってアチャー感が相当否めなくて困ったのですが(ほんとにね!)、まぁそこはおいおいなんとかするとして(します)、メモリ確保だけは確実にめちゃくちゃ減らしました。1/70も減ってるのだから相当中々だと思いたい、ということで、スタート地点としては上等じゃないでしょふか。

↑のベンチマークはBenchmarkDotNetで出していまして、使い方はこないだ別ブログに書いた C#でTypeをキーにしたDictionaryのパフォーマンス比較と最速コードの実装 ので、そちらを参照のことこと。

まだふいんき程度ですが、コードも公開しています。

まだα版とすらいえない状態なので、そこはおいおい。

性能向上のためのアプローチ

競合として、公式のMySQL Connectorと非公式のAsync MySQL Connectorというのがあります。非公式のは、名前空間どころか名前まで被せてきて紛らわしさ超絶大なので、この非公式のやつのやり方は好きじゃありません。

それはさておき、まず非同期の扱いについてなんですが、別に非同期にしたからFastなわけでもありません。だいたいどうせASP.NETの時点でスレッドいっぱいぶちまけてるんちゃうんちゃうん?みたいなところもあるし。むしろ同期に比べてオーバーヘッドが多くなりがち(実装を頑張る必要大!)なので、素朴にやるとむしろ性能低下に繋がります。

さて、で、パフォーマンスを意識したうえで、どう実装していけば良いのか、ですが、MySqlSharpでは以下のものを方針としています。

  • 同期と非同期は別物でどちらかがどちらかのラッパーだと遅い。両方、個別の実装を提供し、最適化する必要がある
  • 禁忌のMutableなStructをReaderとして用意することでGCメモリ確保を低減する
  • テキストプロトコルにおいて数値変換に文字列変換+パースのコストを直接変換処理を書くことでなくす
  • ADO.NET抽象を避けて、プリミティブなMySQL APIを提供する。ADO.NETをはそのラッパーとする
  • 特化したDapper的なMicro ORMを用意する、それは上記プリミティブMySQL APIを叩く
  • Npgsql 3.2のようなプリペアドステートメントの活用を目指す

といったメニューになっていまして、実装したものもあれば妄想の段階のものもあります。

Mutable Struct Reader

structはMutableにしちゃいけない、というのが世間の常識で実際そうなのですが、最近のC#はstruct絡みが延々と強化され続けていて(まだ続いてます - C# Language Design Notes for Jul 5, 2017によるとC# 7.2でrefなんとかが大量投下される)、structについて真剣に考え、活用しなければならない時が来ています。

ところでMySQLのプロトコルはバイナリストリームは、更にPacketという単位で切り分けられて届くようになっています。これを素朴に実装すると

image

Packet単位にクラスを作っちゃって、無駄一時オブジェクトがボコボコできちゃうんですね。

// ふつーのパターンだとこういう風にネストしていくようにする
using (var packetReader = new PacketReader())
using (var protocolReader = new ProtocolReader(packetReader))
{
    var set = protocolReader.ReadTextResultSet();
}

かといって、Packet単位で区切って扱えるようにしないと実装できなかったりなので、悩ましいところです。そこで解決策として Mutable Struct Reader を投下しました。

// MySqlSharpはこういうパターンを作った
var reader = new PacketReader(); // struct but mutable, has reading(offset) state
var set = ProtocolReader.ReadTextResultSet(ref reader); // (ref PacketReader)

PacketReaderはstructでbyte[]とoffsetを抱えていて、Readするとoffsetが進んでいく。というよくあるXxxReader。しかしstruct。それを触って実際にオブジェクトを組み立てる高レベルなリーダーはstaticメソッド、そしてrefで渡して回る(structなのでうかつに変数に入れたりするとコピーされて内部のoffsetが進まない!)。

奇妙なようでいて、実際見かけないやり方で些か奇妙ではあるのですが、この組み合わせは、意外と良かったですね、APIの触り心地もそこまで悪くないですし。もちろんノーアロケーションですし。というわけで、いつになくrefだらけになっています。時代はref。

数値変換を文字列変換を介さず直接行う

クエリ結果の行データは、MySQLは通常テキストプロトコルで行われています(サーバーサイドプリペアドステートメント時のみバイナリプロトコル)。どういうことかというと、1999は “1999″ という形で受け取ります。実際にはbyte[]の”1999″ ですね。これをintに変換する場合、素朴に書くとこうなります(実際、MySQL Connectorはこう実装されてます)

// 一度、文字列に変換してからint.Parse
int.Parse(Encoding.UTF8.GetString(binary));

これにより一時文字列を作るというゴミ製造が発生します、ついでにint.Parseだって文字列を解析するのでタダな操作じゃない。んで、UTF8で、文字数の長さもわかっている状態で、中身が数字なのが確定しているのだから、直接変換できるんじゃないか、というのがMySqlSharpで導入したNumberConverterです。

const byte Minus = 45;
 
public static Int32 ToInt32(byte[] bytes, int offset, int count)
{
    // Min: -2147483648
    // Max: 2147483647
    // Digits: 10
 
    if (bytes[offset] != Minus)
    {
        switch (count)
        {
            case 1:
                return (System.Int32)(((Int32)(bytes[offset] - Zero)));
            case 2:
                return (System.Int32)(((Int32)(bytes[offset] - Zero) * 10) + ((Int32)(bytes[offset + 1] - Zero)));
            case 3:
                return (System.Int32)(((Int32)(bytes[offset] - Zero) * 100) + ((Int32)(bytes[offset + 1] - Zero) * 10) + ((Int32)(bytes[offset + 2] - Zero)));
            // snip case 4..9
            case 10:
                return (System.Int32)(((Int32)(bytes[offset] - Zero) * 1000000000) + ((Int32)(bytes[offset + 1] - Zero) * 100000000) + ((Int32)(bytes[offset + 2] - Zero) * 10000000) + ((Int32)(bytes[offset + 3] - Zero) * 1000000) + ((Int32)(bytes[offset + 4] - Zero) * 100000) + ((Int32)(bytes[offset + 5] - Zero) * 10000) + ((Int32)(bytes[offset + 6] - Zero) * 1000) + ((Int32)(bytes[offset + 7] - Zero) * 100) + ((Int32)(bytes[offset + 8] - Zero) * 10) + ((Int32)(bytes[offset + 9] - Zero)));
            default:
                throw new ArgumentException("Int32 out of range count");
        }
    }
    else
    {
        // snip... * -1
    }
}

ASCIIコードでベタにやってくるので、じゃあベタに45引けば数字作れますよね、という。UTF-8以外のエンコーディングのときどーすんねん?というと

  • 対応しない
  • そん時は int.Parse(Encoding.UTF8.GetString(binary)) を使う

のどっちかでいいかな、と。今のところ面倒なので対応しない、が有力。

Primitive API for MySQL

MySQL Protocolには本来、もっと色々なコマンドがあります。COM_QUIT, COM_QUERY, COM_PING, などなど。まぁ、そうじゃなくても、COM_QUERYを流すのにADO.NET抽象を被せる必要はなくダイレクトに投下できればいいんじゃない?とは思わなくもない?

// Driver Direct
var driver = new MySqlDriver(option);
driver.Open();
 
var reader = driver.Query("selct 1"); // COM_QUERY
while (reader.Read())
{
    var v = reader.GetInt32(0);
}
 
// you can use other native APIs
driver.Ping(); // COM_PING
driver.Statistics(); // COM_STATISTICS
// ADO.NET Wrapper
var conn = new MySqlConnection("connStr");
conn.Open();
 
var cmd = conn.CreateCommand();
cmd.CommandText = "select 1";
 
var reader = cmd.ExecuteReader();
while (reader.Read())
{
    var v = reader.GetInt32(0);
}

APIはADO.NETに似せるようにしてはいますが、余計な中間オブジェクトも一切なく直接叩けるのでオーバーヘッドがなくなります。もちろん、実用的にはADO.NETを挟まないと色々な周辺ツールが使えなくなるので、殆どの場合はADO.NET抽象経由になるとは思いますが。

とはいえ、DapperのようなORMをMySqlSharp専用で作ることにより、直接MySqlSharpのPrimitive APIを叩いて更なるパフォーマンスのブーストが可能です。理屈上は。まだ未実装なので知らんけど。恐らくいけてる想定です、脳内では。

まとめ

実装は、むしろMySQL公式からドキュメントが消滅している - Chapter 14 MySQL Client/Server Protocolせいで、Web Archivesから拾ってきたり謎クローンから拾ってきたりMariaDBのから拾ってきたりと、とにかく参照が面倒で、それが一番捗らないところですね。もはやほんとどういうこっちゃ。

MySQLには最近X-Protocolという新しいプロトコルが搭載されていて、こちらを通すと明らかに良好な気配が見えます。これはProtocol Buffersでやり取りするため、各言語のドライバのシリアライゼーションの出来不出来に、性能が左右されなくなるというのも良いところですね。

が、Amazon AuroraではX-Protocolは使えないし、あまり使えるようになる気配も見えないので、あえて書く意味は、それなりにあるんじゃないかしらん。ちゃんと完成すればね……!それと.NET CoreなどLinux環境下などでも.NET使ってくぞー、みたいな流れだと、当然データベースはMySQL(やPostgreSQL)のほうが多くなるだろう、というのは自然なことですが、そこでDBなども含めたトータルなパフォーマンスでは.NET、遅いっすね!ってなるのはめっちゃ悔しいじゃないですか。でも実際そうなるでしょう。だから、高速なMySQLドライバーというのは、これからの時代に必要なもののはずなのです。

公開しないほうがお蔵入りになる可能性が高いので、公開しました。あとは私の頑張りにご期待下さい。

C#におけるTypeをキーにした非ジェネリック関数の最適化法

MicroResolver 2.3.3!というわけで、例によってバージョンがデタラメになるんですが、アップデートしてました。MicroResolverとその解説については以前のブログ記事 MicroResolver - C#最速のDIコンテナライブラリと、最速を支えるメタプログラミングテクニック をどうぞ。そして、オフィシャルな(?)ベンチマーク結果でも、それなりに勝利を収めています。

Container Singleton Transient Combined Complex Property Generics IEnumerable
No 61
53
68
62
83
103
90
82
119
99
73
79
177
139
abioc 0.6.0 27
37
31
57
48
84
63
72


741
506
Autofac 4.6.0 749
623
707
554
1950
1832
6510
6472
6527
6417
1949
1563
7715
5635
DryIoc 2.10.4 29
42
38
63
55
80
62
70
82
92
50
84
259
184
Grace 6.2.1 27
38
35
58
49
82
67
75
87
94
46
77
265
194
Mef2 1.0.30.0 239
167
254
174
332
256
528
317
1188
680
261
429
1345
758
MicroResolver 2.3.3 31
37
35
59
58
77
92
86
43
66

285
203
Ninject 3.2.2.0 5192
3216
16735
11856
44930
30318
131301
84559
112654
76631
48775
27198
102856
68908
SimpleInjector 4.0.8 66
68
77
70
103
103
129
105
212
146
75
82
795
451
Unity 4.0.1 2517
1375
3761
1962
10161
5372
27963
16013
29064
16150

43685
23347

前回の結果はジェネリック版だったのですが、やっぱ物言いがつきまして、非ジェネリック版でやれよ、という話になりました。で、2.0.0は非ジェネリック版で負けちゃってたのです。うーん、そこそこ気を使ってたはずなんですが、負けちゃった。ジェネリック版なら勝ってるんだぜ!とか主張するのは激ダサなので、なんとかして、非ジェネリック版の最適化を進めました。そして、なんとか幾つかのものは勝利を収めました。いや、普通に幾つかのでは負けてるじゃん、って話もありますが、概ね高水準だし、そこは許してください(?)、ジェネリック版なら勝ってるし(ダサい)。理論上、何やればこれ以上に縮められるかは分かってはいるんですけどねー。

というわけで今回は非ジェネリック関数の最適化法について、です。まず、MicroResolverは(ZeroFormtterやMessagePack for C#もそうですが)ジェネリック版を全てのベースにしています。

// というクラスが生成される
public class ObjectResolver_Generated1
{
    // というコードが生成される
    public override T Resolve<T>()
    {
        return Cache<T>.factory(); // Func<T>.Invoke()
    }
}

Tを元にしてデリゲートを探して、それをInvokeする。その最速系がジェネリックタイプキャッシングだという話でした。非ジェネリックの場合は、Typeをハッシュキーにして、デリゲートを探さなければなりません。ここでMicroResolverの初期の実装ではオレオレハッシュテーブルを作って対処しました。

// こんな構造体を定義しておいて
struct HashTuple
{
    public Type type;
    public Func<object> factory;
}
 
// これがハッシュテーブルの中身、基本的に固定配列が最強です
private HashTuple[][] table;
 
// Resolve<T> は、つまりFunc<T> なわけですが、これはFuncの共変を使って直接 Func<object> に変換できます
// ExpressionTree経由で上からデリゲートを生成して変換する、という手が一般に使われますが、
// それは関数呼び出しが一つ増えるオーバーヘッドですからね!
// というわけで、MicroResolverのRegister<T>のTにはclass制約がかかってます
table[hash][index] = new Func<object>(Resolve<T>);
 
// で実際に呼び出すばやい
public object Resolve(Type type)
{
    var hashCode = type.GetHashCode();
    var buckets = table[hashCode % table.Length];
 
    // チェイン法によるハッシュテーブルの配列は、拡縮を考えなくていいので連結リストではなく固定サイズの配列
    // 当然これがループ的には最速だし、ついでに.Lengthで回せるので配列の境界チェックも削れる
    for (int i = 0; i < buckets.Length; i++)
    {
        if (buckets[i].type == type)
        {
            return buckets[i].factory();
        }
    }
 
    throw new MicroResolverException("Type was not dound, Type: " + type.FullName);
}

理屈的には全く良さそうです!しかし、この実装では「遅くて」他のDIライブラリに対してベンチマークで敗北したのです。敗北!許せない!というわけで、ここから更に改善していきましょう。限界まで最適化されているように見えて、まだまだ余地があるのです。目を皿のようにして改善ポイントを探してみましょう!

非ジェネリック関数はジェネリック関数のラップではない

当たり前ですが、ラップにしたらラップしているという点でのオーバーヘッドがかかり、遅くなります。↑のコードはラップではないように見えて、ラップだったのです。どーいうことかというと

// new Func<object>(Resolve<T>) で生成したデリゲートは、こういう呼ばれ順序になる
object Resolve(Type type) => T Resolve() => Cache<T>.factory()
 
// そう、短縮できますよね、こういう風に
object Resolve(Type type) => Cache<T>.factory()
 
// つまりこういう風に、生のデリゲートを直接登録しちゃえばよかったのです
table[hash][index] = (Func<object>)Cache<T>.factory();
 
// ちなみにExpressionTreeで生成する場合は、もっと呼ばれる段数が多くなるので、理屈として一番遅いですね
object Resolve(Type type) => (object)Resolve() => T Resolve() => Cache<T>.factory()

これはもう先入観として非ジェネリックはジェネリックのラップで作らなきゃいけない、と思いこんでいたせいで、全体のコード生成のパスを見渡してみれば、直接渡してあげても良かったんですね。これで、ジェネリック版も非ジェネリック版も、どちらもどちらかのラップではない、ネイティブなスピードを手に入れることができました。

ちなみにジェネリック版が非ジェネリック版のラップの場合は、Typeのルックアップのコストがどちらも必ずかかってしまうので(ジェネリック版がネイティブなスピードにならない)、とても良くないパターンです。

ハッシュテーブルを最適化する

一件、このケースに特化した最速なハッシュテーブルに見えて、既にアルゴリズム的に遅かったのです。剰余が。modulo is too slow。

// これがゲロ遅い
var buckets = table[hashCode % table.Length];
 
// こうすれば良い(ただしテーブルサイズは2のべき乗である必要があります!)
var buckets = table[hashCode & (table.Length - 1)];
 
// もちろんテーブルサイズは固定なので、予め -1 したのは変数に持っておきましょう
var buckets = table[hashCode & tableLengthMinusOne];

割と純粋なデータ構造とアルゴリズムのお話ですが、ハッシュテーブルのサイズはどうするのが高速なのか問題、で、テーブルサイズが2のべき乗の場合にはビット演算を使って、低速な剰余を避けることが可能です。ハッシュテーブルに関しては「英語版の」ほうのWikipediaが例によって詳しいです - Hash table - Wikipedia

.NETのDictionaryはテーブルサイズとして素数を使っています、そのため剰余が避けられません。今回の最初の実装も.NETのものを参考に作っていたので剰余をそのまんま剰余で残してしまったんですねえ。ただし2のべき乗のほうも弱点はあって、ハッシュ関数が悪い場合に、偏りが生じやすくなるとのこと。素数のほうがそれを避けやすい。ので、一般の実装としてやるなら.NETのDictionaryが素数を使うのは最適なチョイスとも思えます。ただ、今回はTypeのGetHashCode、はそれなりにしっかり分散されてるもの(だと思われる)なので、2のべき乗をチョイスするのが効果的といえるでしょう。この辺を弄れるのも、汎用コレクションを使わない利点ですね。まぁ、エクストリームなパフォーマンスを求めるなら、という話ですが。

あとは衝突しなければしないほど高速(衝突したらforループ回る回数が多くなる)なので、テーブルに対するload factorは相当余裕のある感じの設定にしました。かなりスカスカ。まぁ、別にちょっと余計なぐらいでもいいでしょう。

TypeがKeyで、Value側がジェネリックで自由に変更可能な、汎用な固定サイズハッシュテーブルの実装はFixedTypeKeyHashtable.csに置いておきますんで、使えるケースがありそうな人は是非どうぞ。ハヤイデス。Keyは別にType以外にしてもいいんですが、汎用にするとIEqualityComaprer経由で呼ばなきゃいけなくてオーバーヘッドがあるので、もしKeyを他のに変えたければ、そこだけ変えた特化実装を別途用意するのが良いでしょう。Value側は気にする必要はないんですけどね。あと、KeyのGetHashCodeの性質には注意したほうがいいかもです(上述の通り、素数ではないので性質に影響されやすい)

まとめ

どちらの対策も同じように効果絶大でした。どっちも普通だったらそこまで大したことないようなことなんですけどね、マイクロベンチマークで超極小の差を競い合ってる状況では、この差が死ぬほどでかい。というわけで、もう完全に限界の領域。とはいえ、まだまだIoC Performance的には、Singletonには明確に改善の余地があって、事前に生成済みインスタンスを渡してあげるオーバーロードを用意して、その場合は直接埋め込んじゃえばいいとか、そういうこともできます。これは幾つかのDIライブラリがやってますね。役に立たないとは言わないけれど、基本的にはベンチマークハックっぽくて好きくないですが、まぁ、まぁ。

非ジェネリックに関しては type == type を削る余地が残ってます(信頼性は若干犠牲にしますが、事実上問題にならない)。どうやって、というと、登録すべきTypeが全部既知なんですよね、コード生成時に。つまり、非ジェネリック版ももっとアグレッシブにコード生成する余地があり、ハッシュテーブルのルックアップ部分まで含めてコード生成すれば、より改善され(る余地があ)るということです。擬似コードでいえば

// こういうコードを生成する
object Resolve(Type type)
{
    var hashCode = type.GetHashCode();
    switch(hashCode)
    {
        case 341414141:
            // もしハッシュコードが同一のものがあった場合は、生成時に追加でifを入れる
            // ただし通常そんなことは起こらない + 同一ハッシュコードの別タイプが来るケースはほぼない、のでtypeの真の同一値比較を省く
            return new Transient1(); // この中でインライン生成する
        case 643634533:
            return HogeSingleton.Value; // シングルトンは値をそのままルックアップするだけ
        // 以下、型は全て既知でハッシュコードも全部知っているので、羅列する
    }
}

ってコードを作ればいいわけです。こういうのは、まさに動的コード生成の強みを120%活かすって感じで面白くはあります。

ただしintの数値がバラバラの場合は「C#コンパイラが」二分探索コードを作るので - C#のswitch文のコンパイラ最適化について - Grani Engineering Blog、IL生成でこれやるのはかなり骨の折れる仕事です。しかも、二分探索と高速化したハッシュテーブルでは、かなりいい勝負が出来ている状態なので、あえてここまでやるのはちょっと、ってとこもあります。でも、生成部分まで完全にインライン化するのは効果大なので、やればきっと速くなりそうです(でも生成コードサイズはクソデカくなりそうだ)。このアプローチはabiocというIoCライブラリが取っていて、なので実際に最速のパフォーマンスを出せているわけですね。abiocのコード生成はIL EmitではなくRoslynを使っているので、こういった「C#コンパイラ」がやる仕事を簡単に記述できます。アプローチとして面白いやりかたです。

というわけで(?)理論値に挑んだわけですが、どうでしょう。速いコードって実は難しいコードではなくて、コードパスが短いコードが速くなるわけです、どうしても、そりゃそうだ、と。複雑なことをどうやって短い命令数のコード(短いコードという意味ではない)で表現するか。実行時にのみ知りうる情報を使ったコード生成技術を駆使することで、最短のパスを作り込んでいく。そういうことなんですね。

そのうえで、超基本的なアルゴリズムの話が残ってたりするところがあったりで、コンピューターの世界はモダンになったようで、実はあまり変わってないね、という側面もあったりで面白い感じです。

C#は簡単に遅いコードが書ける言語だし、正直割と痛感しているところもあるのですが、とはいえかなりの部分で高速に仕上げる余地が残っている言語でもあります(テンプレートメタプログラミングはできませんが!)。ILを自由に弄れる技術が身につくと「理論上存在する想像する最高のコード」に到れる道のりがグッと広がるので、ぜひぜひ習得してみるのも面白いかと思います。

MicroResolver - C#最速のDIコンテナライブラリと、最速を支えるメタプログラミングテクニック

MicroResolver、というDIコンテナを作りました。Microといいつつ、フルフルではないですがそれなりにフルセットな機能もあります。DIの意義とか使い方とかは割とどうでもいい話なので、何をやったら最速にできるのかってところを中心に説明しますので、DIに興味ない人もどうぞ。

例によってインストールはNuGetからで、.NET 4.6 から .NET Standard 1.4 で使えます。

DIコンテナはIoC Performanceという、存在するDIライブラリは全部突っ込んだ総合ベンチマークがあるので、そこで好成績を出せれば勝ったといえるでしょう。

Container Singleton Transient Combined Complex
No 53
50
58
51
71
73
87
67
abioc 0.6.0 46
47
67
55
72
66
86
65
Autofac 4.6.0 562
477
545
488
1408
1252
4726
4350
DryIoc 2.10.4 49
37
47
47
62
60
69
57
fFastInjector 1.0.1 21
27
61
52
145
108
373
223
Mef2 1.0.30.0 187
119
199
133
274
159
447
266
MicroResolver 2.0.0 26
33
31
39
50
55
72
63
Ninject 3.2.2.0 3978
2444
12567
7963
34620
19315
95859
60936
SimpleInjector 4.0.8 58
44
82
59
93
76
109
80
Unity 4.0.1 1992
1042
2745
1523
7161
3843
19892
10586

てわけで、TransientとCombinedで勝ってます。フル結果はでっかいのでこちら。ただし、これはジェネリクス版に書き換えて比較しているので、ノンジェネリクスで統一している場合は若干異なる結果になります。つまり、MicroResolverにやや有利になってます。その辺どうしていきましょうかってのは要議論。

使い方イメージ

高速化の説明の前に、さすがに簡単な使い方がわからないとイメージつかないと思うので、使い方の方を軽く。

// Create a new container
var resolver = ObjectResolver.Create();
 
// Register interface->type map, default is transient(instantiate every request)
resolver.Register<IUserRepository, SqlUserRepository>();
 
// You can configure lifestyle - Transient, Singleton or Scoped
resolver.Register<ILogger, MailLogger>(Lifestyle.Singleton);
 
// Compile and Verify container(this is required step)
resolver.Compile();
 
// Get instance from container
var userRepository = resolver.Resolve<IUserRepository>();
var logger = resolver.Resolve<ILogger>();

というわけで、ObjectResolver.Create でコンテナを作って、そこにRegisterでインターフェイス-具象型の関連をマップしていって、Compileで検証とコード生成。あとはResolveで取り出せる。みたいなイメージです。普通のDIコンテナです。APIは私が一番触り心地が楽なように、かつ、一般的なものとは外れないように選んでいきました。Bind().To()とかいうような Fluent Syntax でやらせるやつは最低の触り心地なので、ナイですね。ナイ。まじでナイ。

IL生成時インライン化

単発のパフォーマンスは普通に動的コード生成やれば普通に出るのでいいんですが、少し複雑な依存関係を解決する、ネストの深い生成時にパフォーマンスの違いが大きく現れます。↑のベンチマークも、見方がわからないと漠然と速いとか遅いとかしかわからないと思うんですが、ぶっちゃけSingletonはどうでもよくて(というのも、別にDI使う時にSingletonで生成するものってあんま多くないよね?)大事なのはTransientとCombined、あるいはComplexです。Transientは単発の生成、Combinedは依存関係のある複数生成、ComplexはCombinedよりも多くの複数生成になってます。ようはこういうことです。

// こんなクラスが色々あるとして
public class ForPropertyInjection : IForPropertyInjection
{
    [Inject]
    public void OnCreate()
    {
    }
}
 
public class ForConstructorInjection : IForConsturctorInjection
{
    [Inject]
    public IForFieldInjection MyField;
}
 
public class ComplexType : IComplexType
{
    [Inject]
    public IForPropertyInjection MyProperty { get; set; }
 
    public ComplexType(IForConsturctorInjection instance1)
    {
 
    }
 
    [Inject]
    public void Initialize()
    {
    }
}
 
// このComplexTypeをどのようにライブラリは生成するか想像しましょう?
var v = resolver.Resolve<IComplexType>();

で、最初に、私はこういう実装にしたんですね。

static IComplexType ResolveComplexType(IObjectResolver resolver)
{
    var a = resolver.Resolve<IForConsturctorInjection>();
    var b = resolver.Resolve<IForPropertyInjection>();
 
    var result = new ComplexType(a);
    result.MyProperty = b;
    result.Initialize();
 
    return result;
}

まぁ別におかしくはない、素直なコード生成の実装だったんですが、これでベンチマーク走らせたら見事に負けたんですね。負けた!マジか!どういうことだ!ってことでよーく考えたんですが、中で多段にResolve<T>してるとこがネックっぽい。それなりに、というかかなり気を使って単発Resolve速度は上げてるんですが、とはいえ、多段呼び出しは多段呼び出しで、恐らくそれのせいで負けてるわけです。というか、もはやここを削る以外にやれることないし。というわけで、考えた手法はインライン化です、依存を解決した生成コードは全部フラットにインライン化してIL埋め込みます。

static ComplexType ResolveComposite()
{
    var a = new ForConstructorInjection();
    a.MyField = new ForFieldInjection();
    var b = new ForPropertyInjection();
    b.OnCreate();
 
    var result = new ComplexType(a);
    result.MyProperty = b;
    result.Initialize();
 
    return result;
}

↑のようなイメージのコードが型毎に生成されてます。これの効果は絶大で、Transientでは勝ってるのにCombinedでは負けたー、という状況もなくなり、他をきちんとなぎ倒せるようになりました。めでたしめでたし。実装的にもIL Emitの分割点を適切に切って足すだけなので、実はそんな難しくない。コスパ良い。

Dynamic Generic Type Caching

コード生成ってようするにデリゲートを作ることなんですが、それを型で分類してキャッシュするわけですが、それをどうやって保持して取り出しましょうか、という問題が古くからあります。普通はDictionary<Type, T>とか、ConcurrentDictionary<Type, T>とか使うんですが、ジェネリクスを活用すればもう少し速くできるんですね。ようするに

static Cache<T>
{
    // ここに保持すればいいんじゃもん
    public Func<T> factory;
}

こういうことです。これは別に珍しくなく、 EqualityComparer<T>.Default とかで割と日常的に使ってるはずです。しかしコンテナって複数作ったりするので、staticクラスにはできないんですよねー、ということで困ってしまうわけですが、私はこういうふうに解決しました。まず、これがObjectResolver(コンテナ)のシグネチャ(一部)です。

public abstract class ObjectResolver
{
    public abstract T Resolve<T>();
}

で、ObjectResolver.Createで新しいコンテナを作成する際に、こういう型を動的生成しています(とにかくなんでも生成するのです!)

public class ObjectResolver_Generated1 : ObjectResolver
{
    public override T Resolve<T>()
    {
        // 余計なものが一切ない超絶シンプルなコードパスにまで落とし込んでいるので、当然最強に速い
        return Cache<T>.factory();
    }
 
    Cache<T>
    {
        // IL生成時インライン化のとこで説明したコードがここに代入されてる
        public Func<T> factory;
    }
}

さすがにもはや文句のつけようもなく、これ以上速くするのは難しいでしょう。しいていえばTransientとSingletonが共通化されているので(Singletonの場合はfactory()を呼ぶと中でLazy.Valueを返すようになってる)、もしSingletonなら.Valueで取れたほうが速くなります。ただ、そうなるとTransientとSingletonで分岐コード書かなきゃいけなくなって、Transientの速度が犠牲になるんですよね。明らかにTransientを優先すべきなので、分岐なしのTransientを最速にする実装にしています。

ところで、これやるとコンテナを解放することはできません。作った型は消せません。あと、やっぱコンテナ生成速度はそれなりに犠牲になってます。ただまぁ、コンテナ山のように作ることって普通ないと思うんで(生成速度が遅いといっても、ユニットテストとかでテストメソッド毎に作るぐらいなら別に許せるレベルですよ)いいでしょう。山のように作らなければ、解放できないことによるメモリ云々カンヌンも大したことないはずなので。

非ジェネリック用の特化ハッシュテーブル

いくらジェネリクスを最速にしても、フレームワークから使われる時って object Resolve(Type type) を要求することが多いんですよね。なので、そっちのほうも最適化してやらなきゃいけません。んで、デザインとしてMicroResolverは事前Compileで、以後追加はない、完全に中身が固定化されるという仕様にしたので、マルチスレッドは考えなくていい。つまりConcurrentDictionaryはサヨナラ。そしてDictionaryも、さようなら。エクストリームな領域では汎用コンテナを使ったら負けです。中身が完全に固定されていて追加がない状態なら、固定配列を使ってもう少しパフォーマンスを稼げるはずだし、実装も簡単。

// こんな構造体を定義しておいて
struct HashTuple
{
    public Type type;
    public Func<object> factory;
}
 
// これがハッシュテーブルの中身、基本的に固定配列が最強です
private HashTuple[][] table;
 
// Register<T> は、つまりFunc<T> なわけですが、これはFuncの共変を使って直接 Func<object> に変換できます
// ExpressionTree経由で上からデリゲートを生成して変換する、という手が一般に使われますが、
// それは関数呼び出しが一つ増えるオーバーヘッドですからね!
// というわけで、MicroResolverのRegister<T>のTにはclass制約がかかってます
table[hash][index] = new Func<object>(Resolve<T>);
 
// で実際に呼び出すばやい
public object Resolve(Type type)
{
    var hashCode = type.GetHashCode();
    var buckets = table[hashCode % table.Length];
 
    // チェイン法によるハッシュテーブルの配列は、拡縮を考えなくていいので連結リストではなく固定サイズの配列
    // 当然これがループ的には最速だし、ついでに.Lengthで回せるので配列の境界チェックも削れる
    for (int i = 0; i < buckets.Length; i++)
    {
        if (buckets[i].type == type)
        {
            return buckets[i].factory();
        }
    }
 
    throw new MicroResolverException("Type was not dound, Type: " + type.FullName);
}

実装は別に難しくなくて、難しいのは汎用コンテナを捨てる、という決断だけですね。捨ててもいいんだ、という発想を持てること。が何気に大事です。当たり前ですが一般論はDictionaryを使えってことですが、使わないという選択を完全に捨て去ってしまうのは間違いです。そこの塩梅を持てるようになると、一歩ステップアップできるんじゃないでしょうか?杓子定規の綺麗事ばかり言ってると人間進歩しないですしね。むしろ世の中の本質は汚いところにある。

さて、とはいえ、ジェネリック版が優先で、非ジェネリックはサブなんですが、実装によっては非ジェネリックを優先で、ジェネリックはフォールバックにする実装もあります。というか普通はそっちです。ので、ベンチマークではどっち優先のものかで差が出ちゃうんですよね。今回私が計測したのはジェネリック優先のベンチマークにしましたが、非ジェネリック優先のベンチマークだと、そのものが非ジェネリック優先で作られたものに負けてしまったりします。きわどい勝負をしてるので、むつかしいところですね。

DIとしての機能

一応DIとしてはちゃんと機能あって、コンストラクタインジェクション、プロパティインジェクション、フィールドインジェクション、メソッドインジェクションをサポートしてます。インジェクト対象は明示的に[Inject]をつけてください。かわりに、プライベートでも問答無用で差し込めます。

public class MyType : IMyType
{
    // field injection
 
    [Inject]
    public IInjectTarget PublicField;
 
    [Inject]
    IInjectTarget PrivateField;
 
    // property injection
 
    [Inject]
    public IInjectTarget PublicProperty { get; set; }
 
    [Inject]
    IInjectTarget PrivateProperty { get; set; }
 
    // constructor injection
    // if not marked [Inject], the constructor with the most parameters is used.
    [Inject]
    public MyType(IInjectTarget x, IInjectTarget y, IInjectTarget z)
    {
 
    }
 
    // method injection
 
    [Inject]
    public void Initialize1()
    {
    }
 
    [Inject]
    public void Initialize2()
    {
    }
}
 
// and resolve it
var v = resolver.Resolve<IMyType>();

お行儀が良いのはコンストラクタインジェクションで、お行儀が一番悪いのはプライベートフィールドインジェクションなんですが、ぶっちけコンストラクタインジェクションに拘る必要はないでしょうね。プライベートフィールドインジェクションとかするとDIコンテナ以外から生成できないじゃん!とかいうけど、どうせDIコンテナ使ったらアプリケーション全体でDIコンテナ依存するので、コンストラクタインジェクションならDIコンテナなしでもDependency Injection Patternとしてキレイにおさまるからいいよね、とかクソどうでもいいので無視でいいでしょう。むしろライブラリ使うんなら諦めてライブラリと心中するぐらいの覚悟のほうが、いい結果残せるでしょう。

まぁプライベートフィールドインジェクションすると警告出て(未初期化のフィールドを触ってます的なあれそれ)ウザかったりもしますが。

そういう意味ではService Locator is an Anti-Patternもどうでもよくて、Service Locatorの何が悪い(どうせキレイに作ってもなんらかのライブラリに依存するんだから、Service Locatorなしでメンテナンスビリティ云々とかないでしょふし、どうせそもそも深い依存関係をDIコンテナから生成するならコンストラクタで依存を表明とか実質ないんでどうでもよろし)。ってのはありますね。でも普通にService Locatorでやるよりも依存のトップからMicroResolverでResolveしたほうがパフォーマンスが良いので、そういう観点から適当に判断しましょう:)

まぁあと、RegisterCollectionで登録しておくと T[]とかで取り出したりできます。大事大事。

// Register type -> many types
resolver.RegisterCollection<IMyType>(typeof(T1), typeof(T2), typeof(T3));
 
resolver.Compile();
 
// can resolve by IEnumerbale<T> or T[] or IReadOnlyList<T>.
resolver.Resolve<IEnumerable<IMyType>>();
resolver.Resolve<IMyType[]>();
resolver.Resolve<IReadOnlyList<IMyType>>();
 
// can resolve other type's inject target.
public class AnotherType
{
    public AnotherType(IMyType[] targets)
    {
    }
}

Lifetime.Scopedとかもありますが、その辺はReadMe見てください。この辺までカバーしておけば、別にパフォーマンス特化で機能犠牲、ってわけでもなく、ちゃんとDIライブラリとしての機能は満たしているといえるでしょう。実際満たしてる。

まとめ

テストのための設計、というのがすごく好きじゃなくて、テスタビリティのためにシンプルなプロダクトの設計を、大なり小なり歪めるでしょうね。そして、どうしてもDependency Injection Patternのようになっていくわけですが、ライブラリなしでそのパターンやると、相当キツいってのが間違いなくあるんですねー。ライブラリのチョイスとか利用ってものすごく大事だと思っていて、何も考えずテスト最高!とかいってるのはあまりにもお花畑なんで、一歩引いて考えたい、と。とはいえ、さすがに無策なのはそれはそれでしょーもないんで、改めてDIパターンとは、サービスロケーターとは、そしてDIライブラリとは、っていうところから見つめ直してみました。

DIライブラリのパフォーマンスは、まぁそこまで大事ではないと思います、少なくともシリアライザよりは。なので、さすがにベンチマークであからさまに遅いのは正直使う気起きなくなると思いますが(Ninject!)、そこそこのなら別にいいんじゃないかと。SimpleInjectorは速度と機能、そしてコミュニティの成熟度からバランスは良さそうだなーって印象ありますね。AutofacやUnity(DIライブラリの)は、基幹的な設計が(パフォーマンス的な意味で)古いというところもあってベンチ結果は一歩遅いんですが、とはいえこれがネックになるかどうかでいうと、なんともってところです。とはいえあえて古臭いものを使いたいかって話はある。

DIライブラリ全体の印象としては、雨後の筍のように山のようにあるだけに、上位のものはみんなかなりパフォーマンス的に競っていて、それぞれ良いアプローチをしていて、「ランキング一位を目指す」的なプログラミング芸としては中々楽しかった!それじゃただの趣味プロですね。いい加減さすがにC#メタプログラミングは極めた感ある。というか2~3日腰据えて書いただけで一位取れちゃうってのもどうなのかね、うーん。

まぁ、それなりにいい感じにまとまってるとは思うんで、MicroResolverも、よければ使ってみてくださいな。ちなみにUnity(ゲームエンジン)版はありません(今回の目的がハナからベンチマークで一位を取る、というところにフォーカスしてるんでIL生成芸以外のことはやる気なし)

Microsoft MVP for Visual Studio and Development Technologies(C#)を再々々々々々受賞しました

今年の受賞で、7年目です。今回から周期がズレていて、全体で7月に統一ということらしいのですが(私は前は4月でした)、正直忘れていたりしなかったりもなかったんで反応遅れてましたが受賞してました。変わらずの Visual Studio and Development Technologies という長いやつで、ようするにC#です。

私の主な活動は、OSSと、実践的で先鋭的なC#というところで、その領域では他の誰よりも結果を出せているでしょう。特にOSS面では、今までがある意味、ただ作るだけに近かったものが、近年では、より戦略的に世界に向けて使わせる・流行らせるということを明確な意思を持ってやってますし、成果も出ていると思います。毎年更新とはいえ、毎年同じように変わらずにいてもしょうがないので、より新しく、意味ある結果を残していければいいと考えています。逆に言えば、何も変わりなくなれば、死んだみたいなものなので辞めどきでしょう。幸い、まだ死んではいないようですし、常に新しい成果で客観的にそうであると納得させられなければ意味がないので、MVPの更新という目は一つの実証ではありますが、それよりも厳しい目で律していきたいです。

私自身、まだ表現したいことは沢山あるので、次の期では、今までの延長線上とは違う、また別の何かを見せられればというところです。何れにせよ、絶対の安泰なんてない世界だとは思ってるので、より踏み込んで示していきたいので、よろしくお願いします。

MessagePack for C# 1.4.1 - JSONサポート強化, dynamic対応, Typelessシリアライズなど

めちゃくちゃ久々ですが、この間、何も書いてないわけではなかったです!会社ブログのほうに、Unite 2017 Tokyo講演「「黒騎士と白の魔王」にみるC#で統一したサーバー/クライアント開発と現実的なUniRx使いこなし術」リアルタイム通信におけるC# - async-awaitによるサーバーサイドゲームループMessagePack for C#に見るC#でのバイナリの読み方と最適化法と三本書いてました。

また、Unite 2017とAWS Summit 2017という大きめの会場での発表もしていました。

【Unite 2017 Tokyo】「黒騎士と白の魔王」にみるC#で統一したサーバー/クライアント開発と現実的なUniRx使いこなし術 from UnityTechnologiesJapan
「黒騎士と白の魔王」gRPCによるHTTP/2 - API, Streamingの実践 from Yoshifumi Kawai

Uniteはクライアントサイド中心に、AWS Summitではサーバーサイド中心にという形で用意していたのですが、特にUniteのほうは幅広く扱いすぎて散漫になってしまって、割と反省しています。どちらのセッションもコード成分が少なめになってしまったのも如何ともし難いところで、どこかでもう少しコードコードしたものをしたい気は割としています。

MessagePack for C# 1.4.1

さて、本題。MessagePack for C#の1.4.1をリリースしました。ちなみに表記する際 MessagePack-CSharp と呼ぶべきか MessagePack for C# と呼ぶべきかが悩ましいですね。1.0.0の時から、特に機能追加でのアナウンスをしていなかったので、一挙紹介したいと思います。かなり強化されています……!

JSONサポート

もともとToJsonだけだったのですが(MessagePackBinaryをJSON形式に変換、バイナリなので中身がわかりにくいmsgpackの中身を解析するのに便利)、FromJsonが追加されています。

// JSON文字列をMessagePackバイナリ(byte[])に変換
var msgpackBin = MessagePackSerializer.FromJson(@"{""hoge"":""foo"",""huga"":2000}");
 
// byte[]は送信するなり保存するなり、MessagePackとしてDeserializeするなりお好きなように。
 
// {"hoge":"foo","huga":2000}
Console.WriteLine(MessagePackSerializer.ToJson(msgpackBin));

FromJson、便利なの?というと、んー、まぁあんまり使うことはないかなー、とは思いますが、(互換的な意味/ブラウザからだから)JSONで受けて、内部的にはMsgPackで流す、みたいなシナリオもなくはないんですよね。そういうところではいいんじゃないでしょうか。また、後述するdynamicと組み合わせると以外と便利かもしれません。

Dynamicデシリアライズ

XMLだと、構造を見て、手でマップしていくということが割とあったのですが、JSONではXMLにおける属性など複雑な要素がないぶんだけ、そのままストレートにデシリアライズでマッピングするだけで事足りることがほとんどになった気がします。ましてやMessagePackはバイナリなので、手付けで対応つけるのもやりにくいでしょう。とはいえ、C#的な構造に1:1でマッピング出来ないような構造がこないとも限らず、簡単に、動的に弄れる機構があれば、かなり有意義なのは間違いないでしょう。MessagePack for C#は、標準でdynamicで受けることで、動的オブジェクトとして操作できるようになります。

// こんなデータがあったとして
var bin = MessagePackSerializer.Serialize(new Dictionary<object, object>
{
    { "Name" , "foobar" },
    { "Arguments", new object[]{ 1, 100.424, "hugahuga" } },
});
 
// dynamicでデシリアライズ!
var d = MessagePackSerializer.Deserialize<dynamic>(bin);
 
// インデクサを使って動的に辿って取り出せる
Console.WriteLine(d["Name"]); // foobar
Console.WriteLine(d["Arguments"][1]); // 100.424
Console.WriteLine(d["Arguments"][2]); // hugahuga
 
// データ構造はToJsonで確認しておけばよろし
// {"Name":"foobar","Arguments":[1,100.424,"hugahuga"]}
Console.WriteLine(MessagePackSerializer.ToJson(bin));

ちなみにFromJsonとDeserialize<dynamic>を組み合わせれば、MessagePack for C#だけで簡易的なJSON解析・値の取得が可能になります。

// FromJsonとDeserialize<dynamic>を組み合わせてDynamicJsonになる
var d = MessagePackSerializer.Deserialize<dynamic>(MessagePackSerializer.FromJson(@"{""hoge"":""foo"",""huga"":2000}"));
 
Console.WriteLine(d["hoge"]); // foo
Console.WriteLine(d["huga"]); // 2000

性能的には、まぁわざわざmsgpackのbyte[]を介しているので、超速い!ってわけじゃないんですが、そもそもMessagePack for C#の速度が他の数倍速いということもあって、普通にかなりの速度が出ます。

なお、dynamicデシリアライズの正確な実体は PrimitiveObjectResolver で、StandardResolverの最後のフォールバックとして組み込まれています。

Typelessシリアライズ

Typelessって何?ってことですが、BinaryFormatterみたいなものです。普通の(?)シリアライザは、デシリアライズ時に<T>だの引数にTypeだのと、とにかく型を要求します。何故かと言うと、どの型に変換すればいいのかわからないから。でもBinaryFormatterは違います、APIを見てください、Typeを要求していないのです!

public object Deserialize(Stream serializationStream);

それなのにobjectで返されたほうには、ちゃんとシリアライズした時の型で帰ってくる。すごいね!便利だね!その理由は……、.NETの型がバイナリに埋まってるから。バイナリに埋まってるので、その情報を元にデシリアライズしているのです。というわけで、そんなTypelessで処理できるバージョンが実装されました。

// .Typeless経由でトップレベルのTypelessSerializerが使える
var bin = MessagePackSerializer.Typeless.Serialize(new MyClass() { Hoge = 100 });
 
// ちゃんとMyClass.Hoge = 100 でデシリアライズされてる
var mc = MessagePackSerializer.Typeless.Deserialize(bin);
 
// こんな風に、型名が先頭にシリアライズされてる。
// Dump結果はMapのように見えますが、実際はMsgPackの拡張領域(100)を使い、型を埋めている
// {"$type":"ConsoleApp73.MyClass, ConsoleApp73","Hoge":100}
Console.WriteLine(MessagePackSerializer.ToJson(bin));

実装的には TypelessContractlessStandardResolver 経由でシリアライズされているので、普通のシリアライズと混ぜることができます。どういうことかというと、object[]とかでも問答無用にきちんとシリアライズ/デシリアライズできます。

// こんな型があったとして
public class RpcInfo
{
    public string MethodName { get; set; }
    public object[] Arguments { get; set; }
}
 
// ----
 
var info = new RpcInfo
{
    MethodName = "Hoge/Huga",
    Arguments = new object[] { "foo", 100, new MyClass() }
};
 
// RpcInfoとしてシリアライズ
var bin = MessagePackSerializer.Serialize<RpcInfo>(info, TypelessContractlessStandardResolver.Instance);
 
// (object[] Arguments)が正しく復元されている
var info2 = MessagePackSerializer.Deserialize<RpcInfo>(bin, TypelessContractlessStandardResolver.Instance);

こういう、ふつーだと出来ないことが色々できる感じで夢広がりますね。前述のPrimitiveObjectResolverでも、まぁまぁ賄えるのですが、独自型とかを入れると扱いが厄介になってしまうので、そういう点でこちらの TypelessResolver のほうがイケテル度は高いです。

ところで、型を埋め込み、任意の型でデシリアライズできる場合には脆弱性が出る可能性があります。詳しくはBreaking .NET Through Serializationという資料を読んでほしいのですが(この資料は大変素晴らしいのでC#書く人は絶対読んだほうがいいですよ)、中には酷いクラスがあって、例えば System.CodeDom.Compiler.TempFileCollection はデストラクタでFile.Delete が走ります。基本的にインターネットの外からやってくるものに絶対の安全はありません。MessagePackはバイナリだからといって、別に不正データが投げつけられないわけではないので、TempFileCollection を型情報として埋めて、File.Deleteの対象をデシリアライズさせるものを投げつければ、ファイルをボロボロに削除されちゃうでしょう。

MessagePack for C#ではそれなりの安全性(最もキケンな[Serializable]のルールには従わない、↑で挙げられてるようなヤベークラスはそもそもデシリアライズできないようにしている)はありますが、絶対の保証がある、と言い切れるかというとなんともというところです。まぁ、シリアライザを作るってことは、表面上に見えるよりも、もっと色々なことを考えて作ってるんですよ、ということで。

標準Resolverから外しているように、Typeless自体がオススメかどうかというと微妙なのですが(型を埋め込む都合上バイナリサイズも膨らむし、他言語との互換性も消滅する)、欲しいシチュエーションというのは間違いなく存在するので、そういう時に覚えていてもらえれば嬉しいです。

Stream API

基本的にMessagePack for C#はbyte[]レベルで動作します。byte[]を直接読み、byte[]に直接書く。それにより、あらゆるオーバーヘッドを削減しているんですが、既存フレームワークなどにシリアライザ拡張を仕込む場合、Streamを引数に取るケースが多いんですね、というか普通そうですよね。そんな場合、高レベルAPI(MessagePackSerializer.Serialize/Deserialize)にはStreamオーバーロードが用意されているのですが、プリミティブなAPI(MessagePackBinary)には、ありませんでした。

さすがにそれはやりづらいねー、ってのはわかるー、ので、新しくMessagePackBinaryのWrite/ReadにStreamを受け取るオーバーロードが用意されました。最終的にbyte[]に読み取って/書き込んでから処理するのですが、そこのところを内部のメモリープールを通したりして、なるべくオーバーヘッドが少なくなるようにしています。

また、新たに MessagePackSerializer.Deserialize(Straem stream, bool readStrict) というオーバーロードが高レベルAPIに登場しました。readStrictがtrueの場合、Streamから読み取る範囲が、きっちりMessagePackのブロック分だけになります。デフォルトはfalseです。falseの場合はStreamを最後まで呼んで、そのbyte[]ブロックを処理します。そのため、Streamに連続的にMessagePackのバイナリが詰まっていた場合に処理できなかったんですね、これがreadStrictなら、正しくDeserializeを連発するだけでも動作させられます。

using (var ms = new MemoryStream())
{
    // Streamに連続的に書き込む
    MessagePackSerializer.Serialize(ms, new[] { 1, 10, 100, 1000 });
    MessagePackSerializer.Serialize(ms, new[] { 1000, 100, 10, 1 });
 
    ms.Position = 0;
 
    // readStrict: trueで正しく順番にデシリアライズできる
    var a1 = MessagePackSerializer.Deserialize<int[]>(ms, readStrict: true); // [1, 10, 100, 1000]
    var a2 = MessagePackSerializer.Deserialize<int[]>(ms, readStrict: true); // [1000, 100, 10, 1]
}

じゃあtrueがデフォルトのほうがいいじゃん!ってことなんですが、パフォーマンス的にはfalseのほうがいいのです。というのも正確にMessagePackのブロック範囲を読み取るために、先にブロック範囲を解析する必要があるので……。これは、MessagePack for C#がbyte[]レベルで動作しているため、正しくストリーミングで読み書きできるわけじゃないからです。その辺のトレードオフは承知の上でbyte[]レベルを基本に敷いています。ストリーミングでやるから単純にロスなしでパフォーマンス良いんだぜ!じゃないところが世の中の現実的なところ、ということで。

Resolverによる拡張

MessagePack for C#の拡張ポイントは IFormatterResolver のみです。なんたらオプションとかなんたらセッティングスとかなく、どのリゾルバーを使うか。それだけの単純明快な仕様になっています。そして、それだけで十分すぎるほど機能するのです!なんでそうなのかというと、本質的にシリアライザって、ある型にたいしてどういうbyte[]を書く/読むか、ってことの連続にすぎないんですね。なので MessagePack for C# ではそこだけに注目して、ある型にたいしてどういうbyte[]を書く/読むか、を定義することがシリアライザの最小の実装としました。それがIMessagePackFormatter<T>で、Tに対してSerializeとDeserializeを定義します。組み込みで126個用意されてるようです、凄い、地道な作業です……。

image

スクロールバーの長さがものがたる。

IFormatterResolver は何かというと、その IMessagePackFormatter を取り出す機構です。

// IntFormatterが出てくる
var intFormatter = resolver.GetFormatter<int>();

で、それがどこで使われているかというと、IMessagePackFormatterです。IMessagePackFormatterを取り出すIFormatterResolverはIMessagePackFormatterで使われる、というわけわからん感じですが、どういうことかというと、例えばオブジェクトをシリアライズする場合。

[MessagePackObject]
public class SampleModel
{
    [Key(0)]
    public int Id{ get; set; }
    [Key(1)]
    public Person User { get; set; }
    [Key(2)]
    public DateTime CurrentTime { get; set; }
}
 
public sealed class SampleModelFormatter : IMessagePackFormatter<SampleModel>
{
    public int Serialize(ref byte[] bytes, int offset, SampleModel value, IFormatterResolver formatterResolver)
    {
        if (value == null)
        {
            return MessagePackBinary.WriteNil(ref bytes, offset);
        }
 
        var startOffset = offset;
 
        offset += MessagePackBinary.WriteFixedArrayHeaderUnsafe(ref bytes, offset, 3);
 
        // formatterResolver経由で各型のシリアライザを取得している
        offset += formatterResolver.GetFormatter<int>().Serialize(ref bytes, offset, value.Id, formatterResolver);
        offset += formatterResolver.GetFormatter<Person>().Serialize(ref bytes, offset, value.User, formatterResolver);
        offset += formatterResolver.GetFormatter<DateTime>().Serialize(ref bytes, offset, value.CurrentTime, formatterResolver);
 
        return offset - startOffset;
    }
}

オブジェクトのシリアライズが代表的ですが、型はネストするんですね、ネストした各プロパティの型の子シリアライザを取得するためにformatterResolverが使われます。このformatterResolverはシリアライズの際のトップレベルから渡され続けて、それにより挙動がカスタマイズできます。

// デフォルト:Contract(属性付与)が必要なResolver
MessagePackSerializer.Serialize(model, MessagePack.Resolvers.StandardResolver.Instance);
 
// 無指定で全てのpublic型をシリアライズなJSON.NETライクにカジュアルに使えるResolver
MessagePackSerializer.Serialize(model, MessagePack.Resolvers.ContractlessStandardResolver.Instance);

Resolverは大量に用意されているのですが、大きく分けて、他のと混ぜて使うためのものと、トップレベルで渡されることを想定した複合の二種があります。例えば単独だとDateTimeには組み込みで二種類あります。

// DateTimeFormatter, MsgPackのTimestampの仕様でシリアライズ/デシリアライズする。UTCになる。
var formatterA = BuiltinResolver.Instance.GetFormatter<DateTime>();
 
// DateTime.ToBinaryで.NETに特化した仕様でシリアライズ/デシリアライズする。DateTimeKindが保持される。
var formatterB = NativeDateTimeResolver.Instance.GetFormatter<DateTime>();

では、NativeDateTimeResolverを使いたい、という場合には、使いたいResolverを先に持ってけばいい、と。

// StandardResolverによる解決の前にNativeDateTimeResolverで解決させる
MessagePack.Resolvers.CompositeResolver.RegisterAndSetAsDefault(
    NativeDateTimeResolver.Instance,
    StandardResolver.Instance);

CompositeResolverは組み込みのお手軽にResolverのカスタムチェーンを作れる代物ですが、CompositeResolverにこだわらず、自分でResolverを作ってしまうのも良いです(むしろ割とそちらのほうがオススメ、ReadMeに書かれているものをコピペすれば、別に難しくはありません)。ちなみにStandardResolverは以下のような単発Resolverの混合品になっています。

// StandardResolverの解決順序
static readonly IFormatterResolver[] resolvers = new[]
{
    BuiltinResolver.Instance, // Try Builtin
    AttributeFormatterResolver.Instance, // Try use [MessagePackFormatter]
    DynamicEnumResolver.Instance, // Try Enum
    DynamicGenericResolver.Instance, // Try Array, Tuple, Collection
    DynamicUnionResolver.Instance, // Try Union(Interface)
    DynamicObjectResolver.Instance, // Try Object
    PrimitiveObjectResolver.Instance // finally, try primitive resolver
};

ここから足したり引いたりして、オレオレStandardResolverを作っても良いわけです。それがMessagePack for C#のシリアライズ動作のカスタマイズになっています。なお、リゾルバーの解決チェーンはTの解決時に一回だけ走るようになっていて、そこで確定したら(ジェネリクスの利用法のハックにより)C#レベルでキャッシュされるので、超高速に取り出すような構造にしています。毎回、解決のチェーンを回したり、TypeをキーにしてDictionaryから引っ張る、とかやってたりしたら遅いですからね。

こういった仕組みだけで、ここまで徹底的に過激にやってる例は他にないんですが、めちゃくちゃ機能するので、世の中は見習うといいでしょう。

MessagePackFormatterAttribute

基本的にオブジェクトのシリアライズは、IMessagePackFormatterにより提供される外部シリアライザ経由で実行されます。通常は、属性付与により動的にシリアライザが生成されますが、全く別個のカスタマイズされた挙動をさせたい場合もなくはないでしょう、その際にはカスタムResolverを作って、通常利用するResolverの先頭に差し込んで貰う、というのも面倒くさいので、クラスに対して1:1で固有のシリアライザを紐付けられる属性を追加しました。

// この属性で渡したTypeがシリアライザとして使われる
[MessagePackFormatter(typeof(CustomObjectFormatter))]
public class CustomObject
{
    string internalId;
 
    public CustomObject()
    {
        this.internalId = Guid.NewGuid().ToString();
    }
 
    // ネストしたクラスの中にシリアライザがあるので、プライベートフィールドのシリアライズも可能
    // みたいな自由なカスタマイズができるようになる
    class CustomObjectFormatter : IMessagePackFormatter<CustomObject>
    {
        public int Serialize(ref byte[] bytes, int offset, CustomObject value, IFormatterResolver formatterResolver)
        {
            return formatterResolver.GetFormatterWithVerify<string>().Serialize(ref bytes, offset, value.internalId, formatterResolver);
        }
 
        public CustomObject Deserialize(byte[] bytes, int offset, IFormatterResolver formatterResolver, out int readSize)
        {
            var id = formatterResolver.GetFormatterWithVerify<string>().Deserialize(bytes, offset, formatterResolver, out readSize);
            return new CustomObject { internalId = id };
        }
    }
}

このシリアライザの選択もResolverによって提供されていて、AttributeFormatterResolverがこの解決を行ってくれる代物になっています。なので、「MessagePackFormatterAttributeを無視したい」という場合はAttributeFormatterResolverを抜いたリゾルバーを渡せばいい、ということになります。また、それを無視した、更に別の挙動に変えたい場合は、「その前」にその型に適合するResolverを用意しておけばいいわけですね。シリアライザの挙動のカスタマイズは全てリゾルバーで解決可能、な問題になるように全体的なAPIを調整してあるのは、優れた点だと思っています。

DataContract対応

今まで独自属性(MessagePackObjectAttributeやKeyAttribute)のみだったのですが、DataContractAttributeにも対応しました。

[DataContract]
public class Sample1
{
    [DataMember(Order = 0)]
    public int Foo { get; set; }
    [DataMember(Order = 1)]
    public int Bar { get; set; }
}

Orderをint key, Nameをstring key代わりにできます。DataContractを使うことのメリットは、共有したい型のプロジェクトをMessagePack for C#の参照のないプレーンなプロジェクトにできることです。デメリットはAnalyzerの解析対象外になることと、mpc.exeによるコードジェネレート対象外になること。また、UnionやSerializationConstructorなどの、より強力なMessagePack for C#の機能は使えません。なので、できればMessagePack for C#を参照したほうがオススメです。

強い署名

すとぅろんぐねーむさいんど、好きですか?私は嫌いです。今の世の中に全く見合ってないレガシーなシステムだと思っています。しかし、.NETの世界は残念ながら強い署名と共に生きていくしかないのです。それは.NET Core時代であっても。CorefxのStrong Name Signingというドキュメントが最新の見解になりますが、もうこれが存在する理由は、互換性のためしょうがなく維持する必要があり、そして、署名されたものが存在すれば、そこからは署名の負の連鎖が繋がっているという、そういう荒涼とした世界だけです。

というわけで現状、NuGetでは署名したのが配られています。

性能改善

地道に出来るとこはやってますねん。特にオブジェクトをMapでシリアライズする場合(ContractlessResolverやKey(string)など)の性能を向上してます。これはJSONリプレイス的な意味で、かなり使われる形式なので、ちゃんと手を打ちたかったので。具体的にどんな形になったかというと

// こんなよくあるものがあるとして
[MessagePackObject(keyAsPropertyName: true)]
public class SampleModel
{
    public int Age { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
}
 
// Beforeのサンプル
public sealed class SampleModelFormatter : IMessagePackFormatter<SampleModel>
{
    public int Serialize(ref byte[] bytes, int offset, SampleModel value, IFormatterResolver formatterResolver)
    {
        if (value == null)
        {
            return MessagePackBinary.WriteNil(ref bytes, offset);
        }
 
        var startOffset = offset;
 
        // 個数3が固定なので、コード生成時に15以下は判定なし(FixedMapHeaderUnsafe)で書き込み
        offset += MessagePackBinary.WriteFixedMapHeaderUnsafe(ref bytes, offset, 3);
 
        // {"プロパティ名":値} を書き込んでいく
        offset += MessagePackBinary.WriteString(ref bytes, offset, "Age");
        offset += MessagePackBinary.WriteInt32(ref bytes, offset, value.Age);
 
        offset += MessagePackBinary.WriteString(ref bytes, offset, "FirstName");
        offset += MessagePackBinary.WriteString(ref bytes, offset, value.FirstName);
 
        offset += MessagePackBinary.WriteString(ref bytes, offset, "LastName");
        offset += MessagePackBinary.WriteString(ref bytes, offset, value.LastName);
 
        return offset - startOffset;
    }
}

Beforeはせやな、って感じの、わりとストレートな実装でした。しいていえば、Mapのヘッダーサイズだけは最適化しています(コード生成時に判定できるので15以下ならFixed、それ以上なら内部で個数判定してフォーマットを決めるWriteMapHeaderを使ったコードを生成する)。

Afterは、というと

// Afterのサンプル
public sealed class SampleModelFormatter : IMessagePackFormatter<SampleModel>
{
    // プロパティ名のバイト列は固定なので、事前に変換しておく
    readonly byte[][] stringByteKeys = new byte[][]
    {
        global::System.Text.Encoding.UTF8.GetBytes("Age"),
        global::System.Text.Encoding.UTF8.GetBytes("FirstName"),
        global::System.Text.Encoding.UTF8.GetBytes("LastName"),
    };
 
    public int Serialize(ref byte[] bytes, int offset, SampleModel value, IFormatterResolver formatterResolver)
    {
        if (value == null)
        {
            return MessagePackBinary.WriteNil(ref bytes, offset);
        }
 
        var startOffset = offset;
 
        offset += MessagePackBinary.WriteFixedMapHeaderUnsafe(ref bytes, offset, 3);
 
        // 文字列のバイナリです、ということでそのままシーケンシャルに書いていく
        // コード生成なら、生成時点で順番を固定で確定できるので、Dictionary<string, byte[]>みたいな辞書参照コストがかかるようなこともしない
        offset += MessagePackBinary.WriteStringBytes(ref bytes, offset, stringByteKeys[0]);
        offset += MessagePackBinary.WriteInt32(ref bytes, offset, value.Age);
 
        offset += MessagePackBinary.WriteStringBytes(ref bytes, offset, stringByteKeys[1]);
        offset += MessagePackBinary.WriteString(ref bytes, offset, value.FirstName);
 
        offset += MessagePackBinary.WriteStringBytes(ref bytes, offset, stringByteKeys[2]);
        offset += MessagePackBinary.WriteString(ref bytes, offset, value.LastName);
 
        return offset - startOffset;
    }
 
    // deserialize...
}

プロパティ名は常に固定なのだから、事前に変換して持っておけばいいでしょ、という単純なお話でした。Beforeは毎回UTF8.GetBytesしていたわけですが、Afterではそのコストがゼロになっています。これはさすがに誰がどう見ても明らかにafterのほうが速い。実際に実装する時は、こういうようなコンセプトコードを書いた上で、動的生成のためILを打ち込みます。今回は変更量も大したことなかったので、割とサクッと書けました。よかったですね。

こういうのって、言われるとそりゃそーだってところだし難しい話でもなんでもない単純なことなんですが、割と見逃しちゃうところだったりします。コロンブスの卵的な。実装的にも(特にIL書く量が増えて)面倒くさいし。そういう部分を徹底的に精査して最適化を埋め込みまくってるのが、MessagePack for C#の速さの秘訣です。地道で、徹底的な改善こそが全て。近道なんてないのです。

Mapの場合、デシリアライズ速度も改善可能なんですが、アイディアはありつつちょっと具体的な実装がないのでまだ保留中。理屈的にはロスを減らせるんですが、せっかく実装しても、それが実際速いかどうかが別問題だったりで難しいんですよねえ。

まとめ

MessagePack for C#は既に黒騎士と白の魔王で全面的(Unityクライアント-gRPCサーバー間の通信と、サーバーサイドでのRedisへのシリアライズデータ格納)に使われているため、バグも概ね取り除かれていて、プロダクション環境で安心して使わえるレベルになっています。機能面でも、シリアライザに要求される幅広いシナリオに、ほとんど対応できるレベルになっています。というか、むしろ機能面でここまで揃ってるシリアライザも実際ないですね。JSON, Typeless, dynamic、そして拡張性。最強っぽい。細かいできることはまだ色々残っていますが(循環参照のサポートが一番大きいかな)、普通に使う限りは全く不便しないはずです。Unity向けにはコードジェネレータの利便性を高める(Macサポートとか)ってのがだいぶ優先度高めで未だに抜本的には手が出てません……。

ASP.NET Core MVCサポートも、私が適当に書いたものよりも、Using MessagePack with ASP.NET Core MVCといったちゃんとした(ちゃんとした!)実装を用意してもらったりなど、採用してもらっていってるかなー、と思います。それ以外にDatadogSharpという私が現在書いているDatadog APM用のクライアントの通信もMessagePack for C#を用いています。SignalRにMsgPack Protocolを採用するという話もあるんですが、それは強い署名がなかったので敗退したんですが、署名もしたしStream APIも入れたんで、機会あればもう少し粘りたいかな、といったところですね。

ところで、今日(今日!)のGTMF 2017 OSAKAにて株式会社CRI・ミドルウェアさんと共に「「黒騎士と白の魔王」の CRIWARE 活用事例」というセッションを行います。大阪です。実はこの記事、東京-大阪の新幹線の中で書いてるんですねー。また、同じ内容を7/14のGTMF 2017 TOKYOでも行いますので是非是非よろよろしくお願いします。懇親会などでもふらついていますので、よければ捕まえてやってください。

Comment (1)

rosdi : (07/01 11:46)

Awesome! I will be using this in my next project. Cheers!

Trackback(0)
Write Comment

C# 7.0 custom task-like の正しいフレームワークでの利用法

例年、この頃はMVP更新が云々とかなのですが、今年からシステムが変わって更新時期に変動があるんで何もありませんが、一応まだ継続しています。それはともかくとしてVisual Studio 2017が出ました。会社でも全プロジェクトがVS2017に移行完了を果たして、代わり映えしないようで、タプル記法のデコンストラクションとか工夫すると結構便利だな、とか使い始めると色々発見があります。タプル記法やデコンストラクションの工夫に関しては、弊社エンジニアリングブログのC# 7.0 が使えるようになったので ValueTuple を活用してみたをどうぞ。

そんな中で、私がはよ来てくれ……と願っていたC# 7.0の新機能は、task-likeです。Proposal: arbitrary task-like types returned from async methodsで延々と議論されていたようですが、これは何かというと、asyncでTask以外の型が返せるようになります。もともとC# 7.0からValueTaskが入って、async ValueTask<T> を返せるようになる必要があったついでに搭載されたみたいなものですが、色々何か出来そうですよね!?

というわけで、早速有効に使えるシチュエーションを用意しました。というか早速投下しています。

task-likeがない場合の苦痛

現在、私はMagicOnionというgRPCをベースにしたフレームワークを作っています。シリアライザはこないだ公開したエクストリーム速くて軽量なMessagePack for C#です。と、そういう細かいことはどうでもいいとして、MagicOnionではこんな風に書きます。

// 定義を用意して
public interface IMyFirstSerivce : IService<IMyFirstSerivce>
{
    UnaryResult<int> Sum(int x, int y);
}
 
public class MyFirstSerivce : ServiceBase<IMyFirstSerivce>, IMyFirstSerivce
{
    // これがサーバーで呼び出される実装になる
    public UnaryResult<int> Sum(int x, int y)
    {
        var sum = x + y;
        return UnaryResult(x + y);
    }
}
 
static async Task Run()
{
    var channel = new Channel("localhost:1111", ChannelCredentials.Insecure);
 
    // インターフェースで動的にクライアントを自動生成する
    var client = MagicOnionClient.Create<IMyFirstSerivce>(channel);
 
    // 自然な感じでサーバー - クライアント通信で受け取れる
    var result = await client.Sum(10, 20);
 
    Console.WriteLine(result);
}

まぁまぁ自然な感じでいいじゃん?ってところですが、面倒くさいのは UnaryResult<T> を返さなければならないところ。そのため UnaryResuylt() というヘルパー関数を読んで包んだのをリターンする羽目になってます。これが地味に面倒くさい。return x + y; って書きたいじゃん、って。

で、MagicOnionがUnaryResultを強制するには理由があって、多くの場合は戻り値そのものだけで良いんですが、場合によってはレスポンスヘッダを取りたいとかステータスコードを取りたいとか、そういうのに対応する必要があるんですね。

// awaitしない
var response = client.Sum(10, 20);
 
// headerを取るとか
var header = await response.ResponseHeadersAsync;
 
// statusを取るとかしたかったりする
var trailer = response.GetStatus();
 
// 結果を取る場合。 await response はこれのショートカットでしかなかったりする
var result = await response.ResponseAsync;

APIの触り心地に関してはものすごく考えたんですが、最終的にこの辺が妥協点になってくるかな、と。しょうがないね。さて、ではasyncになるとどうでしょう?

public interface IMyFirstSerivce : IService<IMyFirstSerivce>
{
    Task<UnaryResult<string>> EchoAsync(string message);
}
 
public class MyFirstSerivce : ServiceBase<IMyFirstSerivce>, IMyFirstSerivce
{
    // サーバー側の書き味は普通、なんですが……
    public async Task<UnaryResult<string>> EchoAsync(string message)
    {
        await Task.Delay(TimeSpan.FromSeconds(10));
 
        return UnaryResult(message);
    }
}
 
static async Task Run()
{
    var channel = new Channel("localhost:1111", ChannelCredentials.Insecure);
    var client = MagicOnionClient.Create<IMyFirstSerivce>(channel);
 
    // await await !!!
    var result = await await client.EchoAsync("hogehoge");
 
    // というのも、await一発でUnaryResultの取得になる
    var response = await client.EchoAsync("takotako");
 
    //  ようするにこれのショートカットはawait awaitになってしまうのだ……
    var result2 = await response.ResponseAsync;
}

注目はawait awaitです。なんと、await awaitという世にも奇っ怪な記述が合法として出てくるのであった、最悪……。

task-likeがある場合

そこでC# 7.0 task-likeですよ!

// SyncもAsyncも共にUnaryResultとして定義
public interface IMyFirstSerivce : IService<IMyFirstSerivce>
{
    UnaryResult<int> SumAsync(int x, int y);
    UnaryResult<string> EchoAsync(string message);
}
 
public class MyFirstSerivce : ServiceBase<IMyFirstSerivce>, IMyFirstSerivce
{
    public async UnaryResult<int> SumAsync(int x, int y)
    {
        // UnaryResult()で囲む必要なし!やったー!
        return x + y;
    }
 
    public async UnaryResult<string> EchoAsync(string message)
    {
        // 勿論awaitする場合も普通に
        await Task.Delay(TimeSpan.FromSeconds(3));
        return message;
    }
}
 
static async Task Run()
{
    var channel = new Channel("localhost:1111", ChannelCredentials.Insecure);
    var client = MagicOnionClient.Create<IMyFirstSerivce>(channel);
 
    // 自然に扱える!
    var result1 = await client.SumAsync(1, 100);
    var result2 = await client.EchoAsync("hogehoge");
}

UnaryResult()でのラップもawait awaitも不要です。非常に綺麗にすっきりと扱えるようになりました。あってヨカッタtask-like。かなり有意義に使えてると思いますです。

これは何をやっているかというと、async UnaryResult の場合に独自のコード生成が入って、UnaryResult()の呼び出しを自動で行ってくれるようになってます。UnaryResult()でのラップやawait awaitもダルいのですが、地味に辛いのがTask<UnaryResult<T>>という、ジェネリクスが二階層になっているところですね。継承の連鎖が悪で少ないに越したことはないのと同様に、ジェネリクスのネストも、書き味的にも読み味的にも、少ないに越したことはないのです(ところでかんすーがたげんごの人は型をネストさせまくることの可読性低下にあまりにも無頓着すぎる気がとってもしてます、よくないね)。

警告を無視する

ところで、asyncでawaitなしだと警告がでます。CS1998 Async method lacks ‘await’ operators and will run synchronously というあれ。お薦めは、ガン無視することです。プロジェクト設定のほうで1998は警告「しない」にしちゃうのがいいでしょう。

image

ずっと会社でasyncまみれになってン年間過ごして思ったのは、この警告いらないわ。別に。抵抗感あるかもとは思いますが、それでもなお無視したほうが幸せ度上がると思います。

task-likeの作り方

適当にやりました。いや、だってよくわからんし。なんで適当にAsyncTaskMethodBuilderに丸投げです。まぁこれはValueTaskのtask-likeと一緒です。ノリが同じなのでそれで動くと思ってたし、実際それで動いた。超絶手間なくtask-like対応できたわー。

// 対象の型にAsyncMethodBuilder属性をつける
[AsyncMethodBuilder(typeof(AsyncUnaryResultMethodBuilder<>))]
public struct UnaryResult<TResponse>
{
}
 
// こちらがその中身。基本AsyncTaskMethodBuilderに丸投げです。
public struct AsyncUnaryResultMethodBuilder<T>
{
    private AsyncTaskMethodBuilder<T> methodBuilder;
    private T result;
    private bool haveResult;
    private bool useBuilder;
 
    public static AsyncUnaryResultMethodBuilder<T> Create()
    {
        return new AsyncUnaryResultMethodBuilder<T>() { methodBuilder = AsyncTaskMethodBuilder<T>.Create() };
    }
 
    public void Start<TStateMachine>(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine
    {
        methodBuilder.Start(ref stateMachine);
    }
 
    public void SetStateMachine(IAsyncStateMachine stateMachine)
    {
        methodBuilder.SetStateMachine(stateMachine);
    }
 
    public void SetResult(T result)
    {
        if (useBuilder)
        {
            methodBuilder.SetResult(result);
        }
        else
        {
            this.result = result;
            haveResult = true;
        }
    }
 
    public void SetException(Exception exception)
    {
        methodBuilder.SetException(exception);
    }
 
    public UnaryResult<T> Task
    {
        get
        {
            if (haveResult)
            {
                return new UnaryResult<T>(result);
            }
            else
            {
                useBuilder = true;
                return new UnaryResult<T>(methodBuilder.Task);
            }
        }
    }
 
    public void AwaitOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : INotifyCompletion
        where TStateMachine : IAsyncStateMachine
    {
        useBuilder = true;
        methodBuilder.AwaitOnCompleted(ref awaiter, ref stateMachine);
    }
 
    [SecuritySafeCritical]
    public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
        where TAwaiter : ICriticalNotifyCompletion
        where TStateMachine : IAsyncStateMachine
    {
        useBuilder = true;
        methodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
    }
}

まぁ細かいことはいいんです、どうでも。

まとめ

C# 7.0は良い。というかMagicOnionはもはやC# 7.0が前提みたいな新世代フレームワークと化してますとかかんとか。MagicOnionは現在CM放送中(!)の黒騎士と白の魔王でも全面採用しています。黒騎士ではHTTP/1 Web APIはほぼ使われてないのです。クライアント-サーバー間もサーバー-サーバー間も全てgRPC。時代はHTTP/2。圧倒的な次世代。gRPCも、Unityでも動くようにgRPCにかなりの魔改造を施したカスタム仕様で、かなりアグレッシブな感じです。

その一端はUnite 2017でお話するつもりなので是非是非来てくださいな。もちろん、UniteはUnityのイベントなのでクライアントサイド中心の話なのでサーバー側(gRPC/MagicOnion)の話は少なめになりますが、近いうちに他のイベントでサーバー側でもお話できればな、と思ってます。ちょうど5月6月はクラウド系の大規模カンファレンスがラッシュでありますしね。

C#(.NET, .NET Core, Unity, Xamarin)用の新しい高速なMessagePack実装

と、いうものを作りました。MessagePackのC#版です。以前に作ったZeroFormatterのコードをベースに、バイナリの読み書きをMsgPackのフォーマットに差し替えたものになります。MsgPackのライブラリはすでにあるじゃん(MsgPack-Cli)!ってことなんですが、パフォーマンスにかなり差があります。

JSON.NET(スタンダードで、豊富なAPIを持ってる)に対するJil(スピード特化、APIは必要十分はあるけれどJSON.NETほどではない)のようなものと思ってください。とはいえ、生のまま使っても問題は出ない(デフォルトのままで最高速が出るようにチューニングしてある)でしょうし、カスタマイズの口自体も十分用意してあります!詳しくは「拡張」の項で説明しますが、既に私自身が他のライブラリへの対応・インメモリデータベースの内部構造・RPCのシリアライゼーションフォーマットとして応用アプリケーションを作りまくっていて、それの要求に十分応えられるだけの拡張性があります。

今回のコードは、未来のアーキテクチャで実装された、C#のシリアライザ設計を一歩前進させる、隙のない代物になっています。というのは大げさでもなく、現代最先端のC#の設計技術を投下してあるので、世代的に今までのものとは、一つ二つ先を行ってます。C#でJSON以外のフォーマットのシリアライザを使おうと考えたら、もうこれ一択で悩まなくていいですよ。いや、ZeroFormatterとは悩んでください。

そう、ZeroFormatterは?というと、性能特性にクセがあるので、汎用フォーマットとしてはMsgPackのほうがずっと使いやすい、ですね。もちろん、無限大高速な性質はハマるシチュエーションではすごくハマると思いますよ!別にオワコンじゃないです!しかし、FlatBuffersが主流にはならないのと同じように、ハマるシチュエーションをきちんと考えたほうが良いかな、といったところはやっぱあります。使い勝手は工夫しましたが、どうしても、これ系のバイナリ形式そのもののクセは存在しちゃうので。

ところで、詳しくは圧縮の項で説明しますが、LZ4を内蔵したことにより、パフォーマンスを比較的維持したまま、更にファイルサイズを縮めることを可能にしています。これは、ただたんに出来上がったものを上からLZ4で圧縮しているのではなくて、MessagePack + LZ4のパイプラインを一体化して、LZ4のネイティブAPIを効率よく叩くことによって実現しています。また、lz4自体のオプションもシリアライザと併用して使うのに最適になるように調整してあります(コードもメモリプールを使って圧縮のために使う辞書のアロケーションをなくしたりなどの改造を入れてる)

Unity向けには、更にunsafeな拡張をONにしるとVector3[](など)のシリアライズがJsonUtilityの20倍高速化される拡張機能なども設けてます。これは超強力で、Meshなどの巨大データや大量の位置データのやり取りなどに役立つはずです。C#マジおせーからC++で書こうぜ、に最後の最後はなるにしても、それまでの遊び幅は大幅に拡張されるでしょう。

使いかた

Unity版はサイトのReleasesページから、.NETはNuGet経由で入れてもらうのがいいでしょふ。

APIのノリは完全に一緒で、静的関数のSerializeかDeserializeを呼ぶだけです。ただし対象クラスへの特別なマークが必要です。

// 属性をつけるのは「必須」です、これは堅牢性を高めるためです
[MessagePackObject]
public class MyClass
{
    // Keyは配列のindexとして扱います、これはバージョニングで重要です
    // Key名はIntかStringが選べて、Intの場合はArrayで、Stringの場合はMapでシリアライズされます
    [Key(0)]
    public int Age { get; set; }
 
    [Key(1)]
    public string FirstName { get; set; }
 
    [Key(2)]
    public string LastName { get; set; }
 
    // publicメンバーで不要なフィールドは明示的に[IgnoreMember]を付与する必要があります
    [IgnoreMember]
    public string FullName { get { return FirstName + LastName; } }
}
 
class Program
{
    static void Main(string[] args)
    {
        var mc = new MyClass
        {
            Age = 99,
            FirstName = "hoge",
            LastName = "huga",
        };
 
        // 基本的に Serialize/Deserialize を呼ぶだけの直感的で単純なAPIが全てです
        var bytes = MessagePackSerializer.Serialize(mc);
        var mc2 = MessagePackSerializer.Deserialize<MyClass>(bytes);
 
        // ToJsonメソッドによってバイナリを簡単に読みやすいJSON文字列に変換できます
        // これはデバッグ用途などで非常に役に立つでしょう!
        var json = MessagePackSerializer.ToJson(bytes);
        Console.WriteLine(json); // [99,"hoge","huga"]
    }
}

属性をつけるのが「必須」なのは煩わしいところですが、これは堅牢性を高めるためです。MsgPack-Cliとの機能面での最大の差はオブジェクトシリアライズの扱いで、MsgPack-CliはデフォルトでArray、かつ、何もマークしていないものもシリアライズ可能です。これは、プロパティが増えた時の挙動(バージョニング)が極めて危険で、全くよろしくない。そのため、そもそも必須扱いにしてプログラム実行時の限りなく早いタイミングで気づけるようにしています。

かわりにこの煩わしさは、Visual StudioのAnalyzerによってある程度緩和できるようにしています。

また、気楽にやりたい場合は、[MessagePackContract(keyAsPropertyName = true)]にすると、プロパティへの属性付けは不要で、プロパティ名をキーとして扱いMap形式でシリアライズします。JSONライクで手軽ですが、シリアライズ/デシリアライズにかかる時間と、バイナリサイズは肥大化します。ただしKeyに名前がついてるとデバッグ時の楽さはあがるのと、遅くなるといっても依然高速なので、「アリ」な選択ではあるでしょう。

後述しますが引数にFormatterResolverを渡すことによってシリアライザの挙動がカスタマイズできて、標準で用意している ContractlessStandardResolver を渡すと(あるいはSetDefaultResolverでデフォルト挙動を差し替えることも可能)、[MessagePackObject]属性の付与も不要になります。

MessagePackSerializer.Serialize(mc, MessagePack.Resolvers.ContractlessStandardResolver.Instance);

この場合もキー名を文字列としてMapでシリアライズします。Mapを使うので、バージョニングに対する不安もありません。このオプションを合わせた場合が、最もお気楽に使える、 JSON.NETとの互換性というか使用感は変わらない感じになるんじゃないかと思います。また、この場合は匿名型もシリアライズできます(デシリアライズはできない)。

と、色々ありますが、お薦めは明示的にMessagePackObjectをつけて、KeyをIntにすることです。ようするにデフォルトのままが最も最高の効率で最もお薦め、ということです!まぁContractlessStandardResolverも悪くはないです、特に後述するLZ4圧縮と組み合わせれば配列など気になるデッカいデータを処理する時にはきちんとキーを縮められるので、全然良いかなとは。

パフォーマンス/最適化

細かい機能は置いといて、まずパフォーマンスについて詳しく見ていきましょう!

オールスターで並べてみました。小さくて見えませんね、もう少し大きい図はGitHubのページにあるのでそちらを。とりあえず最強に速いです、ということで。

どんなケースが来ても、まぁ、速いデス。圧倒的に。で、速い理由というか他が遅い理由は無限大に説明できるんで、まぁいいでしょう。基本的にはZeroFormatterで行ったことがそのままあてはまってますが、それに加えてMessagePackの仕様に対する最適化と、ZeroFormatterよりも効率的なIL生成によって、なんか結果ZeroFormatterより速くなってしまってなんともかんとも……。

・一切無駄なオブジェクトを生成しない、最終的なbyte[]以外のアロケーションは一切なし
・シリアライズ時のbyte[]の拡張が必要な場合も、64K以下は効率的に内蔵の作業用メモリプールを使うためアロケーションなし
・Streamベースではなくbyte[]ベースのプリミティブAPIにより、Stream抽象による呼び出しオーバーヘッドを削減
・シリアライザのキャッシュ/ルックアップにジェネリクス型変数からの取り出しによるDictionary呼び出しコストを削減
・効率的なメモリプールの使用による作業領域のメモリ拡張の削減
・デリゲート経由ではなく直接、型をIL生成することによる余分な呼び出しコストの削減
・ILコード生成時にプリミティブに対する書き込み/読み込みは、プリミティブAPIを直接呼び出すコード生成によりメソッド呼び出しコスト削減
・ILコード生成時にMsgPackの固定範囲に収まっているキーは範囲分岐判定せず直接呼び出すコードを埋め込み
・コレクションのイテレートをIEnumerable抽象で扱わず、各コレクションそれぞれに対し個別に最適化
・プリミティブ配列に関しては更にジェネリクスも使わず各プリミティブ配列専用のビルトインシリアライザを用意
・ルックアップテーブル事前生成によるデシリアライズ時のタイプ判定コードを削除
・文字列など長さが必要な可変フォーマットに対するヒューリスティックな長さ判定によるコピーコスト削減
・全コードパスがジェネリクスで貫通していてボクシング一切なし
・IL生成ができない環境ではソースコード解析からの事前コード生成による対応

頭からつま先までギッチシと最適化してあるんで、これ以上の速いシリアライザを書くことは不可能でしょう。ってZeroFormatterの時にも言った気がするので説得力が微妙になくなってますが、今度の今度こそもうやれることは絶対にない、というレベルでありとあらゆる設計と技法を突っ込んだので、これがC#の性能限界でしょう、しかも今回はunsafeではなくてsafeなのです!(LZ4, Unityのunsafe拡張を除く)。unsafeがなくてもC#は速いんです。はい。これはMsgPackがBigEndianなのでunsafe使ってもうまみがあんまないから、非unsafeに倒してみたってところですんが。

IL生成がより効率的になったのは、ZeroFormatter以降に何故かILを書きまくる羽目になったせいか、私自身のIL書き能力が向上したことによる余裕によって、結構アグレッシブに生成時分岐で最適なコードを直接埋め込んでみたからです。やっただけ効果は出ますねえ、やはり。なるほど。

コレクションのイテレートに関しては、さすがに数多いので抽象化はしてるんですが、こんなジェネリクス型を用意しました。

public abstract class CollectionFormatterBase<TElement, TIntermediate, TEnumerator, TCollection> : IMessagePackFormatter<TCollection>
    where TCollection : IEnumerable<TElement>
    where TEnumerator : IEnumerator<TElement>

微妙に奇々怪々な内容になっていますが、これが最も速いコレクションのシリアライズ/デシリアライズをするために必要な抽象なのです。例えば、これなら各コレクション専用のstruct enumeratorを使うことができます。ただたんにIEnumerable<T>をforeachするだけじゃ遅くてやってられないのですよ。

というような細かいハックは沢山入ってるんですが、とはいえ基本的にはStreamを捨ててbyte[]ベースにしたというのが大きいですね。byte[]ベースなのストリーミングでのシリアライズ/デシリアライズができないのですが、例えば巨大配列のケースではプリミティブAPIと小シリアライザを使って対処するとか、逃げ口はそれなりに用意されてるので、超絶巨大な一個のオブジェクト、みたいなシチュエーションじゃなきゃ大概なんとかなるものです。

System.IO.Pipelinesが出たら、Pipelines版作ってもいいかな、とは思いますが。しかし、そっちがあればbyte[]版とかイラネー?っていうと、実際のところそんなこたぁなくて、In/Outがbyte[]で確定してる状況では、byte[]版のほうが良いでしょうね。System.IO.Pipelinesで作るとストリーミングでシリアライズ/デシリアライズできるので、その点は良くなると思うんですが、利用するフレームワークの口が大抵はbyteで空いてるんで、ほとんどのシチュエーションでbyte[]版のほうが良好ってことになりそうだとは思ってます。ので、別にそんな優先度も希望も高くは持ってません。XxxAsyncみたいな非同期APIも同じような話が言えて、細切れでawaitかけるような中身になってると、むしろ相当遅くなってしまいます。基本的にはガリッとバッファ確保してガッと書いて、ガッとFlushにしないとダメなのですよ。なので、まぁPipelines版は別ですが、ふつーの形で非同期APIを作る意味は全くないと思ってるんで、それはナシです。むしろそういうのがあると、そっちのほうが良いのかな、とユーザーに思わせてしまうのでAPI設計的に非常によろしくない。

ファイルサイズと圧縮

MessagePackのイケてるところは、型の表現力が非常に高いのに、バイナリサイズが小さくなるところ。一般的にオブジェクトへのシリアライズにはArrayフォーマットが使われて、これはProtoなどのTagで1バイト使用するより小さくなる。もちろん、Arrayを使うことはバージョニングに問題を抱えていないこともないですが、概ねNil埋めで大丈夫な範囲に収まるので許容できるのではないかと考えています。

が、それと圧縮は別問題で、やっぱ圧縮は圧縮で、かけると非常に縮むんですよね。でも当然圧縮は別途パフォーマンスロスを抱えてしまうわけで、と、そこでMessagePack for C#は最速を誇るlz4での圧縮を標準でサポートしました。LZ4は圧縮率はそこそこですが、圧縮/伸張が速い(特に伸張がヤバいぐらい速い)という特徴があります。これはMessagePackのユースケースにかなりハマるんじゃないでしょうか(圧縮率が重要なシチュエーションでは、lz4と同作者のZStandardというものがあって、これもバランス良くて素晴らしい)。

// 基本的に MessagePackSerializer のかわりに LZ4MessagePackSerialzier を呼ぶだけ
var bytes = LZ4MessagePackSerialzier.Serialize(mc);
var mc2 = LZ4MessagePackSerialzier.Deserialize<MyClass>(bytes);
 
// ToJsonメソッドによってバイナリを簡単に読みやすいJSON文字列に変換できます
// これはデバッグ用途などで非常に役に立つでしょう!
var json = LZ4MessagePackSerialzier.ToJson(bytes);
Console.WriteLine(json); // [99,"hoge","huga"]

んで、とにかく速い。ほとんど変わらないだけの圧縮/伸張速度なのにファイルサイズは激縮み!ただし、一応言っておくと圧縮はデータの内容によって全く効かないこともあれば、重複だらけデータなら効果はてきめんになったり(だからJSON+GZipで配列縮めると大量の同じような文字列キーが縮んでほぼ無視できるようになる)ということがあります。この試験データは重複多めなので、圧縮が効きやすいうえに効率も良いのでめっちゃ縮んでいるだけです。処理時間も複雑なデータであれば、このデータのようにあんま変わらない、などということはなく2倍ぐらいの差になるケースも出てきます(それでも他のシリアライザを単独で使うより速いというのが驚異的な話なのですが!)。この辺は相性とかモノ次第って面もありますが、実際リアルなデータ(現在開発中のゲーム)での色々寄せ集めて集合させた5Mぐらいのデータは800KBになりました、速度的にはx1.5がけぐらい。全然割に合います。

で、このLZ4圧縮はMsgPackで出来上がったデータに対して上からLZ4をかけてるわけではありません。まず、これ自体が正しいMsgPackデータになってます(なので他のMessagePackシリアライザにそのまま渡しても認識はできる、デシリアライズはできませんが、正しく実装されたシリアライザなら少なくとも(Bodyはbinaryですが)Dumpは可能)。MsgPackの仕様のExt領域を使って(TypeCode:99)、LZ4圧縮によるMsgPackという形でシリアライズしています。

なんでかというと、そもそもLZ4がbyte[]ブロックベースで動作する圧縮フォーマットなのです。(C#の)Streamとして使えるベンリAPIがあったりしますが、それはただのラッパーで、むしろかなり速度低下させる一因です。黙ってbyte[]ベースの最もプリミティブなLZ4のAPIを叩く。それが最高に速い。そして、つまりこれって今のMessagePack for C#の実装とめっちゃ相性が良い、こっちもbyte[]ベースですから。相性が良いのは良いとして、ただたんに左から右に流すだけだと、無駄なbyte[]コピーが発生しちゃうんですよね(最終サイズのbyte[]にリサイズするコストとかがどうしてもある)。どうせLZ4通すなら、別にその時点はただの中間地点なので、リサイズする必要はないんで、当然ノーリサイズでそのまま流す。リサイズするのはLZ4通した本当の最後の最後だけ。

それとLZ4の生デコンプレスAPIは、「復元後(圧縮前)のサイズを知っている」ことで、より効率的にデコンプレスできるようになっています。が、LZ4自身には復元後のサイズは埋まってません。なるほど。なるほど。なのでふつーに左から右に流すだけ圧縮だと、真の意味で効率的な復元は実現できません。そこでExt領域を使っている理由がでてきて、MessagePack for C#のLZ4統合では、復元後のサイズを先頭に埋め込んであります。それを使うことにより、真の最高速でのLZ4によるデコンプレスを実現してます。

なお、独断と偏見により64バイト以下はLZ4として圧縮せず素通しするようにしています。なので頻繁に送受信する軽量なデータは圧縮/伸張によるパフォーマンスの影響を一切受けません。これもExt領域を使った意味があって、素通しでもLZ4でも、そのまんまMsgPackとして扱えるんですね。どちらもValidなMsgPackなので、きっちり正しくクライアント側でハンドリングできるようになりました。

シリアライザの選択に悩まないと言いましたが、MessagePackSerializerを使うかLZ4MessagePackSerializerを使うかは、悩みますねえー。

イミュータブルオブジェクトへのデシリアライズ

デシリアライズ処理には通常publicなsetterを要求しますが、MessagePack C#はイミュータブルオブジェクトへのデシリアライズを可能にしています。これが出来ると、

[MessagePackObject]
public class Point
{
    [Key(0)]
    public int X { get; }
    [Key(1)]
    public int Y { get; }
 
    public Point((int, int) p)
    {
        this.X = p.Item1;
        this.Y = p.Item2;
    }
 
    [SerializationConstructor]
    public Point(int x, int y)
    {
        this.X = x;
        this.Y = y;
    }
}

KeyがIntの場合は引数の位置で、Stringの場合は名前(大文字小文字無視)でマッチさせます。ある程度「気を利かせてくれる」とかではなく、明確に仕様として設け、コンフィグの口を持っているところは目新しいんじゃないかと。そして、これ、実際便利です。

Union

Union(インターフェイスのシリアライズ/ポリモーフィズム)は2要素の配列として表現しています。一つ目が識別キー。二つ目が中身。

// mark inheritance types
[MessagePack.Union(0, typeof(FooClass))]
[MessagePack.Union(1, typeof(BarClass))]
public interface IUnionSample
{
}
 
[MessagePackObject]
public class FooClass : IUnionSample
{
    [Key(0)]
    public int XYZ { get; set; }
}
 
[MessagePackObject]
public class BarClass : IUnionSample
{
    [Key(0)]
    public string OPQ { get; set; }
}
 
// ---
 
IUnionSample data = new FooClass() { XYZ = 999 };
 
// serialize interface.
var bin = MessagePackSerializer.Serialize(data);
 
// deserialize interface.
var reData = MessagePackSerializer.Deserialize<IUnionSample>(bin);
 
// use type-switch of C# 7.0
switch (reData)
{
    case FooClass x:
        Console.WriteLine(x.XYZ);
        break;
    case BarClass x:
        Console.WriteLine(x.OPQ);
        break;
    default:
        break;
}

これ、C# 7.0の型でswitchできるのと相性良いんですよね。便利で良くなったと思います。

拡張

今回、デフォルトでやたら拡張パッケージがあります。

Install-Package MessagePack.ImmutableCollection
Install-Package MessagePack.ReactiveProperty
Install-Package MessagePack.UnityShims
Install-Package MessagePack.AspNetCoreMvcFormatter

ImmutableCollectionやReactivePropertyをシリアライズ可能にするやつ。UnityShimsはUnityと相互通信する際のVector3とかとそのシリアライザ。AspNetCoreMvcFormatterはASP.NET Core MVC用のシリアライザ換装するやつです。

拡張を有効にする場合は、Resolverというものを使っていきます。こんな感じで。

// set extensions to default resolver.
MessagePack.Resolvers.CompositeResolver.RegisterAndSetAsDefault(
    // enable extension packages first
    ImmutableCollectionResolver.Instance,
    ReactivePropertyResolver.Instance,
    MessagePack.Unity.Extension.UnityBlitResolver.Instance,
    MessagePack.Unity.UnityResolver.Instance,
 
    // finaly use standard(default) resolver
    StandardResolver.Instance);
);

この辺のは細かい使い方といったところなので、ReadMeを見てもらえれば、なのですが、MessagePack for C#ではコンフィグ/拡張ポイントをResolverに寄せているので、これの仕組みさえ理解してもらえれば全ての拡張の方法がわかります!逆に、これがちょっと初見だとむつかしめなので、もう少し優しい何かも用意したい気もしなくはないですが、多分、このままでいいんじゃないかな、とも思ってます。

for Unity

今回はZeroFormatterと違って、コードジェネレート不要です!なんですと!!!きっちりとUnityでちゃんと動作するILGenerationによって、ふつーの.NET版と変わらない動的コード生成/パフォーマンスでUnityでも動きます。IL2CPPじゃなければ。IL2CPPじゃなければ。PCでもAndroidでもどんとこい、なんですが、IL2CPPはダメです。IL2CPPの場合は、やっぱりコードジェネレートしてください、今回もコードジェネレーター同梱してあります(そして未だにWindowsでしか動作しません、なんとかしたい……)

更に今回はunsafeじゃありません!ほとんどのコードがsafeで動いてるのでソースコードべた配布。やったね。unsafe使わなくても結構速く出来るんですよ。とはいえ、LZ4がunsafeバリバリなので、LZ4使いたい場合はunsafeを有効にしてください。詳しいことはReadMeで。

ついでにunsafe時のスペシャルフィーチャーとして、エクストリーム高速なVector3[]シリアライザをUnity用に特別に用意しました。

JsonUtilityの20倍速い。これならMeshとかの大量の頂点を扱うものでも、そこそこなんとか戦えるんじゃないでしょうか。それ以上頑張りたかったらC++で、ですけれど、C#でもここまでなら頑張れる……!

なんで速いかというと、structの配列はメモリ上に一列に並ぶというC#の特性を利用して、まるっとそのままコピーしてるからです。Oh……。まぁ、アリでしょう。アリでしょう。なお、さすがにこれは正規のMessagePackの配列じゃなくなる(純粋なバイト列)ので、拡張フォーマットとしてマークして押し込んでます。MessagePackはこれが便利……なんか特化したの突っ込んでも仕様的にValidだと言い張れる。てわけで、アリでしょう。アリ。最高にクールな機能だと思ってます。

MsgPack-Cliとの互換性

あまり考えてない&こちらからサポートする気はあんまナイデス。互換性は基本的にあるんですが、微妙にありません!多分、普通に使ってる場合は非互換になります。C#の型をMsgPackとしてどう表現するか、というところで差異が出ちゃうんで、しょーがない。

Enumのシリアライズ/デシリアライズが、MessagePack for C#ではデフォルトはIntegerになります。文字列でのシリアライズ/デシリアライズのサポートは、Enumを文字列で扱うと明らかに遅くなるのでやる気nothing、と思ってたんですがまさかの1.0.0を投げた直後に要望が来たのでしょうがなく追加で入れることになったのであった。1.0.1スタートの理由、おうふおうふ。というわけでResolverを差し替えることによってEnumを文字列で扱う対応はできます。よかったね。なお、MsgPack-Cliは文字列になるほうがデフォです。なのでデフォのままだと、ここで互換性なくなります。

DateTimeの形式が互換性ありません。MessagePack for C#ではProposalで提唱されているTimestamp拡張を実装しています(ほぼほぼファイナルなんだと思うけど一向にマージされないので、早まったかな、どうなんだろう……)。これもResolverを自前で書けば解決可能なので適当にどうぞ。

あとはdecimalとかGuid辺りの扱いもちょっと違いますがResolverを自前で(以下略)

多言語間での通信

C#独自の型になると、なんというかよしなにハンドリングしてください状態になってしまうんですが、基本型だけ使ってる分には概ね大丈夫でしょう。ただしDateTimeだけは↑に書いたように、特殊なハンドリングしてるんで他の言語のサポート状況次第です。不安なら文字列にして送ったりUnixTimestampにして送ったりすればいいんじゃないでしょーか。DateTimeが互換の問題になるのは別にMsgPackに限らず、JSONでもよくあることですねー。故に標準で型としてサポートして欲しいし、↑のTimestamp拡張がAcceptされるのを待ち望んでいます。

あとは、オブジェクトはIntがキーのArrayかStringがキーのMapのどちらかです、ってことですね。これは他の言語も概ねその二択なので、問題なく相互変換できると思っています。

Protobufとの比較

Protocol Buffersと比較すると、MsgPackはダンプ耐性があるのが好みです。自己記述的で、スキーマと照らし合わせなくても良いため、デバッグとかで何かと捗ります(MessagePack for C#についてるJSONへのダンプ機能は超嬉しいはず、ていうか私が超嬉しい)。また、nullの扱いが明確なのも嬉しいところで、Protobufはそれがかなりのハマりどころで、色々と詰むんですが、MsgPackは完全にC#をシリアライズ/デシリアライズしても自然のまま扱えます。どういうことかというとこういうことです。

[ProtoContract]
public class Parent
{
    [ProtoMember(1)]
    public int Primitive { get; set; }
    [ProtoMember(2)]
    public Child Prop { get; set; }
    [ProtoMember(3)]
    public int[] Array { get; set; }
}
 
[ProtoContract]
public class Child
{
    [ProtoMember(1)]
    public int Number { get; set; }
}
 
using (var ms = new MemoryStream())
{
    // nullをシリアライズすると
    ProtoBuf.Serializer.Serialize<Parent>(ms, null);
 
    ms.Position = 0;
    var result = ProtoBuf.Serializer.Deserialize<Parent>(ms);
 
    // なんとデシリアライズするとstructのように0埋めされたものになってデシリアライズする!これはヤバい。
    Console.WriteLine(result != null); // True
    Console.WriteLine(result.Primitive); // 0
    Console.WriteLine(result.Prop); // null
    Console.WriteLine(result.Array); // null
}
 
using (var ms = new MemoryStream())
{
    // 空配列をシリアライズする
    ProtoBuf.Serializer.Serialize<Parent>(ms, new Parent { Array = new int[0] });
 
    ms.Position = 0;
    var result = ProtoBuf.Serializer.Deserialize<Parent>(ms);
 
    // nullになって帰ってくる!なんじゃそりゃ、マジでヤバい。
    Console.WriteLine(result.Array == null); // True, null!
}

protobuf-netの問題というか、protobuf自体の型表現力的にしょーがないんですねー、protobufの表現力は実はかなり弱いのです……。なので、protobufを.protoからの生成じゃなく使う、つまり普通の汎用シリアライゼーションフォーマットとして使うのは激しくお薦めしません。実運用に入ると間違いなく問題になるはずです(というか実際グラニでは激しく問題になった!もう二度とprotobuf-netは使わん!)

かわりに、protobufはIDLやそのRPCフレームワークであるgRPCが強力で、多言語間での通信仕様として使うには、圧倒的に秀でていると思います。gRPCは最高ですよ。MsgPackはオブジェクトシリアライズの統一的仕様が存在しないので、言語間での通信仕様としては正直、かなり厳しいと思いますね。いや、別にJSONのように手で調整するなら構わないし、It’s like JSONってのはそういうことだろっていうとそういうことなんですが、話が違うのはいかんせんバイナリだということ。JSONはテキストなので目で見て調整できたり、暗黙的にObjectはStringがKeyのMapですよね、で統一されてるんですが、MsgPackはバイナリなので調整辛いし、オブジェクトがArrayなのかMapなのかも統一感なかったりで、ちょっとショッパイと言わざるをえないです。

なので、gRPCとか言語超えたRPCではProtobufが圧倒的に優勢で、これは未来永劫変わらないでしょう。MsgPack-RPCやMsgPack-IDLはコケた、といっても過言ではないし、別に蘇ることもないと思うんで。

しかしバイナリ仕様としては非常に優れてるし、Dump可能なところも嬉しすぎるので、多言語間通信「以外」での局面では、最高のフォーマットだと思います。多言語間通信においても自社内とかの閉じたところなら調整はやりやすいので、決してダメというわけでもない、でしょうが、まぁそういう場合はIDL欲しくなるのがフツーなので、訴求力は弱くなっちゃてるでしょうねえ、現状で既に(MsgPackを「選ばない」理由としては至極真っ当だと思います)。RPCを捨てて、JSON-Schema的な純粋な仕様定義を再展開すればあるいは?とは、やっぱあんま思わないんで、ここはしゃーなしで諦めたほうがいいかしらん、外野の意見では。

MessagePack-RPC/gRPC

と、言っておきながらなんですが、MessagePack for C#を使ったRPCを作っています。MagicOnion - gRPC based HTTP/2 RPC Streaming Framework for .NET, .NET Core and Unity. ということで、通常gRPCはprotobufで通信するんですが、そのシリアライゼーションレイヤーをMessagePackに置き換えてます。なんでかっていうと、それによってIDL不要でRPCできるようにしてからです。IDLを使わない局面ではMsgPackは上で言った通り最高のフォーマットなので。

MagicOnionの特徴は、IDLを使わなくても、型安全で通信のスキーマがかっちり決まった状態になることです。何故か、というと、C#そのものがスキーマとして動くので。MagicOnionは Server C# - Client C# の通信フレームワークになっていて、多言語ではなく同言語間に限定することによって、MsgPackのウィークポイントを塞ぎつつ、素のgRPCよりも、よりC#の特色を活かした強力な機能と書きやすさを付与しています。パフォーマンスも文句なく良い、むしろ素のgRPCよりも良い(シリアライザの性能差で)

まだ開発中なので、今後に乞うご期待:) 実際にUnityで開発中のゲームはこのフレームワークを使ったものになっています。HTTP/1 APIは完全消滅。中々アグレッシブです。

まとめ

ZeroFormatterよりもResolver回り(拡張/オプション)のAPIが大幅に改善されてます。ふつーの利用時は関係ないんですが、フレームワークに組み込んだり、拡張する場合に、こちらのほうが圧倒的に良いです。性能特化のDIを用意したってことなんですが、まぁ相応に良いですねぇ。ちょっとDI嫌いは返上しよう……。ZeroFormatterにも後で移植しよう……。

改めてZeroFormatterとどっちがいーんですか!というと、特性に合わせて選んでくださいとしか言いようがありません。ZeroFormatterが効果アリ!なシチュエーションでピンポイントで使っていけば勿論それは効果アリ!ですが、ぶっちけ7割がた、MsgPackのほうが良いケースのほうが多いとは思っています。MsgPackは偉大なフォーマットですぞよ(ただしTimestampのフォーマットは早く決めて欲しい)。私の中でZeroFormatterのようなフォーマットが必要な理由が、MasterMemoryを作ったことにより、そっちのほうが上位の形で解消されてしまったというのがんががが……。

MsgPack-Cliとでは、まぁお好みで。アタリマエですが実績は無視できないファクターでしょう。ライブラリのメンテナーとして信頼できるかどうかも違いですね(私よりもずっと安定感あると思います!別に私もやらないわけじゃないんですが、ムラがあるんで)。それと私はSilverlightとかWindows Phoneとかサポートする気はないんで、その辺が必要な場合は必須ですね。

世の中、もう十分枯れきったと思っているところでも全然ゆるくて、手を加えられる余地はあるんだなぁ、というのは発見でした。シリアライザがここまで性能伸ばせるなんて、やってみるまで思いもしなかった。C#の良くないところに、ピーキーにチューニングされたライブラリが少ない(Javaのほうが遥かに多いのは事実でしょう!)ことがあり、それが諸々のパフォーマンステストや、そもそもの実績に影響を与えているのですよね。

結局、今までC#がその辺を「ゆるく考えていた」ことの積み重なりが、今の体たらくを招いていることの一因だとも思っています。別にMicrosoftだけではなくコミュニティ全体がね。吐気がするような継承の瓦礫の塔を築いたり、無駄にFunctionalであろうとしたり。私は、C#は好きな言語だから使っているというだけじゃなくて、「前線で戦える言語」だから使っているのです。何かの理想を追う言語ではなく、真に実践的な言語であるから全力で投資しているのです。常に戦場であり、他の言語なりフレームワークなりと戦っているフィールドであり、そこではフェアに評価されるべきであり、戦って死ね。と。C#を前線で戦わせるためにも、こうして一つ一つ、証明し続けていかなければならないでしょう。

Comment (9)

G.O.R.N : (03/14 16:45)

MessagePack RPCがいけると、Jubatusを叩くのに幸せになれる気がします。

neuecc : (03/14 21:47)

本文にも書いてますがMessagePack-RPCにはネガティブなんですよねー。

honya : (03/29 12:37)

mpc.exeは手元でビルドして作る感じでしょうか。
Visual Studioのバージョンが古くてC#7.0がコンパイル出来ず悩んでいる感じです。
Visual Studioのバージョン上げろと言われればそれまでですが(^_^;)

neuecc : (03/29 20:27)

ああ、すみません!
NuGetだと入れた後のpackage/toolsに
Unityの場合はReleaseページにあるzipの中に入ってる

はずだったんですが
・NuGetのほうはちょっと記述漏れで含まれてない気配
・zipのほうは、最新の1.1.1では間違えて.unitypackagedだけをあげてしまった
という状態です……。
近日、なおしたのをあげたいんですが、とりあえずは
Ver 1.1.0のとこにあるzipの中に入っています。

honya : (03/30 10:24)

1.1.0に入っていることを確認しました。
ありがとうございます(^◇^)

koichi3 : (07/06 01:36)

MessagePack使わせて頂いてます。ほんと素晴らしいです!
通信周りの負荷がこれで解消されそうです!感謝!
別途Unity向けパッケージでWindows Store Buildだと、
エラーがでてしまうのですが、実装上は仕方ない感じなものなのでしょうか?

neuecc : (07/09 23:52)

Windows Store Build向けもちゃんと対応していきたいですし、
別に無理じゃないはずなので、ただたんなる確認漏れって感じです(実際確認していない……)
UniRxでもよく頻繁に漏れてるんですよね(確認しないせい……)
ちょっと見てみますー。

neuecc : (07/30 01:39)

とりあえずUWPビルド対応は完了しました……!

koichi3 : (08/15 14:48)

遅レスになりましたが、UWP対応 助かります!
ご対応頂きありがとうございました!m(_ _)m

Trackback(2)
Write Comment

UniRxを支えるユニットテスト - RuntimeUnitTestToolkit for Unity

オープンなようなクローズドなような、ラウンドテーブルディスカッションのような、少人数のところでUnityのユニットテストについて話してきました。というか、UniRxのために作って、以降、私の作るUnity用の色々なので使いまわしてる自作のユニットテストフレームワークについて、ですね。

RuntimeUnitTestToolkit for Unity from Yoshifumi Kawai

このフレームワークはずっとUniRxの中に埋まったまんまだったんですが、使える形でパッケージしたのを、今日GitHubに公開しました。unitypackageとしても置いてあるので、一応インポートはしやすいはずです。

とりあえず必要な機能しか入れてないんで、汎用テストフレームワークとしては足りない機能が普通に多いので、その辺も作ってからアセットストアに公開したいなぁ、と思ってはいたんですが、まぁそうなるといつまで経っても公開できなさそうなので、とりあえず現段階のもので公開、です。

.NETのテスト事情、或いはUnityでテストを書かないことについて

私はライブラリとしてはふつーの.NETと共通で動くものを作ることが多いんで、まぁそういう場合は大部分はふつーの.NETのユニットテストを書いたほうが遥かに書きやすいでしょう!つまりUnityでテストを書くコツはUnityで書かないということです!!!みもふたもない。

image

テストのメソッドを右クリックしてデバッグ実行で直接Visual Studioのデバッガでダイレクトにアタッチできたりとか、基本的に最高ですね。

さて、スライドにも書いたのですが、最近はxUnit.netを好んで使っています。MSTestはいい加減投げ捨てていいでしょう、というか投げ捨てるべきでしょう。NUnitは知らん。いらん。補助としてChainingAssertionは変わらず使ってるんですが、.NETCore対応を内部では作って使ってるんですが公開には至ってない……。

また、モックライブラリとしてはMicrosoft Fakes Frameworkのような大仰なものは「絶対に」使うべきではない、という思いが強くなってます。テストはただでさえ負債になりやすいのに(盲目的にテストは書くべき信仰してる人は、テストの負債化に関して全く言及しないのがポジショントークなのか脳みそお花畑なのか、頭悪そうですね)、大きな自動生成を伴うものは負債の連鎖を作りやすいなー、と。シンプルに作らないと、シンプルに投げ捨てることができない、というね。そして、投げ捨てるのは簡単ではなく、投げ捨てるのもまた技術なわけです。

RuntimeUnitTestToolkit

.NETでテスト書くからそれでOK、というわけは当然なくて、Unityだけでしか動かない部分もあるし、そもそもUnityでちゃんと動くかどうかの保証はない。更にはIL2CPPに通した場合はやっぱり別物の挙動というか動かなくなるケースは「非常に多い」ので、ちゃんとIL2CPPで動くことを保証しなければならない。そこで作ったのがRuntimeUnitTestToolkitです。Unityには標準でテストツールあるじゃん、って話ですが、あれは実機動作させられないので論外です。それで用が満たせりゃあ標準の使うわ。

image

テストが並べられて、ボタン押したら実行、ボタンが緑になったら成功、赤になったら失敗というシンプルなふいんきのものです。一個のシーンになってるので、ビルドして実機転送すればそのまま実機で動きます。

実際に自分で使うには、Releaseページからunitypackageを落としてきてインポート。で、UnitTest.sceneを開いて再生すればOK。簡単簡単。

テストの書き方ですが、基本的にはMonoBehaviourを継承したりもしないシンプルなクラスを用意します。

// make unit test on plain C# class
public class SampleGroup
{
    // all public methods are automatically registered in test group
    public void SumTest()
    {
        var x = int.Parse("100");
        var y = int.Parse("200");
 
        // using RuntimeUnitTestToolkit;
        // 'Is' is Assertion method, same as Assert(actual, expected)
        (x + y).Is(300);
    }
 
    // return type 'IEnumerator' is marked as async test method
    public IEnumerator AsyncTest()
    {
        var testObject = new GameObject("Test");
 
        // wait asynchronous coroutine(UniRx coroutine runnner)
        yield return MainThreadDispatcher.StartCoroutine(MoveToRight(testObject));
 
        // assrtion
        testObject.transform.position.x.Is(60);
 
        GameObject.Destroy(testObject);
    }
 
    IEnumerator MoveToRight(GameObject o)
    {
        for (int i = 0; i < 60; i++)
        {
            var p = o.transform.position;
            p.x += 1;
            o.transform.position =  p;
            yield return null;
        }
    }
}

属性とかは特に必要なく、戻り値voidのパブリックメソッドは強制的にテストメソッドとして認識します。また、戻り値IEnumertorのクラスは非同期テストメソッドとして認識してコルーチンとして動かすので、中でyieldとか他のコルーチンを動かしての待機とかも自由にできます。

さすがに定義だけでテストクラスを認識できないので、それとは別にテストローダーを書いてあげます。

public static class UnitTestLoader
{
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    public static void Register()
    {
        // setup created test class to RegisterAllMethods<T>
        UnitTest.RegisterAllMethods<SampleGroup>();
 
        // and add other classes
    }
}

これで実行してやれば、書いたクラスが実行時にボタンとしてシーンに追加されます。

ある程度リフレクションでメソッドとかの認識をしているんですが、ちゃんとIL2CPPで動作するギリギリのリフレクション加減で仕上げつつ、書きやすい直感的にAPIに仕立てたというのが工夫ポイントですね!

with UniRx

UniRxは結構ユニットテスト向けだったりします。例えば何かアクションを加えてイベントが発行されることを確認したい、という場合に、IObservableとして公開されているならば

public IEnumerator WithUniRxTestA()
{
    // subscribe event callback
    var subscription = obj.SomeEventAsObservable().First().ToYieldInstruction();
 
    // raise event 
    obj.RaiseEventSomething();
 
    // check event raise complete
    yield return subscription;
 
    subscription.Result.Is();
}

と、サクッと書けたりします。あるいは、何か色々によって色々値が変わるということは

public IEnumerator UniRxTestB()
{
    // monitor value changed
    var subscription = obj.ObserveEveryValueChanged(x => x.someValue).Skip(1).First().ToYieldInstruction();
 
    // do something
    obj.DoSomething();
 
    // wait complete
    yield return subscription;
 
    subscription.Result.Is();
}

と、ObserveEveryValueChangedで外側からサクッと値の監視が可能です。また、各種のObservableTriggerを突っ込むことによって、外側から内部の状態をサクッとモニタできます。あまり実際のプログラムでは使うことはないようなことも、ユニットテストなら派手に使っても構わないし、そういう時に楽ができるツールがUniRxには揃っています。外側からサクッとどうこうする手段がないと、インスペクタにユニットテスト用の特別な何かを仕込んでアサートとかいう、しょぼいテストフレームワーク(UnityのIntegration Test Frameworkのことですよ!)になってしまいがちですので。

まとめ

現状のUnityの単体テストツールは、必要な要件を全く満たしてなくて使えなさすぎですぅ。テストツールは結構大事で、とりあえずテスト大事、とりあえずテスト書くんだ、とかいってしょうもないツールを土台にやってるとボロボロに負債になるんで、ちゃんと自分の要件を意識して選択しないとダメですね。そこも把握できてなかったり、あとシンタックスも非常に大事で、Spec系がぶっちゃけ書き方違うだけで本質的に変わらないのに非常に感触が変わるのと同じで、そういうの大事にできない人はプログラミングの感性足りてないんで、小手先のテスト信仰とかしてないで、それ以前にまともな感性磨いたほうが良さそうですね。

とはいえ、Unity 5.6から良くなる気配を見せていて、少なくともその延長線上にはちゃんとした未来がありそうなだけの土台は作れてそうなので良かった。それ以前の(現在の)は本当にセンスなさすぎて、こいつらの感性の先に未来はなさそうだなー、と思ってたんで。

RuntimeUnitTestToolkitをオススメするかっていうと、実機で動かすのに困ってればいいんじゃないでしょうか!とはいえ、素朴すぎるってところはあるんで、もう少し作り込まないと使えないというケースは多そうってところです。私も、自分の作る程度の規模では困ってないんですが、会社のプロジェクトに入れると困るところは多く出てきそうだなー、という感じですね。足らないところを自分で補っていけるならというところです。

近況

ところでなんと今年に入ってブログ書いてなかった!はうう!というのは、書きかけのプロジェクトが多くてそれにあくせくあくせくだからんですねえ。公開まであともう一歩、というところまでに持ってけているのは MessagePack for C#(.NET, .NET Core, Unity, Xamarin) です。

ZeroFormatterあるじゃん、なのに何故、って話ですが、まぁそれは公開時にでも。とりあえず、エクストリーム速いです。それと、拡張性も重視して組んでいて、Unity用の特殊な拡張をアドオンとして有効化すると、例えばVector3[]のシリアライズ/デシリアライズがJsonUtilityの50倍高速化(50倍!)とか、色々強力で強烈になってます。乞うご期待。

それと会社ブログ - Grani Engineering Blog始めましたということで、そっちに幾つか記事書いてますね。C#のswitch文のコンパイラ最適化についてとか。あとgRPC化とか。

NextGen Server/Client Architecture - gRPC + Unity + C# from Yoshifumi Kawai

こちらも、シリアライザのMessagePack for C#化とか大工事を何度かしつつも、もうすぐとりあえずStableといえるとこまで持ってけそうです。

また、Unity用のインメモリ内蔵データベースとしてMasterMemoryというのも作っていて

*neuecc/MasterMemory

これももうすぐ公開できそうかもかもといったところで、とりあえず色々あって大変大変。どれもUnityでのユニットテストには RuntimeUnitTestToolkit で動かしてるんで、私自身は超ヘビーに使いまくってますよ、です。

2016年を振り返る

振り返る、のも五回目。今年は、ものすごくC#を書く技量が向上した気がします。いやほんと。私も結構歳とった感があるのですが(昨日誕生日で33歳でした!)、まだグッと成長できる切り口が残ってたんだなぁと思うと大変嬉しい話です。正直今年はあまり良いニュースはなかったのですが、自分のメインの軸で自己成長を実現できたというのは、次のステップ頑張ろうって気になれます。

C#

プログラミングって、ある程度はパターンがあって、このシチュエーションにはこれを当てはめて、こういう風に組み立てていけば勝てる、みたいな手札の多さが強さ(?)みたいなところがあると思ってるんですが、ここ2年ほど私自身のデッキは割と安定していたんですよね。言語やフレームワークのアップデートに従って組み替えたり、他のライブラリを見て手札を、アイディアを増やすというのは随時やっていってましたが、大きく変わるようなことはなかったなあ、と。言語がアップデートされると、そりゃ当然手法も大きく変わるんですが、良くも悪くもC#は安定期に入っていて、ぶっちけそんな変わってないし、次のC#も大して変わらないですしね。

って状況だったんですが、今年はガラッと書き方、考え方が変わりました。もちろん、使い続けている手札もいっぱいありますが、新規に入ってきた要素もとても多くて。そのお陰で、APIの表現力も大幅に上がりました。組み合わせの問題でもあるので、手札が多いと、やれることの幅やAPIの表現力が爆発的に上がっていくので非常に良いことです(逆に手札が少ない人の作るAPIは窮屈だったりするというのはありますね、そういうのみると慢心してる感じだなあ、とか思ったりはします)

変わった要因は2つあって、一つは、今年はパフォーマンスを極限まで追い求めたコードを色々書いたから。ブログを漁るとUnityでのボクシングの殺し方、或いはラムダ式における見えないnewの見極め方Unityにおけるコルーチンの省メモリと高速化について、或いはUniRx 5.3.0でのその反映UniRx 5.4.0 - Unity 5.4対応とまだまだ最適化と、UniRxの継続アップデートはいつも新しいことを考えたり、導入したりするきっかけになっています。UniRxも今年はGitHubで1000Star越えを果たしたり、スーパーマリオラン(5000万ダウンロード!)に採用されていたりと、一つの山を超えた感じはあります。

個人的にブレークスルーだったのはLINQ to GameObject 2.1 - 手書き列挙子による性能向上と追加系をより使いやすくで、改めてLINQ、そしてパフォーマンスとは、に関して見直すきっかけになりました。そしてZeroFormatter - C#の最速かつ無限大高速な .NET, .NET Core, Unity用シリアライザーで、集大成として結実しました。いやぁ、大変だった。ほんと大変だった、終わってみればあっさりって気もしなくもないんですが、いやぁ、大変だった……。シリアライザなんて枯れた群雄割拠な代物と思ってましたが、性能面でもまだまだ全然追求できる幅あったんだというのは驚きで。意外と世の中まだやれることは無限にある。C#もまだまだ限界は迎えてない。

性能は最大の機能だ、というのは勿論なのですけれど、究極的にそれを実現するためには新しいアイディアを大量に投下しなきゃいけなかった。今まで自分はいかにヌルいコードを書いてたんだ、と痛感させられました。また、そんな性能追求ギプスのお陰で沢山の手札を手に入れられて、それは視野の広がりをもたらして、ただたんに性能のために、というだけじゃなく書き方の広がりを手に入れられたと思ってます。

突き詰めてやることにはとても意義がある。逆に、そこまでしなければ手に入れられないものもある。手札を増やすのに他の言語に浮気するってのも悪いことではないですが、その前に目の前のことを突き詰めてみるってのもいいんじゃないのってのはとっても思います。nullがどうこうとか言ってる前にC#どんだけ書けるのよ、みたいな。みたいな?

技術的負債との付き合い方

技術的負債って、優秀なエンジニアがしっかり考えれば発生しない。わけではないんですよね。コードなんて誰が書いても、書いた瞬間から腐敗は始まっていて、アプリケーションとしてローンチする前から負債になっている場合すらある。そして、出来ないエンジニアの作る負債よりも、むしろ出来るエンジニアの作る負債のほうが痛かったりする。JavaScript界隈でよく聞くような、新しい技術をいっぱい取り入れました、でももう時代遅れです!みたいなのは典型ですが(これも普通よりちょっと出来るエンジニアぐらいのほうがハマりやすい罠)、そんなんじゃなくても、大なり小なり腐敗を抱えて生きてるわけです。

永遠に輝くコードなんて存在しないからこそ、むしろいかに捨てるかに腐心するほうが良い。もちろん、私の書くものだって例外じゃあなくて、ゴミは作ってしまうのね。別にゴミだと思って作るわけじゃなくても!ダメだと気づいたら、しょうがないので焼却する。これがね、自分の作ったコードなら躊躇なく捨てられる。捨てた際のカバーもなんとかできる、こともある(できないこともある、ひどぅぃ)。けれど他人の作ったものの扱いはとても難しい。そもそも他人の書いたものをジャッジするのが難しい!自分の書いたものを、あぁ、アイディア自体がゴミでダメですね、と切り捨てれても、他人のものを正しく判定するのはむつかしいんだなあ。いや、現在にたいしてダメか否かの判定は簡単ですけれど、未来の判定をするのがむつかしい。

自分の書いたものだと未来も見えるんですよね、このアイディアの延長線上に何があるか想像がつく、未来がないことが見えた時、やめましょう、投げ捨てましょう、になる。けれど、他人の未来はわからなくて、今はまだまだだけど、もう少しやってりゃあなんとかなるかもしれない……。とか思っちゃうわけです。期待して。或いは目をつむって。実際大抵はそんなことはなくて、ダメなもんはダメだったりするわけですが。

損切りするのが難しいのと一緒で、そりゃうまくできりゃあ良いんですが。というかうまくできなきゃあダメなんですが。傷口は 消毒で誤魔化してないで、腐食が進む前に切り落とさなきゃ本当にダメで。腐った土台のうえでいくら技巧を凝らしても、醜い延命策で、なんの解決にもなってないというか、むしろただの時間の浪費なんですよね。いやはや。

何れにせよ、奢った気持ちで書かれたものはダメですねぇ。「よくできているのにどうしょうもなくダメなプログラム」とは何ぞやか、というのを考え直すきっかけになりましたし、そうして考え直すことは自分の書き方の変化にも繋がりました。自分自身ね、そういうの書いちゃってたりやっぱしてしまうわけで。

お仕事

というわけで技術的負債の返却、じゃないですが、今年の後半は、意識的に、問題を技術で解決するというところにフォーカスしていました。結構ね、状況は余裕じゃないんですが、なんとかして解消しなければならない!

ZeroFormatterを起点に、まだ未完成のものでMagicOnion - gRPC based HTTP/2 RPC Streaming FrameworkMasterMemory - Embedded Readonly In-Memory Document Databaseというのを用意しています。

現状をクソだというのはイージーなんですが、なんとか維持しつつも解決させるってのは結構難しくある。アイデアというのは複数の問題を一気に解決するものであるとはよく言ったものですが、実際、これらの導入によって抱えている問題をそれなりに解決できる。といいなぁ。

技術で技術を返却するってのは、良くも悪くもですね。特に、私自身がCTOという立場でそれやってるのは、結構キワキワだとは思ってます。意識して脳みその9割をコードに割くようにしてるのは、逆に他のことはあまり考えてないってことですからねぇ。正直、あんまいいことじゃあないし、来年も同じようにしたいとは思わないというか、すべきではないと思ってますが、現在の状況からすればこれが最善、かな。と選んでやってます。この辺はしゃーない。もう少しうまくやれりゃあいいのですけれど。

損切りのタイミングを逸したとか、自分で返却しなきゃいけないものを返却できなかったりとか、前期であまり良い決断ができてなかったというのはうーむ、といったところも多々ありつつ。対外的なプレゼンスに関してはよくやれたと思ってますし、その辺の人にはできないことをやってるとは思いますが、それだけでいいと言い切れない程度には歯切れの悪い年でした。

ゲームとか音楽とか

とんかつDJアゲ太郎だけはアニメ全部見ました:) それ以外はアニメもドラマも何もかも完走できてないというかロクに見ちゃいない。本も読んでなければ漫画も見てないんですが、うーん、何が良かったかなあ。本日のバーガーはテーマ的には良かった!色々なハンバーガーがあるし、あっていんだよ、という当たり前のような当たり前を認識できて。

ゲームは、うーん、オーディンスフィア レイヴスラシルは今年でしたか、良かった。あとスーパーマリオランはレート3000、ブラックコインコンプぐらいにはやりました。レートカンストはちょっと不毛感あるので、いったんそろそろいいかな感もありますが。

音楽は、水曜日のカンパネラをよく聴いてましたねー、ジパング私を鬼ヶ島に連れてってが傑作で。あと、つい先日出た戸川純 with Vampillia / わたしが鳴こうホトトギスが良くてホクホク。

来年

年始暫くはひたすらシステムプログラミングですねー。好きでやってていいってことにも、限度が、頻度というものがあって、大げさ大掛かりなものを連続して作らなきゃいけないってのは正直シンドイ。ゆうて神経めっちゃ使うのよ。やるにしても、もう少し間隔あけながらやりたいよぅ、というのも自業自得なんでしょーがない。

というわけかで、去年の目標であったグラフィックプログラミングはちっとも前進しませんでした。今年はVRにもしっかり手を出したかったんですが、あまりやれてないですね、まぁそうしたグラフィックプログラミングも、VRも、あと最近興味あるのはディープラーニングも、ゲームをリリースするまではお預け。

というわけで、リリースしましょう、ってことですね!

ASP.NET Coreを利用してASP.NET Coreを利用しないMiddlewareの作り方

今回の記事はASP.NET Advent Calendar 2016向けのものとなります。最終日!特に書くつもりもなかったのですが、たまたま表題のような機能を持つMiddlewareを作ったので、せっかくなので書いておくか、みたいなみたいな。

.NET 4.6でASP.NET Core

まぁ普通に.NET 4.6でASP.NET Coreのパッケージ入れるだけなんですが。別にASP.NET Coreは.NET Coreでしか動かせないわけではなくて、ちゃんと(?).NET 4.6でも動きます。如何せん.NET Coreがまだ環境として成熟してはいないので、強くLinuxで動かしたいという欲求がなければ、まだまだWindows/.NET 4.6で動かすほうが無難でしょう。Visual Studioのサポートも2015だとちょっとイマイチだとも思っていて、私的には本格的に作り出していくのはVisual Studio 2017待ちです。脱Windowsとして、Linuxでホスティングするというシナリオ自体にはかなり魅力的に思っていますし、ライブラリを作るのだったら今だと.NET Core対応は必須だと思いますけれど。

Hello Middleware

Middlewareとはなんぞやか、というと、ASP.NET公式のMiddlewareのドキュメントが見れば良いですね。

image

Httpのリクエストを受けつけて、レスポンスを返す。ASP.NET Core MVCなどのフレームワークも、Middlewareの一種(図で言うところのMiddleware3にあたる、パイプラインの終点に位置する)と見なせます。このパイプラインのチェーンによって、事前に認証を挟んだりロギングを仕込んだりルーティングしたりなど、機能をアプリケーションに足していくことができます。

考え方も、実質的なメソッドシグネチャもASP.NET Coreの前身のOWINと同一です。今ではOWIN自体の機能や周辺フレームワークは完全に整っていて、ASP.NET Coreで全て賄えるようになっているので、新しく作る場合はASP.NET Coreのことだけを考えればいいでしょう。逆に、OWINで構築したものをASP.NET Coreへ移行することはそう難しくないです

ASP.NET Coreのパッケージはいろいろあって、どれを参照すべきか悩ましいのですが、最小のコア部分となるのはMicrosoft.AspNetCore.Http.Abstractionsです。これさえあればMiddlewareが作れます。

では、パイプラインの各部にフックするだけの単純なMiddlewareを作りましょう!

public class HelloMiddleware
{
    // RequestDelegate = Func<HttpContext, Task>
    readonly RequestDelegate next;
 
    public HelloMiddleware(RequestDelegate next)
    {
        this.next = next;
    }
 
    public async Task Invoke(HttpContext context)
    {
        try
        {
            Console.WriteLine("Before Next");
 
            // パイプラインの「次」のミドルウェアを呼ぶ
            // 条件を判定して「呼ばない」という選択を取ることもできる
            await next.Invoke(context);
 
            Console.WriteLine("After Next");
        }
        catch (Exception ex)
        {
            Console.WriteLine("Exception" + ex.ToString());
        }
        finally
        {
            Console.WriteLine("Finally");
        }
    }
}

注意点としては、完全に「規約ベース」です。コンストラクタの第一引数はRequestDelegateを持ち(その他のパラメータが必要な場合は第二引数以降に書く)、public Task Invoke(HttpContext context)メソッドを持つ必要があります。逆に、それを満たしていればどのような形になっていても構いません。

この規約ベースなところは賛否あるかなぁ、というところですが(私はどちらかというと否)、C#の言語機能としてはしょうがない面もあります。(自分でもこの手のフレームワークを何個か作った経験があるところから理解している上で)実装面の話をすると、この規約で最も大事なところは、コンストラクタの第一引数でRequestDelegateを受け入れるところにあります。そして、C#は具象型のコンストラクタの型の制約は入れられないんですよね。なので、MiddlewareBaseとか作ってもあんま意味がなくて、ならもう全部規約ベースで処理しちゃおうって気持ちは分かります。

Invokeのメソッドシグネチャをpublic Task Invoke(HttpContext context, RequestDelegate next)にすることで、そうしたコンストラクタの制約を受ける必要がなくなって、メソッドに対するインターフェイスでC#として綺麗な制約をかけることは可能になるんですが(私も、なので以前はそういうデザインを取っていた)、そうなるとパフォーマンス上の問題を抱えることになります。Invoke(HttpContext context, RequestDelegate next)というメソッドシグネチャだと実行時に”next”を解決していくことになるのですが、これやるとどうしても、nextを解決するための余計なオブジェクト(クロージャを作るかそれ用の管理オブジェクトを新しく作るか)が必要になりますし、呼び出し階層もその中間層を挟むため、どうしても一個深くなってしまいます。

ミドルウェアパイプラインは構築時にnextを解決することができるわけで、そうした実行時のコストを構築時に抑え込むことが原理上可能です。それが、コンストラクタでnextを受け入れることです。C#を活かした設計の美しさ vs パフォーマンス。このMiddlewareチェーンはASP.NET Coreにおける最も最下層のレイヤー。この局面ではパフォーマンスを選ぶべきでしょう。実に良いチョイスだと思います。

最後に、使いやすいように拡張メソッドを用意しましょう。拡張メソッドなのでnamespaceは浅めのところにおいておくと使いやすいので、その辺は適当に気をつけましょう:)

public static class HelloMiddlewareExtensions
{
    public static IApplicationBuilder UseHello(this IApplicationBuilder builder)
    {
        // 規約ベースで実行時にnewされる。パラメータがある場合はparams object[] argsで。
        return builder.UseMiddleware<HelloMiddleware>();
    }
}

Middlewareを使う

作ったら使わないと動作確認もできません!というわけでホスティングなのですが、これもAspNetCoreのパッケージはいっぱいありすぎてよくわからなかったりしますが、「Microsoft.AspNetCore.Server.*」がサーバーを立てるためのライブラリになってます。IISならIISIntegration、Linuxで動かすならKestrel、コンソールアプリなどでのセルフホストならWebListenerを選べばOK。今回はMicrosoft.AspNetCore.Server.WebListenerで行きましょう。

class Program
{
    static void Main(string[] args)
    {
        var webHost = new WebHostBuilder()
            .UseWebListener()      // ホスティングサーバーを決める
            .UseStartup<Startup>() // サーバー起動時に呼ばれるクラスを指定
            .UseUrls("http://localhost:54321") // 立ち上げるアドレスを指定
            .Build();
 
        webHost.Run();
    }
}
 
public class Startup
{
    // Configure(IApplicationBuilder app)というのも規約ベースで名前固定
    public void Configure(IApplicationBuilder app)
    {
        // さっき作ったMiddlewareを使う
        app.UseHello();
 
        // この場で最下層の匿名Middleware(nextがない)を作る
        app.Run(async ctx =>
        {
            var now = DateTime.Now.ToString();
            Console.WriteLine("---------" + now + "----------");
            await ctx.Response.WriteAsync(DateTime.Now.ToString());
        });
    }
}

例によって規約ベースなところが多いので、まぁ最初はコピペで行きましょう、しょーがない。これでブラウザでlocalhost:54321を叩いてもらえば、現在時刻が出力されるのと、コンソールにはパイプライン通ってますよーのログが出ます。

image

基本のHello Worldはこんなところでしょう、後は全部これの応用に過ぎません。

ASP.NET Coreを利用してASP.NET Coreを利用しない

さて、本題(?)。現在、私はMagicOnionというフレームワークを作っていて(まぁまぁ動いてますが、一応alpha段階)、謳い文句は「gRPC based HTTP/2 RPC Streaming Framework for .NET, .NET Core and Unity」。つまり……?gRPCというGoogleの作っている「A high performance, open-source universal RPC framework」を下回りで使います。つまり、ASP.NET Coreは使いません。さよならASP.NET Core……。

gRPCは(.NET以外では)非常に盛り上がりを見せていて、ググればいっぱい日本語でもお話が見つかるので、知らない方は適当に検索を。非常に良いものです。

gRPCはHTTP/2ベースで、しかもデータは基本的にはProtocol Buffersでやり取りされているので、従来のエコシステム(HTTP/1 + JSON)からのアクセスが使えません。そこでgrpc-gatewayというプロキシを間に挟むことで HTTP/1 + JSONで受けてHTTP/2 + Protobuf にルーティングします。それによりSwaggerなどの便利UIも使えて大変捗るという図式です。素晴らしい!

grpc-gatewayは素晴らしいんですが、Pure Windows環境で使うのは恐らく無理があるのと、MagicOnionではデータをZeroFormatterでやり取りするようにしているので、そのまま使えません。残念ながら。しかし、特にSwaggerが使いたいんで絶対にgrpc-gateway的なものは欲しい。と、いうわけで、用意しました。ASP.NET Coreを利用して(HTTP/1 + JSON)、ASP.NET Coreを利用しない(HTTP/2 + gRPC/MagicOnion/ZeroFormatter)。

public class MagicOnionHttpGatewayMiddleware
{
    readonly RequestDelegate next;
    // MagicOnionのHandler(キニシナイ)
    readonly IDictionary<string, MethodHandler> handlers;
    // gRPCのコネクション
    readonly Channel channel;
 
    public MagicOnionHttpGatewayMiddleware(RequestDelegate next, IReadOnlyList<MethodHandler> handlers, Channel channel)
    {
        this.next = next;
        this.handlers = handlers.ToDictionary(x => "/" + x.ToString());
        this.channel = channel;
    }
 
    public async Task Invoke(HttpContext httpContext)
    {
        try
        {
            var path = httpContext.Request.Path.Value;
 
            // HttpContextのパスをgRPCのパスと適当に照合する
            MethodHandler handler;
            if (!handlers.TryGetValue(path, out handler))
            {
                await next(httpContext);
                return;
            }
 
            // BodyにJSONがやってきてるということにする(実際はFormからの場合など分岐がいっぱいでもっと複雑ですが!)
            string body;
            using (var sr = new StreamReader(httpContext.Request.Body, Encoding.UTF8))
            {
                body = sr.ReadToEnd();
            }
 
            // JSON -> C# Object
            var deserializedObject = Newtonsoft.Json.JsonConvert.DeserializeObject(body, handler.RequestType);
 
            // C# Object -> ZeroFormatter
            var requestObject = handler.BoxedSerialize(deserializedObject);
 
            // gRPCのMethodをリクエストを動的に作る
            var method = new Method<byte[], byte[]>(MethodType.Unary, handler.ServiceName, handler.MethodInfo.Name, MagicOnionMarshallers.ByteArrayMarshaller, MagicOnionMarshallers.ByteArrayMarshaller);
 
            // gRPCで通信、レスポンスを受け取る(ZeroFormatter)
            var rawResponse = await new DefaultCallInvoker(channel)
                .AsyncUnaryCall(method, null, default(CallOptions), requestObject);
 
            // ZeroFormatter -> C# Object
            var obj = handler.BoxedDeserialize(rawResponse);
 
            // C# Object -> JSON
            var v = JsonConvert.SerializeObject(obj, new[] { new Newtonsoft.Json.Converters.StringEnumConverter() });
 
            // で、HttpContext.Responseに書く。
            httpContext.Response.ContentType = "application/json";
            await httpContext.Response.WriteAsync(v);
        }
        catch (Exception ex)
        {
            // とりあえず例外はそのまんまドバーッと出しておいてみる
            httpContext.Response.StatusCode = 500;
            await httpContext.Response.WriteAsync(ex.ToString());
        }
    }
}

細かいところはどうでもいいんですが(あと一部端折ってます、実際はもう少し複雑なので)、基本的な流れはJSONをZeroFormatterに変換→内部で動いてるgRPCと通信→ZeroFormatterをJSONに変換。です。見事に左から右にデータを流すだけー、のお仕事、ですね!

MagicOnion本体は限界までボクシングが発生しないように、ラムダのキャプチャなどにも気を使って、ギチギチにパフォーマンスチューニングしてあるんですが、このGatewayはそんなに気を使ってません:) まぁ、もとより複数回の変換が走ってる、パフォーマンス最優先のレイヤーではないから、いっかな、という。どっちかというとデバッグ用途でSwaggerを使いたいがために用意したようなものです。本流の通信はこのレイヤーを通ることはないので。

image

ちゃんとgRPCでもSwagger使えてめっちゃ捗る。

What is MagicOnion?

gRPCは.protoを記述してサーバーコードの雛形とクライアントコードを生成します。私はこのIDL(Interface Definition Language)の層があまり好きじゃないんですね。そもそも、クライアントもサーバーも、ありとあらゆる層をC#で統一しているので、C#以外を考慮する必要がないというのもあるので。なので、C#自体をIDLとして使えるように調整したり、MVCフレームワークでいうフィルターが標準でないので、それを差し込めるようにしたり、gRPCは(int x, int y, int z)のような引数に並べるような書き方ができない(必ずRequestクラスを要求する!)ので、動的にそれを生成するようにしたりして、より自然にC#で使えるように、かつ、パフォーマンスも一切犠牲にしない(中間層が入ってるからオーバーヘッドと思いきや、むしろプリミティブ型が使えるようになったのでむしろ素のgRPCより速くなる)ようにしています。そもそもそしてUnityでも動作出来るような調整/カスタマイズなどなども込みで、ですね。

それ以外の話はZeroFormatterと謎RPCについて発表してきました。にて少し書いてあります。もう少し詳細な話は、完成した時に……。

まとめ

.NET Coreを本格的に(プロダクション環境で)使うということは、特に開発環境という点でまだ足りないところが多くて(project.json廃止とかゴタついたところもあるし)、VS2017待ちだと判断しています。しかし、ASP.NET Coreのフレームワーク面では十分完成していて、問題ないですね。なので、そちらから随時移行していきたいという気持ちでいます。

まぁ、とはいえ↑で書いたとおり、ほとんどASP.NET Core自体すら使わないんですが。うーん、そうですね、やっぱスタンダードな作り(JSON API)をクロスプラットフォームを紳士に取り組んでます、みたいなことやってる間に、世界は凄いスピードで回ってるんですよね。Microsoftは常に一歩遅いと思っていて、まぁ今回もやっぱそうですよね、という感じで、世間が成熟した頃にやっと乗り出すようなスピード感だと思ってます。ナデラでOSSでスピーディーなのかといったら、別に私はそう思ってないですね、スピードという点では相変わらずだなぁ、と。むしろ「正しくやろうとする」圧力の高さに自分で縛られてしまっている気すらします。スタンダードだからとJSONでコンフィグ頑張ろうとしてやっぱダメでした撤回、みたいな。そういうのあんま良くないし、その辺の束縛から自由になれた時が真のスタートなんじゃないかな。

ともあれ、私はgRPCにベットしてるんで、ASP.NET Core自体は割とどうでも良く思ってます、今のところ。でもそれはそれとして、当然(補助的に)使ってく必要はあるんで、そういう時にちょいちょいと出番はあるでしょう。

C#に置ける日付のシリアライズ、DateTimeとDateTimeOffsetの裏側について

C# Advent Calendar 2016の記事になります。何気に毎年書いてるんですよねー。今年は、つい最近ZeroFormatterというC#で最速の(本当にね!)シリアライザを書いたので、その動的コード生成部分にフォーカスして、ILGenerator入門、にしようかと思ってました。ILGeneratorでIL手書き、というと、黒魔術!難しい!と思ってしまうけなのですが、実のところ別に、分かるとそれほど難しくはなくて(面倒くさい&デバッグしんどいというのはある)、しかし同時にILGeneratorで書いたから速かったり役に立ったり、というのもなかったりします。大事なのは、どういうコードを生成するのかと、全体でどう使わせるようなシステムに組み上げるのか、だったり。とはいえ、その理想的なシステムを組むための道具としてILGeneratorによるIL手書きが手元にあると、表現力の幅は広がるでしょう。

シリアライザ作って思ったのは、Jilは(実装が)大変良くできているし、SigilはIL生成において大いに役立つ素晴らしいライブラリだと思ってます。まぁ、そういうの作る時って依存避けたいので使わなかったけれどね……。

みたいなイイ話をしようと思っていたんですが、ちょっと路線変更でDateTimeについてということにします。えー。まぁいいじゃないですか、DateTimeだって深いですし、IL手書きなんかよりずっと馴染み深いではないですか。役立ち役立ち。

DateTimeとはなんぞやか

シリアライズの観点から言うと、ulongです。DateTimeとはulongのラッパー構造体というのが実体です。ulongとは、Ticksプロパティのことを指していて、なので例えばDayを取ろうとすれば内部的にはTicksから算出、AddHoursとすればhoursをTicksに変換した後に内部的なulongを足して、新しい構造体を返す。といった形に内部的にはなっています。それぞれのオペレーションは除算をちょっとやる程度なので、かなり軽量といってもいいでしょう。

つまり、DateTimeとはなんぞやかというのは、Ticksってなんやねん、という話でもある。

Ticksとは、100ナノセカンド精度での、0が0001/01/01 00:00:00から、最大が9999/12/31 23:59:59.999999までを指す。ほほー。

// 0001-01-01T00:00:00.0000000
new DateTime(ticks: 0).ToString("o");
// 0001-01-01T00:00:00.0000001
new DateTime(ticks: 1).ToString("o");
 
// 3155378975999999999
DateTime.MaxValue.Ticks

DateTimeにはもう一つ、Kindという情報も保持しています。KindはUtcかLocalか謎か(Unspecified)の三択。

public enum DateTimeKind
{
    Unspecified = 0,
    Utc = 1,
    Local = 2
}

ふつーにDateTime.Nowで取得する値は、Localになっています、ので日本時間である+9:00された値が取れます。さて、このKindは内部的にはどこに保持されているかというと、Ticksと相乗りです!ulong、つまり8バイト、つまり64ビットのうち62ビットをTicksの表現に、残りの2ビットでKindを表現しています。なんでそういう構造になっているかといえば、まぁ節約ですね、メモリの節約。まー、コアライブラリなのでそういう気の使い方します、的な何か。

Ticksプロパティ、Kindプロパティはそれぞれ内部データを脱臭した値が出てくるので、そうしたTicks, Kindが相乗りした内部データを取りたい場合はToBinaryメソッドを使います。復元する場合は、FromBinaryです。

// Ticks + Kind(long, 8byte)
var dateData = DateTime.Now.ToBinary();
var now = DateTime.FromBinary(dateData);

これで8バイトでDateTimeの全てを表現できるので、これが最小かつ最速な手法になります。あまり使うこともないと思いますが。

さて、当然ZeroFormatterはそうしたToBinaryで保持してるんだよね!?というと、違います!seconds:long + nanos:intという12バイト使った表現(秒+ナノ秒)にしています。これはProtocol Buffersの表現を流用していて、うーん、一応クロスプラットフォーム的にはそのほうがいいかな、みたいな(でも今考えると別にTicksで何が悪い、って気はする……失敗した……)。そして、Kindは捨てています。シリアライズ時にToUniversalTimeでUTCに変換し、そのUTCの値のみシリアライズしています。

で、Kindは、私は捨てていいと思ってます。一応MSDNのDateTime、DateTimeOffset、TimeSpan、および TimeZoneInfo の使い分けというドキュメントにもありますが

DateTime データを保存または共有する際、UTC を使用する必要があり、DateTime 値の Kind プロパティを DateTimeKind.Utc に設定する必要があります。

UTCかLocalか、なんていうだけの二値はシリアライズに全く向いてないです。それだったらTimeZoneも保存しないと意味がない。アメリカで復元したらどうなんねん、みたいな。なのでシリアライズという観点で見るとKindはナンセンス極まりないです。これはDateTimeの設計が悪いって話でもあるんですが(後述するDateTimeOffsetがDateTimeのラッパーみたいな感じになってますけれど、本質的にはその逆であるべきだと思う)、その辺(初期の.NETのクラスはどうしても微妙にしょっぱいところがある)はshoganaiんで、受け入れるんだったらKindは無視。これが鉄板。

DateTimeOffset

Kindを無視するのはいいけれど、時差の保存は欲しいよね、という時の出番がDateTimeOffset。これは内部的には ulong(DateTime) + short(オフセット分) の2つの値で保持しています。まんま、DateTimeとOffset。DateTime.NowとDateTimeOffset.Nowって同じような値が帰ってくるし違いはなんなんやねん、というと、DateTimeOffsetはローカル時間といったKindじゃなくて、明確に内部的に+9時間というオフセットを持っているということです。

ZeroFormatterでシリアライズする際は、こちらはオフセットも保存していて、 seconds:long + nanos:int + minutes:short の14バイトの構成です。

ZeroFormatter上では明確にDateTimeとDateTimeOffsetは違うものとして取り扱ってるわけですが、よくあるDateTimeをToString(”o”)した場合って(んで、JSONなんかに乗せる場合って)

// 2016-12-07T03:19:23.7683110+09:00
DateTime.Now.ToString("o");
// 2016-12-07T03:19:23.7713117+09:00
DateTimeOffset.Now.ToString("o");

と、いうふうに、完全に一緒なわけです。というか、むしろこれはDateTimeの(文字列への)シリアライズをDateTimeOffsetとして表現している、とも言えます。まぁ、そのほうが実用上は親切ではある。が、これはDateTimeもDateTimeOffsetも区別してない(stringで表現)からっていうことであって、決してKindもシリアライズしているということではないということには注意。そして明確にDateTimeとしてDateTimeOffsetを違うものとして扱うなら(ZeroFormatterの場合)、良くも悪くもこういう表現はできないんだなぁ。不便だけどね。

基本的にDateTimeOffset、のほうが使われるべき正しい表現だと思うんですが、.NETのクラス設計上、DateTimeのほうが簡潔(だし内部構造的にもDateTimeOffsetはDateTime+αという形)で短い名前(名前超大事!)である以上、DateTimeの天下は揺るがないでしょう。残念なことにDateTimeOffsetの登場が.NET 2.0 SP1からだということもあるし。DateTimeOffsetがDateTimeで、DateTimeがLocalDateTimeだったら話は変わってくるでしょうけれど(そしてそんな構造だったらきっとLocalDateTimeは使われない)、まぁ変わらないものは変わらないです。まぁ保存用途ならUTCが良いと思うんで、現代的な意味では逆にDateTimeOffsetの出番はより減ってきたとも言える。データがクラウドに保存されて世界各国で共有されるとか当たり前なので、保存はUTC、表示時にToLocalTimeのほうが合理的。Kindって何やねん、と同じぐらいOffsetって何やねん、みたいな。

まぁLocalDateTime, ZonedDateTime, OffsetDateTimeという3種で表現というJava8方式が良いですよねということになる。

NodaTime

日付と時間に関しては、TimeZoneやCalendarなど、真面目に扱うとより泥沼街道を突っ走らなければならないわけですが、いっそ.NET標準のクラスを「使わない」という手もあります。NodaTimeは良い代替で、Javaの実質標準のJodaTime(後にJava8 Date API)の移植ではありますが、製作者がJon Skeet(Stackoverflow回答ランキング世界一位, Microsoft MVP, google, C# in Depth著者)なので、ありがちなJava移植おえー、みたいなのでは決してないのが一安心。

こういった標準クラスを置き換える野良ライブラリはシリアライズ出来ないのが難点で、そうしたシリアライズの表象でだけDateTime/DateTimeOffsetに置き換えるというのはよくあるパターンですが面倒くさくはある。シリアライザの拡張ポイントを利用してネイティブシリアライズ出来るようにするのが良い対応かなー、というのはあります。NodaTimeは標準でJson.NETに対応した拡張ライブラリが用意されているというところも、(当たり前ですが)わかってるなー度が高くていいですね。ZeroFormatterも拡張ポイントを持っているので、必要な分だけ手書きして対応させれば、まぁ、まぁ:)

まとめ

DateTimeOffsetも可愛い子ではある。時に使ってあげてください。というわけで、次のアドベントカレンダーは@Marimoiroさんです!

ZeroFormatterに見るC#で最速のシリアライザを作成する方法

というタイトルで発表してきました。連続してZeroFormatterネタなのですが、今回はC#実装のほうにフォーカスして紹介しています。

ZeroFormatterに見るC#で最速のシリアライザを作成する100億の方法 from Yoshifumi Kawai

intをシリアライズするところにフォーカスして、何故、既存のシリアライザは遅くて、何故ZeroFormatterは速いのかというところを解説しました。読んでもらえれば、理屈でパフォーマンスについて納得してもらえるんじゃないかと思います。

以下、会場であったFAQなどなぞ。

エンディアン違いは?

現在はリトルエンディアンしかサポートしてません。C#の動く環境ってほとんどリトルエンディアンなのでそこまで大きな問題ではないかな、と(Xboxはダメらしいですが)。対応しようと思えば当然できるんですが、Buffer.BlockCopyを多用しているので、そこの部分をバラさなきゃいけないので若干手間なのですよね(あと、性能面では低下します)。というわけで、要望があって困った、というレポートが来てから対応を考えます。一応、ビッグエンディアン下では例外を吐くようになっていて、そこの例外メッセージの中で、issueに自分の環境を書いていってください、みたいなメッセージを乗せています。

LINQ使っちゃダメなの?

んなこたぁないです。場所によりけりで、ZeroFormatter内部でも、コード動的生成する部分の型情報を舐めてどうこうするところでは使っています。それは「アプリケーションの寿命の中で最初の一回だけだから」「動的に作ったILをコンパイルする時間のほうが比較にならないぐらいに長いので、その程度を節約するのは無意味」だからです。

基本的には使おうよ、ってのは変わりはしないのですけれど、とはいえ、今まで良いとされてきた領域が、必ずしもそうなの?実はそうじゃないんじゃないの?というのを頭に入れて、都度都度考える必要は出てきているんじゃないかな、と思ってます。以前よりも。ゲームなんかでは今も昔も当然そうなのですけれど、ふつーのアプリケーションでも、今まで、単体のコンピューターで動くものは、まぁ限界もあるし、コンピューターの性能は上昇し続けるで、気にする必要はそんななかった。サーバーアプリケーションも。でも、今、サーバーアプリケーションって数十台、数百台のクラスタで動かすことも少なくなくて、それらの場合って少し性能を上げるだけで、数百台の見返りがあるんですよね。塵も積もれば山となる、今まではチリはつもらなかったけれど、今はつもりやすい環境になってきた。ってことを考えると、まぁ、特にライブラリや基盤部分のフレームワークなんかはどこでどう使われるか分からないので、気合入れてこう!っと。

2番じゃダメなんですか

まぁ、ダメですね!

せっかくライブラリ公開するなら多くの人に使ってもらいたいんですよね。これは、単純に使ってもらって嬉しいっていうのと、多くの人に使われることによって、バグが減る、機能のためのアイディアがもらえる、コントリビュートしてもらえてより強力なライブラリになれる、などなどもあります。そういうのって、会社にとってもメリットなんですよね。大きめの規模だったり独自性の高いライブラリは、社内だけで抱えたくないんです。まず、未来がない。未来がないものなんて使いたくない。というわけで、出来る限り、最初から公開を意識して作って、実際公開するわけですが、別に公開したからって未来があるわけでもない。多くの人に使われて、ある程度メジャー感が出て、はじめて未来が生まれる。なので、やるからには精一杯頑張ろうって感じですね。少なくとも何らかのインパクトは残したいと思ってやってます、いつも。

んで、2番ってヒキが全くないわけですよ。1番と2番があったら、そりゃ1番選ぶでしょ。 “Second Place is the First Loser”なわけです(ちょうど勉強会の時に聞いたので早速使ってみた)。というわけで、ヒキのある要素は色々必要で、一点目が「無限大に高速」で、これは勿論、非常に差別化要素になりうる目玉機能です。でも、それだけだとキワモノ臭さが抜けない。やっぱパフォーマンスが最大の機能なんですよね、この手のものは。だから、最速。最初はそれを目指してたわけじゃなかったんですが、スライド中に書いたように、初期設計段階でStreamを排除していたりステートを抜いてたりしたのが功を奏して、ある程度出来た段階で、最速が現実的に狙えると分かったので、そっから先はギアを切り替えてガチガチに書きました。そのせいで完成が若干遅れはしたんですが、結果としては非常に良かったと思ってます。

名前の由来

ゼロ速度のシリアライザということで。ZeroSerializerよりZeroFormatterのほうが格好良いと思います、語感が。

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)で使える通信用のフレームワークをリリースします(!)のもします(ホントに!)

Comment (12)

nn : (11/14 14:55)

1.3.1 zfc.exeで例外が
ハンドルされていない例外: System.InvalidOperationException: Service of type ‘Microsoft.CodeAnalysis.Host.IWorkspaceTaskSchedulerFactory’ is required to accomplish the task but is not available from the workspace.
場所 Microsoft.CodeAnalysis.Host.HostWorkspaceServices.GetRequiredService[TWorkspaceService]()
場所 Microsoft.CodeAnalysis.Workspace..ctor(HostServices host, String workspaceKind)
場所 Microsoft.CodeAnalysis.MSBuild.MSBuildWorkspace..ctor(HostServices hostServices, ImmutableDictionary`2 properties)
場所 Microsoft.CodeAnalysis.MSBuild.MSBuildWorkspace.Create(IDictionary`2 properties, HostServices hostServices)
場所 ZeroFormatter.CodeGenerator.RoslynExtensions.d__0.MoveNext()
— 直前に例外がスローされた場所からのスタック トレースの終わり —
場所 System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
場所 System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(Task task)
場所 ZeroFormatter.CodeGenerator.TypeCollector..ctor(String csProjPath, IEnumerable`1 conditinalSymbols, IEnumerable`1 allowCustomTypes)
場所 ZeroFormatter.CodeGenerator.Program.Main(String[] args)

neuecc : (11/14 18:23)

おおお、ありがとうございます!
動くよう修正したものを上げました!(exeのdll結合での不手際でした)

nn : (11/14 19:19)

コピペで失礼しました
ZeroFormatter使わせてもらっています
特にLazyな辞書が理想です
unityで使っていて、スピード重視だと今までc/c++で必要になるたびプラグインでシリアライザっぽいものやアーカイバを作ってて「スピードは出るけどめんどい…仕様自分で忘れる…c#でできるならc#で統一したい…誰かすごいのぶちあげないかなぁ!(他力本願)」と常々思ってました
理想のものに出会えた感じがしますm(_ _)m

neuecc : (11/14 23:01)

大変助かりましたー。
そして、ありがとうございます,そう言っていただければ何よりです!

報告 : (11/15 16:42)

初めまして。
githubの作法を知らない無作法者で失礼します。
以下のコードでバグが出るようなのでご報告させていただきます。

int size = 10;
DateTime dateTime = new DateTime(2000, 1, 1);
DateTime[] data = new DateTime[size];
for(int i=0;i<size;++i)
{
dateTime = dateTime.AddMinutes(1);
data[i] = dateTime;
}
var bytes = ZeroFormatterSerializer.Serialize(data);
var newData = ZeroFormatterSerializer.Deserialize(bytes);
// newData の先頭が1999/12/31 15:01:00になっている

報告 : (11/15 16:44)

失礼、最後の行は
var newData = ZeroFormatterSerializer.Deserialize(bytes);
でした

報告 : (11/15 16:48)

何度もすいません・・・タグとかが弾かれる設定のようですね
Deserialize<DateTime[]>(bytes)

neuecc : (11/15 17:34)

あー、すみません、シリアライズするとUniversalTimeで保存し
デシリアライズ時には特にローカルタイムに戻さないので、日本でやると9時間遅れます。
ToLocalTimeすることで日本時間に戻せますが、デシリアライズ時にローカルタイムに自動で戻すオプションは検討してもいいかもしれません。

neuecc : (11/15 17:57)

あ、いや、ちょっとDateTimeOffsetの仕様を変えて、そっちのほうでoffsetを保持するようにします

neuecc : (11/15 19:20)

そもそも仕様のバグで、DateTimeOffsetにオフセット(時差)データを保存していませんでした……。
というわけで、正式に、時差も込みで記録・復元したい場合はDateTimeOffsetでの宣言を
UTCで時間情報そのものだけを保存する場合はDateTimeでの宣言を
という分け方にしました、というのをアップしました。
https://github.com/neuecc/ZeroFormatter#datetime

なので、例で頂いた結果は仕様となりますが、DateTimeOffsetでnewしていただければ
想定通りになると思います(この挙動はJilというJSONシリアライザと同様です)

アライメントの質問 : (01/09 11:47)

ZeroFormatterのBinaryUtil中での処理について質問です。
float/doubleへのRead/Writeではoffsetの剰余による分岐がありますが、
intなどへのRead/Writeではoffsetの分岐なしにポインタ演算→間接参照しています。
一方でC#言語仕様のA.4 Pointer conversionsには
“When one pointer type is converted to another, if the resulting pointer is not correctly aligned for the pointed-to type, the behavior is undefined if the result is dereferenced.”
とあり、アライメントが正しくない間接参照は未定義とあります。
BinaryUtilTestの offset = 33 のような場合は未定義になりそうなのですが大丈夫なのでしょうか。

coreclrのBitConverterの実装を見ますと、そちらではintなどでも分岐してWriteしていました。

neuecc : (01/09 20:55)

素晴らしい質問ありがとうございます、また、丁寧な参照助かります。
大丈夫かどうかでいうと、正直悩ましくです……。
挙動は完全に未定義で、CPUによりけりで変わってしまうのではないかと考えています。
そのうえで、現実的に、現状.NETの動く環境では問題はでないと踏んでいるんですが
(float/doubleで分岐しているのは、実際AndroidのCPUで問題が出たから……)
大丈夫だと言い切れるかに関してはなんとも言い難いところです。
もう少し安全寄りに倒すべき、な気はしています。

Trackback(0)
Write Comment

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勉強会」でお話するつもりなので、是非来てくださいな。

Prev |

Search/Archive

Category

Profile


Yoshifumi Kawai
Microsoft MVP for Visual Studio and Development Technologies(C#)

April 2011
|
July 2018

Twitter:@neuecc
GitHub:neuecc
ils@neue.cc