cover

Go の String Interning

sorcererxw

紹介

Javaでは、JVMがデフォルトで文字列のキャッシュプールを作成し、頻繁に新しいstringオブジェクトを作成することを避けています。しかし、Goではこのような最適化は実装されていません(Goはコンパイル時にのみ文字列をキャッシュします)。そのため、大量の動的文字列を扱う際には、少なからずパフォーマンスの低下を招きます。では、Javaを模倣してstring poolを実装し、ホットスポット文字列を局所的にキャッシュすることは可能でしょうか。これがString Interning技術です。

https://stackabuse.com/guide-to-string-interning-in-python/

実装

Goのstringの内部構造を観察してみましょう:

package reflect

// StringHeader is the runtime representation of a string.
// It cannot be used safely or portably and its representation may
// change in a later release.
// Moreover, the Data field is not sufficient to guarantee the data
// it references will not be garbage collected, so programs must keep
// a separate, correctly typed pointer to the underlying data.
type StringHeader struct {
	Data uintptr
	Len  int
}

string の本質はメモリポインタと長さであることがわかります。Go の string は不変の特性を持っているため、string の再利用は安全だと考えられます。以下はその簡単な実装です:

type stringInterner map[string]string

func (si stringInterner) InternBytes(b []byte) string {
	if interned, ok := si[string(b)]; ok {
		return interned
	}
	s := string(b)
	si[s] = s
	return s
}

上記の <code>stringInterner</code> は、[]bytestring に変換する際に、その string が既にインターンされているかどうかをチェックし、されていれば既存の string を直接返し、そうでなければ新しい string をインターンします。この関数は直感に反しているように見えます。直接 <code>string(b)</code> とするよりも多くの操作を行っているにも関わらず、なぜかパフォーマンスが向上しています。その理由を分析してみましょう:

<code>string(b)</code> の原理は、b をそのまま string の Data として扱うため、プログラムが新しいバイトストリームを絶えず読み込むと、新しい string オブジェクトが絶えず生成され、string が占める空間は線形に増加します。上記の関数では、比較の基準として []byte のみを使用しており、新たに生成された <code>string(b)</code>(スタック上に割り当てられる)は関数の終了と共に回収されますが、ヒットしなかった string のみがヒープ上にキャッシュされます。

また、注目すべき点があります。Goでは関数呼び出し時にパラメータと戻り値が完全に値コピーで渡されることは周知の事実ですが、なぜ戻り値のstringがinterningされたstringなのでしょうか?新しいコピーを作成せずに返すのはなぜでしょうか?データのコピーがこの関数の成立に決定的な影響を与えます。Go Internal ABIの説明によると:

string型は、文字列のバックストアを指す *[len]byte ポインタと、文字列の長さを示す int から成るシーケンスです。

パラメータの渡し方や結果の返し方に関わらず、string は実際のデータを指す参照方式であり、string を渡す際にデータがコピーされることはありません。

以上の stringInterning は、繰り返しテキストが多い場合に、ある程度メモリ消費を減らすことができますが、テキストにほとんど繰り返しがない場合には、比較操作を余分に行うため、実行効率が少し低下することがあります。

実験

現在、実際のシナリオを設計して、stringInterning の実際の効果を検証してみましょう。

仮に、大量の重複する文字列を含むJSONデータを逆シリアライズする必要があるとします:

[
		{"year":"2021","month":"January","day":"1"}
		{"year":"2021","month":"January","day":"2"}
		...
		{"year":"2021","month":"December","day":"31"}
]


// calendar generate JSON of 2021
func calendarJSON() []byte {
	var calendar []interface{}
	d := time.Date(2021, 0, 0, 0, 0, 0, 0, time.UTC)
	for d.Year() < 2022 {
		d = d.Add(time.Hour * 24)
		calendar = append(calendar, map[string]string{
			"year":  strconv.Itoa(d.Year()),
			"month": d.Month().String(),
			"day":   strconv.Itoa(d.Day()),
		})
	}
	ret, _ := json.Marshal(calendar)
	return ret
}

それぞれ二つの関数を作成します:

// SimpleString wraps go string.
type SimpleString struct {
	data string
}

func (i *SimpleString) UnmarshalJSON(b []byte) error {
	i.data = string(b)
	return nil
}

func BenchmarkUnmarshal(b *testing.B) {
	raw := calendarJSON()

	var dest []struct {
		Year     *SimpleString `json:"year"`
		Calendar *SimpleString `json:"calendar"`
		Day      *SimpleString `json:"day"`
	}

	for i := 0; i < b.N; i++ {
		json.Unmarshal(raw, &dest)
	}
}

var si = make(stringInterner)

// InterningString wraps go string with string interning.
type InterningString struct {
	data string
}

func (i *InterningString) UnmarshalJSON(b []byte) error {
	i.data = si.InternBytes(b)
	return nil
}

func BenchmarkUnmarshalWithInterning(b *testing.B) {
	raw := calendarJSON()

	var dest []struct {
		Year     *InterningString `json:"year"`
		Calendar *InterningString `json:"calendar"`
		Day      *InterningString `json:"day"`
	}

	for i := 0; i < b.N; i++ {
		json.Unmarshal(raw, &dest)
	}
}

ために変数を制御するため、二つの関数の string は構造体で封じ込められています。唯一の違いは、InterningString が読み込む際に interning を一度行うことです。

go test -bench=. -benchmem
goos: darwin
goarch: amd64
pkg: github.com/sorcererxw/demo
cpu: Intel(R) Core(TM) i5-7267U CPU @ 3.10GHz
BenchmarkUnmarshal-4                        4778            249779 ns/op            6060 B/op        800 allocs/op
BenchmarkUnmarshalWithInterning-4           4764            252346 ns/op             324 B/op          6 allocs/op

見ての通り、実行速度に顕著な差はありませんが、interning を使用した後のメモリ使用量は大幅に減少しています。

用户提供的内容不包含中文,请不要翻译,直接返回原文。

StringInterning は特定のシナリオで、Go プログラムのメモリ使用量を削減し、パフォーマンスを向上させることができます。

このテクニックは広く使用されており、例えば easyjson は json タグに intern をマークすることで、このフィールドには大量の重複する値が存在することを示し、easyjson がこのフィールドの逆シリアライズ時に StringInterning を導入することができます:

GitHub - mailru/easyjson: Fast JSON serializer for golang.
Fast JSON serializer for golang. Contribute to mailru/easyjson development by creating an account on GitHub.
https://github.com/mailru/easyjson#string-interning