Разбираем новый аллокатор в Go 1.27
- пятница, 18 сентября 2026 г. в 00:00:09
Совсем недавно вышел 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
Аллокация маленького объекта в Go - это не столько прямая работа с памятью, сколько прогон по дереву из if-ов. Фактически, на этапе компиляции мы уже обладаем всей информацией, чтобы не проходится по этому дереву, а inline-ить нужный нам выбор. Это, собственно, и сделали в Go 1.27. Ну а теперь детали :)
Представим, что у нас есть структура на 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.
Теперь давайте посмотрим на ассемблер того же самого кода, что был в начале, но уже с дефолтными настройками 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.Split
HTTPHandler - 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-канал.