ブログ

ej-technologies の製品ニュース、アップデート、および技術に関する知見。

RSS で読む。 メール で購読。 でフォロー。

Apache Fory JSON を 4.5 倍高速化した方法

2026-09-07
投稿者: Ingo Kegel

最近、Java シリアライゼーションフレームワークの比較を行っています。バイナリペイロードについては、ウェブサイトで「blazingly-fast(圧倒的に速い)」をうたっており、実際にその通りである Apache Fory の大ファンになりました。比較では、JSON とバイナリペイロードを区別しています。JSON シリアライゼーションの王者は Jackson でした(Kotlin においても同様)。一方で、バイナリペイロードでは Apache Fory が支配的でした。

最近の Apache Fory 1.7.0 リリースでは、主要な新機能として JSON サポートが追加されました。当然、興味をそそられ、シリアライゼーションのデモに Fory JSON を追加しました。自分のペイロードでの数値を見たときは、にわかには信じられませんでした。Fory は実際に Jackson より 2.8 倍遅かったのです。何かがおかしい。そこで、根本原因を突き止めることにしました。

調査

ペイロードサイズは、すでに考えられる原因を示唆していました。Fory は 20,999 バイトのペイロードを書き出したのに対し、Jackson では 9,814 バイトでした。このペイロードにはバイト配列としてサムネイルが含まれていますが、Fory はそれを次のようにシリアライズしたのです。

"thumbnail":[-77,-1,99,-104,...]

これは極めて非効率です。1 バイトが最大 5 文字になり、ランダムなデータでは平均して 3.5 文字を超えます。書き込みには符号判定と 10 進変換が必要で、読み込みでは数字を走査して数値を再構築しなければなりません。さらに、この方式は RFC 7493、protobuf の JSON マッピング、Jackson、Gson、Moshi、kotlinx.serialization など、他のシリアライゼーションフレームワークとも相容れません。JSON にはバイナリ型が存在せず、base64 文字列が標準的な扱い方です。3 バイトごとに 4 文字を書き出します。

同じバイト列を base64 にすると、次のようになります。

"thumbnail":"s/9jmA==..."

前回の比較では、base64 の処理が JSON エンジン間で最も差がついた箇所でした。Fory の新モジュールは、いわば同じ罠にはまり込んでいたのです。しかし、この非効率なエンコードこそが遅さの原因のすべてなのでしょうか。確かめる方法は一つ、プロファイリングです。データは明白で、バイト配列のデコードがコールツリーを支配していました。

修正前の Fory JSON 実行のフレームグラフ。10 進数によるバイト配列のデコードがプロファイルを支配している修正前の Fory JSON 実行のフレームグラフ。10 進数によるバイト配列のデコードがプロファイルを支配している

サムネイルを除けば、Fory JSON は同じラウンドトリップを 3.5 µs で実行しました。新モジュール自体は概ね良好で、問題は完全にバイト配列のデフォルトコーデックにありました。

調べてみると、Fory にはすでに Base64ByteArrayCodec が用意されていますが、これを使うには @JsonBase64 アノテーションが必要でした。まず取りかかったのは、byte[] のデフォルトコーデックを切り替えることです。ところが、その変更を行っても数値は依然としてひどく、改善は 16% にとどまりました。

次のプロファイリング実行で、改善がわずかにとどまった理由が明らかになりました。書き込みと読み込みが正反対の方向に動いたのです。base64 エンコーダーはテーブル駆動で出力バッファに直接書き込むため、10 進数の数字を書き出す場合の約 6 倍の速さがあります。一方、base64 デコーダーは 10 進数のパースよりもさらに遅いままでした。デコーダーは、文字列全体に対して分岐の多い検証スキャンを行い、続く第 2 パスでは、文字ごとのエスケープ処理と数字ごとの 4 方向分岐を伴いながらすべての文字を再読みしていました。シリアライズされた注文ごとに約 7 KB の base64 データがあるため、デコーダーだけで実行全体の 85% を占めていたのです。

base64 をデフォルトにしたものの元の base64 デコーダーのままだった Fory JSON 実行のフレームグラフ。readBase64 がプロファイルを支配しているbase64 をデフォルトにしたものの元の base64 デコーダーのままだった Fory JSON 実行のフレームグラフ。readBase64 がプロファイルを支配している

この修正にはもう少し手間がかかりました。検証は、各 ASCII 文字を対応する 6 ビット値にマッピングし、無効な文字には -1 を割り当てる 128 エントリのテーブルを使った単一のスキャンとして実行するようにしました。デコードのパスは、同じテーブルを通じて 1 回の反復ごとに 4 文字を消費して 3 バイトを生成します。分岐は一切ありません。JSON 文字列には Unicode エスケープシーケンスが正当に含まれ得るため、バックスラッシュを含む base64 本体は引き続き従来の検証パスにフォールバックします。ただし、エンコーダーは base64 文字をエスケープしないため、実際には常に高速パスが実行されます。

最終的に、Fory JSON の数値はラウンドトリップあたり 75.9 µs から 16.8 µs へと大幅に減少しました。

バイト配列の処理 ラウンドトリップあたりの時間 ペイロードサイズ
10 進数の数値配列(Fory 1.7.0) 75.9 µs 20,999 バイト
base64 デフォルト、元のデコーダー 64.0 µs 9,742 バイト
base64 デフォルト、テーブル駆動のデコーダー 16.8 µs 9,742 バイト

最初の修正で 1.16 倍、次の修正で 3.8 倍の速度向上が得られ、合わせて 4.5 倍になりました。バイト配列の処理は最初の 2 つの状態では実行時間の約 90% を占め、修正後も 66% を占めていますが、それははるかに小さくなった総時間における 66% です。

修正後の Fory JSON 実行のフレームグラフ。base64 処理は大幅に減少している修正後の Fory JSON 実行のフレームグラフ。base64 処理は大幅に減少している

このペイロードでは、Fory JSON は以前のように 2.8 倍遅いのではなく、今では Jackson よりも速くなっています。

セットアップ ラウンドトリップあたりの時間
Fory JSON(修正後) 16.8 µs
Jackson 3.2.2 25.7 µs
Jackson 2.22.1 27.0 µs

Jackson 2 に対しては Fory JSON が 1.6 倍速く、Jackson 3 に対しては 1.5 倍速い結果となりました。

つまり、予想に反して、base64 をデフォルトにしたことで正しさと相互運用性、そして 2.2 倍小さなペイロードは得られましたが、速度はあまり向上しませんでした。決定的な差をもたらしたのは、改善した base64 デコーダーでした。

修正の提供

この物語の英雄は Fory のメンテナーたちです。彼らは数時間のうちに私の PR をレビューし、古い 10 進形式をオプトインで利用できるまま保つ @JsonByteArray 形式アノテーションで API を改良し、最終的に翌日には main ブランチへマージしてくれました。

Fory は、データをシリアライズするのと同じスピードで開発されているようです。素晴らしい!そしてこの話の教訓とは何でしょうか。高速なライブラリの新モジュールには、最適化されていない隅が潜んでいることがあります。プロファイラーなら、それを数分で見つけ出せます。

測定方法

測定のセットアップはこれまでの記事と同じです。JProfiler Gradle プラグインがオフラインモードでセットアップごとに 1 つのスナップショットを記録し、CPU サンプリングとアロケーション記録はそれぞれ別の実行で行いました。ホットスポット解析では、コーディングエージェントが JProfiler MCP サーバーを操作しました。プロジェクト一式は GitHub で公開されています。

スナップショットをご自身で確認するには、JProfiler をダウンロードし、build/snapshots-cpu の .jps ファイルを開いてください。

アーカイブ