HN 日本語サマリー

← 一覧へ戻る
プログラミング

noalloc: Golangでゼロヒープアロケーションを強制するためのツールとライブラリ

noalloc: Tooling and libraries to enforce zero heap allocations in Golang (github.com)

38 pointsby g0xA52A2A12 コメント

要約

noallocは、Go言語でヒープアロケーションをゼロに抑えることを強制するためのツールとライブラリ群です。このツールは、関数がヒープアロケーションを行わないことをアサートし、違反箇所を特定します。また、アロケーションを削減するためのboundedデータ構造やゼロアロケーションJSONエンコーダー/デコーダーなどのライブラリも提供します。

全文翻訳

noalloc

免責事項: このリポジトリは完全にAIによって書かれています。READMEも大部分がAIによって書かれています。人間が指示し、The ConsensusではWebサービスとコード検索エンジンの両方で本番環境で使用されています。これは実験的なものであり、大幅に変更される可能性があります。

ヒープにアロケーションしないコードを書くことは一つのことです。コードが変更されてもアロケーションしないようにコードを維持することは別のことです。noallocを使えば、Golangの関数がアロケーションしないことをアサートでき、noalloc CLIは、その関数からのコールツリー全体をヒープアロケーションについてチェックします。

noallocは、アロケーションしているすべての行を教えてくれるため、修正または免除することができます。noallocは、ゼロアロケーションの固定サイズデータ構造とアルゴリズム(JSONエンコーダーおよびデコーダーを含む)も提供し、アロケーションしないコードをより簡単に、よりクリーンに書けるようにします。

go install github.com/theconsensuslabs/noalloc/cmd/noalloc@latest

Go 1.27以降が必要です。

例えば、関数に//noalloc:rootをマークして、そのモジュールでnoallocを実行します。

//noalloc:root
func (c *Counter) Add(name string, n int) string {
	c.counts[name] += n
	return name + "=" + strconv.Itoa(c.counts[name])
}
$ noalloc
counter.go:11: (*example.com/counter.Counter).Add: map write
counter.go:12: (*example.com/counter.Counter).Add: calls strconv.Itoa; string concatenation; escapes: name + "=" + ~r0 escapes to heap

noallocは、1(アロケーションが見つかった)または2(実行できなかった)で終了します。ルートの呼び出しを終了するエラーまたはパニック、および初回のセットアップではアロケーションを許可します。これについては、以下の注意点をご覧ください。

ディレクティブ

  • //noalloc:root 関数とその呼び出しすべてをチェックします。
  • //noalloc:exempt <reason> その行と、その行が呼び出すすべてを免除します。単独の行にある場合、次の行に適用されます。
  • //noalloc:fixed 型にマークすると、そのメソッドはスライスを成長させないため、noallocはそれらを再利用可能なメモリとして扱います。
  • //noalloc:append appendのように動作する関数をマークするため、その呼び出しはappendの呼び出しと同様に評価されます。

再利用可能なメモリとは、リクエスト間で保持されるメモリ、例えば長寿命の構造体内のバッファです。それへの追記は、空きがあることを比較で証明した後にのみ許可されます。型フィールドを再利用可能なメモリとしてマークすることもできます(-scratch pkg/path.Type)。モジュール外の関数への呼び出しは、その関数のコード自体が何もアロケーションしない場合(noallocはソースからこれを把握します)、またはアセンブリで書かれているかランタイムの一部であるコンパイル済みコードの場合に許可されます。ソースでチェックするパッケージ(-source、インポートパスのプレフィックスを取る)は、モジュールの一部としてチェックされます。noalloc自身のライブラリをそのようにチェックしてください(-source github.com/theconsensuslabs/noalloc/bounded)、encoding/jsonも同様です。これにより、それらの//noalloc:fixed型やその他のディレクティブが考慮されますが、モジュール外のコードでは考慮されません。すべてのフラグについてはnoalloc -hを実行してください。

bounded を使った設計

bounded には、最大容量が固定された4つのコンテナがあります: Buf、Vec、Slice、Map。書き込みが容量を超えると、書き込みは何もせずコンテナをフルとマークするため、各書き込み後にチェックする代わりに一度だけチェックできます。コンテナのメモリは独自のメモリでも、PoolからRegionを通じて借りたメモリでも構いません。これらの例はexample_test.goにもあり、go testでその出力をチェックできます。

Buf

A Bufはバイトバッファです。Resetでメモリを与え、書き込みが収まらなくなるとフルになります。

var b bounded.Buf
b.Reset(make([]byte, 32), nil)
b.WriteString("id=")
b.WriteInt(42)
fmt.Println(string(b.Bytes()), b.Full()) // id=42 false

b.WriteString(strings.Repeat("x", 40))
fmt.Println(string(b.Bytes()), b.Full()) // id=42 true

ライターを与えると、バッファがいっぱいになる代わりにそのライターにフラッシュされます。

var b bounded.Buf
b.Reset(make([]byte, 8), os.Stdout)
b.WriteString("hello, ")
b.WriteString("world\n") // "hello, " を先にフラッシュします
b.Flush()                // hello, world を出力します
Vec

A Vecは任意の型のリストです。与えられたメモリで、そのメモリ容量に応じた数のアイテムを保持します。

var v bounded.Vec[int]
v.Reset(make([]int, 0, 3))
for i := range 5 {
	v.Push(i * i)
}
fmt.Println(v.Items(), v.Full()) // [0 1 4] true

ResetFromを使用すると、いっぱいになるにつれてプールからチャンクを借りることができ、リージョンの予算まで使用できます。アイテムは連続していないため、Atで読み取るか、Chunksでチャンクごとに読み取ります。

pool := bounded.NewPool[int](4, 8)
var r bounded.Region
r.Reset(2 * pool.ChunkBytes())
defer r.Release()

var v bounded.Vec[int]
v.ResetFrom(&r, pool)
for i := range 10 {
	v.Push(i)
}
fmt.Println(v.Len(), v.Full(), v.At(6)) // 8 true 6
for c := range v.Chunks() {
	fmt.Println(c) // [0 1 2 3]、次に [4 5 6 7]
}
Slice

A Sliceは連続しています。チャンクがいっぱいになると、appendがスライスをより大きな配列に移動するのと同じように、次のより大きなプールからチャンクに移動できます。

classes := bounded.NewClasses(bounded.NewPool[int](4, 8), bounded.NewPool[int](16, 8))
var r bounded.Region
r.Reset(1 << 10)
defer r.Release()

var s bounded.Slice[int]
s.ResetFrom(&r, classes)
for i := range 10 {
	s.Push(i)
}
fmt.Println(s.Items(), s.Cap()) // [0 1 2 3 4 5 6 7 8 9] 16
Map

A Mapはリセットしたエントリ数まで保持します。それを超える新しいキーは追加されませんが、既存のキーは更新できます。

var m bounded.Map[string, int]
m.Reset(2)
m.Set("a", 1)
m.Set("b", 2)
fmt.Println(m.Set("c", 3), m.Full()) // false true

v, ok := m.Get("a")
fmt.Println(v, ok, m.Len()) // 1 true 2

Growを使用すると、小さく開始し、いっぱいになるにつれてより大きなスロットプールに移動できます。

maps := bounded.NewMapClasses(bounded.NewMapPool[string, int](4, 8), bounded.NewMapPool[string, int](64, 8))
var r bounded.Region
r.Reset(1 << 20)
defer r.Release()

var m bounded.Map[string, int]
m.Grow(&r, maps)
for i := range 20 {
	m.Set(strconv.Itoa(i), i)
}
fmt.Println(m.Len(), m.Limit()) // 20 64
Pool と Region

A Poolは固定数のチャンクを貸し出します。すべて貸し出されると、Takeはリージョンが返すまでnilを返します。

pool := bounded.NewPool[byte](1<<10, 2)
var a, b bounded.Region
a.Reset(4 << 10)
b.Reset(4 << 10)

bounded.Take(&a, pool) // a は最初のチャンクを取得します
bounded.Take(&a, pool) // そして2番目も
bounded.Take(&b, pool) // nil: プールは空です
a.Release()
bounded.Take(&b, pool) // b は a のうちの1つを取得します
b.Release()

st := pool.Stats()
fmt.Println(st.Lent, st.Empty) // 3 1

Takeの代わりにWaitを使用してチャンネルが閉じるまでチャンクを待ち、Reserveを使用して一度に複数またはゼロを取得します。

動的な入力の処理

リクエストの入力サイズは、入力が到着するまで不明なため、開始時に各リクエストに固定予算のRegionを与えます。その予算内で、リクエストは入力が必要とするチャンクを共有Poolから借り、リクエスト終了時にリージョンを解放するとそれらがすべて返却されます。

var rows = bounded.NewPool[byte](16<<10, 64)

// nowOnly は閉じられた状態で開始されるため、Reserve は空きチャンクを取得し、待機しません。
var nowOnly = func() chan struct{} { c := make(chan struct{}); close(c); return c }()

func (p *page) serve(w io.Writer) error {
	p.region.Reset(4 * rows.ChunkBytes())
	defer p.region.Release()
	if !bounded.Reserve(nowOnly, &p.region, rows, 1) {
		return errBusy
	}
	p.out.ResetFrom(&p.region, rows, w)
	for p.next() {
		p.writeRow(&p.out)
	}
	p.out.Flush()
	return p.out.Err()
}

ここでは、ページは1つのチャンクをストリーミングし、それが満たされるとフラッシュされるため、長いページでも短いページと同じメモリしか必要としません。全体を保持する必要がある出力は、代わりにBuf.Borrowを呼び出すことができます。これは、リージョンの予算が尽きるまで、バッファがいっぱいになるたびに、より大きなチャンクに移動します。

メモリ予算を使い果たす可能性のあるすべてのサイトには、使い果たした場合の計画が必要です。例えば、以下のような方法があります。

  • ページを早期に終了し、クライアントがカーソルから再開できるようにする。
  • 遅いパスでも正しい結果が得られる場合は、最適化をスキップする。
  • メモからエントリを忘れる。
  • 解析を開始する前に大きすぎる入力を拒否する。

Bufを介して出力をストリームし、そのメモリがレスポンスとともに増加しないようにします。リクエストが必要とするものをアドミッションコントロールが許可したときに予約し、プールを、許可されたすべてのリクエストが一度に保持できる量にサイズ設定します。

JSONのエンコードとデコーディング

encoding/jsonは、最初の呼び出しを含め、アロケーションなしでJSONをエンコードおよびデコードします。これは、jsongenコマンドが型用に書き込むメソッドを通じて行われます。

//go:generate go run github.com/theconsensuslabs/noalloc/cmd/jsongen -write Reply -read Request

WriteJSONは、Writerを通じて、提供して呼び出し間で保持するメモリに書き込み、それがいっぱいになるとio.Writerにフラッシュします。

var w json.Writer
w.Reset(mem, out)
reply.WriteJSON(&w)
if w.Flush() != nil {
	return errReply
}

ReadJSONは、Readerを通じて、メモリから、またはバッファを介してio.Readerから読み取ります。文字列はReaderのArenaにデコードされ、メモリ上に配置されます。いっぱいになるとMoreを呼び出して別のチャンクを取得し、メモリを再利用するまで有効です。スライスは、その容量にデコードされ、ポインタはその指す先にデコードされます。それらのいずれかを超えると、デコードはアロケーションではなくFullエラーで失敗します。

req := Request{Tags: make([]string, 0, 16)}
var r json.Reader
r.Arena.Reset(stringMemory)
r.ResetBytes(body)
req.ReadJSON(&r)
if r.Finish() != nil {
	return errRequest
}

WriterまたはReaderはローカル変数に配置でき、そこでアロケーションしませんが、提供したメモリはアロケーションする可能性があります。Resetはスライスを保持し、その背後にある配列をヒープに移動します。Err、Flush、Finishが返すエラーはWriterまたはReader内のポインタを指すため、nilと比較してください(上記のように)、全体をヒープに移動する返却やログ記録ではなく。

WriteJSONが書き込むものは、標準ライブラリのencoding/jsonでjson.Marshalの出力がデコードされるものにデコードされ、ReadJSONはjson.Unmarshalが行うものをデコードしますが、timeはインスタントを保持しますがUTCでデコードされます。jsongenは、マップやインターフェースなど、アロケーションなしでエンコードまたはデコードできない型を拒否し、その理由を述べます。型は独自のWriteJSONまたはReadJSONを代わりに書き込むことができ、encoding/json/jsontestは、ラウンドトリップによって標準ライブラリに対してどちらのタイプもチェックします。

パフォーマンス

jsongenが書き込むコードと標準ライブラリおよび他の4つのライブラリを、それぞれ独自の最も高速なドキュメント化されたパスで比較します。500アイテム(約130KB)のフィードと、セッションクッキーサイズの小さなオブジェクトをエンコードおよびデコードし、それぞれを再利用可能な値にデコードします。すべてのライブラリはencoding/jsonと同じ値にデコードします。

各数値は、CPU秒(ユーザーおよびシステム)で、3,000フィードまたは3,000,000クッキー操作あたりの値です。ライブラリ固有のプロセスで、ガベージコレクションと固定のセットアップコストが含まれます。これらは、10コアとGo 1.27.0を搭載したApple M4で、負荷平均が18から37の間で実行された4回の交互ラウンドの中央値です。

ライブラリフィードエンコード (s)フィードデコード (s)クッキーエンコード (s)クッキーデコード (s)フィードデコードあたりのアロケーション数
goccy/go-json v0.11.20.640.740.680.674
noalloc の jsongen0.871.160.751.080
go-faster/jx v1.2.00.981.540.801.133,500
mailru/easyjson v0.9.00.971.770.871.884,001
bytedance/sonic v1.15.51.521.041.891.823
encoding/json/v21.582.631.822.113,196
encoding/json1.522.941.772.651,696

sonicの最速パスはamd64用に構築されているため、M4(arm64マシン)ではこれらの数値はおそらく過小評価されています。

HTMLとURLのエスケープ

html/templateには、html/templateが選択する16のエスケーパーのうち6つのアペンドバージョンがあります。HTMLテキストと属性値用、URLフィルター、正規化、エスケープ、およびJSONとして既にエンコードされた値でスクリプト値を完了するステップです。テンプレートの解析や実行、CSS、JavaScript文字列、コメント、srcsetのエスケープは行いません。net/urlにはurl.QueryEscapeのアペンドバージョンがあります。それぞれが標準ライブラリの対応物が返すバイトを書き込み、渡したスライスが成長する必要がある場合にのみアロケーションします。

よくある間違い

これらの例は、以下の宣言を共有しています。

var (
	pool    = bounded.NewPool[byte](4<<10, 8)
	classes = bounded.NewClasses(bounded.NewPool[int](16, 8), bounded.NewPool[int](256, 8))
	r       bounded.Region
	b, next bounded.Buf
	s       bounded.Slice[int]
)
Region.Release後のメモリの使用

Regionが解放されると、VecまたはSliceを使用するとパニックが発生します。BufまたはMapはそれを検出できず、次の借り手が書き込んだものを読み取ります。

r.Reset(1 << 20)
b.ResetFrom(&r, pool, nil)
b.WriteString("alice's page")
r.Release()
r.Reset(1 << 20)
next.ResetFrom(&r, pool, nil)
next.WriteString("bob's page..")
fmt.Printf("%q\n", b.Bytes()) // "bob's page.."

リクエストが借りたすべてのものを使用し終わったらすぐにリージョンを解放してください。Resetの直後のdeferで行うことができます。-raceは、別のリクエストが借りたメモリをリクエストが読み取ることを検出できます。

書き込みをまたいだItemsまたはBytesの保持

Slice、または借りることができるBufが容量に達すると、より大きなチャンクに移動し、古いチャンクをプールに返却できます。ItemsまたはBytesから取得したスライスは、移動前に古いチャンクを指したままになります。

s.ResetFrom(&r, classes)
s.Push(1)
first := s.Items()
s.Extend(make([]int, 100)) // より大きなチャンクに移動します
first[0] = 2               // 返却されたチャンクに書き込みます

各書き込み後にItemsまたはBytesを再度呼び出してください。

アドミッション外での予約

リージョンは、残りを待っている間、保持しているチャンクを保持します。そのため、それぞれが2チャンクプールの両方を必要とする2つのリージョンは、それぞれ1つを取得してデッドロックする可能性があります。

two := bounded.NewPool[byte](64, 2)
for range 2 {
	go func() {
		var r bounded.Region
		r.Reset(1 << 20)
		bounded.Reserve(nil, &r, two, 2) // デッドロックする可能性があります
		r.Release()
	}()
}

リクエストがアドミッションされてから一度だけ予約し、プールを、アドミッションされたリクエストが一度に保持できるすべての予約を保持するようにサイズ設定してください。

アイテムごとの借り入れ

借り入れと返却はそれぞれロックを取るため、アイテムごとではなくリクエストごとに一度借りてください。

for _, line := range lines {
	r.Reset(1 << 20)
	b.ResetFrom(&r, pool, w)
	b.WriteString(line)
	b.Flush()
	r.Release()
}
goroutine 間でのRegionの共有

Poolはgoroutine間で共有しても安全ですが、Regionは安全ではありません。-raceで検出できます。

r.Reset(1 << 20)
for range 4 {
	go bounded.Take(&r, pool) // r で競合します
}
ctx.Done()をWaitまたはReserveに渡す

キャンセル可能なコンテキストのDoneの最初の呼び出しは、そのチャンネルをアロケーションします。

bounded.Reserve(ctx.Done(), &r, pool, 1) // 1つのアロケーション

noallocはこれを初回のセットアップとみなし報告しませんが、新しいコンテキストごとに独自のチャンネルがアロケーションされます。代わりに、一度作成したチャンネル(サーバーシャットダウン時に閉じるチャンネルなど)を渡してください。

ゼロ化されたチャンクの期待

プールは、チャンクの型がポインタを保持する場合にのみ、返されたチャンクをゼロ化します。それ以外の場合は、最後の借り手が残したものを取得します。

first := bounded.Take(&r, pool)
first[0] = 'x'
r.Release()
r.Reset(1 << 20)
again := bounded.Take(&r, pool)
fmt.Printf("%q\n", again[0]) // 'x'

ゼロが必要な場合は、チャンクを自分でクリアしてください。

チェックを静かにするための行の免除

免除は、その行が呼び出すすべてをカバーします。ここでは、noallocはformatの中を調べないため、それがアロケーションするものは何も報告されません。

//noalloc:root
func (p *page) render(w io.Writer) {
	p.format(w) //noalloc:exempt response: the page
}

行自体のみを免除するには、理由の種類(ここではresponse)を-line-onlyに渡します。

免除は、それが通過させるものをまだ表示します。-reportは、各免除の下に、その行のアロケーションと、その行のみが呼び出すもののリストを表示します。-write-exemptionsで書き込まれるリストも、免除の下にインデントされて保持されるため、免除がカバーするものが変更されるとチェックが失敗し、レビューしてリストを再度書き込む必要があります。到達したが何も通過させなかった免除も失敗します。それを削除してください。

注意点

アセンブリで書かれた関数

noallocは、Go本体がない関数をコンパイル済みコードで判断します。ランタイムアロケーターへの呼び出しを追跡します。レジスタを介した呼び出しは追跡できないため、そのような呼び出しを行う関数はアロケーションするとみなされます。

func sum(xs []int) int // sum_arm64.s で書かれている

//noalloc:root
func Total(xs []int) int { return sum(xs) } // sum のコードがアロケーターに到達できる場合にのみ報告されます

noallocがアロケーションしないとわかっている関数を追跡できない場合、それをmeasuredとしてリストし、alloctest.Zeroでテストします。これは関数をtesting.AllocsPerRunで実行し、カウントがゼロでない場合に失敗し、-race(自身のブックキーピングのためにアロケーションする)の下でテストをスキップします。

func TestSumAllocatesNothing(t *testing.T) {
	xs := []int{1, 2, 3}
	alloctest.Zero(t, "sum", func() { sum(xs) })
}
モジュール外への呼び出し

noallocは、モジュール外の各関数がアロケーションするものを、ビルドがコンパイルするコードから把握し、ユーザーキャッシュのnoallocディレクトリにキャッシュします。新しいツールチェーン、プラットフォーム、ビルドタグセット、または依存関係バージョンのセットで初めて実行すると、再度把握し、後続の実行よりも時間がかかります。The Consensus のサーバーでは、39秒対8秒です。追跡できない関数(関数値を通じて呼び出す関数など)はアロケーションするとみなされ、-reportはアロケーションにつながった呼び出しの連鎖を各発見の下に表示します。

counter.go:12: (*example.com/counter.Counter).Add: calls strconv.Itoa; string concatenation; escapes: name + "=" + ~r0 escapes to heap
    because strconv.Itoa calls internal/strconv.Itoa at number.go:215 > internal/strconv.Itoa calls internal/strconv.FormatInt at itoa.go:53 > internal/strconv.FormatInt calls internal/strconv.formatBits at itoa.go:47 > internal/strconv.formatBits: string([]byte) conversion at itoa.go:146
コンパイラの出力

noallocはgo build -gcflags=-mの出力を解析し、その出力はGoのリリース間で変更される可能性があります。go.modでツールチェーンをピン留めし、アップグレード時にnoallocを再実行してください。

go 1.27.0

toolchain go1.27.0
noallocが信頼するもの

noallocは、許可リスト、//noalloc:fixed、およびすべての免除を検証せずに信頼します。許可リストは組み込みのリストに、-allowで渡されるファイルが追加されたものです。これは、コードが示していないにもかかわらずアロケーションしないとみなされる関数をオーバーライドするだけです。各行は関数名と、コードが示していないのにアロケーションしないとみなされる理由を記述します。理由は次のいずれかです。

  • amortized: ランタイムが保持・再利用するメモリ(ブロックされたsync.Mutexが待機者から取得するキャッシュなど)を成長させるためだけにアロケーションします。
  • measured: コードを追跡できず、それを呼び出すテストでアロケーションがゼロとカウントされます。
  • debugまたはprofiling: デバッグまたはプロファイリングのパスでのみアロケーションします。
measured: example.com/fmtx.Format

組み込みリストはsyncのロックとウェイトグループをamortizedとして扱います。-reportは、どのチェックがどのチェックに依存したかを示すすべての行をリストします。measured行の場合は、テストを記述してください。

func TestFormatAllocatesNothing(t *testing.T) {
	buf := make([]byte, 0, 64)
	alloctest.Zero(t, "fmtx.Format", func() { fmtx.Format(buf[:0], 42) })
}
解決できない呼び出し

noallocが関数値がどの関数を参照しているかわからない場合、その呼び出しを報告します。インターフェース呼び出しには独自のセクションがあります。

//noalloc:root
func Run(f func(int) int, n int) int {
	return f(n)
}
$ noalloc
run.go:5: example.com/app.Run: call with no known callee: f

これを修正するには、具体的な関数または型を呼び出すか、呼び出しを免除してください。

インターフェース呼び出し

noallocは、インターフェース呼び出しが到達できるすべてのメソッドをチェックします。

モジュールが値の具体的な型を示す場合、その型のみのメソッドがチェックされます。noallocはこれをVTA(変数型解析)で見つけ、ローカル変数やフィールドに代入されたものを追跡します。

//noalloc:root
func Area(n int) int {
	var s Shape = Square{n}
	return s.Area() // Square.Area のみチェックされます。
}

それ以外の場合、ルートのパラメータと同様に、見つけられるすべての実装がチェックされます。モジュール内の実装は定義された場所でチェックされます。モジュール外の実装はコードでチェックされ、いずれかがアロケーションすると呼び出しが報告されます。

//noalloc:root
func Send(w io.Writer, p []byte) (int, error) {
	return w.Write(p)
}
$ noalloc
send.go:5: example.com/app.Send: calls io.Writer.Write

ここで、(*bytes.Buffer).Writeはバッファを拡張できます。context.ContextのErrへの呼び出しは、標準ライブラリの実装がアロケーションしないため、成功します。

実装されていないメソッドは、call with no known calleeとして報告されます。型パラメータに対するメソッド呼び出しは、パラメータの制約に対してチェックされます。メソッド値またはメソッド式(f := s.Area、その後f()など)は、メソッドを直接呼び出すのと同じようにチェックされ、埋め込まれたインターフェースから昇格されたメソッドも同様です。

noallocは以下を許可します。

  • 型アサーションと型スイッチ。
  • 値がスタック上に留まるか、ゼロサイズ(単一バイト)またはブール値の場合、インターフェースへの値の変換。
  • 独自の型をインターフェースとしてモジュール外のコードに渡すこと。sort.Sort(byLen(xs))はbyLenのLen、Less、Swapをチェックします。

制限事項:

  • モジュールがインポートするパッケージのみが実装を検索対象とします。モジュール外の呼び出し元は別の場所から実装を渡す可能性があり、モジュール内のどこかにインポートを追加すると、呼び出しがチェックされるものが変更される可能性があります。
  • モジュール内のインターフェースを実装するすべての型がチェックされます。呼び出し元が渡さない型であってもです。
  • 名前付きインターフェース(例: interface{ Do() int })とerrorは、モジュール外でnoallocがルックアップできる実装がありません。それらへの呼び出しは、calls error.Errorのように報告されます。

報告されたインターフェース呼び出しを許可するには、行を免除するか、-allowで渡される許可リストファイルに呼び出しを追加します。

measured: io.Writer.Write
ルートが到達しないコード

noallocはルートが到達するコードのみをチェックします。ルートからこの関数を呼び出すものはないため、noallocはそれを調べません。

func Handle(w io.Writer) {
	fmt.Fprintf(w, "%d", 1)
}

したがって、すべてのエントリポイントを//noalloc:rootでマークしてください。

エラーとパニックパス

noallocは、ルートの呼び出しを終了する障害(パニックするか、ルートの戻り値に到達するnilでないエラーを返す)でのアロケーションを許可します。

//noalloc:root
func Parse(s string) (int, error) {
	if s == "" {
		return 0, fmt.Errorf("empty input") // noalloc はこれを許可します
	}
	return len(s), nil
}

呼び出しを終了する障害は、呼び出しが読み取る入力量に関係なく、固定数回のアロケーションを消費します。呼び出しを終了しない障害は、次のアイテムで再度発生する可能性があるため、他のパスと同様にチェックされます。

for _, s := range items {
	n, err := parse(s) // parse の fmt.Errorf は報告されます
	if err != nil {
		continue
	}
	total += n
}

ループ内で呼び出された関数によって回復されたパニック、およびエラーを記録して続行するブランチ(最初の障害を保持するコードのように、ルートの呼び出しで複数回実行される可能性がある場合)にも同様のことが当てはまります。呼び出しごとに1回は許可されます。

if n > limit {
	r.fail(fmt.Errorf("%d is over the limit", n)) // noalloc はこれを許可します
	return 0
}

モジュール外のコードは、渡された関数を任意の回数呼び出し、エラーを破棄し、パニックを回復できるため、コールバックの障害はチェックされます。ルート自身の障害は、それがどのように到達されても許可されたままです。

クライアントがリクエストごとに無効な入力を送信できる場合でも、各リクエストは1回アロケーションします。リクエストが頻繁に到達するパスでは、一度作成したセンチネルエラーを返してください。

var errEmpty = errors.New("empty input")

noallocは、エラーが発生した呼び出しからすべてのパスを追跡します。nilやパッケージレベルのセンチネルとの比較、再代入、エラーを返すモジュールのヘルパーへの入り込みなどです。フィールドに格納されたエラー、またはフラグやメッセージのように呼び出し元が停止する結果に変換されたエラーは、飲み込まれたように見え、その障害が報告されます。自分自身を直接呼び出す関数は再度呼び出されたとみなされますが、相互再帰はそうではありません。

初回のセットアップ

noallocは、フィールドまたはパッケージ変数がnilであることを見つけて設定するブランチ(直接またはsync/atomic経由)を許可します。

if p.index == nil {
	p.index = make(map[string]int, 64) // noalloc はこれを許可します
}

リクエスト間でフィールドがnilにリセットされると、各リクエストがアロケーションします。値を作成するときに一度設定してください。

コンパイル済みコードとのチェック

noallocは、コンパイル済みコード内のアロケーションするランタイム呼び出しの結果を比較します。この比較にはいくつかのギャップがあります。

  • ベースラインは失敗をリストできず、免除はそれらをカバーしないため、コードを変更するまで実行は失敗し続けます。
  • コンパイラのコードとシンボル名に依存しており、Goのリリース間で変更される可能性があります。
  • 他のパッケージからインライン化された呼び出し、noallocがチェックしないコードも含む行の呼び出し、分類できないランタイム関数への呼び出しをカウントしますが、比較しません。
cross-check: 9 allocating calls inlined from other packages and 0 on lines that also hold unchecked code were not compared; calls to runtime functions not known either way: map[typeAssert:3]
  • コンパイラが選択したインライン化により、プラットフォームと1つのビルドしか表示されません。

すでにアロケーションしているコード

すでにアロケーションしているコードにnoallocを追加する場合は、ベースラインを開始して比較してください。

noalloc -baseline noalloc.txt -start
noalloc -baseline noalloc.txt

2番目のコマンドは、新しいアロケーションが発生した場合、またはリストされたアロケーションを削除した場合に失敗します。-pruneを実行するまでです。コードレビューで新しい免除を確認するには、リストを保持してください。-exemptions file -write-exemptionsはそれを書き込み、-exemptions fileはコードとリストが一致しない場合に失敗します。

パフォーマンス

boundedのPoolとMapは、sync.PoolとGoのmapよりも遅いですが、メモリフットプリントが制限されているという利点があります。これらは6回の実行の中央値で、負荷平均は3.5から11の間です。

操作Gobounded
16 KBバッファの借用と返却8.0 ns (sync.Pool)15.0 ns
10コアで同時に実行、借用あたり1.5 ns (sync.Pool)3.1 ns
1,024回のセットとゲット12.0 µs (map)17.3 µs (Map)
4,096個のintの読み取り4.8 µs (slice)4.0 µs (Vec.At), 4.8 µs (Vec.Chunks)

これらの数値は、他のビルドが実行されている間の10コアとGo 1.27.0を搭載したApple M4から取得されたため、ご自身のマシンと比較するのではなく、行同士を比較してください。

これらの比較を繰り返すには、boundedでgo test -bench .を実行してください。

先行事例

  • OxCamlのzero_allocチェッカーは、コンパイラに[@zero_alloc]とマークされた関数と、それが呼び出すすべてがヒープにアロケーションしないことを証明させます。
  • gcassertは、注釈を付けた特定の行をチェックします。
  • testing.AllocsPerRunは、テストが実行する入力に対するアロケーション数をカウントします。
  • TigerBeetleは、すべてのメモリを起動時にアロケーションします。
  • PostgreSQLのメモリコンテキストは、クエリのすべてのメモリを一度に解放します。
  • Zigは、アロケーションするすべての関数にアロケーターを渡します。

ライセンス

MIT。LICENSEを参照してください。Goのソースから適応されたコードは、GoのBSDライセンスを保持します。これは、GO_LICENSEまたはその横のLICENSEにあります。cmd/jsongen、html/template、net/url、およびencoding/json(そのinternal/strconvはGo自身のコピーです)です。encoding/json/testdata/JSONTestSuiteは、Nicolas SeriotのMITライセンスの下にあるJSONTestSuiteです。