scala-zero-formatterのパフォーマンス改善と疑似Stage2対応
とりあえず動くものを~ということで0.1.0でリリースしていたのですが、ベンチマークをとったらさすがにJSON系ライブラリよりは速いものの、他のバイナリシリアライザに倍以上差をつけられる結果に「さすがに遅すぎる」と改善することにしました。
この記事を書いた時点では0.3.0が最新版です。
改善内容
- foldLeft,、forearchからtailrec、while loopに
- 初期は動くこと優先で高階関数を使っていた
- が、 http://d.hatena.ne.jp/xuwei/20130709/1373330529 にもあるとおりtailrecやwhileのほうが早いので差し替え
- 抽象化をそもそも想定していないのでとれる選択(読みづらいけど)
- ちなみにこれだけでそこそこ速度が改善する
- mutableなコレクションやnewBuilderの利用
var x = Hoge.empty
で変更するよりmutableコレクションから変換したほうが早い(そりゃそうだ)
- Encoder, Decoderクラスを追加
- mutableな設計に倒した
- 余計な処理が少し減る
- 一部のHListを自前のマクロに置き換え
- foldLeftは遅い
- fixed sizeなコレクションは個別にFormatterインスタンスを用意
- 汎用的なやつだと毎回
Array[Byte]
のサイズチェックやコピーが挟まるので遅い - ので、サイズがわかっているものは最初に一括でリサイズして、配列のサイズチェックなしに書き込む
- 汎用的なやつだと毎回
UnionやEnumには手を入れていないのであれらはまだ遅いままかも。
改善後のベンチマーク
以下てきとーベンチマーク。 てきとーなのであんまりフェアじゃないかも(とくにJSON系)。
sbt "jmh:run -i 10 -wi 10 -f 2 -t 1 benchmarks.EncodingBenchmark" Benchmark Mode Cnt Score Error Units EncodingBenchmark.encodeBarZ thrpt 20 1324256.775 ± 82009.746 ops/s EncodingBenchmark.encodeFooMZ thrpt 20 518515.724 ± 8547.872 ops/s EncodingBenchmark.encodeFooPB thrpt 20 1650635.690 ± 24153.038 ops/s EncodingBenchmark.encodeFooZ thrpt 20 1402245.092 ± 69284.956 ops/s EncodingBenchmark.encodeFoosA thrpt 20 1356.404 ± 80.054 ops/s EncodingBenchmark.encodeFoosC thrpt 20 2157.034 ± 219.598 ops/s EncodingBenchmark.encodeFoosMZ thrpt 20 11213.833 ± 1080.450 ops/s EncodingBenchmark.encodeFoosZ thrpt 20 15255.728 ± 1111.828 ops/s EncodingBenchmark.encodeIntsA thrpt 20 10119.779 ± 894.966 ops/s EncodingBenchmark.encodeIntsC thrpt 20 14285.633 ± 1583.352 ops/s EncodingBenchmark.encodeIntsMZ thrpt 20 83462.462 ± 1648.580 ops/s EncodingBenchmark.encodeIntsPB thrpt 20 151786.501 ± 2536.334 ops/s EncodingBenchmark.encodeIntsZ thrpt 20 219506.973 ± 6170.565 ops/s sbt "jmh:run -i 10 -wi 10 -f 2 -t 1 benchmarks.DecodingBenchmark" Benchmark Mode Cnt Score Error Units DecodingBenchmark.decodeBarZ thrpt 20 7898101.929 ± 854734.733 ops/s DecodingBenchmark.decodeFooMZ thrpt 20 769205.462 ± 60756.223 ops/s DecodingBenchmark.decodeFooPB thrpt 20 1178288.024 ± 36754.823 ops/s DecodingBenchmark.decodeFooZ thrpt 20 1377253.293 ± 234093.569 ops/s DecodingBenchmark.decodeFoosA thrpt 20 796.463 ± 111.745 ops/s DecodingBenchmark.decodeFoosC thrpt 20 1607.345 ± 79.042 ops/s DecodingBenchmark.decodeFoosMZ thrpt 20 10599.926 ± 1889.855 ops/s DecodingBenchmark.decodeFoosZ thrpt 20 17261.595 ± 260.918 ops/s DecodingBenchmark.decodeIntsA thrpt 20 6230.473 ± 659.867 ops/s DecodingBenchmark.decodeIntsC thrpt 20 12751.050 ± 921.923 ops/s DecodingBenchmark.decodeIntsMZ thrpt 20 59862.336 ± 1785.270 ops/s DecodingBenchmark.decodeIntsPB thrpt 20 125009.494 ± 9799.178 ops/s DecodingBenchmark.decodeIntsZ thrpt 20 85695.355 ± 27599.620 ops/s
Foo
が単発のcase classでFoos
がMap[String, Foo]
、ints
はVector[Int]
です。
Bar
はZeroFormatter+フィールドをcats.Eval
にしたものです(後述)。
AがArgonaut, Cがcirce、PBがScalaPB、MZがmsgapck4z、Zがscala-zero-formatterになります。
注意事項としては、元ネタのcirce-benchmarkはListだったのですが、ScalaPBのdecode用メソッド(mergeFrom
で調べよう)がVectorを使って生成してSeqをフィールドに持つようになっているので他のやつも合わせました。
あと、jmhの仕組み的にネストしたクラスが扱えないらしくScalaPBのベンチマークがとれていません。
ScalaPBは速いなぁ、というのがまず最初の感想ですね。 この中で唯一ジェネレータ系かつ生成されるコードがこれまた無駄がないので簡単なcase classであれば他を圧倒します。 sbtプラグインのおかげで10行程度(?)でプロジェクトの設定ができるし、gRPC対応してるしで、Scalaでの開発に限って言えばまず有力な候補に上がるの間違いなしです。
scala-zero-formatterはScalaPBと良い勝負をしているものの、バイナリサイズやIDLの利便性を考えるとどうなのでしょうねぇ。 後述のStage2あたりが差別化要因という話はありますが、さてはて。
msgpack4zはByteArrayOutputStream
を経由しているのがネックなのかな……あるいはベンチマークに使ったコードが微妙なのかも?
これに関してはもう少し調査してみるつもりです。
でないとフェアじゃないので……。
JSON系が遅いのはしょうがない。 目的が異なる(?)ので、そもそもバイナリシリアライザと比較するものじゃない気がする。 ではなぜ比較したかといえば、circe-benchmarkをforkしたからなのでした。
ちなみにscodec-msgpackを比較対象にいれていないのは、そもそもスタックオーバーフローが解決できなかったという致命的な問題のせいです……なんとかしないと。
ScalaPBが早い理由
推測も混ざっているのでご注意ください。
- シリアライズの最初にバイナリサイズを計算
- サイズがわかっているので余計な操作が入らない
- scala-zero-foratterは「足りなくなったら増やす」戦略をとっているため随所でサイズチェックと
Array[Byte]
のコピーが発生する - デフォルト値のときは何もしない
- 今回のベンチマークではそんなに関係ないけど一応
- ジェネレート時のインライン展開
- 生成されたコードの
writeTo
やmergeFrom
に各フィールドのシリアライズ、デシリアライズ処理が手続き的に並んでいる - 余計な呼び出しがないならそれだけ遅くならないということ
- このあたりでいくらか差が出る場合もある
- sun.misc.Unsafe?
scala-zero-formatterのStage2エミュレーション
ZeroFormatterの仕様には幾つかのStageがあります。 Stage2はObject、FixedSizeList、VariableSizeListのフィールドや要素のデシリアライズを遅延する仕様になっています。
で、これをScalaでどう実現するかですが……今回はとりあえずcats.Eval
を使うことにしました。
ユーザが自前で定義する系なのでObjectや遅延リスト以外に適用して事故を起こす可能性もある……とはいえ、がんばらずにStage2の挙動を手に入れられるのでこれでいいかなーと。
equalsとかがまともに使えなくなる問題もありますが、比較したい時点で全部正格評価すべきだと思います。
バイナリのキャッシュ
C#のZeroFormatterにはバイナリをキャッシュして書き換えた部分以外はdeep copyする機構があって、これでシリアライズも省電力化することができます。
ZeroFormatterの仕様に存在しない仕組みとはいえ、こういう機能もできればscala-zero-formatterに欲しい……ということで、まずは単純にキャッシュするだけのAccessor
というものを用意してみました。
case classの値が書き変わらないのであれば内部で溜め込んでいたバイナリをそのままシリアライズの結果として返します。
完全に横流し専用ですね。
そのうち部分更新の機能もサポートしたいのですが、ネストしたフィールドのcase classの変更をどうやって最上位のcase classにまで伝播させれば良いのかで詰まっていて当分先になりそうです……。 Lensを参考にしようと思っていましたがなかなかうまくいかないものですね。
まとめ
バイナリシリアライザのパフォーマンスチューニングは無限大に時間を消費するなーと。
とはいえ、もう少し何かできそうではあるので他言語版ともとも頑張ると思います。