Совсем недавно вышел 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.04go version go1.27.1 linux/amd64
TLDR;
Аллокация маленького объекта в Go — это не столько прямая работа с памятью, сколько прогон по дереву из if-ов. Фактически, на этапе компиляции мы уже обладаем всей информацией, чтобы не проходится по этому дереву, а inline-ить нужный нам выбор. Это, собственно, и сделали в Go 1.27. Ну а теперь детали 🙂
Сколько стоит new(T)
Представим, что у нас есть структура на 32 байта без указателей. Максимально простой код:
type N32 [4]uint64var p *N32 // нужен, чтобы объект уезжал на heapfunc BenchmarkNewNoscan32(b *testing.B) {for b.Loop() {p = new(N32)}}
Если запустить это на Go 1.27 с выключенной фичей (то есть эмулировать строго то поведение, которое было в 1.26), то получим:
cpu: AMD Ryzen 9 7940HS w/ Radeon 780M GraphicsBenchmarkNewNoscan/size=032-4 46012696 14.30 ns/op 32 B/op 1 allocs/opBenchmarkNewNoscan/size=032-4 43486191 13.69 ns/op 32 B/op 1 allocs/opBenchmarkNewNoscan/size=032-4 45476173 13.66 ns/op 32 B/op 1 allocs/op
Около 14 наносекунд на операцию. Частота процессора около 4.6ГГц, то есть около 60 тактов на аллокацию 32 байт… Почему?
Лезем в кишочки
Для того, чтобы понять, что происходит, давайте взглянем на ассемблер. Самое интересное там вот в этой одной инструкции:
NewNoscan32 CALLruntime.newobject(SB)
runtime.newobject — это обёртка в одну строку над mallocgc. А mallocgc — это дерево.
// go1.27 src/runtime/malloc.go:1067func 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:1364func mallocgcSmallNoscan(size uintptr, typ *_type, needzero bool) (unsafe.Pointer, uintptr) {mp := acquirem()mp.mallocing = 1c := getMCache(mp)var sizeclass uint8if 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 + 1if freeidx%64 == 0 && freeidx != s.nelems {return 0}s.allocCache >>= uint(theBit + 1)s.freeindex = freeidxs.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 CALLruntime.newobject# дефолт Go 1.27NewNoscan32 CALLruntime.mallocgcSmallNoScanSC4
Вместо одной точки входа компилятор зовёт функцию, привязанную к конкретному size class.
Что же там под капотом?
// go1.27 src/cmd/compile/internal/ssagen/ssa.go:804func (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: linuxgoarch: amd64pkg: ssmbench/microcpu: 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=nosizespecializedmallocssmbench/gate.NewNoscan32 STEXT size=74 ... $24-0 // <- кадр 24 байтаLEAQtype:ssmbench/gate.N32(SB), AXCALLruntime.newobject(SB) // <- один аргумент# дефолт Go 1.27ssmbench/gate.NewNoscan32 STEXT size=85 ... $40-0 // <- кадр 40 байтMOVL$32, AX // <- размерLEAQtype:ssmbench/gate.N32(SB), BX // <- типMOVL$1, CX // <- needzeroCALLruntime.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: linuxgoarch: amd64pkg: ssmbench/macrocpu: 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-канал.
ссылка на оригинал статьи https://habr.com/ru/articles/1083424/