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

[インデックス 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では、スタックは動的に拡張・縮小されます(スタック分割)。
  • ヒープ: makenew で明示的に割り当てられたり、コンパイラがエスケープ解析の結果ヒープに配置すると判断したデータなど、生存期間が長いデータが格納されます。ガベージコレクションの対象となります。

strconv.Format 系関数が新しい文字列を返す場合、その文字列はヒープに割り当てられる可能性が高いです。一方、strconv.Append 系関数は既存のバイトスライスに追記するため、適切にバッファを再利用すればヒープ割り当てを避けることができます。

技術的詳細

このコミットの核心は、encoding/json パッケージが数値をJSON文字列としてエンコードする際に、strconv.Format 系関数から strconv.Append 系関数に切り替えたことです。

変更前: strconv.Format の使用

変更前は、encodeStatereflectValueQuoted メソッド内で、数値型(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.Appendscratch バッファの導入

変更後、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) // ここが変更点
    }

この変更のポイントは以下の通りです。

  1. e.scratch[:0] の利用: e.scratch[64]byte 型の配列であり、e.scratch[:0] はこの配列の容量を持つが長さが0のバイトスライスを作成します。このスライスはスタック上に割り当てられるか、encodeState 構造体の一部としてヒープに割り当てられたとしても、そのメモリは再利用されます。
  2. strconv.AppendInt の使用: strconv.AppendInt(e.scratch[:0], v.Int(), 10) は、v.Int() の値を e.scratch[:0] スライスに追加します。strconv.AppendInt は、必要に応じてスライスの容量を拡張しますが、最初の呼び出しでは e.scratch の既存のメモリを利用しようとします。これにより、数値の文字列変換のための新しいヒープ割り当てが回避されます。
  3. 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の数値リテラルとしてではなく、文字列として扱われる場合)そうでないかによって処理を分けています。quotedtrue の場合は writeString(e, string(b)) を使用し、false の場合は e.Write(b) を使用します。string(b) はバイトスライスから文字列への変換であり、これはGoにおいて新しい文字列の割り当てを伴う可能性があります。しかし、quotedfalse の場合(通常の数値エンコード)、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の数値型(int64float64)を文字列に変換した際の最大長を考慮して選ばれています。

数値エンコーディングロジックの変更

例えば reflect.Int のケースを見てみましょう。

b := strconv.AppendInt(e.scratch[:0], v.Int(), 10)
if quoted {
    writeString(e, string(b))
} else {
    e.Write(b)
}
  1. e.scratch[:0]: これは、e.scratch 配列の先頭から始まり、長さが0のスライスを作成します。重要なのは、このスライスが e.scratch の基盤となる配列を共有していることです。これにより、strconv.AppendInt が数値を書き込むための既存のメモリ領域を提供できます。
  2. 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バイトの容量内であれば、新しいメモリ割り当ては発生しません。これにより、数値の文字列変換ごとにヒープ割り当てが行われるのを防ぎます。
  3. if quoted { ... } else { ... }:
    • quotedtrue の場合(例えば、JSONの数値が文字列として扱われるべき場合)、string(b) を使ってバイトスライス b を文字列に変換し、writeString 関数で e.Buffer に書き込みます。string(b) は新しい文字列を割り当てる可能性がありますが、これは quoted の特殊なケースであり、通常の数値エンコードでは発生しません。
    • quotedfalse の場合(通常の数値エンコード)、e.Write(b) を使ってバイトスライス b を直接 e.Buffer に書き込みます。bytes.BufferWrite メソッドは []byte を引数にとるため、このパスでは中間的な文字列割り当てが完全に回避されます。

同様のロジックが reflect.Uintreflect.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のリフレクションに関する情報源: