Совсем недавно вышел 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.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.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-канал.