Устранение проверок границ в Golang с помощью unsafe
- title
- Устранение проверок границ в Golang с помощью unsafe
- type
- summary
- summary
- unsafe.SliceData + unsafe.Add убирают проверку границ, которую компилятор не смог оптимизировать, ускоряя LE-чтение в 2 раза
- tags
- go, performance, compilers, microarchitecture
- sources
- go-bounds-checks-unsafe
- created
- 2026-07-23
- updated
- 2026-07-29
- lang
- ru
- translation_of
- go-bounds-checks-unsafe
- source_updated
- 2026-07-29
- translated
- 2026-09-01
- translator
- lllm/antigravity/gemini-3.7-flash-medium
Andrii (blog.andr2i.com) в своей серии заметок об оптимизациях пишет о крайнем средстве для устранения проверок границ (bounds check elimination) в Golang: когда вы можете доказать, что индекс входит в диапазон, а компилятор нет, арифметика указателей через unsafe убирает проверку, которую компилятор упорно оставляет.
Во что обходится проверка границ
Скомпилируйте func load(src []byte, i int) byte { return src[i] } с флагом -B (отключение проверок границ), и вы получите три инструкции:
MOVQ AX, 0x8(SP)
MOVZX 0(AX)(DI*1), AX
RET
Без -B компилятор добавляет пару CMPQ DI, BX / JAE и CALL runtime.panicBounds. В маленькой функции CALL - самая дорогая часть: функция перестаёт быть листовой (leaf), что тянет за собой ещё и PUSHQ BP / MOVQ SP, BP / POPQ BP. Так что в крошечной горячей функции устранение проверки убирает не только сравнение с переходом.
По мнению автора, браться за устранение проверок границ в первую очередь стоит не только из-за ветвлений. Меньше инструкций - значит меньше нагрузки на L1 icache и кэш микроопераций (uop-cache), меньше записей в структурах предсказания переходов на фронтенде и меньше давления на регистры. На горячем пути, который и так страдает от промахов кэша из-за нехватки ёмкости или конфликтов, сокращение потока инструкций помогает вдвойне.
Чтобы найти их, ассемблер читать необязательно:
go build -gcflags="-d=ssa/check_bce/debug=1" .
Сначала стандартное решение
Компилятор сам выкинет проверку, если доказать ему корректность диапазона - обычно обращением к верхней границе перед циклом или изменением условия цикла. Реальный пример из функции matchLen:
a = a[:limit]
b = b[:len(a)]
i := 0
for ; i <= len(a)-8; i += 8 {
xor := loadU64(a[i:]) ^ loadU64(b[i:])
...
}
Условие i <= len(a)-8 позволяет компилятору доказать, что каждое обращение к a укладывается в границы; b = b[:len(a)] переносит это доказательство на b. С каждым релизом Golang компилятор справляется всё лучше, а стандартные приёмы собраны на странице BCE сайта go101. Браться за unsafe стоит только тогда, когда это не помогло.
Загрузка через unsafe
Показательный пример - binary.LittleEndian.Uint32, где уже есть всем известная подсказка:
func (littleEndian) Uint32(b []byte) uint32 {
_ = b[3] // bounds check hint to compiler
return uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
}
Подсказка схлопывает четыре проверки в одну. Оставшуюся и убирает unsafe:
//go:build !purego && (amd64 || 386 || arm64 || loong64 || ppc64le || wasm)
func loadU32LE(b []byte, i uint) uint32 {
return *(*uint32)(unsafe.Add(unsafe.Pointer(unsafe.SliceData(b)), i))
}
unsafe.SliceData(b) возвращает тот же указатель, что и &b[0], но без проверки, которую порождает b[0], и в отличие от &b[0] корректно работает на пустом срезе. unsafe.Add выполняет смещение, как сделало бы &b[i], после чего результат приводится к *uint32 и разыменовывается. Три вызова ради устранения одной проверки, и все три компилятор встраивает намертво - на выходе получаются MOVQ AX, 0x8(SP) / MOVL 0(AX)(DI*1), AX / RET.
Изменение сигнатуры важно не меньше, чем само тело функции. Вызов из стандартной библиотеки выглядит как binary.LittleEndian.Uint32(data[offset:]), и это взятие среза тянет собственную проверку границ на стороне вызывающего кода. Передача (data, offset) двумя аргументами убирает проверку ещё и оттуда.
Где это допустимо, а где нет
Директива сборки (build tag) здесь критически важна сразу по двум причинам. Прямой порядок байтов (little-endian) - очевидное ограничение. Второе, более тонкое, на которое указал ncruces на HN после публикации, - невыровненный доступ: разыменование *uint32 по произвольному байтовому смещению безопасно только на платформах, поддерживающих невыровненное чтение. Правильный список платформ - это пересечение little-endian архитектур с unalignedOK из cmd/compile/internal/ssa/config.go, а не "все little-endian платформы, какие вспомнились".
И убирать проверку можно лишь тогда, когда вы действительно можете доказать, что она не нужна. Компилятор вставил её не просто так; unsafe перекладывает обязанность доказательства на вас и ничем не поможет, если вы ошиблись.
Числа
Микробенчмарк: суммирование 4096 байт как uint32 на i5-12500:
BenchmarkLoadU32LE 273.7 ns/op 14966.04 MB/s
BenchmarkStdUint32 600.7 ns/op 6818.58 MB/s
Ускорение более чем в 2 раза - ожидаемый результат для бенчмарка, который ничего, кроме загрузки, не делает. Интереснее реальные цифры: замена стандартных LE-чтений на unsafe в поиске совпадений в andybalholm/brotli ускорила близкую к продакшену нагрузку с 90.39 MiB/s до 99.99 MiB/s, +10.62% (n=30, p=0.000). Техника не нова - klauspost/compress делает так годами, - просто о ней редко пишут.
В заключение автор сетует, что в Golang нет опциональной подсказки вроде nobounds на уровне инструкций или функций, поэтому арифметика указателей через unsafe остаётся единственным способом сказать "я это уже проверил".
Связанные страницы
Из той же серии, что и compiler-codegen-luck: производительность скрыта в генерируемом коде, которого не видно в исходниках, а решение сводится к перезаписи, выглядящей чисто косметической. Аргумент в пользу явной фиксации выигрыша руками вместо слепого доверия оптимизатору подробно разобран в everyone-should-know-simd, где главная претензия к автовекторизации - не в том, что она не срабатывает, а в том, что несвязанная правка может незаметно всё сломать. pointer-provenance - формальный аппарат, объясняющий, почему unsafe.Add над указателем на данные среза является корректным (well-defined) способом сделать это, в отличие от приведения целых чисел. Другой край спектра оптимизации горячих путей, где проблема кроется в железе, а не в количестве инструкций, рассмотрен в false-sharing-alignment-128.