Deep Engineering

ЗАМЕР

bench/gogc/practice.go

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/golang/gc

Запись прогона

У этого замера записи прогона нет — только скрипт.

Скрипт

159 строк
//go:build ignore

// Две задачи урока про сборщик мусора: обе отвечают прогоном.
//
// ЗАДАЧА 1 — ПРЕДСКАЗАТЬ ВЫВОД. Пять чисел про одну арифметику: GOGC задаёт,
// НАСКОЛЬКО куче позволено вырасти сверх живых данных, прежде чем начнётся
// следующий цикл. Отсюда и 2 при GOGC=100, и 5 при GOGC=400, и ноль циклов
// при выключенном сборщике.
//
// ПОЧЕМУ ЭТО НЕ «СКОЛЬКО ПАМЯТИ РАЗРЕШЕНО»: GOGC — не предел, а множитель к
// текущему объёму живых данных. Вырастут живые — вырастет и цель, никакого
// потолка в этом числе нет. Потолок задаётся отдельно, через GOMEMLIMIT.
//
// ВЫВОД НАМЕРЕННО БЕЗ ПОДПИСЕЙ: те же строки печатаются в русской и английской
// версиях урока, поэтому в них не должно быть слов ни одного языка.
//
// ЗАДАЧА 2 — ОЦЕНИТЬ КРАТНОСТЬ. Во сколько раз меньше циклов сборки на одном и
// том же объёме выделений, если поднять GOGC со 100 до 400.
//
// ЗАПУСК:
//
//	go run bench/gogc/practice.go
package main

import (
	"fmt"
	"runtime"
	"runtime/debug"
)

var (
	live [][]byte
	sink []byte
)

// Набрать заданный объём ЖИВЫХ данных: они остаются достижимыми через live,
// поэтому сборщик их не тронет и цель следующего цикла считается от них.
func fillLive(mb int) {
	live = nil
	runtime.GC()
	for i := 0; i < mb; i++ {
		live = append(live, make([]byte, 1<<20))
	}
	runtime.GC()
}

func round(v float64) int { return int(v + 0.5) }

// Во сколько раз цель следующего цикла больше живых данных.
func goalOverLive(pct int) float64 {
	debug.SetGCPercent(pct)
	fillLive(16)
	var m runtime.MemStats
	runtime.ReadMemStats(&m)
	return float64(m.NextGC) / float64(m.HeapAlloc)
}

// Сколько циклов сборки случится, пока выделяется фиксированный объём мусора
// ПРИ ФИКСИРОВАННОМ ОБЪЁМЕ ЖИВЫХ ДАННЫХ.
//
// ПОЧЕМУ ЖИВЫЕ ДАННЫЕ ЗДЕСЬ ОБЯЗАТЕЛЬНЫ. Первая редакция замера обнуляла
// live перед циклом — и получала кратность то 3,6, то 8,5 от прогона к
// прогону. Причина не в шуме машины: без живых данных цель следующего цикла
// упирается в нижнюю границу кучи (4 МБ), а не считается от GOGC, и число
// циклов перестаёт зависеть от того, что замер меряет. С фиксированными
// 16 МБ живых данных цель считается арифметикой GOGC, и кратность выходит
// той, которую эта арифметика и предсказывает.
func cyclesFor(pct int) uint32 {
	fillLive(16)
	debug.SetGCPercent(pct)
	runtime.GC()
	var a runtime.MemStats
	runtime.ReadMemStats(&a)
	for i := 0; i < 200000; i++ {
		sink = make([]byte, 1024)
	}
	var b runtime.MemStats
	runtime.ReadMemStats(&b)
	return b.NumGC - a.NumGC
}

// Медиана: одиночный выброс не должен решать, что напечатано в уроке.
func medianCycles(pct int, rounds int) uint32 {
	got := make([]uint32, 0, rounds)
	for i := 0; i < rounds; i++ {
		got = append(got, cyclesFor(pct))
	}
	for i := 1; i < len(got); i++ {
		for j := i; j > 0 && got[j] < got[j-1]; j-- {
			got[j], got[j-1] = got[j-1], got[j]
		}
	}
	return got[len(got)/2]
}

// ---------------------------------------------------------------- задача 1

func task1() {
	// SetGCPercent возвращает ПРЕДЫДУЩЕЕ значение — отсюда идиома
	// «поменять на время и вернуть как было».
	fmt.Println(debug.SetGCPercent(100))

	// Цель следующего цикла = живые данные × (1 + GOGC/100).
	// Округление вынесено в helper, а не в Printf: эти строки целиком
	// переносятся в блок предсказания урока, а обратный слэш в шаблонной
	// строке MDX означал бы там перевод строки, а не два символа.
	fmt.Println(round(goalOverLive(100)))
	fmt.Println(round(goalOverLive(400)))

	// С выключенным сборщиком циклов нет вовсе, сколько ни выделяй.
	fmt.Println(cyclesFor(-1))

	// А ручной вызов считается циклом, даже когда сборщик выключен.
	debug.SetGCPercent(-1)
	var a runtime.MemStats
	runtime.ReadMemStats(&a)
	runtime.GC()
	runtime.GC()
	var b runtime.MemStats
	runtime.ReadMemStats(&b)
	fmt.Println(b.NumGC - a.NumGC)

	debug.SetGCPercent(100)
}

// ---------------------------------------------------------------- задача 2

func task2() {
	lo := medianCycles(100, 5)
	hi := medianCycles(400, 5)
	debug.SetGCPercent(100)

	ratio := float64(lo) / float64(hi)
	fmt.Printf("\n  живых данных всё время        %6d МБ\n", 16)
	fmt.Printf("  циклов сборки при GOGC=100    %6d\n", lo)
	fmt.Printf("  циклов сборки при GOGC=400    %6d\n", hi)
	fmt.Printf("  кратность                     %6.2f\n", ratio)
	fmt.Printf("  она же округлённо             %6.1f\n", ratio)
	fmt.Printf("\n  Медиана 5 кругов. go1.24.7 linux/amd64\n")
	fmt.Printf("\n  Выделено в обоих случаях одинаково: 200 000 кусков по\n")
	fmt.Printf("  килобайту при одних и тех же 16 МБ живых данных.\n")
	fmt.Printf("  Отличается только порог: при GOGC=100 цикл начинается,\n")
	fmt.Printf("  когда куча дорастает до 32 МБ, при GOGC=400 — до 80 МБ.\n")
	fmt.Printf("  То есть за цикл успевает накопиться вчетверо больше\n")
	fmt.Printf("  мусора, и циклов нужно во столько же раз меньше.\n")
	fmt.Printf("\n  Но это не бесплатное ускорение, а обмен: реже циклы —\n")
	fmt.Printf("  выше пик памяти. Ручка GOGC не делает сборку дешевле,\n")
	fmt.Printf("  она переносит цену со времени на память.\n")
}

func main() {
	fmt.Println("ЗАДАЧА 1 — предсказать вывод")
	fmt.Println()
	task1()
	fmt.Println()
	fmt.Println("ЗАДАЧА 2 — во сколько раз меньше циклов при GOGC=400")
	task2()
}