Выжимаем из Go максимум производительности
- четверг, 27 июня 2024 г. в 00:00:11
Разработчики, которые используют Go, сталкиваются с задачей выжать максимальную производительность из каждой строки кода. Но что делать, если оптимизировать уже нечего, а увеличивать скорость всё равно надо?
Меня зовут Никита Галушко. Я старший программист-разработчик в отделе высоконагруженных систем и оптимизации ВКонтакте. В статье поделюсь, какие хитрости помогут использовать Go на полную мощность.
Расскажу про память, а именно про small-size объекты и интерфейс, покажу пару трюков со стеком.
Поделюсь, как сильно может влиять на производительность BCE (Bounds Check Elimination) и почему не все циклы for
-loop
одинаково полезны.
Раскрою особенности, которые текущий компилятор Go накладывает на наш код.
Затрону такие темы, как оптимальная конвертация string
-> []byte
и []byte
-> string
, конкатенация и связанные с ней оптимизация, сортировка []string
— это важно, так как в наших повседневных программах часто используются строки, и с типом string
связано много мифов.
В первую очередь мы пишем код для человека. Но если нужно выжимать максимум производительности, то от такого подхода придётся отказаться и начать писать код для компилятора. Я не встречал высокопроизводительного кода, который был бы лёгок в чтении, тестировании и модификации.
Всё сказанное будет важно, только если вы уже подумали над теми структурами данных и алгоритмами, которые используются в вашем приложении. Нет смысла пытаться выигрывать наносекунды или единицы мегабайт, если используется неподходящая структура данных.
Это объекты размером не более четырёх машинных слов. Для 64-битных систем — это 32 байта. Чтобы понять, как сильно они могут влиять на производительность, напишем пару бенчмарков.
Представьте, что у вас есть small-size объект, который состоит из четырёх int
. Рядом с ним объект, отличающийся всего на один int
. Функции простые — это сумма двух полей объекта.
type SmallObject struct {
a, b, c, d int
}
type PlainObject struct {
a, b, c, d int
e int
}
func sum1(obj SmallObject) int {
return obj.a + obj.d
}
func sum2(obj PlainObject) int {
return obj.a + obj.d
}
Бенчмарк к этому коду тоже максимально прост.
func BenchmarkSmall(b *testing.B) {
var ret int
for i := i; i < b.N; i++ {
obj := SmallObject{a: i, d: i}
ret = sum1(obj)
}
_ = ret
}
func BenchmarkPlain(b *testing.B) {
var ret int
for i := 0; i < b.N; i++ {
obj := PlainObject{a: i, d: i, e: i}
ret = sum2(obj)
}
_ = ret
}
Конструируем объект на месте и вызываем функцию в зависимости от типа. Если мы его запустим, то увидим, что функция, которая работает со small-size объектом, выполняется в 2–2,5 раза быстрее.
BenchmarkSmallObject-8 1000000000 1.037 ns/op 0 B/op 0 allocs/op
BenchmarkPlainObject-8 423361981 2.571 ns/op 0 B/op 0 allocs/op
Это очень странно: у нас два почти одинаковых объекта, которые отличаются лишь на один int
. Я напомню, что наша цель — экономия наносекунд, так как функция может вызываться тысячи и миллионы раз. Чтобы разобраться в этом, используем ассемблер. Мы будем к нему обращаться на протяжении всей статьи. Поделюсь, как вы можете это делать сами.
Есть ресурс Compiler Explorer (godbolt.org). Мне нравится пользоваться им больше, чем собственноручно компилировать через go tool, потому что можно поэкспериментировать с версиями Go, с архитектурой, под которую будет компиляция. Но если вы хотите экспериментировать на своей локальной машине или нет доступа к godbolt, то можно воспользоваться следующей командой:
go tool compile -S main.go > main.s
Давайте скомпилируем и посмотрим на ассемблер.
Из ассемблера видно, что функция, которая работает со small-size объектом, — это пара инструкций. Отмечу, что здесь мы взаимодействуем с регистрами напрямую. Если работаем с объектом, где лишь на один int
больше, мы тратим время на заполнение стека, а затем по ходу выполнения функции на его чтение.
Small-size объекты не просто экономят память. Они позволяют компилятору генерировать более производительный код, работая напрямую с регистрами CPU. Есть одно но — это очень редкий случай, сложно спроектировать систему только на таких микрообъектах.
Я попытаюсь вас в этом убедить на нескольких иллюстрациях. Представьте, что у вас есть интерфейс, где всего лишь два метода.
type Summer interface {
SumValue(Object) int
SumPointer(*Object) int
}
Один метод принимает аргумент по значению, а второй — по указателю. Предположим, что объект максимально простой — всего лишь два int
.
type Object struct {
A, B int
}
А ещё допустим, что все оптимизации, которые работают для small-size объектов, будут действовать и здесь. Код тоже простой — сумма этих двух полей.
type Sum struct{}
func (v Sum) SumValue(obj Object) int {
return obj.A + obj.B
}
func (p Sum) SumPointer(obj *Object) int {
return obj.A + obj.B
}
Запустим простой бенчмарк.
BenchmarkSumPointer-8 101133675 11.59 ns/op 16 B/op 1 allocs/op
BenchmarkSumValue-8 609446936 1.855 ns/op 0 B/op 0 allocs/op
В результате увидим, что метод, который принимает объект по указателю, работает медленнее, а ещё аллоцирует что-то в heap. Причина в escape analysis, так как он руководствуется в этом месте достаточно простым правилом: если доступ к переменной есть у более чем одной горутины, то переменную нужно отправить в heap.
Вы возразите, что там не было никаких горутин. И я вас сильнее запутаю: если мы будем работать с типом напрямую, без интерфейса, то аллокации не будет.
Почему правило одно и то же, а поведение разное? Потому что в этом правиле есть небольшое продолжение:
Если доступ к переменной имеет более чем одна горутина, то переменную нужно отправлять в heap. Или если нельзя доказать, что только одна горутина может обращаться к этой переменной.
Проблема в том, что escape analysis не заходит в нашу реализацию интерфейса. Он видит, что у нас есть интерфейс и вызов. Необходимо решить, отправлять переменную на heap или нет. В случае с указателем переменная может измениться дальше при вызове других функций. А если мы передаём аргумент по значению, то каждый раз происходит поверхностное копирование, и фактически мы можем просто отсрочить принятие этого решения.
Отмечу, когда ещё интерфейсы могут давать аллокацию. Самое важное, что надо запомнить: любое приведение сложного типа к интерфейсу — всегда аллокация, за исключением нескольких правил:
указатели к интерфейсу;
int
, int32
, int6
, uint
, uint32
, uint64
— приведение int
до 65 тысяч;
очень простые типы (пустой struct
или struct
с полем, размер которого не превышает одно машинное слово).
Поэтому по возможности не используйте интерфейсы. Забудьте, что они есть в Go, если вы хотите выжать максимум.
Есть ещё один пример, достаточно частый паттерн, который я вижу в разных Go-приложениях: есть интерфейс и несколько его реализаций в одном пакете. В нашем случае — это fileDumper
и dbDumper
. Код лёгкий, хорошо тестируется и расширяется. Но есть ряд проблем.
Представим, что у нас есть main
, где мы создаём каждый Dumper
и вызываем makeDump
, который просто вызывает функцию на интерфейсе.
Если мы скомпилируем этот код в ассемблер, то увидим такую историю, как go:itab
.
Напомню, что интерфейс — это тип, состоящий из двух слов, где есть указатель на данные и указатель на тип. Проблема go:itab
в том, что виртуальный вызов — всегда поиск по таблице. А пока мы ищем, полезная работа не выполняется.
Я предлагаю сделать enum
из тех реализаций, которые нам нужны, и оставить всего лишь один тип.
type dumpKind int8
const (
_ dumpKind = iota
toFile
toDB
)
type Dumper struct {
kind dumpKind
}
func (d Dumper) Dump(s string) {
switch d.kind {
case toFile:
// ...
case toDB:
// ...
}
}
У нас не будет интерфейсов, а в реализации мы по свитчу выберем ту или иную имплементацию.
При этом этот код не даёт ещё аллокаций, как предыдущий.
BenchmarkInterfaceDumper-8 28651759 41.81 ns/op 48 B/op 2 allocs/op
BenchmarkStructDumper-8 164893970 7.111 ns/op 0 B/op 0 allocs/op
Но есть цена за производительность:
некрасиво и трудно расширять, если вы не знаете, для чего это было сделано;
сложно тестировать.
Важный тезис: в Go объекты до 64 Кб будут размещены на стеке. Помним про escape analysis, который может по своим правилам отправить переменную в heap, поэтому:
не используем в другой горутине;
не возвращаем из функции;
не передаём в интерфейсы;
Представим, что есть код, где в функции foo
выделяем два слайса по 64 Кб.
func foo() {
a := make([]byte, 641024)
b := make([]byte, 641024)
}
А в функции bar
делаем то же самое, только на 2 байта больше.
func bar() {
a := make([]byte, 641024+1)
b := make([]byte, 641024+1)
}
Кажется, что может пойти не так? А пойти не так может всё.
BenchmarkFoo-8 1000000000 0.3189 ns/op 0 B/op 0 allocs/op
BenchmarkBar-8 73376 16924 ns/op 147456 B/op 2 allocs/op
Функция foo
выполняется на порядок быстрее, чем bar
, и делает 0 аллокаций.
Тут нужно сделать отдельную ремарку, что чем больше вы выделяете памяти на стеке, тем сильнее раздувается размер фрейма функции, а значит, переаллокация стека становится дороже.
Хорошо, 64 Кб выделяется на стеке, но что, если нам нужно выделить слайсы большего размера и строго на стеке? Придётся создавать их несколько по 64 Кб и жонглировать индексами? Нет. В Go есть интересный хак, который позволяет инстанцировать слайс на месте, задавая конкретному индексу какое-то значение, — и это не даст аллокаций.
func baz() {
a := []byte{1_000_000: 0}
}
BenchmarkBaz-8 3636 302480 ns/op 0 B/op 0 allocs/op
Лично для меня это серая зона. Есть issue на эту тему. С одной стороны, тикету уже 3,5 года и ничего с ним не происходит, а с другой стороны, комментарии говорят о том, что есть вопросы.
Go позиционирует себя как безопасный язык, который пытается нас оградить от выходов за границу массива или слайса с помощью BCE — Bounds Check Elimination. Но насколько сильно эта безопасность влияет на производительность?
Представьте, что есть максимально простой код, который из []byte
получает uint64
(взято из стандартной библиотеки).
func toUint64(b []byte) uint64 {
return uint64(b[0]) |
uint64(b[1])<<8 |
uint64(b[2])<<16 |
uint64(b[3])<<24 |
uint64(b[4])<<32 |
uint64(b[5])<<40 |
uint64(b[6])<<48 |
uint64(b[7])<<56
}
Код берёт элемент по индексу и делает две битовые операции. Кажется, что эта функция должна работать максимально быстро. Но если мы запустим бенчмарк, то увидим, что этот код выполняется целых 3 наносекунды.
func BenchmarkBCE(b *testing.B) {
buf := make([]byte, 8)
var ret uint64
for i := 0; i < b.N; i++ {
ret = toUint64(buf)
}
_ = ret
}
BenchmarkBCE-4 342406828 3.361 ns/op
Функция подобного рода может выполняться тысячи и миллионы раз, поэтому хочется сделать её максимально быстрой.
Чтобы понять, почему уходит столько времени, нужно спуститься в ассемблер.
main_toUint64_pc91:
MOVL $7, AX
MOVQ AX, CX
PCDATA $1, $1
CALL runtime.panicIndex(SB)
main_toUint64_pc104:
MOVL $6, AX
MOVQ AX, CX
CALL runtime.panicIndex(SB)
main_toUint64_pc117:
MOVL $5, AX
MOVQ AX, CX
NOP
CALL runtime.panicIndex(SB)
main_toUint64_pc133:
MOVL $4, AX
MOVQ AX, CX
CALL runtime.panicIndex(SB)
main_toUint64_pc146:
MOVL $3, AX
MOVQ AX, CX
CALL runtime.panicIndex(SB)
main_toUint64_pc159:
MOVL $2, AX
MOVQ AX, CX
CALL runtime.panicIndex(SB)
main_toUint64_pc172:
MOVL $1, AX
MOVQ AX, CX
CALL runtime.panicIndex(SB)
main_toUint64_pc185:
XORL AX, AX
MOVQ AX, CX
NOP
CALL runtime.panicIndex(SB)
XCHGL AX, AX
Мы видим, что каждое обращение к индексу по факту оборачивается в if
, который проверяет выход за границу массива. И если условие истинно, кидаем панику, если нет, то делаем джамп и продолжаем выполнение.
Как нам избавиться от ненужных проверок? Что, если мы знаем, что в нашем слайсе уже есть нужное количество элементов? Всё просто — достаточно потрогать самый дальний элемент.
Очевидно (как для человека, так и для Go), что если есть самый дальний элемент (в данном случае под индексом 7), то есть и все остальные. Добавим всего одну строчку в нашу исходную функцию.
func toUint64(b []byte) uint64 {
_ = b[7]
// ...
}
Затем запустим тот же самый бенчмарк ещё раз и увидим ускорение в 2 раза.
BenchmarkBCE-4 715002706 1.657 ns/op
В Go у нас есть два вида циклов: for
-loop
и for
-range
. Я покажу, сколько приходится платить каждый раз, когда мы используем for
-range
, поскольку этот цикл основан на копировании. Всякий раз, когда вы получаете следующий элемент слайса при итерации, происходит его поверхностное копирование.
Тест будет простой. Объект с одним полем int
и две функции, которые пробегают по слайсу из объектов, суммируя значение единственного поля.
type Obj struct {
index int
}
func sumRange(objects []Obj) int {
ret := 0
for _, v := range objects {
ret += v.index
}
return ret
}
func sumLoop(objects []Obj) int {
ret := 0
for i := 0; i < len(objects); i++ {
ret += objects[i].index
}
return ret
}
Если мы запустим бенчмарк (он тривиальный, поэтому код не привожу), то увидим, что функция, которая использует обычный, стандартный for
-loop
по индексу, работает в 4 раза быстрее, чем for
-range
. Чем жирнее объект внутри слайса, тем эта разница будет больше.
BenchmarkForRange-4 443161 2371 ns/op
BenchmarkForLoop-4 1863501 641.7 ns/op
Компилятор не векторизует операции (приходится писать на ассемблере). Код из примера про циклы по факту должен хорошо поддаваться векторизации, но увы, Go сейчас это не умеет. Поэтому если вы хотите использовать векторные инструкции, то придётся писать самому на ассемблере 🙁
Кеш линии процессора. Не нужно забывать про кеш линии. Виртуальный вызов в интерфейсах — это ещё и проблема для кеша, потому что мы каждый раз вымываем инструкции, которые CPU уже взял себе на исполнение. При этом особенность Go в том, что у нас не треды, а горутины, и некоторые фишки, такие как, например, прикрепление конкретной горутины к конкретному ядру CPU, не пройдут (хоть это и частый паттерн в сетевых приложениях).
Когда мы говорим про производительность и всплывает тема строк, то тут же появляется и Unsafe.
У Unsafe в Go есть ряд проблем. Первая — это то, что перевод из строки в []byte
и обратно даёт Unsafe-строчку, то есть любое изменение исходного []byte
отразится на получаемой вами строке.
Но начиная с Go 1.20 вам не надо писать свои функции, которые переводят из []byte
в строчку и из строчки в []byte
, потому что это уже есть в стандартной библиотеке. В ней появились unsafe.StringData
и unsafe.String
. Это важно знать, потому что очень часто на Stack Overflow и подобных площадках можно наткнуться на ответы, где такие функции реализованы ошибочно.
Конвертация в цикле for
-range
не вызывает аллокаций.
s := "GolangConf"
for _, b := range []byte{s} { // 0 аллокаций
// ...
}
Когда вы итерируетесь через for
-range
по строке, приводя её в []byte
или в []rune
, это не даёт аллокаций.
При сравнении слайсов байтов, когда мы переводим их к строке.
s1 := []byte{0: 65, 1024: 90}
s2 := []byte{0: 65, 1024: 90}
if string(s1) == string(s2) { // 0 аллокаций
// ...
}
Если вы хотите сравнить два []byte
как строчки (напомню, что их нельзя сравнивать через == напрямую), то это тоже не даёт аллокаций. Вам в этом месте не нужен никакой Unsafe :)
При поиске в map.
var m map[string]any
key := []byte{...}
value := m[string(key)] // 0 аллокаций
Интересно, что если ключом в map
является строка, а на руках у нас []byte
, то приведение слайса байт к строке при поиске ключа аллокаций не даст.
При конкатенации.
Есть огромный пласт оптимизаций при конкатенации. Например, у нас есть два слайса байт foo
и bar
. В строчке a мы их конкатенируем с непустой строкой, а в строчке b делаем то же самое, только строка пустая.
foo := []byte{100: 65}
bar := []byte{100: 65}
a := "_" + string(foo) + string(bar)
b := "" + string(foo) + string(bar)
Получается так, что при создании строки a будет всего одна аллокация, хотя для строки b — целых три.
Чтобы понять, почему так, нужно снова посмотреть в ассемблер.
Слева конкатенация с непустой строкой, справа с пустой. Конкатенация с непустой строкой — это по факту значит посчитать выровненный адрес всех наших аргументов и вызвать функцию из пакета в runtime. В случае конкатенации с пустой строкой нам нужно сначала foo
и bar
привести к строке, и уже потом вызвать ту же самую функцию.
Суть в том, что Go, понимая, что у него уже есть какая-то исходная строка с ненулевой длиной, может на её основе сразу аллоцировать всю память, то есть ему не приходится аллоцировать сначала для foo, затем для bar
и потом уже для всей строки.
При этом, если мы зайдём в пакет, где реализована эта функция конкатенации, то нас встретит странная штука (на картинке ниже выделена красным блоком), которая означает, что всегда есть какой-то временный буфер размером 32 байта.
Как мы можем это использовать? Конкатенируем строки чанками по 32 байта. Нам не надо экспериментировать с конкатенацией с пустой и непустой строкой.
Если мы запустим бенчмарк, то увидим, что конкатенация с помощью чанков по 32 байта также делает всего одну аллокацию для всей исходной строки.
Если мы запустим бенчмарк, то увидим, что конкатенация с помощью чанков по 32 байта также делает всего одну аллокацию для всей исходной строки.
BenchmarkSimpleConcat-8 14532418 72.20 ns/op 512 B/op 3 allocs/op
BenchmarkChunkConcat-8 16568988 64.24 ns/op 256 B/op 1 allocs/op
У этого знания есть ещё одно преимущество. Любые бенчмарки, которые вы пишете, обязаны использовать данные по объёму, приближённому к объёму данных, которые у вас будут на проде. Иначе вы можете не увидеть тех аллокаций, которые на самом деле есть.
Единственная проблема этого хака в том, что применим он только в кодогенерации, потому что если вы напишете то же самое, но с циклом, это будет работать сильно хуже.
Совсем недавно на GitHub появился тикет. Он говорит о том, что сейчас в Go sort.Strings
, который напрямую работает со строками, действует на 30 % быстрее, чем дженерик-решение slices.Sort
. Это вызвано тем, что sort.Strings
делает всего одно сравнение вместо трёх. Сейчас в Go нельзя отсортировать оптимально список объектов по строковому полю.
Я рассказал, что такое small-size объекты и как они позволяют компилятору создавать более оптимальный код.
Очень надеюсь, что убедил вас в том, что интерфейсы — зло. Если вы хотите выжать максимум, то про них нужно забыть.
Я показал, насколько сильно простая проверка выхода за границы массива может влиять на производительность.
Продемонстрировал, как сильно на производительность влияет тот или иной цикл и что работа со строками в Go хорошо оптимизирована.
Надеюсь, мой опыт поможет вам выжать из Go максимум — и сделать свой продукт ещё круче и быстрее.
В основе статьи — доклад на профессиональной конференции Saint HighLoad++. Здесь можно посмотреть запись выступления.