[インデックス 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言語のリフレクションを理解する" などで検索)