TinyGo で heap allocation をできるだけ減らすための情報

この記事は TinyGo Advent Calendar 2024 14 日目の記事です。
TinyGo に限らず heap allocation は減らせれるなら減らしたほうが良いのは間違いなくて、そのために確認する情報をまとめます。

https://qiita.com/advent-calendar/2024/tinygo

----


## heap allocation とは何か？

ヒープ領域への割り当てのことです。
詳しくはこの辺りを参照してください。

https://ja.wikipedia.org/wiki/%E3%83%92%E3%83%BC%E3%83%97%E9%A0%98%E5%9F%9F

https://ja.wikipedia.org/wiki/%E3%82%AC%E3%83%99%E3%83%BC%E3%82%B8%E3%82%B3%E3%83%AC%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3

## どのような時に発生するか

公式ドキュメントによると、以下の操作で heap allocation が発生します。
常時実行されるループ内などにおいては極力減らしたほうが良いです。

* 関数等で作成したローカル変数のポインタを外部に保存する
* string と []byte 間の変換 (最適化出来るケースもある)
* byte または rune を string に変換
* 文字列の連結
* ポインターより大きな値を持つインターフェースの作成
* クロージャー
* map の作成と変更
* goroutine の開始

上記を含む具体的な操作で自分が特に注意しているのは以下です。
以下を完全に排除することはできないですが、繰り返し呼ばれるようなところではなるべく避けるようにしています。

* `&xxx{}` のような初期化
* slice / map などの生成および拡張
    * `xxx := []byte{}` などを作ってからの `append`
    * `xxx := make([]byte, len)` のような割り当て

この辺りに関しての公式ドキュメントは以下にあります。

https://tinygo.org/docs/concepts/compiler-internals/heap-allocation/

## 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 が発生したことが分かります。
※どのように回避するか、というのは解説が難しいのでここでは省略

```shell
$ 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` を用いて調べることができます。

```go
// $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 が行われています。

```shell
$ 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 秒に数回とか発生する場合は、多くの場合何らかの対策が必要でしょう。

```go
// $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...")
	}
 	// (略)}
```