[インデックス 16008] ファイルの概要
このコミットは、Go言語の標準ライブラリbytes
パッケージ内のBuffer
型におけるメモリ管理の最適化に関するものです。具体的には、Buffer
が内部のバイトスライスを再利用する際の条件を緩和し、過度なメモリのコンパクト化(データのコピー)を避けることで、パフォーマンスを向上させています。ベンチマーク結果では、BenchmarkBufferNotEmptyWriteRead
において3.35%の性能改善が見られています。
コミット
commit 43e38d5defdeffd7ebfff4803bce120c13b55ff2
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date: Fri Mar 29 13:49:14 2013 -0700
bytes: don't compact Buffer so aggressively
benchmark old ns/op new ns/op delta
BenchmarkBufferNotEmptyWriteRead 848416 819983 -3.35%
Update #5154
R=golang-dev, gri, robryk
CC=golang-dev
https://golang.org/cl/8173043
---
src/pkg/bytes/buffer.go | 8 +++++---\n src/pkg/bytes/buffer_test.go | 6 ++++--
2 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/src/pkg/bytes/buffer.go b/src/pkg/bytes/buffer.go
index 0328f4c2d8..69ac6cc014 100644
--- a/src/pkg/bytes/buffer.go
+++ b/src/pkg/bytes/buffer.go
@@ -87,9 +87,11 @@ func (b *Buffer) grow(n int) int {
var buf []byte
if b.buf == nil && n <= len(b.bootstrap) {
buf = b.bootstrap[0:]
- } else if m+n <= cap(b.buf) {
- // We can slide things down instead of
- // allocating a new slice.
+ } else if m+n <= cap(b.buf)/2 {
+ // We can slide things down instead of allocating a new
+ // slice. We only need m+n <= cap(b.buf) to slide, but
+ // we instead let capacity get twice as large so we
+ // don't spend all our time copying.
copy(b.buf[:], b.buf[b.off:])
buf = b.buf[:m]
} else {
diff --git a/src/pkg/bytes/buffer_test.go b/src/pkg/bytes/buffer_test.go
index d629809b57..5b0b8b50cf 100644
--- a/src/pkg/bytes/buffer_test.go
+++ b/src/pkg/bytes/buffer_test.go
@@ -490,8 +490,10 @@ func TestBufferGrowth(t *testing.T) {
}
}\n cap1 := b.Cap()\n-\tif cap1 > cap0 {\n-\t\tt.Errorf(\"buffer cap = %d; too big\", cap1)\n+\t// (*Buffer).grow allows for 2x capacity slop before sliding,\n+\t// so set our error threshold at 3x.\n+\tif cap1 > cap0*3 {\n+\t\tt.Errorf(\"buffer cap = %d; too big (grew from %d)\", cap1, cap0)\n \t}\n }\n
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/43e38d5defdeffd7ebfff4803bce120c13b55ff2
元コミット内容
bytes: don't compact Buffer so aggressively
benchmark old ns/op new ns/op delta
BenchmarkBufferNotEmptyWriteRead 848416 819983 -3.35%
Update #5154
R=golang-dev, gri, robryk
CC=golang-dev
https://golang.org/cl/8173043
変更の背景
このコミットは、bytes.Buffer
の内部実装におけるメモリ管理の効率化を目的としています。bytes.Buffer
は可変長のバイトシーケンスを扱うための型であり、データの追加(書き込み)や読み出しが行われるたびに、必要に応じて内部のバイトスライス(b.buf
)のサイズを調整します。
以前の実装では、Buffer
が読み出しによって先頭部分のデータが不要になった際、残りの有効なデータをスライスの先頭に「スライド」(コピー)させることでメモリをコンパクトに保とうとしていました。このスライド処理は、新しいスライスを割り当てるコストを避けるための最適化ですが、頻繁に発生するとそのコピー処理自体がオーバーヘッドとなり、パフォーマンスを低下させる可能性がありました。
コミットメッセージにあるBenchmarkBufferNotEmptyWriteRead
のベンチマーク結果は、この問題を示唆しています。このベンチマークは、Buffer
にデータを書き込み、その後読み出すという一連の操作を繰り返すシナリオを測定しており、この種の操作でスライド処理が頻繁に発生していたと考えられます。
Update #5154
という記述は、この変更が特定のGoイシュー(問題報告)に対応するものであることを示していますが、現在の検索では直接的なイシュー5154の詳細は見つかりませんでした。しかし、bytes.Buffer
のメモリ管理に関する議論はGoコミュニティで度々行われており、特に内部バッファの成長戦略や、ヒープへのアロケーションを減らすための最適化は重要なテーマです。このコミットは、過度なスライド処理によるコピーコストを削減し、全体的なパフォーマンスを向上させることを目的としたものです。
前提知識の解説
bytes.Buffer
とは
bytes.Buffer
はGo言語のbytes
パッケージで提供される型で、可変長のバイトシーケンスを扱うためのバッファです。io.Reader
やio.Writer
インターフェースを実装しており、バイトデータの読み書きを効率的に行うことができます。内部的にはバイトスライス([]byte
)を保持しており、データが追加されると必要に応じてこのスライスの容量を拡張します。
スライス(Slice)の容量(Capacity)と長さ(Length)
Goのスライスは、内部的に配列を参照しています。
- 長さ (Length): スライスが現在保持している要素の数です。
len(s)
で取得できます。 - 容量 (Capacity): スライスが参照している内部配列の、スライスの開始位置から末尾までの要素の総数です。
cap(s)
で取得できます。
スライスに要素を追加して長さが容量を超えると、Goランタイムはより大きな容量を持つ新しい内部配列を割り当て、既存の要素を新しい配列にコピーします。この再割り当てとコピーのコストは高いため、bytes.Buffer
のようなデータ構造では、このコストを最小限に抑えるための戦略が重要になります。
Buffer.grow
メソッド
bytes.Buffer
のgrow
メソッドは、バッファにn
バイトの追加スペースが必要になった際に呼び出されます。このメソッドの主な役割は、以下のいずれかの方法で必要な容量を確保することです。
- 既存の容量が十分な場合: 新しい割り当ては不要です。
- 内部のブートストラップバッファを利用する場合:
Buffer
は小さな固定サイズの内部バッファ(bootstrap
)を持っており、小さなデータの場合はヒープ割り当てを避けるためにこれを利用します。 - スライド(Slide): バッファの先頭からデータが読み出されて
b.off
(オフセット)が進んでいる場合、有効なデータ(b.buf[b.off:]
)をスライスの先頭(b.buf[:m]
)にコピーし直すことで、既存の容量を再利用します。これにより、新しいスライスを割り当てることなく、バッファの「空き」部分を先頭に移動させることができます。 - 新しいスライスの割り当て: 上記のいずれの条件も満たさない場合、より大きな容量を持つ新しいスライスを割り当て、既存の有効なデータをそこにコピーします。通常、容量は倍々に増やされることで、償却定数時間での追加操作を実現します。
このコミットの変更は、特に3番目の「スライド」の条件に影響を与えます。
技術的詳細
このコミットの核心は、bytes.Buffer
のgrow
メソッド内のスライド処理の条件変更です。
変更前のコード:
} else if m+n <= cap(b.buf) {
// We can slide things down instead of
// allocating a new slice.
copy(b.buf[:], b.buf[b.off:])
buf = b.buf[:m]
この条件m+n <= cap(b.buf)
は、「現在の有効なデータの長さ(m
)と、新しく書き込むデータの長さ(n
)の合計が、現在のバッファの総容量(cap(b.buf)
)以下であれば、スライド処理を行う」という意味です。つまり、バッファのどこかに空きがあれば、その空きを再利用するためにデータを先頭にコピーしていました。
変更後のコード:
} else if m+n <= cap(b.buf)/2 {
// We can slide things down instead of allocating a new
// slice. We only need m+n <= cap(b.buf) to slide, but
// we instead let capacity get twice as large so we
// don't spend all our time copying.
copy(b.buf[:], b.buf[b.off:])
buf = b.buf[:m]
変更後の条件はm+n <= cap(b.buf)/2
です。これは、「現在の有効なデータの長さと新しく書き込むデータの長さの合計が、現在のバッファの総容量の半分以下である場合にのみ、スライド処理を行う」という意味になります。
この変更の意図は、コメントに明確に示されています:
We only need m+n <= cap(b.buf) to slide, but we instead let capacity get twice as large so we don't spend all our time copying.
(スライドするためにはm+n <= cap(b.buf)
で十分だが、コピーに時間を費やさないように、代わりに容量を2倍に大きくすることを許容する。)
つまり、以前はバッファの空き容量が少しでもあればスライドして再利用しようとしていましたが、この変更により、バッファの空き容量が総容量の半分以上ある場合にのみスライドを行うようになりました。これにより、バッファの容量をより積極的に「無駄に」大きく保つことになりますが、その代わりに頻繁なデータコピー(スライド処理)の発生を抑えることができます。
このトレードオフは、メモリ使用量とCPU使用量(コピーコスト)の間でバランスを取るものです。bytes.Buffer
は通常、一時的なデータ処理に使われることが多く、メモリを少し多めに消費しても、コピーによるCPUオーバーヘッドを減らす方が全体的なパフォーマンスには有利であるという判断がなされたと考えられます。特に、BenchmarkBufferNotEmptyWriteRead
のような、書き込みと読み出しが繰り返されるシナリオでは、このスライド処理が頻繁に発生し、性能ボトルネックになっていた可能性が高いです。
テストコードsrc/pkg/bytes/buffer_test.go
の変更もこの意図を反映しています。TestBufferGrowth
では、バッファの容量が過度に大きくなっていないかをチェックしていますが、変更前はcap1 > cap0
(容量が初期容量を超えていないか)という厳密なチェックでした。変更後はcap1 > cap0*3
(容量が初期容量の3倍を超えていないか)という緩和されたチェックになっています。これは、grow
メソッドが「2倍の容量の余裕を許容する」ようになったため、テスト側もその新しい挙動に合わせて許容範囲を広げたものです。
コアとなるコードの変更箇所
src/pkg/bytes/buffer.go
--- a/src/pkg/bytes/buffer.go
+++ b/src/pkg/bytes/buffer.go
@@ -87,9 +87,11 @@ func (b *Buffer) grow(n int) int {
var buf []byte
if b.buf == nil && n <= len(b.bootstrap) {
buf = b.bootstrap[0:]
- } else if m+n <= cap(b.buf) {
- // We can slide things down instead of
- // allocating a new slice.
+ } else if m+n <= cap(b.buf)/2 {
+ // We can slide things down instead of allocating a new
+ // slice. We only need m+n <= cap(b.buf) to slide, but
+ // we instead let capacity get twice as large so we
+ // don't spend all our time copying.
copy(b.buf[:], b.buf[b.off:])
buf = b.buf[:m]
} else {
src/pkg/bytes/buffer_test.go
--- a/src/pkg/bytes/buffer_test.go
+++ b/src/pkg/bytes/buffer_test.go
@@ -490,8 +490,10 @@ func TestBufferGrowth(t *testing.T) {
}
}\n cap1 := b.Cap()\n-\tif cap1 > cap0 {\n-\t\tt.Errorf(\"buffer cap = %d; too big\", cap1)\n+\t// (*Buffer).grow allows for 2x capacity slop before sliding,\n+\t// so set our error threshold at 3x.\n+\tif cap1 > cap0*3 {\n+\t\tt.Errorf(\"buffer cap = %d; too big (grew from %d)\", cap1, cap0)\n \t}\n }\n
コアとなるコードの解説
src/pkg/bytes/buffer.go
の変更
grow
関数は、bytes.Buffer
が内部バッファの容量を増やす必要があるときに呼び出されます。この関数内で、既存のバッファを再利用してデータをスライドさせるか、新しいバッファを割り当てるかを決定するロジックがあります。
変更された行は以下の部分です。
- } else if m+n <= cap(b.buf) {
+ } else if m+n <= cap(b.buf)/2 {
-
変更前:
m+n <= cap(b.buf)
- これは、「現在の有効なデータ長(
m
)と、新しく書き込むデータの長さ(n
)の合計が、現在のバッファの総容量(cap(b.buf)
)以下であれば、スライド処理を行う」という条件でした。つまり、バッファのどこかに空きがあれば、その空きを再利用するためにデータを先頭にコピーしていました。これにより、メモリを最大限にコンパクトに保とうとします。
- これは、「現在の有効なデータ長(
-
変更後:
m+n <= cap(b.buf)/2
- これは、「現在の有効なデータ長と新しく書き込むデータの長さの合計が、現在のバッファの総容量の半分以下である場合にのみ、スライド処理を行う」という条件です。
- この変更により、バッファの空き容量が総容量の半分以上ある場合にのみスライドが行われます。それ以外の場合は、たとえ空き容量があってもスライドは行われず、新しいバッファが割り当てられる可能性が高まります。
- 新しいコメント
We only need m+n <= cap(b.buf) to slide, but we instead let capacity get twice as large so we don't spend all our time copying.
が追加されており、この変更の意図が「コピーに時間を費やさないように、容量を2倍に大きくすることを許容する」ことにあると明記されています。これは、メモリ使用量とCPU使用量(コピーコスト)の間のトレードオフであり、コピー処理の頻度を減らすことで全体的なパフォーマンスを向上させることを目指しています。
src/pkg/bytes/buffer_test.go
の変更
TestBufferGrowth
は、bytes.Buffer
の容量が適切に成長しているか、または過度に大きくなっていないかをテストするものです。
変更された行は以下の部分です。
--- a/src/pkg/bytes/buffer_test.go
+++ b/src/pkg/bytes/buffer_test.go
@@ -490,8 +490,10 @@ func TestBufferGrowth(t *testing.T) {
}
}\n cap1 := b.Cap()\n-\tif cap1 > cap0 {\n-\t\tt.Errorf(\"buffer cap = %d; too big\", cap1)\n+\t// (*Buffer).grow allows for 2x capacity slop before sliding,\n+\t// so set our error threshold at 3x.\n+\tif cap1 > cap0*3 {\n+\t\tt.Errorf(\"buffer cap = %d; too big (grew from %d)\", cap1, cap0)\n \t}\n }\n
-
変更前:
if cap1 > cap0
- これは、「最終的なバッファ容量(
cap1
)が初期容量(cap0
)を超えていればエラーとする」という非常に厳密なチェックでした。これは、バッファが不必要に容量を増やさないことを期待していました。
- これは、「最終的なバッファ容量(
-
変更後:
if cap1 > cap0*3
- これは、「最終的なバッファ容量が初期容量の3倍を超えていればエラーとする」という、より緩和されたチェックです。
- 新しいコメント
(*Buffer).grow allows for 2x capacity slop before sliding, so set our error threshold at 3x.
が追加されています。これは、grow
メソッドがスライドする前に「2倍の容量の余裕」を許容するようになったため、テストもその新しい挙動に合わせて許容範囲を広げたことを示しています。つまり、バッファが初期容量の最大3倍まで大きくなることは許容範囲内であると見なされるようになりました。
これらの変更は一貫しており、bytes.Buffer
がメモリをより積極的に保持することで、頻繁なデータコピーによるパフォーマンスオーバーヘッドを削減するという設計思想の変更を反映しています。
関連リンク
- Go CL 8173043: https://golang.org/cl/8173043
参考にした情報源リンク
bytes.Buffer
の成長と再割り当てに関するStack Overflowの議論: https://stackoverflow.com/questions/20001401/how-does-bytes-buffer-grow-its-capacitybytes.Buffer
の内部スクラッチスペースに関するRedditの議論: https://www.reddit.com/r/golang/comments/102120/bytesbuffer_internal_scratch_space/- Go issue #7661 (bytes.Bufferのヒープ割り当てに関する議論): https://github.com/golang/go/issues/7661
- Go issue #7921 (bytes.Bufferのヒープ割り当てに関する議論): https://github.com/golang/go/issues/7921
- Go issue #52599 (bytes.ErrTooLargeの挙動変更提案): https://github.com/golang/go/issues/52599
- Go issue #53630 (nil *bytes.Bufferのパニックに関する議論): https://github.com/golang/go/issues/53630