SODA Engineering Blog
📈

メモリ効率で比較するスライスとイテレータ in Go1.23

2024/06/25に公開

Go1.23で導入が予定されているイテレータの基本事項について知りたい方は、まずこちらの記事を一読することをお勧めします。
https://zenn.dev/team_soda/articles/understanding-iterators-in-go

はじめに

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

https://github.com/hiroshinakasone/slice-vs-iterator-in-go-mem-usage-comparison

スライス版のベンチマークテスト

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 Engineering Blog
SODA Engineering Blog

Discussion