MEASUREMENT
bench/goescape/internals.go
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
This measurement has no recorded run — only the script.
Script
252 lines//go:build ignore
// Escape-анализ изнутри: что на самом деле отправляет значение в кучу.
//
// ЧЕТЫРЕ БЛОКА — четыре утверждения, каждое из которых проверяется:
//
// 1. каталог случаев с числом выделений. Ходовые правила («указатель — значит
// куча», «структура — значит стек») он опровергает построчно;
// 2. fmt как самая частая причина непрошеного выделения: аргументы уходят в
// интерфейс, а интерфейсу нужен адрес. Здесь же — почему на маленьком
// числе этого НЕ видно: у рантайма есть кеш значений 0..255;
// 3. размер, известный только во время выполнения. Компилятор обязан
// отвести место в кадре ЗАРАНЕЕ, поэтому неизвестная длина — это куча,
// даже когда значение никуда не уходит;
// 4. цена по размеру: чем больше значение, тем БОЛЬШЕ разница — стековая
// колонка от размера не зависит, а выделение включает обнуление.
//
// ЗАПУСК:
//
// go run bench/goescape/internals.go
package main
import (
"fmt"
"io"
"testing"
)
type Small struct{ X, Y int }
type Big struct{ A [32]int }
var (
sinkP *Small
sinkI any
sinkB []byte
sinkN int
sinkS string
sinkBig *Big
sinkF func() int
)
func allocs(f func()) float64 { return testing.AllocsPerRun(200, f) }
// ------------------------------------------------ 1. каталог случаев
//go:noinline
func readOnly(p *Small) int { return p.X + p.Y }
//go:noinline
func keepIt(p *Small) { sinkP = p }
func blockCatalogue() {
fmt.Println("ЧТО ОТПРАВЛЯЕТ ЗНАЧЕНИЕ В КУЧУ")
fmt.Println("──────────────────────────────")
fmt.Printf(" локальная структура без указателя %.0f\n",
allocs(func() { s := Small{1, 2}; sinkN = s.X }))
fmt.Printf(" указатель на локальную, не покидает кадр %.0f\n",
allocs(func() { s := &Small{1, 2}; sinkN = s.X }))
fmt.Printf(" указатель передан в функцию, которая читает %.0f\n",
allocs(func() { s := Small{1, 2}; sinkN = readOnly(&s) }))
fmt.Printf(" указатель передан в функцию, которая хранит %.0f\n",
allocs(func() { s := Small{1, 2}; keepIt(&s) }))
fmt.Printf(" значение положено в интерфейс %.0f\n",
allocs(func() { s := Small{1, 2}; sinkI = s }))
fmt.Printf(" замыкание вызвано на месте %.0f\n",
allocs(func() { s := Small{1, 2}; f := func() int { return s.X }; sinkN = f() }))
fmt.Printf(" замыкание сохранено наружу %.0f\n",
allocs(func() { s := Small{1, 2}; sinkF = func() int { return s.X } }))
fmt.Println()
fmt.Println(" Первые две строки убивают правило «указатель — значит")
fmt.Println(" куча»: во второй указатель есть, а выделения нет.")
fmt.Println()
fmt.Println(" Третья и четвёртая — почему это не свойство ВЫЗОВА.")
fmt.Println(" Одна и та же передача по указателю: когда вызываемая")
fmt.Println(" функция только читает, значение остаётся на стеке; когда")
fmt.Println(" сохраняет — уходит в кучу. Решает не место передачи, а")
fmt.Println(" то, переживает ли значение кадр.")
fmt.Println()
fmt.Println(" Пятая убивает второе правило: указателя нет, а выделение")
fmt.Println(" есть. Интерфейс хранит адрес, и адрес переживает кадр.")
fmt.Println()
fmt.Println(" Шестая и седьмая — тот же вопрос про замыкание. Захват сам")
fmt.Println(" по себе ничего не решает: пока замыкание вызывается на")
fmt.Println(" месте, захваченное остаётся в кадре. Как только замыкание")
fmt.Println(" уходит наружу — сохранено, возвращено или передано в go, —")
fmt.Println(" всё захваченное обязано пережить кадр.")
}
// --------------------------------------------------------- 2. fmt
func blockFmt() {
fmt.Println()
fmt.Println("ПОЧЕМУ ЛОГИРОВАНИЕ ДОБАВЛЯЕТ ВЫДЕЛЕНИЙ — И КОГДА НЕ ДОБАВЛЯЕТ")
fmt.Println("────────────────────────────────────────────────────────────")
small, big := 42, 100000
st := Small{1, 2}
fmt.Printf(" та же переменная без печати %.0f\n",
allocs(func() { sinkN = big * 2 }))
fmt.Printf(" fmt.Fprintln(io.Discard, 42) %.0f\n",
allocs(func() { fmt.Fprintln(io.Discard, small) }))
fmt.Printf(" fmt.Fprintln(io.Discard, 100000) %.0f\n",
allocs(func() { fmt.Fprintln(io.Discard, big) }))
fmt.Printf(" упаковать в интерфейс 255 %.0f\n",
allocs(func() { v := 255; sinkI = v }))
fmt.Printf(" упаковать в интерфейс 256 %.0f\n",
allocs(func() { v := 256; sinkI = v }))
fmt.Printf(" fmt.Sprintf(\"%%v\", структура) %.0f\n",
allocs(func() { sinkS = fmt.Sprintf("%v", st) }))
fmt.Println()
fmt.Println(" Причина выделения не в форматировании, а в сигнатуре: у")
fmt.Println(" этих функций аргументы объявлены как ...any. Чтобы")
fmt.Println(" упаковать значение в интерфейс, нужен АДРЕС, а адрес")
fmt.Println(" переживает кадр вызывающего — значит, значение обязано")
fmt.Println(" уехать в кучу.")
fmt.Println()
fmt.Println(" ПОЧЕМУ ЖЕ 42 НЕ ВЫДЕЛЯЕТ. Первая редакция этого блока")
fmt.Println(" печатала только 42 и получила ноль — то есть опровергала")
fmt.Println(" собственный текст. Причина оказалась не в escape-анализе:")
fmt.Println(" у рантайма есть готовый массив значений 0..255")
fmt.Println(" (runtime.staticuint64s), и упаковка малого числа берёт")
fmt.Println(" адрес оттуда, ничего не выделяя. На 256 кеш кончается —")
fmt.Println(" и появляется выделение.")
fmt.Println()
fmt.Println(" Отсюда сразу два практических вывода. Первый: отладочная")
fmt.Println(" печать в горячем цикле меняет не только время, но и")
fmt.Println(" РАСПРЕДЕЛЕНИЕ ПАМЯТИ измеряемого кода — профиль, снятый")
fmt.Println(" вместе с ней, описывает другую программу. Второй: замер")
fmt.Println(" на маленьких числах может не показать этого вовсе.")
}
// ------------------------------------- 3. размер, известный в рантайме
//go:noinline
func sizeFromRuntime() int { return 64 }
func blockUnknownSize() {
fmt.Println()
fmt.Println("РАЗМЕР, ИЗВЕСТНЫЙ ТОЛЬКО ВО ВРЕМЯ ВЫПОЛНЕНИЯ")
fmt.Println("────────────────────────────────────────────")
fmt.Printf(" make([]byte, 64) — константа %.0f\n",
allocs(func() { b := make([]byte, 64); sinkN = len(b) }))
fmt.Printf(" make([]byte, n) — n известен в рантайме %.0f\n",
allocs(func() { b := make([]byte, sizeFromRuntime()); sinkN = len(b) }))
fmt.Printf(" make([]byte, 64) и срез сохранён %.0f\n",
allocs(func() { sinkB = make([]byte, 64) }))
fmt.Println()
fmt.Println(" Вторая строка — случай, который не выводится ни из")
fmt.Println(" одного расхожего правила. Срез никуда не уходит, и всё")
fmt.Println(" равно куча: место в кадре компилятор обязан отвести")
fmt.Println(" ЗАРАНЕЕ, на сборке, а неизвестную длину заранее не")
fmt.Println(" отведёшь.")
fmt.Println()
fmt.Println(" Практический вывод узкий и полезный: в горячем пути")
fmt.Println(" константная длина буфера — это не педантизм, а разница")
fmt.Println(" между стеком и кучей.")
}
// --------------------------------------------------- 4. цена по размеру
//go:noinline
func smallStack(n int) int {
s := Small{n, n + 1}
return s.X + s.Y
}
//go:noinline
func smallHeap(n int) *Small { return &Small{n, n + 1} }
//go:noinline
func bigStack(n int) int {
var b Big
b.A[0], b.A[31] = n, n
return b.A[0] + b.A[31]
}
//go:noinline
func bigHeap(n int) *Big {
b := &Big{}
b.A[0], b.A[31] = n, n
return b
}
func bench(f func(int)) float64 {
r := testing.Benchmark(func(b *testing.B) {
for i := 0; i < b.N; i++ {
f(i)
}
})
return float64(r.T.Nanoseconds()) / float64(r.N)
}
func bestOf(f func(int), rounds int) float64 {
out := bench(f)
for i := 1; i < rounds; i++ {
if v := bench(f); v < out {
out = v
}
}
return out
}
func blockBySize() {
fmt.Println()
fmt.Println("ЦЕНА УХОДА В КУЧУ ПО РАЗМЕРУ ЗНАЧЕНИЯ")
fmt.Println("─────────────────────────────────────")
fmt.Println(" размер стек куча кратность")
rows := []struct {
label string
stack, heap func(int)
}{
{"16 байт", func(n int) { sinkN = smallStack(n) }, func(n int) { sinkP = smallHeap(n) }},
{"256 байт", func(n int) { sinkN = bigStack(n) }, func(n int) { sinkBig = bigHeap(n) }},
}
for _, r := range rows {
s, h := bestOf(r.stack, 5), bestOf(r.heap, 5)
fmt.Printf(" %-12s %7.2f нс %8.2f нс %9.1f\n", r.label, s, h, h/s)
}
fmt.Println()
fmt.Println(" Кратность РАСТЁТ с размером, и первая редакция этого")
fmt.Println(" блока предсказывала обратное. Данные объяснили почему:")
fmt.Println(" стековая колонка от размера почти не зависит — отвести")
fmt.Println(" место в кадре стоит одинаково, — а кучная растёт, потому")
fmt.Println(" что выделение включает обнуление всей области.")
fmt.Println()
fmt.Println(" То есть цена ухода в кучу — это не фиксированная надбавка")
fmt.Println(" за «указатель», а работа, пропорциональная размеру")
fmt.Println(" значения. Отсюда и практическое: большие структуры,")
fmt.Println(" которые убегают в цикле, стоят дороже, чем кажется по")
fmt.Println(" цифрам для мелких.")
fmt.Println()
fmt.Println(" И вторая часть счёта, которой в этих цифрах НЕТ: работа")
fmt.Println(" сборщика. Замер не даёт куче вырасти, поэтому здесь она")
fmt.Println(" не видна. В настоящей программе она есть, и растёт она")
fmt.Println(" тоже вместе с объёмом мусора.")
}
func main() {
fmt.Println("go1.24.7 linux/amd64 | стек, куча и escape-анализ")
fmt.Println()
blockCatalogue()
blockFmt()
blockUnknownSize()
blockBySize()
}