メモリ効率で比較するスライスとイテレータ in Go1.23
Go1.23で導入が予定されているイテレータの基本事項について知りたい方は、まずこちらの記事を一読することをお勧めします。
はじめに
Go1.23でイテレータの導入が予定されています。イテレータは、シーケンシャルなデータを扱うという点でスライスと似ています。しかし、イテレータはスライスとは異なり、シーケンシャルなデータを処理していく過程で、すべてのデータをメモリ上に展開しなくてもよいという特徴を持っています。簡単なベンチマークテストを作って、スライスとイテレータのメモリ使用量を比較し、その特徴を確認します。
スライスとイテレータのメモリ使用量の仮説
ここでは特定の条件でスライスの要素をフィルタする関数を考えます。フィルタした結果をスライスで返す関数と、イテレータで返す関数の2つを考え、比較します。
それぞれの関数の実装は次のとおりです。sliceFilter
がスライス版、iterFilter
がイテレータ版の関数です。
// スライスを受け取って、特定の条件でフィルタした結果のスライスを返す関数
func sliceFilter(s []int, predicate func(int) bool) []int {
var ret []int
for i := range s {
if predicate(s[i]) {
ret = append(ret, s[i])
}
}
return ret
}
// スライスを受け取って、特定の条件でフィルタするイテレータを返す関数
func iterFilter(seq []int, predicate func(int) bool) iter.Seq[int] {
return func(yield func(int) bool) {
for i := range seq {
if predicate(i) {
if !yield(i) {
return
}
}
}
}
}
sliceFilter
は、外から与えられたスライスの各要素にpredicate
関数を適用して条件にマッチするかを確認し、条件にマッチする場合は新たに作成したスライスに要素をappend
して、このスライスを返します。一方、iterFilter
は、外から与えられたスライスの各要素にpredicate
関数を適用して、条件にマッチする要素をyield
関数に渡すイテレータを返します。新たにスライスは作成しません。
イテレータの方が新たにスライスを作成しない分、メモリ使用量が少ないと推測します。
ベンチマークテスト
実際にベンチマークテストを作成して、両関数のメモリ使用量を計測して比較します。
計測方法
引数に与えるスライスの要素数を100から1000万まで10倍ずつ増やしながら計測し、変化をみます。各要素数での計測は、1万回計測した結果の平均をとります。
計測環境
Go
$ go version
go version go1.23rc1 darwin/arm64
OS
ProductName: macOS
ProductVersion: 13.6.7
BuildVersion: 22G720
ハードウェア
Chip: Apple M1 Max
Total Number of Cores: 10 (8 performance and 2 efficiency)
Memory: 64 GB
計測結果
今回の実装では、スライスとイテレータのメモリ使用量は共に、与えられたスライスの要素数の増加に伴ってほとんど線形に増加していきました。
また、スライスとイテレータのメモリ使用量の比は、要素数を増やすにつれ、2倍から3.5倍程度まで変化しました。
図1. メモリ使用量の変化
要素数 | スライスメモリ使用量[Byte] | イテレータメモリ使用量[Byte] |
---|---|---|
100 | 1,920 | 896 |
1,000 | 16,384 | 8,192 |
10,000 | 210,176 | 81,920 |
100,000 | 2,757,888 | 802,816 |
1,000,000 | 29,086,976 | 8,003,584 |
10,000,000 | 281,081,088 | 80,003,072 |
結論と考察
スライスを新たに作成して返すケースよりも、イテレータを返すケースのほうがメモリ使用量が少ないことがわかりました。
また、与えるスライスの要素数が増加するにつれ、スライスを返すケースのほうが、イテレータを返すケースよりもメモリ使用量の増加も大きいことがわかりました。これは、append
関数のメモリアロケーションのロジックが関係していそうです。
おわりに
今回の計測のために実装したベンチマークテストのソースコードは、GitHubでみることができます。また、同じソースコードを付録としても記載しています。
このベンチマークテストは、testing
パッケージを使用せず、自前で実装しています。testing
パッケージでは、試行回数を大きくするほどメモリ使用量が小さくなるという結果がでて、うまく平均がとれなかったためです。
付録
GitHub
スライス版のベンチマークテスト
package main
import (
"fmt"
"math"
"runtime"
)
const (
NumOfElements = 10_000_000
)
func sliceNumbers(n int) []int {
s := make([]int, n)
for i := range len(s) {
s[i] = i
}
return s
}
func sliceFilter(s []int, predicate func(int) bool) []int {
var ret []int
for i := range s {
if predicate(s[i]) {
ret = append(ret, s[i])
}
}
return ret
}
func benchmarkSlice(n int) {
numbers := sliceNumbers(n)
even := sliceFilter(numbers, func(i int) bool { return i%2 == 0 })
for i := range even {
_ = even[i]
}
}
func main() {
var aveCount uint64
var aveTotalAlloc uint64
var maxTotalAlloc uint64 = 0
var minTotalAlloc uint64 = math.MaxUint64
var aveMallocs uint64
var maxMallocs uint64 = 0
var minMallocs uint64 = math.MaxUint64
var m1, m2 runtime.MemStats
for i := 1; i < 10_000; i++ {
runtime.GC()
runtime.ReadMemStats(&m1)
benchmarkSlice(NumOfElements)
runtime.ReadMemStats(&m2)
diffTotalAlloc := m2.TotalAlloc - m1.TotalAlloc
diffMallocs := m2.Mallocs - m1.Mallocs
aveTotalAlloc = (aveTotalAlloc*aveCount + diffTotalAlloc) / uint64(i)
maxTotalAlloc = max(maxTotalAlloc, diffTotalAlloc)
minTotalAlloc = min(minTotalAlloc, diffTotalAlloc)
aveMallocs = (aveMallocs*aveCount + diffMallocs) / uint64(i)
maxMallocs = max(maxMallocs, diffMallocs)
minMallocs = min(minMallocs, diffMallocs)
aveCount++
}
fmt.Printf("NumOfElements: %d\n", NumOfElements)
fmt.Println()
fmt.Printf("AveTotalAlloc: %d B/op\n", aveTotalAlloc)
fmt.Printf("MaxTotalAlloc: %d B\n", maxTotalAlloc)
fmt.Printf("MinTotalAlloc: %d B\n", minTotalAlloc)
fmt.Println()
fmt.Printf("AveMallocs: %d allocs/op\n", aveMallocs)
fmt.Printf("MaxMallocs: %d allocs\n", maxMallocs)
fmt.Printf("MinMallocs: %d allocs\n", minMallocs)
}
イテレータ版のベンチマークテスト
package main
import (
"iter"
"math"
"runtime"
"fmt"
)
const (
NumOfElements = 10_000_000
)
func sliceNumbers(n int) []int {
s := make([]int, n)
for i := range len(s) {
s[i] = i
}
return s
}
func iterFilter(seq []int, predicate func(int) bool) iter.Seq[int] {
return func(yield func(int) bool) {
for i := range seq {
if predicate(i) {
if !yield(i) {
return
}
}
}
}
}
func benchmarkIterator(n int) {
numbers := sliceNumbers(n)
even := iterFilter(numbers, func(i int) bool { return i%2 == 0 })
for i := range even {
_ = i
}
}
func main() {
var aveCount uint64
var aveTotalAlloc uint64
var maxTotalAlloc uint64 = 0
var minTotalAlloc uint64 = math.MaxUint64
var aveMallocs uint64
var maxMallocs uint64 = 0
var minMallocs uint64 = math.MaxUint64
var m1, m2 runtime.MemStats
for i := 1; i < 10_000; i++ {
runtime.GC()
runtime.ReadMemStats(&m1)
benchmarkIterator(NumOfElements)
runtime.ReadMemStats(&m2)
diffTotalAlloc := m2.TotalAlloc - m1.TotalAlloc
diffMallocs := m2.Mallocs - m1.Mallocs
aveTotalAlloc = (aveTotalAlloc*aveCount + diffTotalAlloc) / uint64(i)
maxTotalAlloc = max(maxTotalAlloc, diffTotalAlloc)
minTotalAlloc = min(minTotalAlloc, diffTotalAlloc)
aveMallocs = (aveMallocs*aveCount + diffMallocs) / uint64(i)
maxMallocs = max(maxMallocs, diffMallocs)
minMallocs = min(minMallocs, diffMallocs)
aveCount++
}
fmt.Printf("NumOfElements: %d\n", NumOfElements)
fmt.Println()
fmt.Printf("AveTotalAlloc: %d B/op\n", aveTotalAlloc)
fmt.Printf("MaxTotalAlloc: %d B\n", maxTotalAlloc)
fmt.Printf("MinTotalAlloc: %d B\n", minTotalAlloc)
fmt.Println()
fmt.Printf("AveMallocs: %d allocs/op\n", aveMallocs)
fmt.Printf("MaxMallocs: %d allocs\n", maxMallocs)
fmt.Printf("MinMallocs: %d allocs\n", minMallocs)
}
株式会社SODAの開発組織がお届けするZenn Publicationです。 是非Entrance Bookもご覧ください! → recruit.soda-inc.jp/engineer
Discussion