Deep Engineering

ЗАМЕР

bench/gogc/internals.go

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

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

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

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

Скрипт

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

// Сборщик мусора изнутри: чем платят за каждую настройку.
//
// ТРИ БЛОКА — три утверждения, каждое из которых проверяется:
//
//  1. GOGC — это обмен «время на память», и обе стороны обмена видны в одной
//     таблице: реже циклы, выше пик кучи;
//  2. пауза НЕ растёт вместе с кучей. Это главное свойство конкурентного
//     сборщика Go и единственное, ради чего стоит помнить его устройство;
//  3. GOMEMLIMIT — потолок, которого у GOGC нет. Он не заменяет GOGC, а
//     страхует от случая, когда живые данные выросли.
//
// ПОЧЕМУ НЕТ БЛОКА «СКОЛЬКО СТОИТ БАРЬЕР ЗАПИСИ». Замерить его в отрыве от
// всего остального на пользовательском коде нечем: барьер включается только
// на время фазы разметки и стоит долей процента. Печатать выдуманное число
// ради полноты картины хуже, чем не печатать никакого.
//
// ЗАПУСК:
//
//	go run bench/gogc/internals.go
package main

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

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

func fillLive(mb int) {
	live = nil
	runtime.GC()
	for i := 0; i < mb; i++ {
		live = append(live, make([]byte, 1<<20))
	}
	runtime.GC()
}

func mb(b uint64) float64 { return float64(b) / (1 << 20) }

// ------------------------------------------- 1. GOGC как обмен

func blockTradeoff() {
	fmt.Println("GOGC: ЧТО ИМЕННО МЕНЯЮТ НА ЧТО")
	fmt.Println("──────────────────────────────")
	fmt.Println("  GOGC   цель/живые   циклов   пик кучи   доля ЦП, накопительно")

	for _, pct := range []int{50, 100, 200, 400} {
		fillLive(16)
		debug.SetGCPercent(pct)
		runtime.GC()

		var before runtime.MemStats
		runtime.ReadMemStats(&before)
		goal := float64(before.NextGC) / float64(before.HeapAlloc)

		peak := before.HeapAlloc
		for i := 0; i < 200000; i++ {
			sink = make([]byte, 1024)
			if i%5000 == 0 {
				var m runtime.MemStats
				runtime.ReadMemStats(&m)
				if m.HeapAlloc > peak {
					peak = m.HeapAlloc
				}
			}
		}

		var after runtime.MemStats
		runtime.ReadMemStats(&after)
		fmt.Printf("  %4d   %9.2f   %6d   %6.1f МБ   %15.3f%%\n",
			pct, goal, after.NumGC-before.NumGC, mb(peak), after.GCCPUFraction*100)
	}
	debug.SetGCPercent(100)

	fmt.Println()
	fmt.Println("  Читать таблицу надо по двум столбцам сразу. Циклов при")
	fmt.Println("  GOGC=400 в разы меньше, чем при 50, — и ровно во столько")
	fmt.Println("  же раз выше пик кучи. Это не ускорение, а ПЕРЕНОС цены со")
	fmt.Println("  времени на память.")
	fmt.Println()
	fmt.Println("  Отсюда и правило выбора: GOGC поднимают, когда памяти в")
	fmt.Println("  избытке, а профиль показывает время в сборке; опускают —")
	fmt.Println("  когда память в обрез. Значения «по умолчанию хорошего»")
	fmt.Println("  здесь нет, есть только то, чего в системе больше.")
	fmt.Println()
	fmt.Println("  ПОСЛЕДНИЙ СТОЛБЕЦ ЧИТАТЬ ПО СТРОКАМ НЕЛЬЗЯ, и это стоит")
	fmt.Println("  сказать прямо. GCCPUFraction — величина НАКОПИТЕЛЬНАЯ: она")
	fmt.Println("  считается с момента старта программы, а не за один проход")
	fmt.Println("  цикла. Поэтому строка GOGC=400 несёт в себе всю работу")
	fmt.Println("  предыдущих трёх, и сравнивать её со строкой GOGC=50 —")
	fmt.Println("  значит сравнивать разные отрезки времени.")
	fmt.Println()
	fmt.Println("  Столбец оставлен не для сравнения, а как порядок величины:")
	fmt.Println("  на такой нагрузке сборщик стоит единицы процентов ЦП. Ради")
	fmt.Println("  этих единиц процентов и не стоит крутить GOGC без профиля.")
}

// ------------------------------- 2. пауза и размер кучи

// ПОЧЕМУ ЗДЕСЬ МЕДИАНА, А НЕ МАКСИМУМ. Первая редакция печатала максимум и
// получила на 16 МБ паузу 755 мкс против 34 мкс на 256 МБ — то есть таблицу,
// из которой следовало, что пауза с ростом кучи ПАДАЕТ. Разбор показал
// причину: первые циклы после набора живых данных дороже остальных, а на
// двухъядерной машине один посторонний процесс портит одну выборку целиком.
// Максимум из двенадцати измерений — это и есть «худший чужой процесс», а не
// свойство сборщика. Медиана отвечает на тот вопрос, который задан.
//
// ПОЧЕМУ ВЫБОРКА СТАЛА БОЛЬШЕ, И ЭТО ВТОРОЕ ИСПРАВЛЕНИЕ ТОГО ЖЕ ДЕФЕКТА.
// Медианы из восемнадцати циклов хватало, чтобы убрать один посторонний
// процесс, и не хватало, чтобы числу можно было верить в абсолюте: три
// прогона подряд на этой машине дали на 16 МБ 55, 71 и 37 мкс. Разброс
// БОЛЬШЕ, чем разница между строками таблицы, — а значит, всякий вывод про
// направление («с ростом кучи пауза падает») был выводом про шум. Здесь
// выборка поднята до двухсот циклов, и рядом печатается ПОВТОР одного и того
// же замера: пока строки таблицы отличаются меньше, чем два повтора одного
// размера, единственное, что блок устанавливает, — пауза НЕ масштабируется
// вместе с кучей. Это и есть то утверждение, ради которого блок написан.
const pauseCycles = 200
const pauseWarmup = 5

func pausesFor(liveMB int) (medianNs, p90Ns float64) {
	fillLive(liveMB)
	debug.SetGCPercent(100)

	// Прогрев отбрасывается: первые циклы после набора живых данных делают
	// лишнюю работу по стекам и меряют не то.
	var samples []float64
	for i := 0; i < pauseCycles; i++ {
		runtime.GC()
		var b runtime.MemStats
		runtime.ReadMemStats(&b)
		// PauseNs — кольцевой буфер; последняя запись лежит по индексу
		// (NumGC+255)%256. Берём именно её, а не длительность цикла: цикл
		// идёт КОНКУРЕНТНО с программой, и его длительность — не пауза.
		if i >= pauseWarmup {
			samples = append(samples, float64(b.PauseNs[(b.NumGC+255)%256]))
		}
	}

	for i := 1; i < len(samples); i++ {
		for j := i; j > 0 && samples[j] < samples[j-1]; j-- {
			samples[j], samples[j-1] = samples[j-1], samples[j]
		}
	}
	return samples[len(samples)/2], samples[(len(samples)*9)/10]
}

func blockPauses() {
	fmt.Println()
	fmt.Println("РАСТЁТ ЛИ ПАУЗА ВМЕСТЕ С КУЧЕЙ")
	fmt.Println("──────────────────────────────")
	fmt.Println("  живых данных   пауза медиана   она же 90-й процентиль")

	meds := map[int]float64{}
	for _, m := range []int{16, 64, 256} {
		med, p90 := pausesFor(m)
		meds[m] = med
		fmt.Printf("  %8d МБ %10.0f мкс %16.0f мкс\n", m, med/1000, p90/1000)
	}

	// ПОВТОР ОДНОГО РАЗМЕРА — это и есть цена деления прибора. Пока строки
	// таблицы отличаются меньше, чем два повтора одного и того же размера,
	// говорить о направлении изменения нельзя.
	again, _ := pausesFor(16)
	fmt.Printf("  %8d МБ %10.0f мкс %16s\n", 16, again/1000, "— повтор")
	debug.SetGCPercent(100)

	spread := meds[16] - again
	if spread < 0 {
		spread = -spread
	}
	rowGap := meds[256] - meds[16]
	if rowGap < 0 {
		rowGap = -rowGap
	}
	fmt.Println()
	fmt.Printf("  разброс между двумя повторами 16 МБ   %.0f мкс\n", spread/1000)
	fmt.Printf("  разница между строками 16 и 256 МБ    %.0f мкс\n", rowGap/1000)
	fmt.Printf("  различима ли разница строк прибором   %s\n", resolvable(rowGap, spread))
	fmt.Printf("  во сколько раз выросла куча           %d\n", 16)
	fmt.Printf("  во сколько раз выросла пауза          %.2f\n", meds[256]/meds[16])

	fmt.Println()
	fmt.Println("  Куча выросла в шестнадцать раз, пауза — нет. Это и есть")
	fmt.Println("  главное свойство сборщика Go: он работает КОНКУРЕНТНО с")
	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("  Что при этом действительно растёт вместе с кучей — это")
	fmt.Println("  РАБОТА разметки, то есть доля процессорного времени. Она")
	fmt.Println("  видна в предыдущей таблице. Пауза и стоимость сборки —")
	fmt.Println("  разные величины, и путать их дорого: программу")
	fmt.Println("  оптимизируют под ту, которая не менялась.")
	fmt.Println()
	fmt.Println("  ЧТО ИМЕННО ЗАМЕРЕНО: PauseNs из MemStats, то есть время")
	fmt.Println("  остановки мира, а не длительность цикла. Длительность")
	fmt.Println("  цикла с кучей растёт — но программа в это время работает.")
	fmt.Println()
	fmt.Println("  И оговорка про машину: замер идёт на двух ядрах, поэтому")
	fmt.Println("  абсолютные микросекунды здесь ничего не значат. Значение")
	fmt.Println("  имеет одно — что столбец не растёт вместе с кучей.")
}

// Различима ли разница прибором: меньше разброса повторов — значит нет.
func resolvable(gap, spread float64) string {
	if gap > spread {
		return "да"
	}
	return "нет"
}

func blockMemLimit() {
	fmt.Println()
	fmt.Println("GOMEMLIMIT: ПОТОЛОК, КОТОРОГО У GOGC НЕТ")
	fmt.Println("────────────────────────────────────────")

	// Сначала — как ведёт себя GOGC при РОСТЕ живых данных.
	debug.SetGCPercent(100)
	debug.SetMemoryLimit(1 << 62) // фактически без предела
	for _, m := range []int{16, 64} {
		fillLive(m)
		var s runtime.MemStats
		runtime.ReadMemStats(&s)
		fmt.Printf("  GOGC=100, живых %3d МБ    цель следующего цикла %6.1f МБ\n",
			m, mb(s.NextGC))
	}

	// Теперь — тот же рост, но с потолком.
	debug.SetMemoryLimit(96 << 20)
	for _, m := range []int{16, 64} {
		fillLive(m)
		var s runtime.MemStats
		runtime.ReadMemStats(&s)
		fmt.Printf("  предел 96 МБ, живых %3d МБ    цель следующего цикла %6.1f МБ\n",
			m, mb(s.NextGC))
	}
	debug.SetMemoryLimit(1 << 62)
	debug.SetGCPercent(100)

	fmt.Println()
	fmt.Println("  Первые две строки показывают, чего у GOGC нет: цель")
	fmt.Println("  считается ОТ ЖИВЫХ ДАННЫХ, поэтому растут живые — растёт")
	fmt.Println("  и цель, и никакого потолка в GOGC не заложено. Программа")
	fmt.Println("  с растущим кешем съест всю память, не нарушив настройки.")
	fmt.Println()
	fmt.Println("  Вторые две — что добавляет GOMEMLIMIT: цель перестаёт")
	fmt.Println("  подниматься выше предела, и сборка начинает идти чаще,")
	fmt.Println("  удерживая программу под ним.")
	fmt.Println()
	fmt.Println("  Предел МЯГКИЙ: если живые данные сами перерастут его,")
	fmt.Println("  рантайм не убьёт программу, а будет собирать почти")
	fmt.Println("  непрерывно. Отсюда рекомендация ставить предел ниже")
	fmt.Println("  настоящей границы контейнера: иначе вместо OOM получится")
	fmt.Println("  программа, живая по метрикам и не делающая работы.")
}

func main() {
	fmt.Println("go1.24.7 linux/amd64 | сборщик мусора изнутри")
	fmt.Println()
	blockTradeoff()
	blockPauses()
	blockMemLimit()
}