Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 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エンコーディングの基本的な知識が必要です。

  1. 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のインターフェース値に変換します。 リフレクションは柔軟性を提供しますが、その動的な性質上、静的な型付けされた操作に比べてパフォーマンスのオーバーヘッドが発生します。
  2. 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エンコーディング方法をカスタマイズできます。
  3. パフォーマンス最適化の一般的なアプローチ:

    • キャッシュ: 計算コストの高い結果を一度計算し、それを保存しておき、同じ入力に対しては再計算せずにキャッシュされた結果を返すことで、パフォーマンスを向上させます。
    • コード生成/事前コンパイル: 実行時に動的に処理する代わりに、事前に特定のロジックを生成またはコンパイルしておくことで、実行時のオーバーヘッドを削減します。
    • リフレクションの削減: リフレクションは便利ですが、パフォーマンスが重要なパスでは可能な限り使用を避けるか、その使用を最小限に抑えることが推奨されます。

このコミットは、これらの前提知識を背景に、リフレクションの使用を最適化し、型ごとのエンコーディングロジックをキャッシュすることで、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にエンコードする具体的なロジックをカプセル化します。

  1. encoderCache の導入: encoderCache というグローバルなキャッシュが導入されました。これは map[reflect.Type]encoderFunc の形式で、Goの型 (reflect.Type) をキーとして、その型に対応する encoderFunc を値として保持します。これにより、一度生成されたエンコーディング関数は、同じ型の値が再度エンコードされる際に再利用されます。

  2. valueEncodertypeEncoder:

    • valueEncoder(v reflect.Value): 与えられた reflect.Value に対応する encoderFunc を返します。内部で typeEncoder を呼び出します。
    • typeEncoder(t reflect.Type, vx reflect.Value): 与えられた reflect.Type に対応する encoderFunc を返します。まず encoderCache を参照し、キャッシュに存在すればそれを返します。存在しない場合は、newTypeEncoder を呼び出して新しい encoderFunc を生成し、それをキャッシュに保存します。
  3. 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 インターフェースを実装している型に対応するエンコーダ。
  4. 再帰的な型への対応: typeEncoder は、再帰的な型(例: 自身を参照する構造体)を正しく扱うために、キャッシュに encoderFunc を追加する際に sync.WaitGroup を使用しています。これにより、エンコーディング関数の構築中に同じ型が再帰的に参照された場合でも、デッドロックを回避し、最終的に正しいエンコーディング関数が提供されるようにしています。

パフォーマンス向上と安定性

この変更により、JSONエンコーディングのパフォーマンスが大幅に向上しました。これは、一度型ごとの encoderFunc が生成されれば、その後のエンコーディングではリフレクションによる動的な型検査やフィールドアクセスが不要になり、事前に「コンパイル」された専用の関数が直接実行されるためです。これにより、実行時のオーバーヘッドが削減され、ベンチマーク結果に示されるように、スループットの向上とベンチマーク結果の安定性が実現されました。

特に、旧コードでベンチマーク結果が不安定だったのは、リフレクションの動的な性質や、Goランタイムの内部的な最適化のタイミングなどによって、実行ごとのパフォーマンスが変動しやすかったためと考えられます。新しいアプローチでは、エンコーディングロジックが事前に固定されるため、より予測可能で安定したパフォーマンスが得られるようになりました。

コアとなるコードの変更箇所

このコミットの主要な変更は、src/pkg/encoding/json/encode.go ファイルに集中しています。

  1. encodeState.reflectValue の変更:

    • 旧: e.reflectValueQuoted(v, false) を直接呼び出していた。
    • 新: valueEncoder(v)(e, v, false) を呼び出すように変更。これにより、型に応じた専用のエンコーディング関数が呼び出されるようになった。
  2. encoderFunc 型の導入:

    type encoderFunc func(e *encodeState, v reflect.Value, _ bool)
    

    特定の型をエンコードするための関数シグネチャを定義。

  3. encoderCache の導入:

    var encoderCache struct {
        sync.RWMutex
        m map[reflect.Type]encoderFunc
    }
    

    型とそれに対応する encoderFunc をキャッシュするためのマップ。

  4. valueEncoder 関数の追加:

    func valueEncoder(v reflect.Value) encoderFunc {
        if !v.IsValid() {
            return invalidValueEncoder
        }
        t := v.Type()
        return typeEncoder(t, v)
    }
    

    reflect.Value から適切な encoderFunc を取得するエントリポイント。

  5. typeEncoder 関数の追加:

    func typeEncoder(t reflect.Type, vx reflect.Value) encoderFunc {
        // ... キャッシュの参照、新しいエンコーダの生成、再帰的な型への対応 ...
    }
    

    reflect.Type に基づいて encoderFunc を取得・生成し、キャッシュに保存する主要なロジック。

  6. newTypeEncoder 関数の追加:

    func newTypeEncoder(t reflect.Type, vx reflect.Value) encoderFunc {
        // ... 各Kindに応じた専用エンコーダの生成ロジック ...
    }
    

    reflect.Kind に応じて、boolEncoder, intEncoder, structEncoder などの具体的な encoderFunc を生成するファクトリ関数。

  7. 各種専用エンコーダ関数の追加: boolEncoder, intEncoder, uintEncoder, floatEncoder (および float32Encoder, float64Encoder), stringEncoder, interfaceEncoder, unsupportedTypeEncoder など、プリミティブ型やインターフェース型に対応する具体的なエンコーディング関数が追加されました。

  8. 構造体、マップ、スライス、配列、ポインタのエンコーダ構造体とメソッドの追加:

    • structEncoder, mapEncoder, sliceEncoder, arrayEncoder, ptrEncoder といった構造体が定義され、それぞれが encode メソッドを持つようになりました。これらの構造体は、内部に encoderFunc を保持し、より特化したエンコーディングロジックを提供します。
    • newStructEncoder, newMapEncoder, newSliceEncoder, newArrayEncoder, newPtrEncoder といったファクトリ関数が、これらのエンコーダ構造体を初期化し、対応する encode メソッドを encoderFunc として返します。
  9. 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 に対応する encoderFuncencoderCache から取得しようとします。

  • キャッシュヒットの場合: encoderCache に既に encoderFunc が存在すれば、それを直接返します。これにより、2回目以降の同じ型のエンコーディングでは、リフレクションによる型判別や switch 文の分岐が完全にスキップされ、事前に生成された専用の関数が高速に実行されます。
  • キャッシュミスの場合: newTypeEncoder が呼び出され、その型に特化した encoderFunc が生成されます。例えば、構造体であれば newStructEncoder が呼び出され、その構造体の各フィールドに対しても再帰的に typeEncoder を呼び出し、フィールドごとの encoderFuncfieldEncs スライスにキャッシュします。この生成された encoderFuncencoderCache に保存され、次回以降の利用に備えます。

特に注目すべきは、structEncoderencode メソッドです。以前は各フィールドをエンコードする際に e.reflectValueQuoted(fv, f.quoted) を再帰的に呼び出していましたが、変更後は se.fieldEncs[i] にキャッシュされたフィールド専用の encoderFunctenc(e, fv, f.quoted) のように直接呼び出すようになりました。これにより、構造体のエンコーディングパスにおけるリフレクションのオーバーヘッドが劇的に削減されます。

この設計は、GoのJSONエンコーディングが初めて特定の型に遭遇したときに、その型をJSONに変換するための「ミニコンパイラ」のような役割を果たし、その結果生成された「コンパイル済み」のエンコーディングロジックをキャッシュするという考え方に基づいています。これにより、初回実行時には多少のオーバーヘッドがあるものの、2回目以降は非常に高速なエンコーディングが可能になります。

関連リンク

参考にした情報源リンク

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