Разбираем новый аллокатор в Go 1.27

Совсем недавно вышел Go 1.27. Я пошел изучать release notes и наткнулся там на следующее:
Faster memory allocation
The compiler now generates calls to size-specialized memory allocation routines, reducing the cost of some small (<80 byte) memory allocations by up to 30%. Improvements vary depending on the workload, but the overall improvement is expected to be ~1% in real allocation-heavy programs. This causes the binary size to increase by about 60 KB (independent of the workload). Please file an issue if you notice any regressions. You may set
GOEXPERIMENT=nosizespecializedmallocat build time to disable it. This opt-out setting is expected to be removed in Go 1.28.
Как-то очень мало текста для такого изменения. Так что давайте разберется вместе.
Дикслеймер
Я предполагаю, что вы уже знаете:
что
new(T)сразу же уезжает на heapчто у Go есть size class-ы
что такое icache и почему размер горячего кода имеет значение
И стандартная оговорка: выигрывать наносекунды на аллокациях имеет смысл только после того, как вы разобрались со структурами данных и алгоритмами над ними в вашем коде.
Все измерения я проводил на:
AMD Ryzen 9 7940HS, 8 ядер / 16 потоков, 1 NUMA-нода
L1 32 КБ на ядро, L2 1 МБ на ядро, L3 16 МБ общий
78 ГБ RAM, Ubuntu 24.04
go version go1.27.1 linux/amd64
TLDR;
Аллокация маленького объекта в Go - это не столько прямая работа с памятью, сколько прогон по дереву из if-ов. Фактически, на этапе компиляции мы уже обладаем всей информацией, чтобы не проходится по этому дереву, а inline-ить нужный нам выбор. Это, собственно, и сделали в Go 1.27. Ну а теперь детали :)
Сколько стоит new(T)
Представим, что у нас есть структура на 32 байта без указателей. Максимально простой код:
type N32 [4]uint64
var p *N32 // нужен, чтобы объект уезжал на heap
func BenchmarkNewNoscan32(b *testing.B) {
for b.Loop() {
p = new(N32)
}
}
Если запустить это на Go 1.27 с выключенной фичей (то есть эмулировать строго то поведение, которое было в 1.26), то получим:
cpu: AMD Ryzen 9 7940HS w/ Radeon 780M Graphics
BenchmarkNewNoscan/size=032-4 46012696 14.30 ns/op 32 B/op 1 allocs/op
BenchmarkNewNoscan/size=032-4 43486191 13.69 ns/op 32 B/op 1 allocs/op
BenchmarkNewNoscan/size=032-4 45476173 13.66 ns/op 32 B/op 1 allocs/op
Около 14 наносекунд на операцию. Частота процессора около 4.6ГГц, то есть около 60 тактов на аллокацию 32 байт… Почему?
Лезем в кишочки
Для того, чтобы понять, что происходит, давайте взглянем на ассемблер. Самое интересное там вот в этой одной инструкции:
NewNoscan32 CALL runtime.newobject(SB)
runtime.newobject - это обёртка в одну строку над mallocgc. А mallocgc - это дерево.
// go1.27 src/runtime/malloc.go:1067
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
if size == 0 { return unsafe.Pointer(&zerobase) }
...
lockRankMayQueueFinalizer()
if debug.malloc { ... }
if asanenabled { size += asanRZ }
if gcBlackenEnabled != 0 { deductAssistCredit(size) }
if size <= maxSmallSize-gc.MallocHeaderSize {
if typ == nil || !typ.Pointers() {
x, elemsize = mallocgcSmallNoscan(size, typ, needzero)
} else if heapBitsInSpan(size) {
x, elemsize = mallocgcSmallScanNoHeader(size, typ)
} else {
x, elemsize = mallocgcSmallScanHeader(size, typ)
}
} else {
x, elemsize = mallocgcLarge(size, typ, needzero)
}
...
}
Смотрите, сколько много if-ов нужно пройти:
нулевой ли размер
включен ли debug
включен ли ASAN
в какой стадии сейчас GC
есть ли в типе указатели
нужен ли хедер или все же влезаем в спан
крупный это объект или мелкий
Идем далее и спускаемся еще ниже:
// go1.27 src/runtime/malloc.go:1364
func mallocgcSmallNoscan(size uintptr, typ *_type, needzero bool) (unsafe.Pointer, uintptr) {
mp := acquirem()
mp.mallocing = 1
c := getMCache(mp)
var sizeclass uint8
if size <= gc.SmallSizeMax-8 {
sizeclass = gc.SizeToSizeClass8[divRoundUp(size, gc.SmallSizeDiv)]
} else {
sizeclass = gc.SizeToSizeClass128[divRoundUp(size-gc.SmallSizeMax, gc.LargeSizeDiv)]
}
size = uintptr(gc.SizeClassToSize[sizeclass])
spc := makeSpanClass(sizeclass, true)
span := c.alloc[spc]
v := nextFreeFast(span)
...
}
Что здесь происходит: перед тем, как произвести собственно аллокацию, мы делаем пару лукапов и пару простых математических операций типа деления и сдвига.
А теперь сравните все описанное выше с сами выделением памяти
// go1.27 src/runtime/malloc.go:969:
// nextFreeFast returns the next free object if one is quickly available.
// Otherwise it returns 0.
func nextFreeFast(s *mspan) gclinkptr {
theBit := sys.TrailingZeros64(s.allocCache) // Is there a free object in the allocCache?
if theBit < 64 {
result := s.freeindex + uint16(theBit)
if result < s.nelems {
freeidx := result + 1
if freeidx%64 == 0 && freeidx != s.nelems {
return 0
}
s.allocCache >>= uint(theBit + 1)
s.freeindex = freeidx
s.allocCount++
return gclinkptr(uintptr(result)*s.elemsize + s.base())
}
}
return 0
}
Соотношение полезной работы к обвязке вокруг нее неприятное. Полезной работы - десяток инструкций, а вокруг намешено дерево из if-ов.
И прикол как раз в том, что компилятор мог бы сам ответить на большинство вопросов, которые задают if-ы. Знает ли компилятор, есть ли в типе указатель - знает. Может ли он вычислить size class - может. Единственное, чего не знает и не может никак узнать компилятор - информацию про работу GC.
Как if-ы уехали в компилятор
Теперь давайте посмотрим на ассемблер того же самого кода, что был в начале, но уже с дефолтными настройками Go 1.27, то есть с включенным новым аллокатором:
# GOEXPERIMENT=nosizespecializedmalloc (как в 1.26)
NewNoscan32 CALL runtime.newobject
# дефолт Go 1.27
NewNoscan32 CALL runtime.mallocgcSmallNoScanSC4
Вместо одной точки входа компилятор зовёт функцию, привязанную к конкретному size class.
Что же там под капотом?
// go1.27 src/cmd/compile/internal/ssagen/ssa.go:804
func (s *state) specializedMallocSym(size int64, hasPointers bool) *obj.LSym {
if !s.sizeSpecializedMallocEnabled() {
return nil
}
const specializedMallocMax = 80 // This must match the constant in mkmalloc.
if size > specializedMallocMax {
return nil
}
divRoundUp := func(n, a uintptr) uintptr { return (n + a - 1) / a }
sizeClass := gc.SizeToSizeClass8[divRoundUp(uintptr(size), gc.SmallSizeDiv)] // <- тот же лукап
if hasPointers {
return ir.Syms.MallocGCSmallScanNoHeader[sizeClass]
}
if size < gc.TinySize {
return ir.Syms.MallocGCTiny
}
return ir.Syms.MallocGCSmallNoScan[sizeClass]
}
Обратите внимание на строку с SizeToSizeClass8. Это тот же самый массив, который мы только что видели в рантайме. Так что лукап никуда не делся. Он просто выполняется один раз при компиляции, а не 100500 раз в рантайме.
Цифры
goos: linux
goarch: amd64
pkg: ssmbench/micro
cpu: AMD Ryzen 9 7940HS w/ Radeon 780M Graphics
│ off │ on │
│ sec/op │ sec/op vs base │
NewNoscan/size=008-4 7.981n ± 6% 5.400n ± 2% -32.34% (p=0.000 n=15)
NewNoscan/size=032-4 13.690n ± 5% 9.168n ± 2% -33.03% (p=0.000 n=15)
NewNoscan/size=064-4 15.99n ± 1% 11.23n ± 3% -29.77% (p=0.000 n=15)
NewNoscan/size=080-4 17.03n ± 3% 12.75n ± 3% -25.13% (p=0.000 n=15)
NewNoscan/size=088-4 17.65n ± 2% 18.00n ± 11% +1.98% (p=0.027 n=15)
NewNoscan/size=128-4 19.56n ± 2% 19.75n ± 2% ~ (p=0.546 n=15)
NewScan/size=008-4 11.610n ± 7% 6.992n ± 1% -39.78% (p=0.000 n=15)
NewScan/size=016-4 15.410n ± 3% 9.107n ± 1% -40.90% (p=0.000 n=15)
NewScan/size=032-4 17.28n ± 1% 10.25n ± 2% -40.68% (p=0.000 n=15)
NewScan/size=080-4 21.72n ± 1% 14.37n ± 2% -33.84% (p=0.000 n=15)
NewTiny/size=001-4 5.650n ± 2% 3.442n ± 1% -39.08% (p=0.000 n=15)
NewTiny/size=015-4 10.860n ± 3% 8.380n ± 1% -22.84% (p=0.000 n=15)
geomean 15.15n 11.42n -24.62%
На микробенчмарках видно, что в среднем примерно на четверть сократилось время исполнения. При этом на типах с указателями выигрыш целых 40%. Причина в битовой карте. В классическом пути writeHeapBitsSmall получает elemsize переменной, а значит, должна проверить, не пересекает ли карта границу машинного слова, и в этом случае сделать две записи вместо одной. В специализированной версии elemsize - константа, признак степени двойки вычисляется на этапе генерации, и ветка с двумя записями просто исчезает.
Также из бенчмарков видно, что если ваша горячая структура немного не влезает в 80 байт, теперь есть смысл её ужать. Можно выиграть до 25% времени на аллокации.
Но какой ценой
hello-world HTTP-сервис
размер дельта размер дельта
.text 626 289 +14 272 3 148 145 +36 480
.gopclntab 643 435 +10 508 2 456 966 +11 115
.data 19 314 +1 344 58 514 +1 344
отладочные секции +15 449 +20 302
--------------------------------------------------------------
Total +41 658 +69 241
Плюс 41КБ на простой hello world и 69КБ на небольшой HTTP-сервис. Треть прироста, кстати, это DWARF, так что с флагом -ldflags=-w будет меньше.
Ну это еще цветочки. Давайте посмотрим на call site. Одна и та же строчка p = new(N32), собранная двумя разными способами:
# GOEXPERIMENT=nosizespecializedmalloc
ssmbench/gate.NewNoscan32 STEXT size=74 ... $24-0 // <- кадр 24 байта
LEAQ type:ssmbench/gate.N32(SB), AX
CALL runtime.newobject(SB) // <- один аргумент
# дефолт Go 1.27
ssmbench/gate.NewNoscan32 STEXT size=85 ... $40-0 // <- кадр 40 байт
MOVL $32, AX // <- размер
LEAQ type:ssmbench/gate.N32(SB), BX // <- тип
MOVL $1, CX // <- needzero
CALL runtime.mallocgcSmallNoScanSC4(SB) // <- три аргумента
И видим, что функция разрослась с 74 байт до 85 и кадр с 24 до 40. Кажется, 11 байт мелочь, но умножайте это на количество мест, где есть аллокация. Получается, что чем больше кода, тем больше мы платим даже за то, что пока не используем :)
Это не только про размер бинарника
Давайте честно, плюс 60КБ к бинарнику никто не заметит на проде. Проблема тут в другом - это код, который лежит рядом с hot path и берется из L1 кеша. И тут самая интересная часть истории. Формально фичу начали делать еще в апреле 2025-го для объектов размером до 512 байт. И она даже вошла в релизный цикл 1.26. Но за 2 с половиной недели до релиза фичу выкинули (https://go-review.googlesource.com/c/go/+/738780) с комментарием:
We’re finding some regressions in overall icache footprint at scale and we should figure out how to deal with those before rolling this out to everybody.
У меня не получилось отловить проблему на синтетических бенчмарках так, чтобы она была реально ощутима. Я доходил до того, что промохов icache на единицу работы было больше 11%, но в абсолютных числах получалось 0,085 промаха icache на тысячу инструкций против 0,076. Ну что, просто смешно и не показательно. Так что я предполагаю, что проблема воспроизводилась на больших сервисах, где icache забит и без аллокатора.
Обманка
Это все, конечно, хорошо, но все это были микробенчмарки, которые специально подобраны таким образом, чтобы показать эффект. Но что, если взять что-то более приземленное. Я написал (ладно, я написал только промпт) три новых бенчмарка:
JSONDecode - просто
json.Unmarshalв циклеTextSplit - имитация парсинга логов через
strings.SplitHTTPHandler - http сервер, который принимает JSON запрос, парсит его, что-то оттуда фильтрует и отвечает обратно тоже JSON-ом
Вот такие результаты у меня получились:
goos: linux
goarch: amd64
pkg: ssmbench/macro
cpu: AMD Ryzen 9 7940HS w/ Radeon 780M Graphics
│ off │ on │
│ sec/op │ sec/op vs base │
JSONDecode-4 2.371m ± 2% 2.407m ± 1% ~ (p=0.218 n=10)
TextSplit-4 954.2µ ± 1% 811.5µ ± 1% -14.95% (p=0.000 n=10)
HTTPHandler-4 3.614m ± 1% 3.602m ± 1% ~ (p=0.436 n=10)
И еще, сколько каждый из тестов проводит времени в аллокаторе:
=== JSONDecode ===
0.06s 0.86% 0.54s 7.78% runtime.mallocgc
0.01s 0.14% 0.08s 1.15% runtime.newobject
0 0% 0.28s 4.03% runtime.mallocgcSmallScanNoHeader
=== TextSplit ===
1.28s 15.76% 1.90s 23.40% runtime.mallocgcSmallScanNoHeaderSC4
0.40s 4.93% 2.75s 33.87% runtime.mallocgc
0.26s 3.20% 3s 36.95% runtime.makeslice
=== HTTPHandler ===
0.01s 0.13% 0.58s 7.65% runtime.mallocgc
Ну то есть в типовой нагрузке вы скорее не заметите разницы в прозводительности, но тут стоит оговорится, что все зависит не только то того, сколько код проводит времени в аллокаторе, сколько то, какие объекты по размеру вы выделяете.
Я попробовал дополнительно поиграться с GC (пустой хип, разного размера балласты), чтобы понять, влияет ли как-то это на работу нового аллокатора. Тут никаких бенчмарков приводить не стану (их и так уже много в статье). Поделюсь только выводами: никакого статистически значимого влияния я не обнаружил.
Выводы
Мне бы хотелось написать тут что-то умное про новый аллокатор, как он поменяет нашу жизнь и всякое такое, но увы. Скорее всего, какой-то значимый эффект будет заметен в очень узких задачах инфраструктурного характера. Типичный продуктовый код ничего и не заметит, кроме увеличения бинарника на пару десятков килобайт.
P.S. Если вам понравилась статья, то заглядывайте в мой Telegram-канал.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.