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

[インデックス 14854] ファイルの概要

このコミットは、Go言語の標準ライブラリである encoding/json パッケージにおける Unmarshal 関数のパフォーマンス改善を目的としています。特に、JSONのプリミティブ型(数値、文字列、真偽値)のデコード処理において、不要なスキャンおよびパースロジックをスキップすることで、大幅な高速化を実現しています。

コミット

encoding/json パッケージの Unmarshal 関数において、プリミティブ型(非オブジェクト/非配列)のJSON値をデコードする際のパフォーマンスを向上させました。これにより、ベンチマークでは最大で約65%の高速化が確認されています。

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/60abc6b577895e88f8e029772ff27f9e6917d23b

元コミット内容

commit 60abc6b577895e88f8e029772ff27f9e6917d23b
Author: Rick Arnold <rickarnoldjr@gmail.com>
Date:   Thu Jan 10 17:58:45 2013 -0800

    encoding/json: improve performance of Unmarshal on primitive types
    
    Skip most of the scanning and parsing logic for simple (non-object/array) JSON values.
    
    benchmark                   old ns/op    new ns/op    delta
    BenchmarkUnmarshalInt             948          436  -54.01%
    BenchmarkUnmarshalUint            930          427  -54.09%
    BenchmarkUnmarshalString         1407          715  -49.18%
    BenchmarkUnmarshalFloat          1114          536  -51.89%
    BenchmarkUnmarshalBool            759          266  -64.95%
    BenchmarkUnmarshalStruct         8165         8181   +0.20%
    
    No significant effects on the go1 benchmarks:
    
    benchmark                 old ns/op    new ns/op    delta
    BenchmarkBinaryTree17    9647362752   9596196417   -0.53%
    BenchmarkFannkuch11      5623613048   5518694872   -1.87%
    BenchmarkGobDecode         32944041     33165434   +0.67%
    BenchmarkGobEncode         21237482     21080554   -0.74%
    BenchmarkGzip             750955920    749861980   -0.15%
    BenchmarkGunzip           197369742    197886192   +0.26%
    BenchmarkJSONEncode        79274091     78891137   -0.48%
    BenchmarkJSONDecode       180257802    175280358   -2.76%
    BenchmarkMandelbrot200      7396666      7388266   -0.11%
    BenchmarkParse             11446460     11386550   -0.52%
    BenchmarkRevcomp         1605152523   1599512029   -0.35%
    BenchmarkTemplate         204538247    207765574   +1.58%
    
    benchmark                  old MB/s     new MB/s  speedup
    BenchmarkGobDecode            23.30        23.14    0.99x
    BenchmarkGobEncode            36.14        36.41    1.01x
    BenchmarkGzip                 25.84        25.88    1.00x
    BenchmarkGunzip               98.32        98.06    1.00x
    BenchmarkJSONEncode           24.48        24.60    1.00x
    BenchmarkJSONDecode           10.76        11.07    1.03x
    BenchmarkParse                 5.06         5.09    1.01x
    BenchmarkRevcomp             158.34       158.90    1.00x
    BenchmarkTemplate              9.49         9.34    0.98x
    
    Fixes #3949.
    
    R=golang-dev, dave, bradfitz, timo
    CC=golang-dev
    https://golang.org/cl/7068043

変更の背景

Go言語の encoding/json パッケージは、JSONデータのエンコード(Goのデータ構造からJSONへ)およびデコード(JSONからGoのデータ構造へ)を提供する標準ライブラリです。Unmarshal 関数はJSONバイト列をGoのインターフェースにデコードする役割を担っています。

このコミットが行われた背景には、Unmarshal 関数がプリミティブ型(整数、浮動小数点数、文字列、真偽値)のJSON値を処理する際に、必要以上に複雑なスキャンおよびパースロジックを実行しているというパフォーマンス上の課題がありました。JSONのオブジェクト({...})や配列([...])は構造を持つため、その内部を再帰的に解析する必要がありますが、単一のプリミティブ値(例: 123, "hello", true)は構造を持たないため、より単純な方法で処理できるはずです。

既存の実装では、プリミティブ値であっても、オブジェクトや配列と同様の汎用的なパースパスを通っていたため、オーバーヘッドが生じていました。このコミットは、この非効率性を解消し、特にプリミティブ値のデコード性能を向上させることを目的としています。ベンチマーク結果が示すように、この変更はプリミティブ型に対して劇的なパフォーマンス改善をもたらしました。

前提知識の解説

JSON (JavaScript Object Notation)

JSONは、人間が読み書きしやすく、機械が解析しやすいデータ交換フォーマットです。主にウェブアプリケーションでデータを送受信するために使用されます。JSONは以下の6つのデータ型をサポートします。

  1. オブジェクト (Object): 波括弧 {} で囲まれ、キーと値のペアの集合です。キーは文字列で、値は任意のJSONデータ型です。例: {"name": "Alice", "age": 30}
  2. 配列 (Array): 角括弧 [] で囲まれ、順序付けられた値のリストです。値は任意のJSONデータ型です。例: [1, 2, 3]
  3. 文字列 (String): 二重引用符 "" で囲まれたUnicode文字のシーケンスです。例: "hello"
  4. 数値 (Number): 整数または浮動小数点数です。例: 123, 3.14
  5. 真偽値 (Boolean): true または false です。
  6. null: 空の値を示す null です。

このコミットで言及されている「プリミティブ型」とは、オブジェクトと配列以外の、文字列、数値、真偽値、nullを指します。

Go言語の encoding/json パッケージ

Go言語の encoding/json パッケージは、Goのデータ型とJSON形式の間で変換を行うための機能を提供します。

  • json.Marshal(): Goのデータ構造をJSONバイト列にエンコードします。
  • json.Unmarshal(): JSONバイト列をGoのデータ構造にデコードします。

Unmarshal 関数は、JSONバイト列を解析し、指定されたGoのインターフェース(通常はポインタ)にその値を格納します。このプロセスには、JSONの構文解析(トークン化、ツリー構築など)と、Goの型への変換が含まれます。

JSONパースにおけるスキャンとパースロジック

JSONのパースは通常、以下のステップで行われます。

  1. 字句解析 (Lexical Analysis / Scanning): 入力バイト列をトークン(例: {, [, "string", 123, true など)に分割します。この段階では、空白文字のスキップなども行われます。
  2. 構文解析 (Syntactic Analysis / Parsing): トークンのストリームを文法規則に従って解析し、JSONの構造(オブジェクト、配列、プリミティブ)を認識します。この段階で、JSONの構造が正しいかどうかが検証されます。

従来の Unmarshal 実装では、プリミティブ値であっても、オブジェクトや配列を解析する際に使用される汎用的なスキャンおよびパースロジックが適用されていました。これは、JSONの先頭文字が { または [ でない場合でも、内部的にはより複雑な状態遷移やトークンチェックが行われることを意味します。

Goのベンチマーク

Go言語には、コードのパフォーマンスを測定するための組み込みのベンチマーク機能があります。

  • ns/op (nanoseconds per operation): 1回の操作にかかる平均ナノ秒。値が小さいほど高速です。
  • MB/s (megabytes per second): 1秒あたりに処理できるメガバイト数。値が大きいほど高速です。
  • delta: 変更前後のパフォーマンス変化率。負の値は改善、正の値は悪化を示します。
  • speedup: 変更前に対する変更後の速度向上倍率。1.00xより大きい値は高速化を示します。

コミットメッセージに記載されているベンチマーク結果は、このコミットが Unmarshal の特定のケース(プリミティブ型)で大幅な高速化を実現したことを明確に示しています。一方で、go1 benchmarks はGo言語全体の主要なベンチマークスイートであり、この変更がGo言語全体のパフォーマンスに大きな悪影響を与えていないことを示しています。

技術的詳細

このコミットの技術的な核心は、encoding/json パッケージの Unmarshal 関数に、JSONバイト列の先頭をチェックする早期リターンパスを追加した点にあります。

従来の Unmarshal 関数は、入力されたJSONデータがオブジェクト({で始まる)または配列([で始まる)であるかどうかに関わらず、一般的なパースロジックを開始していました。しかし、JSONの仕様では、オブジェクトと配列以外のすべての値(文字列、数値、真偽値、null)は、{[ 以外の文字で始まります。

この変更では、Unmarshal 関数の冒頭で、入力バイト列の最初の非空白文字をチェックします。

  1. 空白文字のスキップ: まず、入力バイト列の先頭から空白文字(スペース、タブ、改行、キャリッジリターン)をスキップします。これはJSONの柔軟なフォーマットに対応するためです。
  2. 先頭文字のチェック: スキップ後、最初の非空白文字が { または [ であるかどうかをチェックします。
  3. 早期パスの実行:
    • もし最初の非空白文字が { でも [ でもない場合、それはプリミティブ型(文字列、数値、真偽値、null)であると判断できます。
    • この場合、通常の複雑なスキャンおよびパースロジックをスキップし、decodeState 構造体の literalStore メソッドを直接呼び出します。literalStore は、プリミティブ値を効率的にデコードするための専用ロジックです。これにより、不要な状態遷移やトークン解析のオーバーヘッドが削減されます。
    • この早期パスは、デコード対象がポインタ型であり、かつnilでない場合にのみ適用されます。これは、Unmarshal が値を格納するためにポインタを必要とするためです。
  4. 通常パスの継続: 最初の非空白文字が { または [ であった場合、それはオブジェクトまたは配列であるため、従来の汎用的なパースロジック(new(decodeState).init(data) 以降の処理)が継続されます。

この最適化により、プリミティブ型のデコードにおいて、特に小さなJSON値の処理が大幅に高速化されました。ベンチマーク結果が示すように、UnmarshalIntUnmarshalUintUnmarshalStringUnmarshalFloatUnmarshalBool といったプリミティブ型のデコードが50%以上高速化されています。一方で、UnmarshalStruct のようなオブジェクトのデコードにはほとんど影響がなく、Go言語全体のベンチマーク(go1 benchmarks)にも悪影響がないことが確認されています。

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

src/pkg/encoding/json/decode.go

--- a/src/pkg/encoding/json/decode.go
+++ b/src/pkg/encoding/json/decode.go
@@ -52,6 +52,25 @@ import (
 // an UnmarshalTypeError describing the earliest such error.
 //
 func Unmarshal(data []byte, v interface{}) error {
+
+	// skip heavy processing for primitive values
+	var first byte
+	var i int
+	for i, first = range data {
+		if !isSpace(rune(first)) {
+			break
+		}
+	}
+	if first != '{' && first != '[' {
+		rv := reflect.ValueOf(v)
+		if rv.Kind() != reflect.Ptr || rv.IsNil() {
+			return &InvalidUnmarshalError{reflect.TypeOf(v)}
+		}
+		var d decodeState
+		d.literalStore(data[i:], rv.Elem(), false)
+		return d.savedError
+	}
+
 	d := new(decodeState).init(data)
 
 	// Quick check for well-formedness.

src/pkg/encoding/json/decode_test.go

--- a/src/pkg/encoding/json/decode_test.go
+++ b/src/pkg/encoding/json/decode_test.go
@@ -205,6 +205,13 @@ var unmarshalTests = []unmarshalTest{
 	{in: `{"k1":1,"k2":"s","k3":[1,2.0,3e-3],"k4":{"kk1":"s","kk2":2}}`, ptr: new(interface{}), out: ifaceNumAsFloat64},
 	{in: `{"k1":1,"k2":"s","k3":[1,2.0,3e-3],"k4":{"kk1":"s","kk2":2}}`, ptr: new(interface{}), out: ifaceNumAsNumber, useNumber: true},
 
+	// raw values with whitespace
+	{in: "\n true ", ptr: new(bool), out: true},
+	{in: "\t 1 ", ptr: new(int), out: 1},
+	{in: "\r 1.2 ", ptr: new(float64), out: 1.2},
+	{in: "\t -5 \n", ptr: new(int16), out: int16(-5)},
+	{in: "\t \"a\\u1234\" \n", ptr: new(string), out: "a\\u1234"},
+
 	// Z has a "-" tag.
 	{in: `{"Y": 1, "Z": 2}`, ptr: new(T), out: T{Y: 1}},
 
@@ -217,6 +224,16 @@ var unmarshalTests = []unmarshalTest{
 	{in: `[1, 2, 3+]`, err: &SyntaxError{"invalid character '+' after array element", 9}},
 	{in: `{"X":12x}`, err: &SyntaxError{"invalid character 'x' after object key:value pair", 8}, useNumber: true},
 
+	// raw value errors
+	{in: "\x01 42", err: &SyntaxError{"invalid character '\\x01' looking for beginning of value", 1}},
+	{in: " 42 \x01", err: &SyntaxError{"invalid character '\\x01' after top-level value", 5}},
+	{in: "\x01 true", err: &SyntaxError{"invalid character '\\x01' looking for beginning of value", 1}},
+	{in: " false \x01", err: &SyntaxError{"invalid character '\\x01' after top-level value", 8}},
+	{in: "\x01 1.2", err: &SyntaxError{"invalid character '\\x01' looking for beginning of value", 1}},
+	{in: " 3.4 \x01", err: &SyntaxError{"invalid character '\\x01' after top-level value", 6}},
+	{in: "\x01 \"string\"", err: &SyntaxError{"invalid character '\\x01' looking for beginning of value", 1}},
+	{in: " \"string\" \x01", err: &SyntaxError{"invalid character '\\x01' after top-level value", 11}},
+
 	// array tests
 	{in: `[1, 2, 3]`, ptr: new([3]int), out: [3]int{1, 2, 3}},
 	{in: `[1, 2, 3]`, ptr: new([1]int), out: [1]int{1}},

コアとなるコードの解説

src/pkg/encoding/json/decode.go の変更

Unmarshal 関数の冒頭に以下のコードブロックが追加されました。

	// skip heavy processing for primitive values
	var first byte
	var i int
	for i, first = range data {
		if !isSpace(rune(first)) {
			break
		}
	}
	if first != '{' && first != '[' {
		rv := reflect.ValueOf(v)
		if rv.Kind() != reflect.Ptr || rv.IsNil() {
			return &InvalidUnmarshalError{reflect.TypeOf(v)}
		}
		var d decodeState
		d.literalStore(data[i:], rv.Elem(), false)
		return d.savedError
	}
  1. // skip heavy processing for primitive values: このコメントは、このコードブロックの目的がプリミティブ値に対する重い処理をスキップすることであることを示しています。
  2. 空白文字のスキップ:
    var first byte
    var i int
    for i, first = range data {
        if !isSpace(rune(first)) {
            break
        }
    }
    
    この for ループは、入力 data バイト列の先頭から、isSpace 関数(JSONの空白文字を判定するヘルパー関数)が false を返す最初の文字(非空白文字)を見つけるまでインデックス i と文字 first を進めます。これにより、JSON値の実際の開始位置を特定します。
  3. プリミティブ値の判定と早期リターン:
    if first != '{' && first != '[' {
        rv := reflect.ValueOf(v)
        if rv.Kind() != reflect.Ptr || rv.IsNil() {
            return &InvalidUnmarshalError{reflect.TypeOf(v)}
        }
        var d decodeState
        d.literalStore(data[i:], rv.Elem(), false)
        return d.savedError
    }
    
    • if first != '{' && first != '[': 最初の非空白文字が { でも [ でもない場合、それはJSONオブジェクトでも配列でもないことを意味します。したがって、この値はプリミティブ型(文字列、数値、真偽値、null)であると判断されます。
    • rv := reflect.ValueOf(v): vUnmarshal の第二引数で、デコード結果を格納するGoのインターフェースです。reflect.ValueOf(v) を使用して、v のリフレクション値を取得します。
    • if rv.Kind() != reflect.Ptr || rv.IsNil(): Unmarshal は値を格納するためにポインタを必要とします。このチェックは、v がポインタ型でない場合、またはnilポインタである場合に InvalidUnmarshalError を返してエラー処理を行います。
    • var d decodeState: decodeState はJSONデコードの状態を管理する内部構造体です。
    • d.literalStore(data[i:], rv.Elem(), false): ここが最適化の核心です。literalStore メソッドは、プリミティブなJSONリテラル(文字列、数値、真偽値、null)を効率的にパースし、対応するGoの型に変換して rv.Elem()v が指す実際の値)に格納します。data[i:] は、空白文字をスキップした後のJSONバイト列の残りを渡します。最後の false 引数は、literalStore がトップレベルのJSON値として処理されることを示唆している可能性があります。
    • return d.savedError: literalStore の実行中にエラーが発生した場合、そのエラーが d.savedError に格納され、それが Unmarshal 関数の戻り値として返されます。

この変更により、プリミティブ値のデコードパスが大幅に短縮され、不要な汎用パースロジックの実行が回避されるため、パフォーマンスが向上します。

src/pkg/encoding/json/decode_test.go の変更

テストファイルには、新しい最適化パスが正しく機能することを確認するためのテストケースが追加されています。

  1. // raw values with whitespace:

    	{in: "\n true ", ptr: new(bool), out: true},
    	{in: "\t 1 ", ptr: new(int), out: 1},
    	{in: "\r 1.2 ", ptr: new(float64), out: 1.2},
    	{in: "\t -5 \n", ptr: new(int16), out: int16(-5)},
    	{in: "\t \"a\\u1234\" \n", ptr: new(string), out: "a\\u1234"},
    

    これらのテストケースは、JSON値の前後や内部に空白文字が含まれている場合でも、プリミティブ値(真偽値、整数、浮動小数点数、文字列)が正しくデコードされることを検証しています。これは、decode.go で追加された空白文字スキップロジックの正確性を保証するために重要です。

  2. // raw value errors:

    	{in: "\x01 42", err: &SyntaxError{"invalid character '\\x01' looking for beginning of value", 1}},
    	{in: " 42 \x01", err: &SyntaxError{"invalid character '\\x01' after top-level value", 5}},
    	// ... (同様のエラーケースが続く)
    

    これらのテストケースは、JSONの構文として不正な文字(例: \x01)がプリミティブ値の前後や内部に存在する場合に、SyntaxError が正しく報告されることを検証しています。これは、早期リターンパスが不正な入力に対して堅牢であることを確認するために重要です。

これらのテストケースの追加は、新しい最適化が既存の機能に悪影響を与えず、かつ新しいエッジケース(特に空白文字の扱い)を適切に処理できることを保証します。

関連リンク

参考にした情報源リンク