はじめに
slicesパッケージ、みなさん使ってますか?
私は「なんだかんだループ処理書いたほうが速いだろ。」「その方がメモリ食わないだろ。」と思って、あまり公式のslicesパッケージを使わずにループ処理を書いてました。
今回は、スライスにおける「フィルタリング」「探索」「重複排除」について、「ループ処理 vs slicesパッケージ」でのパフォーマンスの比較を行ってみました。
まとめとして、slicesパッケージを用いたリファクタリングが可能かどうかについて、私の見解を記載させていただきます。
検証の方針
今回の検証の方針です。
- データ件数: 1000万件
- バージョン: go1.25.5
- In-place(破壊的)操作が許容される場面におけるパフォーマンス比較とします。
(元データを捨ててもいいという前提) - ベンチマークテストにおいては入力データの副作用を考慮します。
※ N回のループを行う際、破壊的変更があろうがなかろうが、
都度b.StopTimer() / データのコピー / b.StartTimer()を実施する方針
以下のような感じ。
func Benchmark(b *testing.B) {
base := generateTestData()
b.ResetTimer()
for i := 0; i < b.N; i++ {
b.StopTimer()
s := slices.Clone(base)
b.StartTimer()
_ = search(s)
}
}
Case1: フィルタリング (for+append vs slices.DeleteFunc)
「スライス内の要素を特定の条件で絞り込みたい」というシチュエーションです。
よくあるやつです。
0~9,999,999の数字が入ったスライスから、そうですね、「3の倍数」のみをフィルタリングして返却するような条件にしたいと思います。
下準備
検証用のデータを生成する関数です。
const dataSize = 10000000
func generateTestData() []int {
s := make([]int, 0, dataSize)
for i := 0; i < dataSize; i++ {
s = append(s, i)
}
return s
}
実装コード
ループ処理(for+append)
func loop(s []int) []int {
arr := make([]int, 0, len(s))
for _, v := range s {
if v%3 == 0 {
arr = append(arr, v)
}
}
return arr
}
slicesパッケージ(slices.DeleteFunc)
func slicesPkg(s []int) []int {
return slices.DeleteFunc(s, func(n int) bool { return n%3 != 0 })
}
ベンチマーク結果
go test -v -bench . -benchmem
| Method | 試行回数 | Time/op | Alloc/op |
|---|---|---|---|
| for + append | 98 回 | 11.5 ms | 80 MB |
| slices.DeleteFunc | 88 回 | 13.5 ms | 0 B |
推察
※ループ処理も In-place で実装すれば 0 Alloc になりますが、ここでは一般的な append 方式と比較しています(「実装コストも考慮した上で」と読んでいただければ幸いです)。
- 速度: 微々たる差ですが、「for + append」版の方が速度が出ています。
- メモリ: 「for + append」版は 80 MB ものメモリ確保(Alloc)が発生しています。
これらは最終的にゴミ(Garbage)となり、GC(ガベージコレクション)の負荷に繋がります。
対して DeleteFunc は ゼロアロケーションですね。 - 備考: 1行でかける、といったよさもある(良し悪しを多面的に評価すると意見が割れそうですが)
slices.DeleteFuncは処理速度自体も実用上は十分高速ですが、それ以上に「GCを発生させない」という点でシステム全体のレイテンシ安定化に貢献すると言えます。
補足
実際のロジックでは元のスライスが1000万件であれば、返却用スライスも「とりあえず1000万件分」確保しています。
実際のデータでは、3の倍数の抽出のため結果は約27MBになる見込み。
80MB確保しておいて、実際は27MBしか使わない、残りの53MBは無駄なキャパシティとして圧迫しているが今回の実装例です。
for文で make を使うと、結果のサイズが予測できない場合に最大サイズ(len(s))で確保せざるを得ないです。
そのためメモリを無駄に食います。その点 DeleteFunc は in-place なので無駄がない実装と言えるかと。
Case2: 探索 (forvs slices.IndexFunc)
「特定の条件を満たすEntityがリスト内にあるか」を確認するシチュエーションです。
単純な数値ではなく、構造体のスライスで検証します。
下準備
const dataSize = 10000000
type sample struct {
id int
name string
}
func generateTestData() []sample {
s := make([]sample, 0, dataSize)
dummyName := "abcde"
for i := 0; i < dataSize; i++ {
s = append(s, sample{
id: i,
name: dummyName,
})
}
return s
}
実装コード
ループ処理(for+append)
データを全件探索しながら条件に合致した場合に、処理を中断する方法です。
いわゆる線形探索ですね。
func loop(s []sample) sample {
var target sample
for _, v := range s {
if v.id == dataSize-1 {
target = v
break
}
}
return target
}
slices pkg(slices.IndexFunc)
func slicesPkg(s []sample) sample {
i := slices.IndexFunc(s, func(v sample) bool { return v.id == dataSize-1 })
if i == -1 {
return sample{}
}
return s[i]
}
ベンチマーク結果
| Method | 試行回数 | Time/op | Alloc |
|---|---|---|---|
| for | 217 回 | 4.1 ms | 0 B |
| slices.IndexFunc | 174 回 | 6.8 ms | 0 B |
推察
- 速度: slices パッケージが約1.6倍遅いという結果。
- 単純な整数比較の場合、
IndexFuncはコールバック関数を呼び出すオーバーヘッドがあるため、コンパイラの最適化が効きやすい for ループに分がある、といった解釈です。
探索において、パフォーマンスを評価した場合、依然としてループ処理が強いと感じました。
とはいえ、差は2.7msです。「意図が伝わりやすい可読性」を優先してIndexFuncを採用するのは充分ありと思います。
※ 探索アルゴリズムにおいてはスライスを用いるよりもハッシュ探索等を活用するケースも多々あると思いますので、率先して使う場面がまだ私はイメージ出来てないです。
Case3: 重複排除(Map vs Sort+Compact)
「1000万件のデータ(ただし種類は10種類のみ)」に対して重複排除を行います。
下準備
const dataSize = 10000000
func generateTestData() []int {
s := make([]int, 0, dataSize)
for i := 0; i < dataSize; i++ {
s = append(s, i%10) // 0~9 を繰り返す
}
return s
}
実装コード
具体的には、以下の2つの実装を比較します。
ループ処理(Map)
map をセットとして使いユニーク化し、最後に順序を保証するためにソートします。
func loop(s []int) []int {
seen := make(map[int]struct{}, len(s))
result := make([]int, 0, len(s))
for _, v := range s {
if _, ok := seen[v]; !ok {
seen[v] = struct{}{}
result = append(result, v)
}
}
// 条件を揃えるためソートを実施
slices.Sort(result)
return result
}
slices pkg(Sort + Compact)
ソートして隣り合う重複を削除します。
func slicesPkg(s []int) []int {
slices.Sort(s) // Compactにはソートが必須
return slices.Compact(s)
}
ベンチマーク結果
| Method | 試行回数 | Time | Alloc |
|---|---|---|---|
| for | 18 回 | 64.7 ms | 382 MB |
| Sort + Compact | 24 回 | 47.9 ms | 0 B |
推察
- 速度: Sort + Compact が 約 26% 高速。
- メモリ: 382 MB vs 0 B。圧倒的な差が出てますね。
コンテナ環境でこれを並列実行など、限界があります。 - 可読性もMapと比べると、明らかによいですね。
Mapを使った実装は、内部でハッシュテーブルのバケット確保などを行うため、大量のメモリを消費します。
コンテナ環境(Lambda等)で、並列リクエストごとに 382MB も消費されたら、溜まったもんじゃないですよね。
でもでも。
以下3点、補足を入れてます。
補足
1. ループ処理(Map)のSortって必要なの?
そういう質問来るかなと思いましたので一応補足です。
「出力後のデータの条件を揃える」という意図で、ループ処理のコード内でもslices.Sortを実行しています。
データのファイル書き出し等でも、順序が用件に入ることが標準かなと思いましたし。
以下に、並び替えを実行しなかった場合のベンチマークを記載しますね。
| Method | 試行回数 | Time | Alloc |
|---|---|---|---|
| for | 18 回 | 64.7 ms | 382 MB |
| Sort + Compact | 25 回 | 47.8 ms | 0 B |
2. 一応今回は、データの中身が int だからこの検証結果が出ています
"要素が巨大な構造体の場合"や"コストの高い文字列の場合"、
Sortの比較コストが高くなるため、Mapの方が速くなる、という現象が起きることも可能性としてあります。
3. Mapのキャパシティ、10でいいんじゃない?
確かにその通りです。容量が分かっている前提であれば、以下のような書き方が可能です。
func beforeThird(s []int) []int {
- seen := make(map[int]struct{}, len(s))
- result := make([]int, 0, len(s))
+ seen := make(map[int]struct{}, 10)
+ result := make([]int, 0, 10)
for _, v := range s {
if _, ok := seen[v]; !ok {
seen[v] = struct{}{}
result = append(result, v)
}
}
return result
}
その場合は Alloc: 300B ほどでした。
まとめ
結局どうなの?slicesパッケージでリファクタリングは可能なの?
正直、「絶対slicesだろ!」ほどの優位性が目立った検証結果は得られませんでした。
検証データのデータの特性上、速度が出ている部分もありますし、実務で想定される複雑な構造体を含むスライス操作では、Mapを活用する場面が依然として多いのかなと。
ただ、もちろんケースバイケースで選択できる場面もあると思います。
「GCへの考慮」というのは良い特徴だなと感じている所です。
「データの特性」や「要件」に応じてその都度意図した選択ができればよいかな、と思ってます。