この記事は TinyGo Advent Calendar 2024 14 日目の記事です。
TinyGo に限らず heap allocation は減らせれるなら減らしたほうが良いのは間違いなくて、そのために確認する情報をまとめます。
heap allocation とは何か?
ヒープ領域への割り当てのことです。
詳しくはこの辺りを参照してください。
どのような時に発生するか
公式ドキュメントによると、以下の操作で heap allocation が発生します。
常時実行されるループ内などにおいては極力減らしたほうが良いです。
- 関数等で作成したローカル変数のポインタを外部に保存する
- string と []byte 間の変換 (最適化出来るケースもある)
- byte または rune を string に変換
- 文字列の連結
- ポインターより大きな値を持つインターフェースの作成
- クロージャー
- map の作成と変更
- goroutine の開始
上記を含む具体的な操作で自分が特に注意しているのは以下です。
以下を完全に排除することはできないですが、繰り返し呼ばれるようなところではなるべく避けるようにしています。
-
&xxx{}
のような初期化 - slice / map などの生成および拡張
-
xxx := []byte{}
などを作ってからのappend
-
xxx := make([]byte, len)
のような割り当て
-
この辺りに関しての公式ドキュメントは以下にあります。
heap allocation が発生すると何が問題か?
heap allocation を続けるとメモリが足りなくなります。
それでは困るので Go (および TinyGo) では、 heap allocation されたメモリのうち不要なものを捨てるための GC という処理が発生します。
GC には一定の処理時間がかかるため、リアルタイム動作が重要なアプリケーションでは注意が必要となります。
GC が発生すると数 ms 以上などのある程度の時間処理が止まってしまいます。
例えば 1ms 毎の処理が必要な場合ではリアルタイム性が損なわれてしまいます。
なので、リアルタイム性が必要なアプリケーションにおいては、 heap allocation を極力減らしたい、となります。
どうやって heap allocation している箇所を探すか
第一の選択肢は tinygo build
や tinygo flash
時に --print-allocs=.
を付ける事です。
実行すると例えば以下のような形で main.go の 5 行目で heap allocation が発生したことが分かります。
※どのように回避するか、というのは解説が難しいのでここでは省略
$ tinygo build -o /tmp/out.uf2 --target pico --print-allocs=. .
C:\tinygo\allocs\main.go:4:13: object allocated on the heap: escapes at line 5
C:\tinygo\tinygo\src\runtime\baremetal.go:43:14: object allocated on the heap: size is not constant
C:\tinygo\tinygo\src\internal\task\task_stack.go:75:24: object allocated on the heap: size is not constant
C:\tinygo\tinygo\src\internal\task\task_stack.go:107:12: object allocated on the heap: escapes at line 109
ただし、上記の --print-allocs=.
を使っても append
や map に対する値の追加などによる heap allocation は検出できません。
ではどうするか。
memstat を用いてメモリ消費量を確認する
以下のコードのように runtime.MemStats
を用いて調べることができます。
// $TINYGOROOT/src/examples/memstats/memstats.go
package main
import (
"math/rand"
"runtime"
"time"
)
func main() {
ms := runtime.MemStats{}
for {
escapesToHeap()
runtime.ReadMemStats(&ms)
println("Heap before GC. Used: ", ms.HeapInuse, " Free: ", ms.HeapIdle, " Meta: ", ms.GCSys)
runtime.GC()
runtime.ReadMemStats(&ms)
println("Heap after GC. Used: ", ms.HeapInuse, " Free: ", ms.HeapIdle, " Meta: ", ms.GCSys)
time.Sleep(5 * time.Second)
}
}
func escapesToHeap() {
n := rand.Intn(100)
println("Doing ", n, " iterations")
for i := 0; i < n; i++ {
s := make([]byte, i)
_ = append(s, 42)
}
}
実行結果は以下の通り。
runtime.GC()
の前後で表示している heap 領域の使用量などが確認できます。
この Used で表示される ms.HeapInuse
が増えていくのであれば heap allocation が行われています。
$ tinygo flash --target macropad-rp2040 --size short --monitor examples/memstats
code data bss | flash ram
28368 1344 3192 | 29712 4536
Connected to COM20. Press Ctrl-C to exit.
Doing 99 iterations
Heap before GC. Used: 15552 Free: 238080 Meta: 3964
Heap after GC. Used: 2544 Free: 251088 Meta: 3964
Doing 86 iterations
Heap before GC. Used: 12608 Free: 241024 Meta: 3964
Heap after GC. Used: 2544 Free: 251088 Meta: 3964
Doing 6 iterations
Heap before GC. Used: 2720 Free: 250912 Meta: 3964
Heap after GC. Used: 2544 Free: 251088 Meta: 3964
まとめ
とりあえず一定時間以上動作させるプログラムを作った場合は以下の確認をすると良いです。
よい TinyGo ライフを。
-
--print-allocs=.
によるリストアップ - ループなどに
runtime.MemStats{}
を埋め込んで監視
おまけ
上記を書きつつも最近やってる方法としては GC が発生したら debug print する、というやり方です。
今の所 TinyGo 本体のソースを直接修正する以外に方法はありません。
そのうち、 callback を埋め込めるようにする PR を出すかなぁ、という感じ。
以下の if gcDebug
の所を if true
に変えることで runtime.GC()
が実行されたら println
が実行されます。
これにより、長時間動かしておいて GC が発生されたかどうか、というような確認をすることができます。
十分に頻度が低ければ問題ないでしょう。
1 秒に数回とか発生する場合は、多くの場合何らかの対策が必要でしょう。
// $TINYGOROOT/src/runtime/gc_blocks.go
// runGC performs a garbage collection cycle. It is the internal implementation
// of the runtime.GC() function. The difference is that it returns the number of
// free bytes in the heap after the GC is finished.
func runGC() (freeBytes uintptr) {
if gcDebug {
println("running collection cycle...")
}
// (略)}