Deep Engineering

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.

Cited in
/en/interview/golang/escape-analysis

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()
}