3.4Оптимизации
Проверим конкретные оптимизации через benchmark и учтём, что снижение одной метрики может не ускорить систему целиком.
Преаллокация
append при нехватке ёмкости выделяет новый массив, копирует туда старый и делает
старый мусором. Двадцать append-ов в пустой слайс обходятся в шесть аллокаций,
пять мусорных массивов и 31 скопированный элемент вместо одной аллокации и нуля копий.
Для элементов не больше 32 байт (скажем, int) с Go 1.26 первые из них ложатся в буфер
на стеке, и аллокаций в куче меньше, но копии и мусор остаются.
make([]T, 0, n) убирает всю эту работу.// плохо: 6 аллокаций и мусор
var out []Row
for _, r := range rows { out = append(out, transform(r)) }
// хорошо: одна аллокация
out := make([]Row, 0, len(rows))
for _, r := range rows { out = append(out, transform(r)) }
// Ловушка №1: make([]T, n) вместо make([]T, 0, n)
out := make([]Row, len(rows)) // уже лежат n нулевых элементов
for _, r := range rows { out = append(out, transform(r)) }
// на выходе 2n элементов, первые n нулевые. Классический баг.
// Ловушка №2: append на подслайс пишет в чужой массив
a := []int{1, 2, 3, 4, 5}
b := a[:2]
b = append(b, 99) // затёр a[2]: cap(b) == 5
c := a[:2:2] // full slice expression: cap=2, append скопирует
c = append(c, 99) // a не тронут
// Ловушка №3: слайс переживает срез
data, _ := os.ReadFile("big.bin") // 10 МБ
head := data[:16] // весь массив жив, пока жив head
head = slices.Clone(data[:16]) // а так - только 16 байт
// мапы преаллоцируются так же
m := make(map[string]int, len(keys))
deleteне уменьшает число бакетов. Мапа, разросшаяся до миллиона записей, после полной очистки продолжает занимать память под таблицу. Вернуть её можно, только создав новую мапу (clear(m)из Go 1.21 тоже не уменьшает).- Адрес элемента мапы взять нельзя (
&m[k]не скомпилируется): при росте мапы элементы эвакуируются, адреса менялись бы. - С Go 1.24 мапы внутри перешли на Swiss Tables: меньше памяти, быстрее lookup (особенно на промахах), плотнее раскладка. Внешнее поведение и порядок итерации не изменились.
Снижение аллокаций
// плохо
s := ""
for _, x := range parts {
s += x // новая строка каждую итерацию: O(n^2)
}
key := fmt.Sprintf("u:%d:%s", id, name)
func rows(n int) []Row {
var out []Row
for i := 0; i < n; i++ {
out = append(out, Row{})
}
return out
}
// в цикле: новый буфер каждый раз
for _, r := range reqs {
buf := make([]byte, 0, 4096)
buf = encode(buf, r)
}
// хорошо
var sb strings.Builder
sb.Grow(totalLen) // одна аллокация
for _, x := range parts {
sb.WriteString(x)
}
s := sb.String() // без копии: Builder отдаёт свой массив
key := make([]byte, 0, 32)
key = append(key, "u:"...)
key = strconv.AppendInt(key, id, 10)
key = append(key, ':'); key = append(key, name...)
func rows(n int) []Row {
out := make([]Row, 0, n)
...
}
// один буфер на всё, сброс длины
buf := make([]byte, 0, 4096)
for _, r := range reqs {
buf = buf[:0]
buf = encode(buf, r)
}
strings.Builderвместо+=: конкатенация в цикле стоит O(n²) по копированию.Grow(n)добивает до одной аллокации. При этомBuilder.String()не копирует данные (внутриunsafe), поэтому сам Builder копировать нельзя;go vetтакую копию не ловит, её ловит паника при записи в копию.strconv.Append*иfmt.Appendf(Go 1.19) вместоSprintf: пишут в твой буфер, не создавая строку.- Переиспользование буфера через
buf = buf[:0]: ёмкость сохраняется, аллокаций после прогрева ноль. sync.Poolберут для крупных короткоживущих объектов, обязательно сReset()и с порогом «слишком большой буфер обратно не кладём».- Компилятор уже умеет часть конверсий бесплатно:
m[string(b)]не аллоцирует строку,string(b) == "x"тоже, иfor i, c := range []byte(s)обходится без копии. Это стоит знать до того, как тянуться кunsafe.
Описанное выше тут уже сделали за тебя. Старый
io.ReadAll читал всё в один слайс, растущий через append:
каждый раз, когда буфер кончался, выделялся новый, крупнее прежнего, и копировалось
всё уже прочитанное. На ответе в 10 МБ набегали десятки перевыделений и десятки мегабайт лишнего
копирования. Это не квадратичность, как у s += x в цикле, а та же цена роста без cap, что разобрана выше.
В Go 1.26 функцию переписали: прочитанное копится отдельными кусками (каждый следующий
в полтора раза больше предыдущего), которые никто не перекопирует, а в самом конце всё
один раз склеивается в слайс точного размера. По цифрам релиза вышло примерно вдвое быстрее
и вдвое меньше выделенной памяти (речь о байтах, а не о числе аллокаций). Выводов два. Первый: самодельные обёртки, написанные когда-то
только ради обхода медленного ReadAll, больше не нужны.
Второй остался прежним. Если длина известна заранее (Content-Length, размер
файла), io.ReadFull в преаллоцированный буфер всё равно дешевле: одна
аллокация и ноль склеек. И ReadAll по-прежнему держит всё тело в памяти,
так что большие ответы читай потоком (io.Copy,
json.Decoder), а не ускоренным ReadAll.
// unsafe-конверсии без копирования (Go 1.20+)
func b2s(b []byte) string { return unsafe.String(unsafe.SliceData(b), len(b)) }
func s2b(s string) []byte { return unsafe.Slice(unsafe.StringData(s), len(s)) }
Обычные []byte(s) и string(b) копируют данные, и за счёт этого строки в Go
остаются неизменяемыми и безопасными. unsafe-версии делят одну память, и правила у них жёсткие:
b2s: полученную строку можно использовать только пока никто не пишет в исходный слайс. Поменяешь байт, и строка молча изменится, а если она успела стать ключом мапы, мапа сломана навсегда.s2b: в полученный слайс нельзя писать вообще никогда. Строковые литералы лежат в read-only секции, и запись туда дастSIGSEGV(на macOS SIGBUS). Плюс интернированные компилятором строки общие на всю программу.- Нельзя отдавать результат наружу пакета и нельзя класть в долгоживущие структуры.
Допустимо: горячий путь внутри одного пакета, короткое время жизни, есть бенчмарк,
доказывающий выигрыш, и тесты гоняются под -race, который включает и проверку -d=checkptr.
Недопустимо: «мне показалось, что так быстрее». На собесе отвечай так:
«знаю как, применял, но только за границей горячего цикла и с комментарием, почему это
безопасно именно здесь».
В Go 1.27 объекты меньше 80 байт выделяются примерно на 30 % дешевле: компилятор
подставляет процедуру, специализированную под конкретный размерный класс, вместо универсальной
mallocgc с вычислениями и ветвлениями в рантайме (механику разбирает глава
3.1). За это бинарь прибавляет примерно 60 КБ; выключить специализацию можно через
GOEXPERIMENT=nosizespecializedmalloc.
Из этого не следует, что аллокации можно больше не считать: подешевела одна
статья расходов из трёх. Аллокация в куче платит трижды: за выдачу места, за обход сборщиком на маркировке
и за уборку на sweep. Ускорилась только первая часть, а две остальные зависят от того,
сколько объектов ты создал, и никуда не делись. Ноль аллокаций по-прежнему лучше,
чем дешёвая аллокация. Есть и практический подвох: цифры ns/op из бенчмарков,
снятых на 1.26 и раньше, на 1.27 сдвинутся сами по себе. Сравнивай «до» и «после»
только на одной версии тулчейна, иначе припишешь себе чужую оптимизацию.
Выравнивание структур и порядок полей
У каждого типа своё требование выравнивания. На 64-битных платформах int64, float64 и указатели
выравниваются по 8 байт, int32 по 4, int16 по 2,
byte/bool по 1. Компилятор не переставляет поля (порядок
входит в определение типа) и добивает промежутки padding-ом. Значит, порядок полей напрямую
определяет размер.
type Bad struct { // 24 байта
A bool // 0, +7 padding
B int64 // 8
C bool // 16, +3 padding
D int32 // 20
}
type Good struct { // 16 байт
B int64 // 0
D int32 // 8
A bool // 12
C bool // 13, +2 padding в конце
}
fmt.Println(unsafe.Sizeof(Bad{}), unsafe.Sizeof(Good{})) // 24 16
fmt.Println(unsafe.Alignof(int64(0)), unsafe.Offsetof(Bad{}.B)) // 8 8
$ go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
$ fieldalignment ./...
/app/model.go:12:12: Order has size 24 but the optimal size is 16 leading to a waste of 8 bytes (33%)
$ fieldalignment -fix ./... # переставит поля сам
False sharing: два счётчика разных горутин
в одной кэш-линии (64 байта) заставляют ядра гонять линию туда-сюда, и производительность
падает в разы, хотя счётчики друг от друга не зависят. Лечится паддингом до размера линии:
_ [64]byte между полями. С atomic отдельная история: int64-поле, к которому
применяют atomic.AddInt64, на 32-битных платформах надо вручную выравнивать по 8 байт,
иначе будет паника; это требование действует и сейчас. Поэтому с Go 1.19 бери типы
atomic.Int64/atomic.Pointer[T]: align-хак спрятан у них внутри, а
копирование заодно ловит vet.
Инлайнинг
Инлайн подставляет тело функции в место вызова. Выигрыш не столько в убранном CALL
(он дешёвый), сколько в том, что после подстановки включаются остальные оптимизации:
escape analysis видит всю картину и может оставить объект на стеке, константы сворачиваются,
проверки границ и мёртвые ветки выкидываются.
Бюджет
Компилятор считает «стоимость» тела в условных узлах AST и сравнивает с бюджетом
80 (inlineMaxBudget). Обычная операция стоит 1, а вызов другой функции обходится
в 57 (inlineExtraCallCost). Значит, функция с двумя
неинлайнящимися вызовами внутри почти наверняка сама в бюджет не влезет. С Go 1.12 работает
mid-stack inlining, так что заинлайнить можно и функцию, которая сама кого-то вызывает.
Что мешает инлайну
- Тело дороже 80 узлов.
- Рекурсия ограничивает глубину: рекурсивную функцию подставят на один уровень, дальше цикл не разворачивается (до Go 1.24.3 прямая рекурсия запрещала инлайн совсем).
recover()в теле.deferв теле. Аselect, метки,gotoи циклы сrangeдавно инлайнятся.- Вызов через интерфейс или переменную-функцию: цель неизвестна на этапе компиляции. Компилятор умеет девиртуализовать, когда конкретный тип очевиден, но в общем случае не может.
- Явный запрет
//go:noinline(его ставят в бенчмарках и тестах). - Функции, объявленные на ассемблере, и часть рантайм-функций.
$ go build -gcflags='-m -m' ./pkg 2>&1 | grep -i inlin
pkg/h.go:10:6: can inline small with cost 6 as: func(int) int { return x * 2 + 1 }
pkg/h.go:18:6: cannot inline big: function too complex: cost 90 exceeds budget 80
pkg/h.go:24:6: cannot inline cleanup: unhandled op DEFER
pkg/h.go:31:6: can inline fact with cost 71 as: func(int) int { if n <= 1 { return 1 }; return n * fact(n - 1) }
pkg/h.go:26:14: inlining call to small # подставили здесь
pkg/h.go:35:17: inlining call to fact # рекурсия: fact в себя, один уровень
# отключить инлайн целиком (полезно, чтобы увидеть «честные» решения escape analysis)
$ go build -gcflags='-m -l' ./pkg
Не переписывай код ради инлайна «на всякий случай»: это ухудшает читаемость ради процентов.
Работает другой приём: раздели горячую функцию на маленькую обёртку и холодный хвост.
Обёртка с быстрой проверкой влезает в бюджет и инлайнится, а редкий медленный путь уходит
в отдельную //go:noinline-функцию. Ровно так устроены sync.Mutex.Lock
(быстрый CAS + lockSlow) и sync.Once.Do (проверка флага + doSlow).
PGO - profile-guided optimization
Идея: скормить компилятору реальный CPU-профиль, чтобы он знал, какие пути горячие. Появилась как превью в Go 1.20, стабильной стала в Go 1.21.
# 1. снять профиль с прода под реальной нагрузкой
$ curl -so cpu.pprof 'http://localhost:6060/debug/pprof/profile?seconds=60'
# 2. положить рядом с main-пакетом под именем default.pgo
$ cp cpu.pprof ./cmd/api/default.pgo
# 3. всё. go build подхватывает его сам, начиная с Go 1.21
$ go build ./cmd/api
# либо явно:
$ go build -pgo=./cpu.pprof ./cmd/api
$ go build -pgo=off ./cmd/api # выключить
Что компилятор делает с профилем
- Агрессивнее инлайнит горячие вызовы, потому что бюджет для них поднимается с 80 до 2000.
- Девиртуализация интерфейсных вызовов: если в профиле видно, что 95% вызовов
io.Writer.Writeуходят в*os.File, компилятор ставит прямой вызов с проверкой типа и запасным путём, а этот прямой вызов уже можно заинлайнить. - Выравнивание горячих циклов (с Go 1.23, только amd64 и 386): заголовки горячих циклов выравниваются в коде, это ещё 1–1,5%. Перестановки блоков и функций по профилю в Go пока нет.
Команда Go называет 2–14% на наборе типичных программ (с Go 1.22; в Go 1.21 было 2–7%). Вдвое сервис от этого не ускорится, зато по всему парку ты бесплатно вернёшь себе несколько процентов CPU, а для крупной инсталляции это заметные деньги. Оговорки: профиль должен быть репрезентативным (снят с прода под нагрузкой, а не с ноутбука), устаревший профиль не ломает сборку, а просто перестаёт помогать, время компиляции немного растёт, и профиль надо положить в репозиторий и периодически обновлять, обычно отдельной джобой в CI.
Сначала измерь
Этот порядок стоит проговорить вслух, именно за него ставят плюс:
- Есть ли проблема? SLO, метрики, жалобы. Без цифры задачи нет.
- Где именно? Профиль, а не интуиция. Ускорять то, что занимает 0.5% профиля, бессмысленно по закону Амдала.
- Алгоритм и архитектура. Убрать N+1 запрос, добавить индекс, закэшировать, батчить, не делать работу вовсе. Здесь лежат разы, а не проценты.
- Аллокации. Преаллокация, переиспользование буферов, меньше указателей. Здесь лежат десятки процентов.
- Микрооптимизации. Порядок полей, инлайн, unsafe, PGO. Здесь лежат единицы процентов.
- Проверить в проде. Бенчмарк доказал, что функция стала быстрее; только профиль после выката доказывает, что стало быстрее приложение.
Каждая оптимизация создаёт долг: код усложняется, и следующий человек может его сломать, не понимая, зачем так написано. Поэтому: (1) оптимизируй только измеренные горячие места, а их обычно 1–2% кода; (2) оставляй рядом комментарий с цифрами и ссылкой на бенчмарк; (3) оставляй сам бенчмарк в репозитории, чтобы регрессию поймал CI; (4) если выигрыш меньше разброса измерений, откатывай, даже если «красиво». Для собеса: «по умолчанию пишем читаемо, а нечитаемость надо заслужить цифрами».
Вопросы
6append при нехватке ёмкости выделяет новый массив
и копирует туда старый. Знаешь итоговый размер - скажи его сразу:
make([]T, 0, n) превращает O(log n) аллокаций и O(n) копирований в одну
аллокацию и ноль копий. Ловушки - make([]T, n) вместо 0, n,
общий массив у подслайсов и удержание большого массива маленьким срезом.Как растёт слайс
Когда len == cap, рантайм зовёт growslice. Правило с Go 1.18:
если требуемая длина больше удвоенной ёмкости, берём её саму; иначе при
cap < 256 ёмкость удваивается, а начиная с 256 растёт по формуле
newcap += (newcap + 3*256) / 4, то есть коэффициент плавно съезжает с 2x к 1.25x.
До Go 1.18 порог стоял на 1024, и переход шёл ступенькой. Потом полученный размер в байтах
округляется вверх до класса размеров аллокатора (roundupsize), поэтому
реальный cap часто больше расчётного и цифры в
cap() иногда кажутся «странными».
var s []int
prev := -1
for i := 0; i < 2000; i++ {
s = append(s, i)
if cap(s) != prev { fmt.Print(cap(s), " "); prev = cap(s) }
}
// Реальный вывод go1.27:
// 4 8 16 32 64 128 256 512 848 1280 1792 2560
//
// Дальше удвоение до 256, затем ~1.25x и округление до size class (см. выше).
// А вот начало неожиданное: последовательность стартует не с 1, а с 4. Это работа
// компилятора (Go 1.26+): слайс не убегает из функции, и первый массив он берёт
// на стеке, 32 байта, то есть 4 int. Если тот же append спрятать за
// //go:noinline-функцию, слайс убежит, и вернётся честное 1 2 4 8 16. Для []byte
// стартовая ёмкость по той же причине 32. Мораль: конкретные первые числа -
// деталь реализации, а не гарантия; гарантирована только амортизированная O(1)
// на элемент.
При этом старый массив становится мусором. Рост обходится в копирование, работу для сборщика и всплеск RSS: в момент перевыделения живы оба массива сразу.
Что даёт преаллокация в цифрах
func BenchmarkNoCap(b *testing.B) {
b.ReportAllocs()
for b.Loop() { // Go 1.24
out := []int(nil)
for i := 0; i < 10000; i++ { out = append(out, i) }
_ = out
}
}
func BenchmarkWithCap(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
out := make([]int, 0, 10000)
for i := 0; i < 10000; i++ { out = append(out, i) }
_ = out
}
}
// go test -bench=. на go1.27 (Apple M4 Pro, arm64):
// BenchmarkNoCap-14 31342 36412 ns/op 357625 B/op 19 allocs/op
// BenchmarkWithCap-14 63304 20332 ns/op 81920 B/op 1 allocs/op
Устойчиво воспроизводится выигрыш по памяти: 357 КБ против 82 КБ (в 4,4 раза) и
19 аллокаций против одной. Лишнего набегает много: скопировано и выброшено около
260 КБ, втрое больше, чем нужно самому результату. А вот выигрыш
по времени сильно зависит от машины: в замере выше это всего 1,8x, потому что
десять тысяч итераций append сами по себе стоят дороже, чем перевыделения.
На более медленной памяти разрыв заметно больше, но обещать «в разы быстрее» не стоит,
лучше аргументируй аллокациями и нагрузкой на GC.
Ловушки
make([]T, n)вместоmake([]T, 0, n)встречается чаще всех. Первый вариант создаёт n нулевых элементов, и последующийappendдописывает после них: получаем 2n элементов, из которых первые n нулевые. Ловят на собесе именно на этом.- Подслайс делит массив с оригиналом.
b := a[:2]имеетcap(b) == cap(a), иappend(b, x)молча затрётa[2]. Лечится full slice expressiona[:2:2], который обрезает ёмкость и вынуждаетappendкопировать. - Маленький срез держит большой массив.
head := data[:16]от 10-мегабайтного файла удерживает все 10 МБ живыми: GC видит указатель на начало объекта, а не на «использованную часть». Спасаетslices.Cloneили явное копирование. appendвозвращает новый слайс, и результат обязательно присваивать. Классический баг: слайс передают в функцию, которая делаетappend, а новое значение не возвращают. Иногда изменения видны (влезло в cap), иногда нет.- Преаллокация вслепую тоже вредна.
make([]T, 0, 1e6)на всякий случай гарантированно аллоцирует в куче (объект больше 32 КБ идёт в large object span мимо кэшей) и раздувает RSS. Ёмкость должна быть обоснованной:lenвходных данных,LIMITзапроса, размер батча.
// вырастить заранее под известную добавку (Go 1.21)
s = slices.Grow(s, need) // гарантирует место под need элементов, без лишних копий
s = slices.Clip(s) // cap = len: append больше не пишет в общий хвост; память не освобождается
make(map[K]V, n) сразу создаёт достаточно бакетов и убирает серию
перехеширований при заполнении. Работает это так же хорошо, как cap у слайса.
А вот обратно память не возвращается: ни delete, ни clear(m)
(Go 1.21) не уменьшают число бакетов. Мапа, разово выросшая до миллиона ключей,
держит таблицу до конца жизни, так что кэш на мапе надо периодически пересоздавать.
С Go 1.24 внутри Swiss Tables: раскладка плотнее и лукап быстрее, но правило
«не сжимается» осталось.
«Если знаю размер результата, всегда пишу make([]T, 0, n). Это одна строка,
она убирает лог-число аллокаций, копирование и мусор. Если не знаю, число не выдумываю,
а смотрю в -benchmem: если allocs/op растёт с размером
входа, значит место для преаллокации есть.»
Grow, cap), переиспользовать
(buf = buf[:0], sync.Pool) и не платить за конверсии, которые
компилятор и так умеет делать бесплатно. unsafe - последний шаг, и только
с бенчмарком.strings.Builder
Конкатенация s += x в цикле копирует O(n²) байт: строки
неизменяемы, и каждая итерация создаёт новую. Внутри strings.Builder лежит обычный
[]byte, который растёт по правилам append, а
String() отдаёт его без копирования через
unsafe.String. Поэтому Builder нельзя копировать после первой
записи: он хранит указатель сам на себя (copyCheck) и при копии паникует
«strings: illegal use of non-zero Builder copied by value». go vet такую копию
не ловит: его проверка copylocks знает только типы с методами Lock/Unlock вроде мьютексов.
var b strings.Builder
b.Grow(len(parts) * 8) // одна аллокация вместо серии удвоений
for i, p := range parts {
if i > 0 { b.WriteByte(',') }
b.WriteString(p)
}
return b.String() // без копии
// нужен []byte и переиспользование: bytes.Buffer или свой слайс
// склеить фиксированный набор: strings.Join (одна аллокация)
Разница с bytes.Buffer: Buffer умеет читать, а его
Reset сохраняет выделенную память, поэтому он ложится в пул; Builder только пишет,
его Reset память выбрасывает, зато String() бесплатен. Под «собрать строку и отдать» бери Builder,
под «собрать байты, отправить, повторить» подойдёт Buffer или свой []byte.
Переиспользование буферов
// один буфер на весь цикл: cap сохраняется, аллокаций после прогрева ноль
buf := make([]byte, 0, 4096)
for _, r := range reqs {
buf = buf[:0]
buf = appendJSON(buf, r)
w.Write(buf)
}
// Append-семейство вместо Sprintf: пишем в свой буфер, строку не создаём
buf = strconv.AppendInt(buf, id, 10)
buf = fmt.Appendf(buf, " %s\n", name) // Go 1.19
// и на приёме тоже: json.Decoder/Encoder на потоке вместо Marshal в []byte
sync.Pool
Пул работает как кэш временных объектов, приколоченный к P: у каждого процессора есть приватный слот (доступ без синхронизации вообще) и разделяемая очередь, из которой другие P могут воровать. При сборке мусора пул не очищается сразу: живой набор уезжает в victim cache и умирает только на следующем цикле (Go 1.13), поэтому одна случайная сборка не обнуляет прогрев.
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) }, // всегда указатель, не значение
}
func handle(w http.ResponseWriter, r *http.Request) {
b := bufPool.Get().(*bytes.Buffer)
b.Reset() // обязательно: пришёл чужой мусор
defer func() {
if b.Cap() <= 64<<10 { // порог: раздутый буфер обратно не кладём
bufPool.Put(b)
}
}()
render(b, r)
w.Write(b.Bytes())
}
- Только указатели.
Put(buf)со значением-слайсом кладёт его вany, а упаковка сама по себе аллоцирует, иstaticcheckругается на это кодомSA6002. Кладём*bytes.Bufferили*[]byte. - Reset обязателен, иначе данные утекают между запросами (в худшем случае в тело ответа попадёт чужой ответ, а это уже уязвимость).
- Порог на размер. Без него один запрос на 200 МБ оставит в пуле
200-мегабайтный буфер, и пока пул в работе, этот буфер ходит по кругу и держит память. Ровно этот приём есть в
fmtиnet/http. - Пул не нужен для мелочи. Маленький объект из mcache выделяется за
несколько наносекунд;
Get/Putплюс промахи кэша могут стоить дороже. Пул окупается на крупных (килобайты), короткоживущих и часто создаваемых объектах. - После
Putобъект использовать нельзя: его уже могла забрать другая горутина. Типичная ошибка:defer pool.Put(b)и возвратb.Bytes()наружу.
Конверсии string и []byte
Обычные []byte(s) и string(b) копируют, и это цена
неизменяемости строк. Но компилятор знает несколько случаев, где копия не нужна, и убирает
её сам. Знать этот список важнее, чем уметь писать unsafe:
m[string(b)] // лукап по []byte без аллокации строки
if string(b) == "ping" { } // сравнение без аллокации
switch string(b) { case "get": } // тоже
for i, c := range []byte(s) { } // обход байтов без копии; range string(b) копирует
w.Write([]byte(s)) // здесь копия будет; io.WriteString(w, s) - нет, если у w есть WriteString
// ручные конверсии без копии (Go 1.20+), только для горячего пути
func b2s(b []byte) string { return unsafe.String(unsafe.SliceData(b), len(b)) }
func s2b(s string) []byte { return unsafe.Slice(unsafe.StringData(s), len(s)) }
b2s: строка валидна, только пока никто не пишет в исходный слайс;
если такая строка попала ключом в мапу и байт изменился, мапа сломана навсегда.
s2b: в результат нельзя писать никогда, потому что литералы лежат в read-only
секции и запись даст SIGSEGV (на macOS SIGBUS). Ни то, ни другое нельзя отдавать наружу пакета
или класть в долгоживущие структуры. Допустимо: короткий горячий путь внутри пакета,
есть бенчмарк с выигрышем, тесты гоняются под -race (он включает и
-d=checkptr), рядом комментарий с обоснованием.
Уменьшая аллокации, ты бьёшь сразу по трём статьям: время на сам
mallocgc, частота сборок (темп аллокации напрямую задаёт, как быстро
куча дорастёт до цели GOGC) и стоимость маркировки. Причём последнее
зависит не от числа объектов, а от числа указателей: объект без указателей
попадает в noscan-класс, и сборщик его вообще не сканирует. Отсюда неочевидный приём:
[]Item вместо []*Item и индексы int32 вместо
указателей дают одну аллокацию вместо n и ноль работы маркировщику.
unsafe.Sizeof и анализатор
fieldalignment.Откуда берётся padding
На amd64 и arm64 выравнивание 8 у указателей, int, int64, float64;
4 у int32/float32/rune;
2 у int16; 1 у byte, bool, int8.
Смещение поля обязано быть кратно его выравниванию, а размер всей структуры кратен
максимальному выравниванию среди полей (чтобы в массиве второй элемент тоже лёг ровно).
Отсюда и «хвостовой» padding в конце.
type Bad struct { // 24 байта
A bool // offset 0, +7 padding
B int64 // offset 8
C bool // offset 16, +3 padding
D int32 // offset 20
}
type Good struct { // 16 байт
B int64 // 0
D int32 // 8
A bool // 12
C bool // 13, +2 хвостового padding
}
fmt.Println(unsafe.Sizeof(Bad{}), unsafe.Sizeof(Good{})) // 24 16
fmt.Println(unsafe.Offsetof(Bad{}.D), unsafe.Alignof(Bad{})) // 20 8
$ go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
$ fieldalignment ./...
/app/model.go:12:12: Order has size 24 but the optimal size is 16 leading to a waste of 8 bytes (33%)
$ fieldalignment -fix ./... # переставит поля сам
$ go build -gcflags=-m=2 ./... # подробно: почему убегает и почему не инлайнится
Когда это вообще имеет значение
Сэкономленные 8 байт на структуре, которых в программе три штуки, ничего не решают. Смысл
появляется, когда таких значений миллионы: срез из миллиона Bad занимает
24 МБ против 16 МБ, то есть 8 МБ чистой пустоты и в полтора раза больше памяти, которую
обход тянет через кэш. Второй случай: структура летит по сети или в очередь
миллионами раз в секунду. В обычном бизнес-коде это микрооптимизация последнего уровня,
и ломать логическую группировку полей ради 8 байт на десяти объектах не стоит:
размен плохой.
Ловушки
- Пустая структура в конце. Поле типа
struct{}последним в структуре получает padding до 1 байта: иначе указатель на него смотрел бы за границу объекта, а такие указатели рантайм не допускает. Еслиstruct{}стоит не последним, размер не меняется. - Размер не равен «сумме полей». У
string16 байт (указатель + длина), у[]T24, уinterface{}и любого интерфейса 16, уmap/chan/func8. Их содержимое лежит отдельно в куче,unsafe.Sizeofего не считает. - Не путать выравнивание с числом указателей. Иногда важнее не размер, а то, что структура вообще без указателей попадает в noscan-класс и не сканируется сборщиком.
False sharing ставит обратную задачу: тут padding не убирают, а добавляют.
Два счётчика разных горутин в одной кэш-линии (64 байта) заставляют ядра гонять линию
туда-сюда протоколом когерентности; переменные независимы, а throughput падает в разы.
Помогает вставка _ [64]byte между полями, как в
runtime и в шардированных счётчиках. Отдельная деталь: int64-поле,
к которому применяют atomic.AddInt64, на 32-битных платформах
нужно вручную выравнивать по 8 байт (первым полем структуры), иначе будет паника, и это
правило действует до сих пор. С Go 1.19 вместо этого берут atomic.Int64 и
atomic.Pointer[T]: выравнивание в них гарантировано, а копию ловит
vet.
«Компилятор поля не переставляет, поэтому за раскладку отвечаю я как автор типа.
Сортирую по убыванию выравнивания, проверяю fieldalignment в CI. Но делаю
это только для типов, которых в памяти миллионы. В остальных случаях читаемая
группировка полей дороже восьми байт.»
CALL, сколько в том, что после подстановки
включаются остальные оптимизации - в первую очередь escape analysis. Мешают: превышение
бюджета, defer, go, recover,
вызовы через интерфейс и //go:noinline.Зачем это нужно
Сам вызов на современном процессоре стоит единицы наносекунд, и ради этого никто бы не
старался. Инлайн ценен другим. Тело оказывается в контексте вызывающего,
и дальше работают остальные проходы: escape analysis видит, что указатель никуда не утёк,
и оставляет объект на стеке; константы сворачиваются; проверки границ
удаляются, потому что индекс известен; мёртвые ветки выкидываются. Отсюда и типичная
картина: убираешь //go:noinline, и заодно исчезает аллокация, которой в этой
функции вроде бы неоткуда взяться.
Бюджет
Стоимость считают по узлам AST: обычная операция стоит 1, вызов другой функции целых 57
(inlineExtraCallCost), а лимит равен 80 (inlineMaxBudget).
Выходит, функция с двумя неинлайнящимися вызовами внутри почти наверняка
сама в бюджет не влезет. При этом с Go 1.12 работает mid-stack inlining: функцию,
которая сама кого-то вызывает, всё равно заинлайнят, если её собственная
стоимость (вместе с уже подставленными вызовами) укладывается в бюджет.
Что мешает
- Тело дороже 80 узлов, в выводе
function too complex: cost N exceeds budget 80. - Рекурсия ограничивает глубину: рекурсивную функцию подставят на один уровень, дальше цикл не разворачивается (до Go 1.24.3 прямая рекурсия запрещала инлайн совсем).
recover()в теле.deferиgoв теле (unhandled op DEFER).- Вызов через интерфейс или через переменную-функцию: цель неизвестна на этапе компиляции, инлайнить нечего. Компилятор умеет девиртуализовать, когда конкретный тип очевиден локально, но в общем случае не справляется.
- Явный запрет
//go:noinline: его ставят в бенчмарках, чтобы компилятор не выкинул измеряемый код целиком. - Функции на ассемблере и часть рантайм-функций.
Часть запретов со временем сняли: раньше не инлайнились функции с select,
с range-циклом, с замыканиями внутри, с прямой рекурсией, а сейчас это уже работает.
Свежий пример из той же серии: в Go 1.24–1.25 тело for b.Loop() в бенчмарках
побочно теряло инлайн, а с Go 1.26 перестало.
Поэтому верь не заученному списку, а выводу
-gcflags=-m на конкретной версии Go. Так и отвечай: назови
принцип (бюджет + невозможность построить эквивалентный код в теле вызывающего) и
покажи, что умеешь проверить.
$ go build -gcflags='-m -m' ./pkg 2>&1 | grep -i inlin
pkg/h.go:10:6: can inline small with cost 6 as: func(int) int { return x * 2 + 1 }
pkg/h.go:18:6: cannot inline big: function too complex: cost 90 exceeds budget 80
pkg/h.go:24:6: cannot inline cleanup: unhandled op DEFER
pkg/h.go:31:6: can inline fact with cost 71 as: func(int) int { if n <= 1 { return 1 }; return n * fact(n - 1) }
pkg/h.go:26:14: inlining call to small
pkg/h.go:35:17: inlining call to fact
$ go build -gcflags='-m -l' ./pkg # -l выключает инлайн: видно "честные" решения escape analysis
$ go build -gcflags='-l -l -l -l' # наоборот, агрессивнее (для экспериментов)
Приём: быстрая обёртка плюс холодный хвост
// обёртка укладывается в бюджет и инлайнится в вызывающего
func (c *Cache) Get(k string) (V, bool) {
if v, ok := c.fast[k]; ok { return v, true } // горячий путь: пара операций
return c.getSlow(k) // редкий путь вынесен
}
//go:noinline
func (c *Cache) getSlow(k string) (V, bool) {
// блокировки, промах кэша, поход в БД - сколько угодно кода,
// он не мешает инлайну Get, потому что живёт в другой функции
}
Ровно так устроены стандартные sync.Mutex.Lock (быстрый CAS плюс
lockSlow) и sync.Once.Do (проверка флага плюс doSlow). Это единственная перестройка кода
ради инлайна, которую стоит делать сознательно: она заодно и читается лучше, потому что быстрый путь
видно сразу.
За инлайн платят: код вызывающего распухает, растёт размер бинарника и фрейма стека,
хуже работает кэш инструкций. Компилятор этот баланс уже держит. Резать читаемую
функцию на куски, «чтобы влезла в 80», ради процента почти всегда невыгодно.
Вмешиваться стоит, когда функция в топе профиля,
-m говорит cannot inline и бенчмарк подтверждает выигрыш
от разделения. И для добивки: с PGO бюджет для горячих вызовов
поднимается с 80 до 2000, так что часть «слишком сложных» функций начинает инлайниться
сама, без переписывания.
default.pgo рядом с main-пакетом - и
всё, go build подхватывает сам (превью в Go 1.20, стабильно с Go 1.21).
Даёт агрессивный инлайн горячих вызовов, девиртуализацию интерфейсных вызовов и (на amd64)
выравнивание горячих циклов; официальные цифры - 2–14%.Как включить
# 1. снять профиль с прода под реальной нагрузкой
$ curl -so cpu.pprof 'http://localhost:6060/debug/pprof/profile?seconds=60'
# 2. положить рядом с main-пакетом под именем default.pgo и закоммитить
$ cp cpu.pprof ./cmd/api/default.pgo
# 3. всё; -pgo=auto включён по умолчанию с Go 1.21
$ go build ./cmd/api
$ go build -pgo=./cpu.pprof ./cmd/api # явный путь
$ go build -pgo=off ./cmd/api # выключить (например, для сравнения в бенчмарке)
# профили с нескольких подов лучше слить в один
$ go tool pprof -proto pod1.pprof pod2.pprof pod3.pprof > default.pgo
Что компилятор делает с профилем
- Инлайн горячих вызовов. Для call site, отмеченных в профиле как горячие, бюджет поднимается до 2000 против обычных 80, и «слишком сложные» функции в горячем месте всё-таки подставляются.
- Девиртуализация. Если в профиле видно, что 95% вызовов
io.Writer.Writeуходят в*os.File, компилятор ставит прямой вызов с проверкой типа и запасной веткой на общий случай. А прямой вызов дальше можно ещё и заинлайнить, так что эффекты складываются. - Выравнивание горячих циклов. С Go 1.23 на amd64 и 386 заголовки горячих циклов выравниваются в коде, по замерам команды Go это ещё 1–1,5%. Переставлять блоки и функции по профилю, как BOLT, компилятор Go пока не умеет.
Оговорки, за которые ставят плюс
- Профиль должен быть репрезентативным. Снятый с ноутбука на синтетическом тесте профиль оптимизирует не тот код. Нужен прод (или staging с боевым трафиком) под нагрузкой, желательно усреднённый по нескольким инстансам и снятый не в момент деплоя.
- Устаревший профиль не ломает сборку. Если функции переименовали и код переписали, компилятор просто не найдёт совпадений и соберёт как обычно. Хуже не станет, пропадёт только выигрыш. Обычно профиль обновляют отдельной джобой в CI, например еженедельно.
- Итеративная стабильность. Профиль, снятый с PGO-сборки, годится для следующей PGO-сборки: команда Go специально проверяет, что бинарь не «расходится» от поколения к поколению.
- Платишь временем компиляции. С Go 1.23 это единицы процентов (раньше крупные сборки замедлялись вдвое и больше), немного растёт и размер бинарника. Кэш сборки при смене профиля инвалидируется целиком.
По официальным измерениям Go (с Go 1.22), PGO ускоряет набор типичных программ на 2–14%; в Go 1.21 было 2–7%. Ускорения вдвое не жди. Речь о нескольких бесплатных процентах CPU по всему парку, которые для крупной инсталляции превращаются в реальные деньги и в отложенное масштабирование. На собесе формулируй так: «PGO добирает последний процент, когда алгоритмы и аллокации уже вычищены. Включается одним файлом в репозитории, код менять не надо, поэтому соотношение усилий и выигрыша отличное. Но ставить его первым пунктом в плане оптимизации нельзя».
PGO живёт в компиляторе: флаг -pgo, файл default.pgo.
BOLT/линкерные оптимизации работают уже с готовым бинарником и к Go напрямую
не относятся. Отдельно стоит GC-тюнинг: GOGC и GOMEMLIMIT
меняют поведение рантайма во время работы, а не сгенерированный код. На собесе это
разные ответы, и путать их не стоит.
Порядок действий
- Есть ли проблема? Нарушенный SLO, растущая стоимость железа, жалоба. Без цифры задачи нет. «Кажется, тут медленно» ещё не задача.
- Где именно? Профиль, а не интуиция. По закону Амдала функция, занимающая 2% времени, даже ускоренная вдвое, даст 1%. Это не окупает ни дня работы, ни усложнения кода.
- Алгоритм и архитектура. Убрать N+1 запрос, добавить индекс, закэшировать, сбатчить, не делать работу вовсе. Здесь лежат разы. Быстрее всего работает код, который не выполняется.
- Аллокации. Преаллокация, переиспользование буферов, меньше указателей, меньше лишних копий. Здесь лежат десятки процентов, и заодно снижается нагрузка на GC.
- Микрооптимизации. Порядок полей, инлайн, PGO, unsafe. Здесь лежат единицы процентов.
- Проверить в проде. Бенчмарк доказывает, что функция стала быстрее; что стало быстрее приложение, доказывает только метрика после выката.
Измерять - значит измерять статистически
$ go test -run=^$ -bench=Encode -benchmem -count=10 > old.txt
# ... правка ...
$ go test -run=^$ -bench=Encode -benchmem -count=10 > new.txt
$ benchstat old.txt new.txt
│ old.txt │ new.txt │
│ sec/op │ sec/op vs base │
Encode-14 128.65n ± 3% 36.98n ± 1% -71.25% (p=0.000 n=10)
│ old.txt │ new.txt │
│ B/op │ B/op vs base │
Encode-14 80.00 ± 0% 0.00 ± 0% -100.00% (p=0.000 n=10)
│ old.txt │ new.txt │
│ allocs/op │ allocs/op vs base │
Encode-14 5.000 ± 0% 0.000 ± 0% -100.00% (p=0.000 n=10)
Один прогон «до» и один «после» ничего не доказывают: разброс на ноутбуке легко даёт
10–15%. Нужны -count и benchstat с p-value. Если
p > 0.05 или дельта меньше разброса, изменения нет и правку откатываем,
даже если она «явно быстрее по логике».
Компромисс с читаемостью
- Оптимизируй только измеренное. Горячего кода в сервисе обычно 1–2%; остальные 98% должны быть максимально скучными и понятными.
- Локализуй сложность. Хитрость прячется за обычным интерфейсом: снаружи
Encode(w, v), а внутри пул, буфер и unsafe. Тогда её читает один человек, а не весь отдел. - Комментарий с цифрами обязателен. Не «так быстрее», а «был Sprintf: 129 нс, 5 аллокаций; стало 37 нс, 0 аллокаций; см. BenchmarkEncode». Без этого следующий человек «упростит» код обратно и будет прав, потому что обоснования нет.
- Бенчмарк остаётся в репозитории. Он защищает от регрессии: без него оптимизация протухнет за пару рефакторингов.
- Ограничивай радиус. unsafe и ручные трюки держи внутри одного пакета, не в API и не в доменных типах.
Кнута цитируют как «преждевременная оптимизация - корень всех зол», а полностью фраза звучит так: «мы должны забыть про малую эффективность примерно в 97% случаев; преждевременная оптимизация - корень всех зол. Но нельзя упускать возможности в оставшихся 3%». То есть речь не про «оптимизировать не надо», а про порядок: сначала найди те 3%, потом работай. На собесе от тебя ждут не отказа от оптимизации, а дисциплины измерения.
- «Я везде использую
sync.Poolиunsafe, так быстрее». Без профиля это заявка на баги, а не на производительность. - «Заменил
appendна копирование вручную, стало на 3% быстрее», а сервис при этом 90% времени ждёт БД. Классическая оптимизация не того места. - «Померил на ноутбуке с открытым браузером, разница 5%». На таком стенде это шум.
- «Оптимизировал, но метрики после выката не смотрел», то есть работу не довёл до конца.
«По умолчанию пишу читаемо, а нечитаемость надо заслужить цифрами. Порядок такой: метрика показала проблему, профиль показал место, бенчмарк зафиксировал базу, правка дала статистически значимый выигрыш, комментарий объяснил зачем, метрика в проде подтвердила. Если хоть один шаг не сошёлся, откатываю.»
Что стоит запомнить
- Предварительное выделение полезно при разумно известном размере.
- Layout структур стоит измерять на целевой архитектуре.
- PGO помогает оптимизировать типичные пути по профилю.
Частые ошибки
- Меньше аллокаций не гарантирует меньшую задержку.
- Сначала нужен baseline и одинаковый сценарий.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Разбери результат
Дан код предварительного выделения. Какова длина и ёмкость результата? Какую проблему устраняет указанный capacity?
items := make([]int, 0, 3)
items = append(items, 4, 5, 6)
fmt.Println(len(items), cap(items))
Ответ и разбор
Печатается 3 3: было выделено место на три элемента, и добавлено ровно три. Это избегает роста backing array для этого числа элементов; последующее превышение ёмкости всё равно может выделить новый массив.
Код-ревью
2. Проверь вывод и исправь
Для сборки GOARCH=amd64 автор обещает размер Record в 16 байт. Согласен ли ты? Предложи порядок полей с меньшим padding и объясни, что ещё проверить перед изменением.
type Record struct {
A byte
B int64
C byte
}
fmt.Println(unsafe.Sizeof(Record{}))
Ответ и разбор
При обычном выравнивании Go на amd64 код печатает 24: перед B нужны 7 байт padding, после C ещё 7. Порядок B int64; A byte; C byte даёт 16 байт. Измерь unsafe.Sizeof на целевых архитектурах и benchmark горячего пути. Меньший размер структуры не гарантирует ускорение; проверь совместимость позиционных литералов и кода, зависящего от layout.
Применение
3. Реши сценарий
Батч содержит ровно 5000 элементов. Как оценить пользу make([]T, 0, 5000) и какие числа сравнить?
Ответ и разбор
Сравни benchmark варианта с append без capacity и с preallocation на том же батче. Смотри allocs/op, B/op и время, при этом учитывай стоимость выделения полного буфера, если батч часто оказывается меньше. Оставь preallocation, если измерение репрезентативно и резерв не создаёт лишний расход памяти.
Применение
4. Разбери другое ограничение
После перестановки полей Record размер уменьшился, но публичный бинарный формат сохраняется как raw memory dump. Можно ли оставлять изменение?
Ответ и разбор
Не меняй layout структуры, используемой как wire/disk format: padding, порядок полей и endian не являются переносимым контрактом. Определи явный формат сериализации и версионируй его; размер структуры измеряй отдельно для runtime памяти.