Разбираем новый аллокатор в 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=nosizespecializedmalloc at 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/