MEASUREMENT
bench/gogc/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/gc
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
283 lines//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()
}