0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Go小ネタ — 高性能環境における Slice の拡張コストに注意

Posted at

はじめに

append() は Go 言語で最も頻繁に使用される組み込み関数の一つで、ほとんどの業務ロジックで使用されます。

しかし、パフォーマンスや安定性を重視する場面では、潜在的なボトルネックになることもあります。

本記事では、ソースコードとベンチマーク結果を組み合わせて、slice の拡張(容量増加)の仕組みを体系的に解析し、CPU やメモリに影響する重要なポイントを探ります。そして、最適化のための実践的な方法を紹介します。


append 内部での slice の原理

Go において、slice は配列の軽量ラッパーであり、内部的には以下の構造体で表されます:

type slice struct {
    array unsafe.Pointer // 基盤となる配列へのポインタ
    len   int            // 現在の要素数
    cap   int            // 配列の容量
}

append(s, elems...) を実行すると、Go は追加後の長さを確認し、拡張が必要かどうかを判断します:

  1. len(s) + len(elems) <= cap(s) の場合、配列に空きがあるため、新しい要素は直接書き込まれます
  2. len(s) + len(elems) > cap(s) の場合、拡張がトリガーされ、runtimegrowslice が呼び出されます

拡張処理の流れ

拡張が発生すると、growslice は以下のステップを実行します:

  1. 追加後の総長さ needed = len + len(elems) を計算
  2. 古い容量と必要量から新しい容量 newcap を決定
  3. より大きなメモリ領域を割り当て
  4. memmove で既存データを新しい配列にコピー
  5. slice の内部ポインタ、長さ、容量を更新して新しい slice を返す

このプロセスは開発者からは透過的ですが、性能上の消費ポイントとなります。

拡張は完全なデータコピーと新しいメモリ割り当てを伴うためです。

拡張戦略

Go の拡張戦略は runtime/slice.go 内の nextslicecap() によって決まります(Go 1.21 の場合):

func nextslicecap(newLen, oldCap int) int {
	newcap := oldCap
	doublecap := newcap + newcap
	if newLen > doublecap {
		return newLen
	}

	const threshold = 256
	if oldCap < threshold {
		return doublecap
	}
	for {
		newcap += (newcap + 3*threshold) >> 2
		if uint(newcap) >= uint(newLen) {
			break
		}
	}

	if newcap <= 0 {
		return newLen
	}
	return newcap
}

この拡張ロジックは大きく三段階に分かれます:

  1. 倍増段階newLen <= 2 × oldCap かつ oldCap < 256 の場合は 2 倍
  2. 緩やか成長段階:閾値を超えると増加率を徐々に緩やかにし、最終的に約 1.25 倍
  3. 安全段階:オーバーフローや容量不足の場合は newLen を直接返す

この設計により、小さな slice は高速に拡張され、大きな slice はメモリ効率を考慮して緩やかに拡張されます。

まとめ

容量不足時の拡張は、データコピーと新メモリ割り当てを伴います。

増加ロジックは nextslicecap により性能とメモリのバランスを制御しています。

  • 小さな slice (<256) は 2 倍拡張
  • 大きな slice (>=256) は 約 1.25 倍拡張
  • 各拡張は必ず memmove によるコピーを伴う

このプロセスの理解は、頻繁な追加や大規模データ処理の最適化に不可欠です。

なお、append は slice の内部配列割り当てを操作するものであり、他のコレクション構造の拡張戦略とは異なります。


拡張が性能に与える影響

append() のコストを直感的に理解するために、容量未指定と事前指定の 2 つのケースでベンチマークを行いました:

func BenchmarkSliceAppend(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := []int{}
        for j := 0; j < 10_000_000; j++ {
            s = append(s, j)
        }
    }
}

func BenchmarkSliceAppendWithCap(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := make([]int, 0, 10_000_000)
        for j := 0; j < 10_000_000; j++ {
            s = append(s, j)
        }
    }
}

ベンチマーク結果:

BenchmarkSliceAppend-8             19    59692110 ns/op
BenchmarkSliceAppendWithCap-8     121     9187971 ns/op

性能差は 6 倍以上!

容量未指定の場合、追加ごとに拡張が発生し、以下の操作が繰り返されます:

  1. 新しいメモリ領域を割り当て
  2. memmove で既存データをコピー
  3. 古い配列を GC に回収させる

これらの操作は数千万要素では数百回実行され、ヒープ割り当てと GC の頻度が急増します。事前割り当てを行った場合、GC はほとんど発生せず、メモリ使用量も安定します。


拡張による性能問題の回避方法

容量の事前推定と make の利用

要素数をおおよそ予測できる場合は、slice 作成時に容量を指定 (cap) します:

s := make([]int, 0, estimatedSize)

これにより、数十〜数百回のコピーと GC を回避できます。

sync.Pool による slice の再利用

高並列環境で頻繁に生成される一時 slice は sync.Pool で再利用可能です:

pool := sync.Pool{New: func() any {
    return make([]int, 0, 1024)
}}
s := pool.Get().([]int)[:0]
defer pool.Put(s)

これによりヒープ割り当ての頻度と GC 負荷が大幅に軽減されます。

データの分割処理(チャンク化)

データ量が不確定な場合は、チャンク単位で処理すると良いでしょう:

const chunkSize = 10000

for i := 0; i < totalItems; i += chunkSize {
    end := i + chunkSize
    if end > totalItems {
        end = totalItems
    }

    chunk := make([]int, 0, end-i)
    for j := i; j < end; j++ {
        chunk = append(chunk, j)
    }

    processChunk(chunk)
}

ピークメモリを制御でき、瞬間的な大容量割り当てを避けられます。

一時的な大容量 slice の使用に注意

寿命の短い大容量 slice は、ヒープメモリを圧迫し GC を頻繁に誘発します。

var pool = sync.Pool{
    New: func() any {
        return make([]byte, 0, 1_000_000)
    },
}

func handleLargeData() {
    buf := pool.Get().([]byte)
    defer func() {
        buf = buf[:0]
        pool.Put(buf)
    }()

    buf = append(buf, loadData()...)
    processLargeBuffer(buf)
}

どうしても大きなバッファが必要な場合は、再利用バッファや mmap などでヒープ外に移す方法も有効です。


有名プロジェクトでの最適化例

プロジェクト 最適化方法 使用シーン
Prometheus 事前割り当てバッファ + オブジェクトプール 時系列データ集計
Kubernetes sync.Pool によるスケジューリングキャッシュ最適化 スケジューリングキュー
Etcd バッチ書き込みで拡張回避 Raft ログ同期

共通点は 高頻度データ構造で自動的な拡張を避ける ことです。


Java の ArrayList と比較

特性 Go Slice Java ArrayList
デフォルト拡張戦略 <256 は 2 倍、それ以上は約 1.25 倍 1.5 倍
メモリ割り当て方式 make で手動指定可能 自動割り当て
メモリ管理 GC 管理、値型のコピーは断片化やオーバーヘッドあり GC 管理、拡張時は参照コピーのみ
拡張コスト 高い、値型の要素をコピーする必要がある 比較的低コスト、参照コピーのみ

Go slice の拡張コストは値型配列で顕著です。Java の ArrayList は参照のみをコピーするため、大きなオブジェクト配列でもパフォーマンスへの影響は軽微です。


まとめ

Go のメモリ管理は優れているとはいえ、大量データ・高性能環境では、slice 容量を意識せずに append() を多用すると Java よりもパフォーマンスコストが高くなる場合があります。

高パフォーマンス Go コードのための推奨実践:

  • 容量を事前に予測して make で作成:複数回の拡張を回避
  • オブジェクト再利用sync.Pool を活用してヒープ割り当てと GC 負荷を軽減
  • データを分割処理:一度に大きな slice を作らず、瞬間的なメモリピークを避ける
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?