[インデックス 17127] ファイルの概要
このコミットは、Go言語の標準ライブラリ encoding/json パッケージにおけるJSONエンコーディングのパフォーマンス改善を目的としています。具体的には、型ごとの構造体フィールド情報のキャッシュ方法を見直し、実行時のリフレクションを最小限に抑えることで、エンコーディング速度を大幅に向上させています。
コミット
commit 89b5c6c0af854c53ba16da8bc8394853e04e6bb0
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date: Fri Aug 9 09:46:47 2013 -0700
encoding/json: faster encoding
The old code was caching per-type struct field info. Instead,
cache type-specific encoding funcs, tailored for that
particular type to avoid unnecessary reflection at runtime.
Once the machine is built once, future encodings of that type
just run the func.
benchmark old ns/op new ns/op delta
BenchmarkCodeEncoder 48424939 36975320 -23.64%
benchmark old MB/s new MB/s speedup
BenchmarkCodeEncoder 40.07 52.48 1.31x
Additionally, the numbers seem stable now at ~52 MB/s, whereas
the numbers for the old code were all over the place: 11 MB/s,
40 MB/s, 13 MB/s, 39 MB/s, etc. In the benchmark above I compared
against the best I saw the old code do.
R=rsc, adg
CC=gobot, golang-dev, r
https://golang.org/cl/9129044
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/89b5c6c0af854c53ba16da8bc8394853e04e6bb0
元コミット内容
このコミットは、encoding/json パッケージのエンコーディング速度を向上させるものです。以前のコードは、型ごとの構造体フィールド情報をキャッシュしていましたが、この変更では、特定の型に特化したエンコーディング関数をキャッシュするようにしました。これにより、実行時の不要なリフレクションを回避し、一度エンコーディング関数が構築されれば、その後の同じ型のエンコーディングではその関数を直接実行できるようになります。
ベンチマーク結果では、BenchmarkCodeEncoder が旧コードの 48,424,939 ns/op から 36,975,320 ns/op へと 23.64% 改善し、スループットは 40.07 MB/s から 52.48 MB/s へと 1.31倍向上しています。また、旧コードではベンチマーク結果が不安定であったのに対し、新コードでは約 52 MB/s で安定した数値を示すようになりました。
変更の背景
Go言語の encoding/json パッケージは、GoアプリケーションでJSONデータを扱う上で非常に重要なコンポーネントです。しかし、大規模なデータ構造や頻繁なエンコーディング処理を行う場合、そのパフォーマンスはアプリケーション全体のボトルネックとなる可能性があります。特に、Goのリフレクション機能は非常に強力ですが、実行時に型の情報を動的に取得・操作するため、コンパイル時に型が確定している通常の関数呼び出しに比べてオーバーヘッドが発生します。
このコミット以前の encoding/json パッケージでは、JSONエンコーディングのために構造体のフィールド情報をリフレクションを使って取得し、それをキャッシュしていました。しかし、このアプローチでは、エンコーディングのたびにリフレクションによる動的な処理が完全に排除されるわけではなく、まだ最適化の余地がありました。
このコミットの背景には、JSONエンコーディングのパフォーマンスをさらに向上させ、特に高負荷な環境での安定性とスループットを改善したいという意図があります。リフレクションのオーバーヘッドを最小限に抑えるために、型ごとに特化したエンコーディングロジックを事前に生成し、それを再利用するアプローチが採用されました。
前提知識の解説
このコミットを理解するためには、以下のGo言語の概念とJSONエンコーディングの基本的な知識が必要です。
-
Go言語のリフレクション (reflectパッケージ): Go言語のリフレクションは、プログラムの実行時に型情報を検査し、値の操作を行う機能です。
reflectパッケージを通じて提供されます。これにより、コンパイル時には未知の型を扱う汎用的なコードを書くことができます。reflect.Type: Goの型の情報を表します。reflect.Value: Goの値の情報を表します。v.Kind(): 値の基本的な種類(例:reflect.Struct,reflect.Int,reflect.Stringなど)を返します。v.Field(i)やv.MethodByName(name): 構造体のフィールドやメソッドにアクセスします。v.Interface():reflect.Valueを実際のGoのインターフェース値に変換します。 リフレクションは柔軟性を提供しますが、その動的な性質上、静的な型付けされた操作に比べてパフォーマンスのオーバーヘッドが発生します。
-
encoding/jsonパッケージの動作原理:encoding/jsonパッケージは、Goのデータ構造とJSONデータの間で変換を行います。- Marshal (エンコード): Goの値をJSONバイト列に変換します。
json.Marshal()関数がこれを行います。 - Unmarshal (デコード): JSONバイト列をGoの値に変換します。
json.Unmarshal()関数がこれを行います。 - 構造体タグ: 構造体のフィールドに
json:"fieldName,omitempty"のようなタグを付けることで、JSONへのマッピング方法を制御できます。 json.Marshalerインターフェース: 任意の型がMarshalJSON() ([]byte, error)メソッドを実装することで、その型のJSONエンコーディング方法をカスタマイズできます。
- Marshal (エンコード): Goの値をJSONバイト列に変換します。
-
パフォーマンス最適化の一般的なアプローチ:
- キャッシュ: 計算コストの高い結果を一度計算し、それを保存しておき、同じ入力に対しては再計算せずにキャッシュされた結果を返すことで、パフォーマンスを向上させます。
- コード生成/事前コンパイル: 実行時に動的に処理する代わりに、事前に特定のロジックを生成またはコンパイルしておくことで、実行時のオーバーヘッドを削減します。
- リフレクションの削減: リフレクションは便利ですが、パフォーマンスが重要なパスでは可能な限り使用を避けるか、その使用を最小限に抑えることが推奨されます。
このコミットは、これらの前提知識を背景に、リフレクションの使用を最適化し、型ごとのエンコーディングロジックをキャッシュすることで、encoding/json のパフォーマンスを向上させています。
技術的詳細
このコミットの核心は、encoding/json パッケージがJSONエンコーディングを行う際に、Goのリフレクションの使用を最適化し、型ごとのエンコーディング処理を事前に「コンパイル」してキャッシュする新しいメカニズムを導入した点にあります。
以前のアプローチ(変更前)
変更前のコードでは、reflectValue 関数がリフレクションを使ってGoの値をJSONに変換していました。この関数は、値の型(reflect.Kind)に基づいて、bool, int, string, struct, map, slice, array, interface, ptr などの各ケースに対応する処理を switch 文で分岐していました。
また、構造体のエンコーディングにおいては、cachedTypeFields 関数を使って構造体のフィールド情報を一度解析し、それをキャッシュしていました。しかし、このキャッシュはフィールド情報自体に限定されており、実際のエンコーディングロジック(例えば、各フィールドの型に応じたJSON変換処理)は、エンコーディングのたびにリフレクションを通じて動的に決定されていました。つまり、型ごとのフィールド情報はキャッシュされていても、そのフィールドをJSON文字列に変換する具体的な「操作」は、毎回リフレクションを介して行われていたため、オーバーヘッドが残っていました。
新しいアプローチ(変更後)
新しいアプローチでは、encoderFunc という新しい型が導入されました。これは func(e *encodeState, v reflect.Value, quoted bool) というシグネチャを持つ関数型で、特定のGoの型をJSONにエンコードする具体的なロジックをカプセル化します。
-
encoderCacheの導入:encoderCacheというグローバルなキャッシュが導入されました。これはmap[reflect.Type]encoderFuncの形式で、Goの型 (reflect.Type) をキーとして、その型に対応するencoderFuncを値として保持します。これにより、一度生成されたエンコーディング関数は、同じ型の値が再度エンコードされる際に再利用されます。 -
valueEncoderとtypeEncoder:valueEncoder(v reflect.Value): 与えられたreflect.Valueに対応するencoderFuncを返します。内部でtypeEncoderを呼び出します。typeEncoder(t reflect.Type, vx reflect.Value): 与えられたreflect.Typeに対応するencoderFuncを返します。まずencoderCacheを参照し、キャッシュに存在すればそれを返します。存在しない場合は、newTypeEncoderを呼び出して新しいencoderFuncを生成し、それをキャッシュに保存します。
-
newTypeEncoderによるエンコーディング関数の生成:newTypeEncoder(t reflect.Type, vx reflect.Value)関数が、与えられた型tに応じて最適なencoderFuncを生成します。この関数は、値のKind()に応じて、以下のような専用のエンコーダ関数を返します。boolEncoder,intEncoder,uintEncoder,float32Encoder,float64Encoder,stringEncoder: プリミティブ型に対応するエンコーダ。interfaceEncoder: インターフェース型に対応するエンコーダ。newStructEncoder: 構造体型に対応するエンコーダ。構造体の各フィールドに対しても再帰的にtypeEncoderを呼び出し、フィールドごとのencoderFuncを事前に決定します。newMapEncoder: マップ型に対応するエンコーダ。newSliceEncoder,newArrayEncoder: スライス型および配列型に対応するエンコーダ。newPtrEncoder: ポインタ型に対応するエンコーダ。valueIsMarshallerEncoder,valueAddrIsMarshallerEncoder:json.Marshalerインターフェースを実装している型に対応するエンコーダ。
-
再帰的な型への対応:
typeEncoderは、再帰的な型(例: 自身を参照する構造体)を正しく扱うために、キャッシュにencoderFuncを追加する際にsync.WaitGroupを使用しています。これにより、エンコーディング関数の構築中に同じ型が再帰的に参照された場合でも、デッドロックを回避し、最終的に正しいエンコーディング関数が提供されるようにしています。
パフォーマンス向上と安定性
この変更により、JSONエンコーディングのパフォーマンスが大幅に向上しました。これは、一度型ごとの encoderFunc が生成されれば、その後のエンコーディングではリフレクションによる動的な型検査やフィールドアクセスが不要になり、事前に「コンパイル」された専用の関数が直接実行されるためです。これにより、実行時のオーバーヘッドが削減され、ベンチマーク結果に示されるように、スループットの向上とベンチマーク結果の安定性が実現されました。
特に、旧コードでベンチマーク結果が不安定だったのは、リフレクションの動的な性質や、Goランタイムの内部的な最適化のタイミングなどによって、実行ごとのパフォーマンスが変動しやすかったためと考えられます。新しいアプローチでは、エンコーディングロジックが事前に固定されるため、より予測可能で安定したパフォーマンスが得られるようになりました。
コアとなるコードの変更箇所
このコミットの主要な変更は、src/pkg/encoding/json/encode.go ファイルに集中しています。
-
encodeState.reflectValueの変更:- 旧:
e.reflectValueQuoted(v, false)を直接呼び出していた。 - 新:
valueEncoder(v)(e, v, false)を呼び出すように変更。これにより、型に応じた専用のエンコーディング関数が呼び出されるようになった。
- 旧:
-
encoderFunc型の導入:type encoderFunc func(e *encodeState, v reflect.Value, _ bool)特定の型をエンコードするための関数シグネチャを定義。
-
encoderCacheの導入:var encoderCache struct { sync.RWMutex m map[reflect.Type]encoderFunc }型とそれに対応する
encoderFuncをキャッシュするためのマップ。 -
valueEncoder関数の追加:func valueEncoder(v reflect.Value) encoderFunc { if !v.IsValid() { return invalidValueEncoder } t := v.Type() return typeEncoder(t, v) }reflect.Valueから適切なencoderFuncを取得するエントリポイント。 -
typeEncoder関数の追加:func typeEncoder(t reflect.Type, vx reflect.Value) encoderFunc { // ... キャッシュの参照、新しいエンコーダの生成、再帰的な型への対応 ... }reflect.Typeに基づいてencoderFuncを取得・生成し、キャッシュに保存する主要なロジック。 -
newTypeEncoder関数の追加:func newTypeEncoder(t reflect.Type, vx reflect.Value) encoderFunc { // ... 各Kindに応じた専用エンコーダの生成ロジック ... }reflect.Kindに応じて、boolEncoder,intEncoder,structEncoderなどの具体的なencoderFuncを生成するファクトリ関数。 -
各種専用エンコーダ関数の追加:
boolEncoder,intEncoder,uintEncoder,floatEncoder(およびfloat32Encoder,float64Encoder),stringEncoder,interfaceEncoder,unsupportedTypeEncoderなど、プリミティブ型やインターフェース型に対応する具体的なエンコーディング関数が追加されました。 -
構造体、マップ、スライス、配列、ポインタのエンコーダ構造体とメソッドの追加:
structEncoder,mapEncoder,sliceEncoder,arrayEncoder,ptrEncoderといった構造体が定義され、それぞれがencodeメソッドを持つようになりました。これらの構造体は、内部にencoderFuncを保持し、より特化したエンコーディングロジックを提供します。newStructEncoder,newMapEncoder,newSliceEncoder,newArrayEncoder,newPtrEncoderといったファクトリ関数が、これらのエンコーダ構造体を初期化し、対応するencodeメソッドをencoderFuncとして返します。
-
decode_test.goの変更:TestMarshalEmbedsという新しいテストケースが追加され、埋め込み構造体のJSONマーシャリングが正しく機能するかを検証しています。NewDecoderの呼び出しでbytes.NewBuffer(in)がbytes.NewReader(in)に変更されています。これは機能的な変更ではなく、より適切なio.Readerの実装を使用するように修正されたものです。- テストのエラーメッセージに、元の入力とマーシャリング結果を追加する行が追加され、デバッグ情報が強化されています。
これらの変更により、encoding/json パッケージは、リフレクションを直接使用する代わりに、型ごとに最適化されたエンコーディング関数を事前に生成し、キャッシュして再利用する「コード生成」に近いアプローチを採用するようになりました。
コアとなるコードの解説
このコミットの最も重要な変更は、src/pkg/encoding/json/encode.go 内の reflectValue 関数とその周辺のロジックです。
変更前:
func (e *encodeState) reflectValue(v reflect.Value) {
e.reflectValueQuoted(v, false)
}
// reflectValueQuoted writes the value in v to the output.
// If quoted is true, the serialization is wrapped in a JSON string.
func (e *encodeState) reflectValueQuoted(v reflect.Value, quoted bool) {
// ... (Marshalerインターフェースのチェック) ...
writeString := (*encodeState).WriteString
if quoted {
writeString = (*encodeState).string
}
switch v.Kind() {
case reflect.Bool:
// ...
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
// ...
// ... 他のKindに対する処理 ...
case reflect.Struct:
e.WriteByte('{')
first := true
for _, f := range cachedTypeFields(v.Type()) {
fv := fieldByIndex(v, f.index)
if !fv.IsValid() || f.omitEmpty && isEmptyValue(fv) {
continue
}
if first {
first = false
} else {
e.WriteByte(',')
}
e.string(f.name)
e.WriteByte(':')
e.reflectValueQuoted(fv, f.quoted) // ここで再帰的にreflectValueQuotedを呼び出す
}
e.WriteByte('}')
// ...
}
}
変更前のコードでは、reflectValueQuoted 関数がすべての型のエンコーディングを担っていました。この関数は、switch v.Kind() を使って値の型を判別し、それぞれの型に応じたエンコーディングロジックを直接実行していました。構造体の場合、cachedTypeFields でフィールド情報を取得した後、各フィールドに対して再帰的に reflectValueQuoted を呼び出していました。このアプローチでは、エンコーディングのたびに switch 文による分岐とリフレクションによる動的なフィールドアクセスが発生し、これがパフォーマンスのボトルネックとなっていました。
変更後:
func (e *encodeState) reflectValue(v reflect.Value) {
valueEncoder(v)(e, v, false) // ここが大きく変わった
}
type encoderFunc func(e *encodeState, v reflect.Value, _ bool)
var encoderCache struct {
sync.RWMutex
m map[reflect.Type]encoderFunc
}
func valueEncoder(v reflect.Value) encoderFunc {
if !v.IsValid() {
return invalidValueEncoder
}
t := v.Type()
return typeEncoder(t, v)
}
func typeEncoder(t reflect.Type, vx reflect.Value) encoderFunc {
encoderCache.RLock()
f := encoderCache.m[t]
encoderCache.RUnlock()
if f != nil {
return f // キャッシュヒット
}
// キャッシュミス: 新しいエンコーダを生成
encoderCache.Lock()
if encoderCache.m == nil {
encoderCache.m = make(map[reflect.Type]encoderFunc)
}
var wg sync.WaitGroup
wg.Add(1)
// 再帰的な型のために、まず間接的な関数をキャッシュに設定
encoderCache.m[t] = func(e *encodeState, v reflect.Value, quoted bool) {
wg.Wait() // 実際の関数が準備できるまで待機
f(e, v, quoted)
}
encoderCache.Unlock()
// ロックなしでエンコーダを構築 (並行して他のエンコーダ構築をブロックしない)
f = newTypeEncoder(t, vx)
wg.Done() // 実際の関数が準備できたことを通知
encoderCache.Lock()
encoderCache.m[t] = f // 実際の関数をキャッシュに保存
encoderCache.Unlock()
return f
}
func newTypeEncoder(t reflect.Type, vx reflect.Value) encoderFunc {
// ... (Marshalerインターフェースのチェック) ...
switch vx.Kind() {
case reflect.Bool:
return boolEncoder
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return intEncoder
// ... 他のKindに対する専用エンコーダの返却 ...
case reflect.Struct:
return newStructEncoder(t, vx) // 構造体専用エンコーダを生成
// ...
default:
return unsupportedTypeEncoder
}
}
// structEncoder の定義と encode メソッド
type structEncoder struct {
fields []field
fieldEncs []encoderFunc // 各フィールドに対応する専用エンコーダのリスト
}
func (se *structEncoder) encode(e *encodeState, v reflect.Value, quoted bool) {
e.WriteByte('{')
first := true
for i, f := range se.fields {
fv := fieldByIndex(v, f.index)
if !fv.IsValid() || f.omitEmpty && isEmptyValue(fv) {
continue
}
if first {
first = false
} else {
e.WriteByte(',')
}
e.string(f.name)
e.WriteByte(':')
if tenc := se.fieldEncs[i]; tenc != nil {
tenc(e, fv, f.quoted) // ここでフィールド専用エンコーダを直接呼び出す
} else {
// Slower path. (これは通常発生しないはず)
e.reflectValue(fv)
}
}
e.WriteByte('}')
}
func newStructEncoder(t reflect.Type, vx reflect.Value) encoderFunc {
fields := cachedTypeFields(t)
se := &structEncoder{
fields: fields,
fieldEncs: make([]encoderFunc, len(fields)),
}
for i, f := range fields {
vxf := fieldByIndex(vx, f.index)
if vxf.IsValid() {
// 各フィールドの型に対応するエンコーダを事前に取得し、キャッシュ
se.fieldEncs[i] = typeEncoder(vxf.Type(), vxf)
}
}
return se.encode
}
変更後のコードでは、reflectValue が直接 valueEncoder(v)(e, v, false) を呼び出すようになりました。
valueEncoder は、まず typeEncoder を呼び出して、与えられた reflect.Type に対応する encoderFunc を encoderCache から取得しようとします。
- キャッシュヒットの場合:
encoderCacheに既にencoderFuncが存在すれば、それを直接返します。これにより、2回目以降の同じ型のエンコーディングでは、リフレクションによる型判別やswitch文の分岐が完全にスキップされ、事前に生成された専用の関数が高速に実行されます。 - キャッシュミスの場合:
newTypeEncoderが呼び出され、その型に特化したencoderFuncが生成されます。例えば、構造体であればnewStructEncoderが呼び出され、その構造体の各フィールドに対しても再帰的にtypeEncoderを呼び出し、フィールドごとのencoderFuncをfieldEncsスライスにキャッシュします。この生成されたencoderFuncはencoderCacheに保存され、次回以降の利用に備えます。
特に注目すべきは、structEncoder の encode メソッドです。以前は各フィールドをエンコードする際に e.reflectValueQuoted(fv, f.quoted) を再帰的に呼び出していましたが、変更後は se.fieldEncs[i] にキャッシュされたフィールド専用の encoderFunc を tenc(e, fv, f.quoted) のように直接呼び出すようになりました。これにより、構造体のエンコーディングパスにおけるリフレクションのオーバーヘッドが劇的に削減されます。
この設計は、GoのJSONエンコーディングが初めて特定の型に遭遇したときに、その型をJSONに変換するための「ミニコンパイラ」のような役割を果たし、その結果生成された「コンパイル済み」のエンコーディングロジックをキャッシュするという考え方に基づいています。これにより、初回実行時には多少のオーバーヘッドがあるものの、2回目以降は非常に高速なエンコーディングが可能になります。
関連リンク
- Go言語の
encoding/jsonパッケージ公式ドキュメント: https://pkg.go.dev/encoding/json - Go言語のリフレクションに関する公式ドキュメント: https://pkg.go.dev/reflect
- Go言語のベンチマークに関する公式ドキュメント: https://pkg.go.dev/testing#hdr-Benchmarks
参考にした情報源リンク
- Go言語のコミット履歴 (GitHub): https://github.com/golang/go/commits/master
- Go言語のコードレビューシステム (Gerrit): https://go.dev/cl/ (コミットメッセージに記載されている
https://golang.org/cl/9129044は、このGerritの変更リストへのリンクです。) - Go言語のリフレクションに関するブログ記事やチュートリアル (一般的な情報源):
- The Go Blog: The Laws of Reflection: https://go.dev/blog/laws-of-reflection
- Go言語のリフレクションについて解説している技術ブログ記事など (例: "Go言語のリフレクションを理解する" などで検索)