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で問題が出たから……)
大丈夫だと言い切れるかに関してはなんとも言い難いところです。
もう少し安全寄りに倒すべき、な気はしています。

Name
WebSite(option)
Comment

Trackback(0) | http://neue.cc/2016/11/14_543.html/trackback

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