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

[インデックス 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.Readerio.Writerインターフェースを実装しており、バイトデータの読み書きを効率的に行うことができます。内部的にはバイトスライス([]byte)を保持しており、データが追加されると必要に応じてこのスライスの容量を拡張します。

スライス(Slice)の容量(Capacity)と長さ(Length)

Goのスライスは、内部的に配列を参照しています。

  • 長さ (Length): スライスが現在保持している要素の数です。len(s)で取得できます。
  • 容量 (Capacity): スライスが参照している内部配列の、スライスの開始位置から末尾までの要素の総数です。cap(s)で取得できます。

スライスに要素を追加して長さが容量を超えると、Goランタイムはより大きな容量を持つ新しい内部配列を割り当て、既存の要素を新しい配列にコピーします。この再割り当てとコピーのコストは高いため、bytes.Bufferのようなデータ構造では、このコストを最小限に抑えるための戦略が重要になります。

Buffer.growメソッド

bytes.Buffergrowメソッドは、バッファにnバイトの追加スペースが必要になった際に呼び出されます。このメソッドの主な役割は、以下のいずれかの方法で必要な容量を確保することです。

  1. 既存の容量が十分な場合: 新しい割り当ては不要です。
  2. 内部のブートストラップバッファを利用する場合: Bufferは小さな固定サイズの内部バッファ(bootstrap)を持っており、小さなデータの場合はヒープ割り当てを避けるためにこれを利用します。
  3. スライド(Slide): バッファの先頭からデータが読み出されてb.off(オフセット)が進んでいる場合、有効なデータ(b.buf[b.off:])をスライスの先頭(b.buf[:m])にコピーし直すことで、既存の容量を再利用します。これにより、新しいスライスを割り当てることなく、バッファの「空き」部分を先頭に移動させることができます。
  4. 新しいスライスの割り当て: 上記のいずれの条件も満たさない場合、より大きな容量を持つ新しいスライスを割り当て、既存の有効なデータをそこにコピーします。通常、容量は倍々に増やされることで、償却定数時間での追加操作を実現します。

このコミットの変更は、特に3番目の「スライド」の条件に影響を与えます。

技術的詳細

このコミットの核心は、bytes.Buffergrowメソッド内のスライド処理の条件変更です。

変更前のコード:

		} 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-capacity
  • bytes.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