[インデックス 10822] ファイルの概要
このコミットは、Go言語の標準ライブラリである encoding/json
パッケージ内の encode.go
ファイルに対する変更です。このファイルは、Goのデータ構造をJSON形式にエンコードするロジックを実装しています。具体的には、数値型(int
, uint
, float
)のエンコーディング処理が最適化されています。
コミット
このコミットは、encoding/json
パッケージにおいて、JSONエンコーディング時のメモリ割り当てを削減するために strconv.Append
系関数を使用するように変更しました。これにより、ベンチマークで示されるように、エンコーディングのパフォーマンスが向上しています。
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/29264c6f4f341586b733e2c5b165ca627b6050d8
元コミット内容
json: use strconv.Append variants to avoid allocations in encoding
Before/after, best of 3:
json.BenchmarkCodeEncoder 10 183495300 ns/op 10.58 MB/s
->
json.BenchmarkCodeEncoder 10 133025100 ns/op 14.59 MB/s
But don't get too excited about this. These benchmarks, while
stable at any point of time, fluctuate wildly with any line of
code added or removed anywhere in the path due to stack splitting
issues.
It's currently much faster, though, and this is the API that
doesn't allocate so should always be faster in theory.
R=golang-dev, dsymonds, rsc, r, gri
CC=golang-dev
https://golang.org/cl/5411052
変更の背景
この変更の主な背景は、Goの encoding/json
パッケージにおけるパフォーマンスの最適化、特にメモリ割り当ての削減です。JSONエンコーディングは、Webサービスやデータ処理において頻繁に行われる操作であり、その効率はアプリケーション全体のパフォーマンスに大きく影響します。
従来の strconv.FormatInt
, strconv.FormatUint
, strconv.FormatFloat
といった関数は、数値を文字列に変換する際に新しい文字列をヒープに割り当てていました。これは、エンコードされる数値の数が増えるにつれて、ガベージコレクションの負荷を増大させ、パフォーマンスのボトルネックとなる可能性がありました。
コミットメッセージに示されているベンチマーク結果は、この変更によって json.BenchmarkCodeEncoder
の実行時間が約27%短縮され、スループットが約38%向上したことを示しています。これは、メモリ割り当ての削減が直接的なパフォーマンス向上につながったことを裏付けています。
ただし、コミットメッセージでは「stack splitting issues」によるベンチマークの変動についても言及されており、Goの初期のバージョンではスタックの動的な拡張(スタック分割)がパフォーマンスベンチマークに影響を与えることがあったという背景も示唆しています。しかし、strconv.Append
系関数がメモリ割り当てを行わないという本質的な特性から、理論的には常に高速であるべきだと述べられています。
前提知識の解説
1. Go言語の encoding/json
パッケージ
encoding/json
パッケージは、Goのデータ構造とJSON形式の間で変換を行うための標準ライブラリです。json.Marshal
関数はGoの値をJSONバイトスライスにエンコードし、json.Unmarshal
関数はJSONデータをGoの値にデコードします。このパッケージは、リフレクションを使用してGoの構造体のフィールドをJSONオブジェクトのキーにマッピングし、適切な型変換を行います。
2. strconv
パッケージ
strconv
パッケージは、基本的なデータ型(数値、真偽値など)と文字列の間で変換を行うためのGoの標準ライブラリです。
Format
系関数 (FormatInt
,FormatUint
,FormatFloat
): これらの関数は、指定された数値を文字列に変換し、その結果を新しい文字列として返します。この際、新しい文字列のためのメモリがヒープに割り当てられます。Append
系関数 (AppendInt
,AppendUint
,AppendFloat
): これらの関数は、指定された数値を既存のバイトスライスに追加し、その結果のバイトスライスを返します。これにより、新しいメモリ割り当てを避けることができます。特に、[]byte
型のバッファを再利用することで、ヒープ割り当てを最小限に抑え、ガベージコレクションの頻度を減らすことが可能になります。
3. bytes.Buffer
bytes.Buffer
は、可変長のバイトバッファを実装するGoの型です。io.Writer
インターフェースを実装しており、バイトデータを効率的に書き込むことができます。encoding/json
パッケージでは、エンコードされたJSONデータを一時的に保持するために bytes.Buffer
が内部的に使用されます。
4. reflect
パッケージと reflect.Value
reflect
パッケージは、Goのプログラムが実行時に自身の構造を検査・操作するための機能を提供します。reflect.Value
は、Goの任意の値を抽象的に表現する型です。encoding/json
のような汎用的なエンコーダは、リフレクションを使用して、エンコード対象のGoの構造体のフィールドの型や値を取得し、それに応じてJSON形式に変換します。
5. メモリ割り当てとガベージコレクション (GC)
Goはガベージコレクタを持つ言語であり、開発者が手動でメモリを解放する必要はありません。しかし、頻繁なメモリ割り当て(特にヒープ割り当て)は、ガベージコレクタの実行頻度を増加させ、アプリケーションのパフォーマンスに悪影響を与える可能性があります。メモリ割り当てを削減することは、GCのオーバーヘッドを減らし、全体的な実行速度を向上させるための一般的な最適化手法です。
6. スタックとヒープ
Goプログラムのメモリは主にスタックとヒープに分けられます。
- スタック: 関数呼び出しやローカル変数など、生存期間が短いデータが格納されます。Goでは、スタックは動的に拡張・縮小されます(スタック分割)。
- ヒープ:
make
やnew
で明示的に割り当てられたり、コンパイラがエスケープ解析の結果ヒープに配置すると判断したデータなど、生存期間が長いデータが格納されます。ガベージコレクションの対象となります。
strconv.Format
系関数が新しい文字列を返す場合、その文字列はヒープに割り当てられる可能性が高いです。一方、strconv.Append
系関数は既存のバイトスライスに追記するため、適切にバッファを再利用すればヒープ割り当てを避けることができます。
技術的詳細
このコミットの核心は、encoding/json
パッケージが数値をJSON文字列としてエンコードする際に、strconv.Format
系関数から strconv.Append
系関数に切り替えたことです。
変更前: strconv.Format
の使用
変更前は、encodeState
の reflectValueQuoted
メソッド内で、数値型(reflect.Int
, reflect.Uint
, reflect.Float
)を処理する際に、以下のように strconv.Format
系関数が使用されていました。
// 変更前 (例: Int型)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
writeString(e, strconv.FormatInt(v.Int(), 10))
strconv.FormatInt(v.Int(), 10)
は、v.Int()
の値を10進数文字列に変換し、その結果を新しい string
として返します。この新しい string
はヒープに割り当てられ、その後 writeString
関数によって e.Buffer
に書き込まれます。このプロセスは、エンコードされる数値ごとに新しい文字列の割り当てとコピーを伴うため、メモリ割り当てのオーバーヘッドが発生します。
変更後: strconv.Append
と scratch
バッファの導入
変更後、encodeState
構造体に scratch [64]byte
という固定サイズのバイト配列が追加されました。
type encodeState struct {
bytes.Buffer // accumulated output
scratch [64]byte // 新しく追加されたフィールド
}
そして、数値型のエンコーディングには strconv.Append
系関数が使用されるようになりました。
// 変更後 (例: Int型)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
b := strconv.AppendInt(e.scratch[:0], v.Int(), 10) // ここが変更点
if quoted {
writeString(e, string(b))
} else {
e.Write(b) // ここが変更点
}
この変更のポイントは以下の通りです。
e.scratch[:0]
の利用:e.scratch
は[64]byte
型の配列であり、e.scratch[:0]
はこの配列の容量を持つが長さが0のバイトスライスを作成します。このスライスはスタック上に割り当てられるか、encodeState
構造体の一部としてヒープに割り当てられたとしても、そのメモリは再利用されます。strconv.AppendInt
の使用:strconv.AppendInt(e.scratch[:0], v.Int(), 10)
は、v.Int()
の値をe.scratch[:0]
スライスに追加します。strconv.AppendInt
は、必要に応じてスライスの容量を拡張しますが、最初の呼び出しではe.scratch
の既存のメモリを利用しようとします。これにより、数値の文字列変換のための新しいヒープ割り当てが回避されます。e.Write(b)
の直接利用:strconv.AppendInt
が返したバイトスライスb
は、e.Buffer
に直接書き込まれます。これにより、string(b)
のように一度string
に変換してからwriteString
を呼び出す場合に発生する可能性のある一時的な文字列割り当てを避けることができます(writeString
は内部でe.WriteString
を呼び出し、これはstring
を引数にとるため、string(b)
変換は避けられない場合もありますが、e.Write(b)
は[]byte
を直接受け取るため、より効率的です)。
quoted
の条件分岐は、JSONの数値が引用符で囲まれるべきか(例: JavaScriptの数値リテラルとしてではなく、文字列として扱われる場合)そうでないかによって処理を分けています。quoted
が true
の場合は writeString(e, string(b))
を使用し、false
の場合は e.Write(b)
を使用します。string(b)
はバイトスライスから文字列への変換であり、これはGoにおいて新しい文字列の割り当てを伴う可能性があります。しかし、quoted
が false
の場合(通常の数値エンコード)、e.Write(b)
は []byte
を直接 bytes.Buffer
に書き込むため、中間的な文字列割り当てを完全に回避できます。
この最適化により、特に大量の数値データをJSONエンコードする際に、メモリ割り当ての回数が大幅に削減され、ガベージコレクションの負荷が軽減され、結果として全体的なパフォーマンスが向上します。scratch
バッファのサイズが64バイトであるのは、一般的な数値の文字列表現がこのサイズに収まることを想定しているためです。
コアとなるコードの変更箇所
変更は src/pkg/encoding/json/encode.go
ファイルの以下の部分です。
encodeState
構造体への scratch
フィールドの追加
--- a/src/pkg/encoding/json/encode.go
+++ b/src/pkg/encoding/json/encode.go
@@ -197,6 +197,7 @@ var hex = "0123456789abcdef"
// An encodeState encodes JSON into a bytes.Buffer.
type encodeState struct {
bytes.Buffer // accumulated output
+ scratch [64]byte // 追加された行
}
reflect.Int
系のエンコーディングロジックの変更
--- a/src/pkg/encoding/json/encode.go
+++ b/src/pkg/encoding/json/encode.go
@@ -275,14 +276,26 @@ func (e *encodeState) reflectValueQuoted(v reflect.Value, quoted bool) {
}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
- writeString(e, strconv.FormatInt(v.Int(), 10))
-
+ b := strconv.AppendInt(e.scratch[:0], v.Int(), 10) // 変更された行
+ if quoted {
+ writeString(e, string(b))
+ } else {
+ e.Write(b) // 変更された行
+ }
reflect.Uint
系のエンコーディングロジックの変更
--- a/src/pkg/encoding/json/encode.go
+++ b/src/pkg/encoding/json/encode.go
@@ -275,14 +276,26 @@ func (e *encodeState) reflectValueQuoted(v reflect.Value, quoted bool) {
}
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
- writeString(e, strconv.FormatUint(v.Uint(), 10))
-
+ b := strconv.AppendUint(e.scratch[:0], v.Uint(), 10) // 変更された行
+ if quoted {
+ writeString(e, string(b))
+ } else {
+ e.Write(b) // 変更された行
+ }
reflect.Float
系のエンコーディングロジックの変更
--- a/src/pkg/encoding/json/encode.go
+++ b/src/pkg/encoding/json/encode.go
@@ -275,14 +276,26 @@ func (e *encodeState) reflectValueQuoted(v reflect.Value, quoted bool) {
}
case reflect.Float32, reflect.Float64:
- writeString(e, strconv.FormatFloat(v.Float(), 'g', -1, v.Type().Bits()))
-
+ b := strconv.AppendFloat(e.scratch[:0], v.Float(), 'g', -1, v.Type().Bits()) // 変更された行
+ if quoted {
+ writeString(e, string(b))
+ } else {
+ e.Write(b) // 変更された行
+ }
コアとなるコードの解説
このコミットのコアとなる変更は、encodeState
構造体に scratch [64]byte
というフィールドを追加し、数値の文字列変換に strconv.Append
系関数とこの scratch
バッファを組み合わせることで、メモリ割り当てを削減した点です。
encodeState
構造体への scratch
フィールドの追加
type encodeState struct {
bytes.Buffer // accumulated output
scratch [64]byte
}
encodeState
はJSONエンコーディングの状態を保持する構造体です。bytes.Buffer
はエンコードされたJSONデータを蓄積するために使用されます。新しく追加された scratch [64]byte
は、数値の文字列変換のための一時的なバイトバッファとして機能します。この配列は encodeState
の一部として割り当てられるため、エンコード処理中に新しいヒープ割り当てを発生させることなく、数値をバイトスライスに変換できます。64バイトというサイズは、Goの数値型(int64
や float64
)を文字列に変換した際の最大長を考慮して選ばれています。
数値エンコーディングロジックの変更
例えば reflect.Int
のケースを見てみましょう。
b := strconv.AppendInt(e.scratch[:0], v.Int(), 10)
if quoted {
writeString(e, string(b))
} else {
e.Write(b)
}
e.scratch[:0]
: これは、e.scratch
配列の先頭から始まり、長さが0のスライスを作成します。重要なのは、このスライスがe.scratch
の基盤となる配列を共有していることです。これにより、strconv.AppendInt
が数値を書き込むための既存のメモリ領域を提供できます。strconv.AppendInt(e.scratch[:0], v.Int(), 10)
:v.Int()
:reflect.Value
から実際のint64
値を取得します。10
: 10進数で文字列に変換することを指定します。- この関数は、
v.Int()
の値をe.scratch[:0]
スライスに追加し、結果として得られるバイトスライスをb
に返します。strconv.AppendInt
は、必要に応じてスライスの容量を自動的に拡張しますが、e.scratch
の64バイトの容量内であれば、新しいメモリ割り当ては発生しません。これにより、数値の文字列変換ごとにヒープ割り当てが行われるのを防ぎます。
if quoted { ... } else { ... }
:quoted
がtrue
の場合(例えば、JSONの数値が文字列として扱われるべき場合)、string(b)
を使ってバイトスライスb
を文字列に変換し、writeString
関数でe.Buffer
に書き込みます。string(b)
は新しい文字列を割り当てる可能性がありますが、これはquoted
の特殊なケースであり、通常の数値エンコードでは発生しません。quoted
がfalse
の場合(通常の数値エンコード)、e.Write(b)
を使ってバイトスライスb
を直接e.Buffer
に書き込みます。bytes.Buffer
のWrite
メソッドは[]byte
を引数にとるため、このパスでは中間的な文字列割り当てが完全に回避されます。
同様のロジックが reflect.Uint
と reflect.Float
のケースにも適用されています。この変更により、JSONエンコーディングにおける数値の文字列変換が、ヒープ割り当てを最小限に抑える形で実行されるようになり、全体的なパフォーマンスが向上しました。
関連リンク
- Go Change-ID:
5411052
(これはコミットメッセージに記載されているGoの内部的な変更リストのIDです。GitHubのコミットページに直接リンクされています。)
参考にした情報源リンク
- Go言語の
strconv
パッケージ公式ドキュメント: - Go言語の
bytes
パッケージ公式ドキュメント: - Go言語の
encoding/json
パッケージ公式ドキュメント: - Go言語のメモリ管理とガベージコレクションに関する一般的な情報源(例: Goの公式ブログや技術記事)
- (具体的なURLはコミットメッセージには含まれていませんが、Goのパフォーマンス最適化に関する一般的な知識として参照されます。)
- 例: "Go's work-stealing garbage collector" や "Go memory management" などのキーワードで検索すると関連情報が見つかります。
- Goのリフレクションに関する情報源:
- https://pkg.go.dev/reflect
- 例: "The Laws of Reflection" (Go公式ブログ)