encoding/json/v2 を最速の JSON エンコーダ/デコーダにする
Go の encoding/json には速度や一部仕様に準拠しない挙動など、多くの課題がありました。
これらを解決するために encoding/json/v2 が導入され、ついに Go 1.27 で experimental が外れました。この encoding/json/v2 により仕様面の問題は解消されました。
一方、速度面については安定性を重視しているのもあり、サードパーティの JSON エンコーダ/デコーダのライブラリ/ツールと比較してまだ劣る状況です。
そこで、encoding/json/v2 の安定性を享受しつつ、必要な場面ではサードパーティ並みの速さを手に入れられるツール odjson を紹介します。
odjson では、型情報を利用して最適化したエンコーダ/デコーダ処理を各型のMarshalJSONTo/UnmarshalJSONFromメソッドに生成することで、encoding/json/v2 を利用したまま、コード書き換えを必要としない以下1行のコメント追加のみでの高速化を実現します。
//go:generate go tool odjson
そして、この高速化により、下図に示したようなサードパーティライブラリを含めても最速での JSON エンコード/デコードをが可能となります。

この記事では、この odjson のコンセプト及び、その中で使用している高速化手法を紹介します。
背景: 既存 JSON ライブラリとその課題
encoding/json では様々な形式の JSON を受け入れるために、エンコード・デコードともに any(誕生当時は interface{}) 型で引数をとり、バイト列との変換をしていました。この結果発生してしまうリフレクションが大きな速度低下を招いていました。
この encoding/json の速度面での課題を解消するため、odjson を作る前から多くの高速な JSON エンコード/デコードライブラリ・ツールが開発されてきました。
これらは主に以下の2種類に分類でき、それぞれが課題を抱えていました。
リフレクション結果キャッシュ型
1つの解決策がそもそもの原因であるリフレクションをできる限りキャッシュして回避する方向の手法です。
json-iterator/goは型ごとのエンコーダ/デコーダをクロージャの組み合わせとして構築し、型をキーにキャッシュすることで2回目以降のリフレクションを回避します。
goccy/go-jsonは型情報を独自の命令列にコンパイルしてキャッシュし、それを仮想マシン上で実行する方式をとっています。encoding/json との完全な互換性をもち、import を書き換えるだけで使用できる JSON エンコーダ/デコーダとしては多くのケースで最も速いです。導入が非常に容易なため、自分も ISUCON などで重宝しています。
bytedance/sonicは goccy/go-json から更に進んで、JIT コンパイルを行い型ごとの機械語をキャッシュし、かつ SIMD(AVX2 等)を活用した文字列探索・エスケープ処理を行います。odjsonを除いた場合、多くの場合で Go 言語の最も高速な JSON エンコーダ/デコーダです。
この方向性の最大の利点は encoding/json からの置き換えやすさと考えています。json-iterator/go/goccy/go-json は encoding/json と API 上は完全な互換性を持ち、import を置き換えるだけで利用できます。bytedance/sonicは import を書き換えるのみでは利用できませんが、かなり近い API を持つため簡単に書き換え可能です。
一方、リフレクションが発生するのを完全には回避できず、キャッシュ参照分のコストが固定でかかる他、型情報を利用した最適化に限界があります。
コード生成型
別の方向として、事前に型ごとの専用エンコード/デコード用コード生成することで、リフレクションなしでエンコード/デコードする手法が存在します。
この方向性で代表的なツールとして mailru/easyjson があります。mailru/easyjson は{{}}。
また、知名度は低めとは思いますが、自分の大学サークル同期の @sapphi-red の開発した sapphi-red/json-constantiater という JSON エンコーダがあります。sapphi-red/json-constantiater は型情報を基にして極力定数で JSON を組み立てる JSON エンコード用関数を生成し、設定次第では bytedance/sonic に匹敵する速さでの JSON エンコードを可能にします。
コード生成型の手法では、コード生成時に比較的時間をかけて型情報を基にした最適化を行えるのが最大の利点です。
一方で、encoding/json を使用している状況での導入コストが大きくなるのがデメリットとしてあると考えています。mailru/easyjson は比較的 encoding/json に近い API を持ちますが、引数は専用の Marshaler/Unmarshaler になっており、対象の型全てでコード生成を行う必要があります。
この導入障壁起因で mailru/easyjson 以降にさらなる高速化を行うツールが出ず、リフレクション結果キャッシュ型と比べ発展が遅れた結果、速度面でも劣ってしまい bytedance/sonic が最速の JSON エンコーダ/デコーダとなっていると自分は考えています。
odjson のコンセプト
これらを踏まえて odjson は「コード生成による速度」と「標準ライブラリ互換性による導入障壁の低さ」の両立を意識した設計としています。
具体的には、型ごとに最適化された MarshalJSONTo/UnmarshalJSONFrom メソッドを生成しています。
encoding/json のときから、Marshaler/Unmarshaler インターフェースを実装することで型ごとの JSON エンコード/デコード処理をカスタマイズすることができました。
encoding/json/v2 では MarshalJSONTo/UnmarshalJSONFrom メソッドを作成し、MarshalerTo/UnmarshalerFrom インターフェースを実装することでもカスタマイズが可能になりました。
この MarshalJSONTo/UnmarshalJSONFrom メソッドは encoding/json のときと異なりストリーム処理で JSON を組み立てていくことが可能になっており、高速な動作が可能になっています。
このため、型情報をもとにこれらのメソッドを生成することで、encoding/json/v2 を使ったまま高速化できるのでは?というのが基本的な考え方になります。
この結果、encoding/json/v2 を使っている状態であれば、以下のコメント1行をコードに追加して、go generate を行うのみで高速化される導入コストの低さが実現されます。
//go:generate go tool odjson
高速化手法
これらのコンセプトを踏まえて、実際に高速化するために行った実装が以下です。
このあたりに関しては、ほぼ全て Fable 5.1 が見つけてきて行ったものになり、自分もコードレビューしながら感心していました。正直、もともとは encoding/json/v2 のオーバーヘッドもあるため bytedance/sonic を超えられるとは思っていませんでした。ただ、Claude Code に指示を出して朝起きたら bytedance/sonic と同等のベンチマークスコアが出ており、自分自身かなり驚いていました。
エンコード/デコード共通で機能しているもの、エンコードで機能するもの、デコードで機能するものに分けて、odjson で特徴的なものを説明していきます。
エンコード/デコード共通
encoding/jsontext 内部バッファ直読み書き
今回のコンセプトを実現するにあたって、最重要の高速化です。
encoding/json/v2 の説明の際にほぼ必ずと言ってよいほど登場する以下の図ですが、これは Proposal 提起時に作成されたものです。
最終形も基本的な構造は同じなのですが、json パッケージが jsontext パッケージの Encoder.WriteValue/Decoder.ReadValue を呼ぶという部分に変更が入っています。

最終形の encoding/json/v2 では内部バッファへの直接読み書きを行っている箇所が多数あります(内部バッファ取り出しコード箇所)。
このようになっている理由は、Encoder.WriteValue/Decoder.ReadValue のオーバーヘッドがあります。
これらのメソッドは標準ライブラリ外からアクセスされる可能性があるため、encoding/json/v2 で十分担保されているフィールドの重複が発生していないかの確認を行う必要がある他、どうしても二重で文字列の操作が発生してしまうなどのオーバーヘッドが避けられません。
これらのオーバーヘッドを避けるため、encoding/json/v2 では内部バッファに直接読み書きを行っています。
このオーバーヘッドは非常に大きく、odjson でもこれを回避しない限り最速どころかもともとの encoding/json/v2 の速度を超えることすらできない状態でした。
このため、同様に内部バッファへの直読み書きを行うことで大幅なパフォーマンスの向上を実現しています。
ただし、Encoder/Decoder の内部バッファは encoding/json/internal 内の値をキーとしてアクセスが防がれており、当然通常の手段ではアクセスできません。
そこで、odjson ではパッケージ初期化時にリフレクションで Encoder/Decoder のメモリレイアウトを調査し、それを基に内部バッファにアクセスしています。
当然、この処理は変更が入る可能性のある内部処理に依存しており、リスキーな操作となっています。このため、現状と同じ挙動が守られる可能性の高い Go 1.27 までのみこの挙動を有効にし、1.28 であることを検出した場合は速度低下を許容して Encoder.WriteValue/Decoder.ReadValue 経由での操作を行うようになっています。
odjson を使用する場合には、この挙動については注意するようにお願いします。
SWAR(SIMD within a Register)
文字列を読むにあたって、通常は文字を 1Byte(8bit) ごとに処理していくことになります。
しかし、SIMD 命令でない通常の CPU 命令でも 8Byte(64bit) を 1命令で処理でき、これを使い切らないのはもったいないです。
そこで、bit 演算などを活用して 8Byte 分の文字を一括を処理するのが、SIMD within a Register という方法です。
この方法であれば、使用するのは通常の 64bit 演算の命令であるため Pure Go の範疇でも使用可能です。
bit 演算しか使用できないため制約はかかりますが、それでも=と<の演算は可能であるため、かなり多くの処理が行えます。
odjson では、これを利用して"や\の検出、マルチバイト文字の判定を行っています。
詳細は以下で Claude Code が解説してくれているので、そちらを見ることをお勧めします(自分もこれを見てやっと理解できました)。
なお、この方法では1度に 8Byte までの処理が可能ですが、AVX2 などの SIMD 命令を使用した場合はこの 4倍の 32Byte を一括で処理可能になります。
bytedance/sonic はアセンブリを使用することで SIMD 命令を実際に使用しているため、マルチバイト文字の処理については bytedance/sonic の方が odjson よりも早くなります。
odjson は他の最適化やリフレクションがないことによるアドバンテージなどによりトータルで大抵の JSON で bytedance/sonic より速くなっているため、後述のベンチマークで分かるようにマルチバイト文字中心の JSON の場合は bytedance/sonic の方が高速となります。
エンコード
文字列の 16Byte 単位定数書き込み
文字列を実行時に結合などを行うのは、当然事前に構築された定数文字列をそのまま利用するのと比べ CPU の実行時間を食います。
そこで、odjson ではコード生成段階で徹底的に定数として事前に組み上げることで、実行時処理を高速化しています。
具体的には、配列型が空の場合に出現する []や、bool の true/false のようなエンコードで発生する確率の高いパターンは定数として前後ふくめて結合したパターンを コード生成時点で用意しておきます。
また、この際、定数の一括で書き込む長さは常に 16Byte 以下となるようにしています。
これは、Go のコンパイラが定数の書き込み処理を 16Byte 以下の場合のみ move 命令で行うようにし、高速化されるためです(コンパイラ該当実装箇所)。
16Byte を超える場合、runtime.memmove 関数が利用されるのですが、こちらは関数呼び出しなどのオーバーヘッドが入るため、ただの move 命令と比べてどうしても遅くなります。
これは、定数文字列に限って行われる最適化なため、Pure Go の範疇ではコード生成型でしか実現不可な最適化です。
この結果、以下のようなエンコード処理が生成されます。

デコード
フィールド名の決定木判定
コード生成型ではコード生成段階で JSON の大半のフィールド名は予測可能です。
このため、フィールド名を絞り込むのに確認が必要な文字の位置を踏まえたコード生成が可能です。
例として id、ids、title の 3フィールドがある構造体の JSON デコードを考えます。
この時、フィールドの 1Byte 目が i の場合、JSON のフィールドは id、ids、未知フィールドのいずれかに絞り込めます。
そして、この状態で 3Byte 目が " か s かを確認することで、id、ids のいずれかを候補から消せます。
後は残った1フィールドが一致するか比較を行えば、文字列比較回数を減らして、高速にフィールドを判定できます。
このとき、最後の定数比較はエンコード時と同様に 16Byte 単位での比較を行っています。
これは、エンコードと同様に、コンパイラで定数文字列比較では 16Byte 単位の場合に 1命令で実行され runtime.memequal の呼び出しが行われなくなる最適化が行われるためです(コンパイラ該当処理箇所)。
こちらもコード生成型でなければ Pure Go では実現不可能な高速化手段です。
この結果、以下のようなフィールド判定処理コードが生成されます。

メモリ確保の削減
文字列 interning
同一の文字列のメモリを共有することで、メモリ確保を回避し高速化する手法です。
Go には 1.23 で入った標準の unique パッケージもあり、これを利用して interning を行うことも可能ではありますが、odjson では独自実装での interning を行っています。
これは、unique パッケージが汎用性を考慮している分速度面で odjson の求める水準には足りていないというのもありますが、そのほかにも odjson では utf8 として valid かの検証状態保持が必要だったというのもあります。
JSON には基本的に utf8 として invalid な値は許容されず、encoding/json/v2 でははじくようになっています。
これに対応するため、文字列が utf8 として valid かの検証を行いますが、文字列使用箇所でも検証が必要な箇所とそうでない箇所が存在します。
このため、1度検証済みであれば検証済みという情報を紐づけて interning することで再検証を回避できるように、独自実装の interning を行っています。
any 変換用のメモリ領域事前確保
デコード先の構造体フィールドの型が any の場合、odjson では JSON の値を基に型を判別し、フィールドに格納する必要があります。
しかし、any 型はヒープ上の値へのポインタを保持するメモリ構造なため、格納時にヒープの確保が発生します。
これが多発すると GC 対象のオブジェクトが増えて GC 負荷が上がります。
そこで、odjson では頻出する string・slice・float64 の3つの型に対し、メモリ確保用のスライスを一括で確保します。
これにより、ポインタの参照先が同一の配列オブジェクトとなることで GC 負荷を軽減でき、高速化が実現できます。

sync.Pool によるバッファ再利用
この手の高速化では定番の手法です。使用し終わったヒープ領域を GC が走るまで保持し、再利用することで、メモリ確保回数を大幅に減らせる標準ライブラリの機能です。
ただし、odjson では実は sync.Pool を用いる箇所はごくわずかです。
というのも、sync.Pool はあくまでも GC までメモリを再利用する機能であり、一定量メモリ確保が発生することは変わりません。
このため、前述の2つの手法のような型ごとに特化したメモリ領域を用意し、専用の管理を行う方が、事前に型が特定できている odjson の場合は高速となります。
この結果、 sync.Pool は encoding/jsontext 内部バッファ直読み書きが無効になった場合や、文字列アンマーシャル時の文字列確保のようなごく一部のケースでのみ sync.Pool が利用されています。
フィールド重複の bitset 判定
odjson では、構造体のフィールド数を N として、コード生成時に以下のような配列を構築します。そして、出現してフィールドごとの対応する bit を立てていくことで、最小限のメモリ確保で高速にフィールドの重複判定を行います。
var seen [(N+63)/64]uint64
また、未知のフィールドについては、基本的には数が少ないという仮定の基にスライスで管理し、フィールド追加を高速に行えるようになっています。
ベンチマーク
リポジトリ中の bench でベンチマークを行いました。ベンチマークデータとしては、特に明言のない限り文字列・数値ともに含まれており、バランス良く速さを計測可能な bytedance/sonic の small・medium・large をベンチマークデータを使用しています。データサイズおよび大まかな内容は以下になります。
| 種類 | サイズ | 内容 |
|---|---|---|
| small | 340B | 中国語書籍1つの題名・値段・作者情報などを模した JSON |
| medium | 13KiB | Twitter(現 X) の英語メッセージ検索結果を模したと思われる JSON |
| large | 616KiB | Twitter(現 X) の実際の日本語検索結果の JSON |
また、Twitter(現 X) のデータを扱う medium・largeはいずれも any 型を含みます。
また、いずれも以下の環境で計測しています。
計測環境
Go バージョン: 1.27.1
OS: Ubuntu 24.04(WSL2)
CPU: AMD Ryzen 9 7950X
メモリ: 48GB DDR5
既存 JSON エンコーダ/デコーダとの比較
冒頭に示した通り、エンコード/デコードともにどのデータサイズでも odjson 有りの encoding/json/v2 が最も実行時間が短いです。エンコードではどのサイズでも4倍以上の高速化が実現されているほか、デコードでも 2.69~3.39 倍の高速化が実現されています。

データごとの特性の比較
次に、型ごとでのエンコード/デコード速度を計測し、odjson の特徴を計測しました。
ここでは、ベンチマークに自前構築の shapesのデータを用いています。
全てのデータを用いると数が多くなるため、ここでは encoding/json/v2 と bytedance/sonic に絞った、特徴的な計測結果に絞ってグラフにしています(全データ)。
これを見ると、encoding/json/v2 には大半の項目で勝てているものの、bytedance/sonic には odjsonが全ての項目で速度で上回っているわけではないのが分かると思います。
文字列、特にマルチバイト文字の処理(text-cjk-short)では、bytedance/sonicが odjson より高速になっています。これについては、SWAR の解説でも書いた通り、Pure Go の SIMD 命令を使用できない制約に起因するもので、odjson の設計上覆しようのない差です。
一方で、数値・map 関連処理で bytedance/sonic に劣っている箇所については、純粋な処理の高速化不足により生まれている差となり、今後十分覆せる差となります。
また、これらの要素の速度面で劣りつつも、桁数の少ない float やオブジェクトのネストなどの出現頻度の高い要素の処理速度で上回ることで、トータルの速度で上回っていることが読み取れます。

encoding/json/v2 を経由しない場合
ここまでで、odjson が encoding/json/v2 経由で利用するコンセプト通りの使用方法の場合でも bytedance/sonic を上回ることを説明してきました。
しかし、encoding/json/v2 のオーバーヘッドを軽減するために内部バッファの直読み書きなどを行っていたことからも分かるように、コンセプト通りの odjson の使い方では encoding/json/v2 がかなりのオーバーヘッドとなっており、それを上回る高速化を行うことで bytedance/sonic などを上回っています。
つまり、コンセプトから外れ移行の容易さは捨てることになりますが、encoding/json/v2 を経由せずに odjson を直接使用すれば、さらなる速度を得られます。
実際に -direct オプションで生成した直接利用可能なエンコーダ/デコーダ関数を利用した際のベンチマーク結果が下図です。
オーバーヘッドの影響の大きくなるサイズが小さい JSON で特に、encoding/json/v2 経由よりさらに速くなっています。
とにかく速度が欲しい場合には検討する価値はあると思います。

まとめ
ここまで、encoding/json/v2 を高速化し、最速の JSON エンコーダ/デコーダにするツール odjson を紹介しました。
高速であることを謳った JSON エンコーダ/デコーダライブラリはこれまでも多くありましたが、encoding/json/v2 を使ったままで高速化するというアプローチは存在しなかったと思っており、我ながらなかなかに面白いツールになったと思っています。
また、開発過程で見た Fable 5.1 の高速化能力にはかなり驚かされました。一切手を触れることなく bytedance/sonic を超える速さを叩き出したのは凄いと思いましたし、高速化手法自体も自分自身コードを見てかなり勉強になりました。
興味を持っていただけた方は、是非 odjson を使ってみていただけると嬉しいです。また、star もしていただけると非常にうれしいです。
Discussion