
Введение
Многие программисты, начав работать с Go, удивляются, что скомпилированная программа работает со скоростью Python-скрипта. Как так может получиться, ведь Go — это компилируемый язык?
Разработчики, пишущие многопоточный код, с удивлением замечают, что Go частенько не может нагрузить все ядра процессора более 50-60%.
В этой статье мы рассмотрим некоторые оптимизации, которые могут пригодиться при создании высокопроизводительных вычислительно-ориентированных приложений на Go.
Идея этой статьи появилась у меня, когда мне пришлось ускорять функцию инверсии в конечном поле из библиотеки math/big, а также убыстрять высокооптимизированную библиотеку uint256, которая используется в проектах экосистемы Etherium и других криптовалют.
Предостережение: в этой статье не идёт речь о том, как создавать читаемый и интуитивно понятный код. Речь идёт о ситуациях, когда нужно выжать всё, что можно. Не занимайтесь подобными оптимизациями без серьёзной нужды.
Оптимизации тестировались на серверном процессоре Epyc 7C13 (архитектура x86_64, Zen 3) на Go версиях 1.24 и 1.25. Возможно, на вашем процессоре или в следующих версиях Go эти приёмы не дадут ускорения. Но это маловероятно, так как в целом разработчики Go и сам язык консервативны. Я не думаю, что что-то будет существенно меняться, потому что это уже зрелая технология, и далее она будет развиваться очень медленно и эволюционно.
Если для вас следование паттернам важнее скорости, то не читайте далее — там один сплошной антипаттерн.
Пару слов о компиляторе Go
На самом деле он очень хороший и эффективно компилирует код. Поэтому, если опускаться до уровня тонких оптимизаций, можно добиться практически ассемблерной производительности или как минимум производительности Си в тяжёлых вычислительных задачах. То есть разница будет непринципиальная.
Оговорюсь, что имеются в виду задачи без или с минимальным использованием сетевых фишек Go, интерфейсов, каналов, defer, мапов, слайсов.
Я тестировал различные способы ускорения Go на версиях 1.24, 1.25. Возможно, что в следующих версиях какие-то способы могут перестать работать.
Воспроизводимость результатов
При оптимизации я постоянно сталкивался с тем, что скорость в результате тестирования сильно плавает. Чтобы как можно более точно её измерять, я использую следующие приёмы.
Указание конкретного числа итераций вместо времени
go test -bench=BenchmarkFunction -benchtime=5000000x -run=^$
Вместо:
go test -bench=BenchmarkFunction -benchtime=10s -run=^$
Измерение продолжительности работы функции в тактах процессора
Go не имеет нативной поддержки этого. Пришлось разработать маленький пакет с использованием CGo:
package utils
/*
#include <immintrin.h>
#include <stdint.h>
static inline uint64_t rdtsc() {
_mm_lfence();
unsigned long long tsc = __rdtsc();
_mm_lfence();
return tsc;
}
*/
import "C"
func GetCPUCycles() uint64 {
return uint64(C.rdtsc())
}
Использование:
func BenchmarkMyFunction(b *testing.B) {
b.Run("MyFunction, func(b *testing.B) {
// Прогрев
for i := 0; i < 1000; i++ {
моя_функция
}
b.ResetTimer()
b.ReportAllocs()
start := time.Now()
totalCycles := utils.GetCPUCycles()
for i := 0; i < b.N; i++ {
моя_функция
}
totalCycles = utils.GetCPUCycles() - totalCycles
duration := time.Since(start)
opsPerSec := float64(b.N) / duration.Seconds()
avgCycles := float64(totalCycles) / float64(b.N)
b.ReportMetric(opsPerSec, "ops/sec")
b.ReportMetric(avgCycles, "cycles")
})
}
Запускаем так:
go test -bench=BenchmarkInversion -benchtime=5000000x -run=^$
Результаты бенчмарка будут выглядеть примерно так:
goos: linux
goarch: amd64
pkg: github.com/sukamenev/KangGo/ffmod
cpu: AMD EPYC 7C13 64-Core Processor
BenchmarkInversion/Inverse-128 5000000 1429 ns/op 2859 cycles 699598 ops/sec 0 B/op 0 allocs/op
PASS
ok github.com/programmer/Project/MyPackage 7.162s
Один и тот же код может выполняться разное число тактов на разных процессорах. Этот подход позволяет корректно сравнивать скорость алгоритмов на процессорах одного семейства, но разной частоты и является «золотым стандартом» при измерении производительности в криптографии.
Почему компилируемый Go тормозит?
Этому есть объяснение. Go работает великолепно во всём, кроме одного момента — сборки мусора. Сборка мусора — это процесс, который по своей сути однопоточный. Дело в том, что для выделения памяти ОС требуется использование мьютекса, чтобы ОС могла выбрать свободный кусок и отдать его.
Среда выполнения Go весьма умная и, безусловно, старается минимизировать обращение к функциям ОС по работе с памятью, для этого она имеет некий кеш из кусочков памяти (span и mspan).
Но, как вы понимаете, даже если ей и удаётся избежать обращения к системным процедурам по выделению памяти, то ценой вычислений, которые могут быть нежелательны.
При частых выделениях и освобождениях памяти (аллокациях и деаллокациях) она ещё и фрагментируется, что становится ахиллесовой пятой Go, снижая скорость компилируемого языка до уровня интерпретируемого.
Проблема аллокаций памяти
Многие встроенные функции Go динамически выделяют и отдают память. Это совсем неочевидно, так как ожидаешь от стандартных библиотек уровня оптимизации «Бог».
Также многие практики и шаблоны программирования в Go поощряют приёмы работы, связанные с аллокациями.
Когда мы пишем программы на Go с использованием ИИ, то тоже часто получаем код, в котором используются указатели, на которые затем выделяется память. Чем это плохо?
Аллокация — это сам по себе весьма дорогой вызов. И если у нас многопоточная программа, мы получаем значительно более сильное замедление, чем у однопоточных программ. Это как раз именно из-за того, что выделение памяти имеет склонность к однопоточности. Просто потому, что сложно сделать алгоритмы, которые могут параллельно размещать/освобождать память без ошибок и пересечений. По своей природе, когда есть ограниченное хранилище, алгоритмы не могут быть полностью безблокировочными.
В самих аллоцированиях нет ничего страшного, но всё зависит от частоты их использования. Если вы выделяете память, скажем, один раз в начале какой-то тяжёлой процедуры или в начале программы, это не имеет никакого значения. Но если у вас аллокации и деаллокации происходят в цикле, то ваша программа начнёт безбожно тормозить. Её производительность будет, как говорится, «ниже плинтуса».
Основной принцип оптимизации
Иными словами, ключевой момент в оптимизации по скорости программ — при условии, что Go-программы сами по себе оптимальны — это всяческое избегание аллокаций и максимальное размещение переменных в стеке. Что такое «стек» и как на нём размещать переменные будет объяснено далее.
Безусловно, если использовать Go как менеджер каких-то сервисов, как приём-передача JSON, то такие оптимизации могут и не потребоваться. Потому что скорость интернет-соединения очень малая по сравнению со скоростями передачи данных внутри компьютера или по сравнению со скоростями аллокаций/деаллокаций.
Но если наша задача — сделать что-то производительное в смысле вычислений, то нам в первую очередь придётся всяческим образом уменьшать число аллокаций, а также использовать другие хитрости для увеличения скорости.
Конкретные рецепты оптимизации
Как увидеть аллокации?
Для этого нужно запустить go test с ключом -benchmem. А можно и без ключа, если в процедуре бенчмарка вызвать b.ReportAllocs(). Результат будет одинаков.
Вы увидите число аллокаций и выделенную память на 1 запуск функции.
Использование sync.Pool
Этот рецепт есть везде, поэтому я не буду приводить примеры. Его основной смысл — чтобы однажды выделенные кусочки памяти не возвращались в общий пул, а переиспользовались при создании новых переменных.
Но гораздо лучше, чем аллокации + sync.Pool, будет неиспользование аллокаций и sync.Pool. Детали в следующем совете.
Использование стека вместо кучи
Итак, самый простой способ борьбы с аллокациями — использовать переменные на стеке. Как можно меньше использовать указателей и как можно больше использовать стека.
Что такое стек? Операционная система при размещении программы в памяти выделяет уже некую преаллоцированную область памяти для программы под названием стек. Также даёт программе указатель на этот стек, где она может размещать какие-то данные и сдвигать этот указатель.
Соответственно, переменные в стеке не требуют задействования сборщика мусора ни при выделении этой переменной, ни при удалении этой переменной. Потому что эта область уже выделена, и после удаления переменной указатель стека, как правило, просто смещается назад.
Чтобы переменная была размещена на стеке, достаточно объявить локальную переменную (не указатель) внутри функции или метода и не возвращать её адрес в вызывающий код.
Если адрес (указатель на переменную) будет отдаваться за пределы функции, то компилятор будет делать так, чтобы выделить память под неё из кучи, чтобы не задействовать стек.
func megafunc() uint64 {
// Создаём переменную на куче
pMyStruct := NewEmptyMyStruct()
...
}
В стеке:
func megafunc() uint64 {
// Создаём на стеке и инициализируем копированием, а не конструктором
var localStruct MyStruct
// Далее инициализируем без выделения памяти
...
}
Инициализация структур
Кстати, можно убыстрить её, если обойтись без использования конструкторов. Если у вас есть заранее созданный экземпляр объекта, вы можете его просто присваивать новому объекту без всяких конструкторов — это очень убыстряет дело.
Я не вижу в этом никаких проблем. В особенности это часто используется, когда в функции вам нужно создать какой-то временный объект — тогда мы просто создаём его на стеке и инициализируем копированием.
Давайте сразу оговорюсь, что речь идёт о небольших структурах, в которых нет указателей, ссылочных значений или Go-структур типа мьютексов.
Обычный подход:
func megafunc() {
pMyStruct := NewEmptyMyStruct()
...
}
Для ускорения:
var PEmptyMySctruct *MyStruct
func init() {
PEmptyMySctruct = NewEmptyMyStruct()
}
func megafunc() {
// Создаём на стеке и инициализируем копированием, а не конструктором
MyStruct := *PEmptyMySctruct
...
}
Я уже говорил, что для увеличения быстродействия нужно как можно меньше использовать указатели и динамическое создание объектов, а больше создавать структуры на стеке. Но важно добавить, что для успеха этого подхода внутри структур тоже должны не использоваться указатели или использоваться по минимуму. Потому что указатели внутри структур, когда их инициализируют, точно так же обращаются к рантайму Go, а он, в свою очередь, к операционной системе для выделения памяти под новые переменные для этих указателей.
Сравнение структур и чисел
Некоторые приёмы могут показаться вам грязными, но они реально улучшают скорость. Например, мы можем использовать сравнение структур целиком и полностью с потрохами без всяких хитрых методов с помощью ==. Оговариваюсь, что внутри структур не должно быть указателей, а только поля с данными.
Если же нам нужно сравнить что-то с числом, то целесообразнее будет изменить логику так, чтобы сравнивалось не с числом, а с 0. Так как для сравнения с числом процессору придётся сделать вычитание. А если мы сравниваем с 0 или не 0, то процессору это намного проще сделать, так как есть на аппаратном уровне логика для этого элементарна. Это всего лишь одно логическое И или ИЛИ.
Передача параметров функции
Отдельно нужно сказать про передачу параметров функции. Известно, что можно передавать любой параметр как по значению, так и по указателю. Когда что выгоднее? Обычно рекомендуется при передаче параметра обращать внимание на его размер. Если размер больше 100 байт, то передавать его как указатель, если меньше — можно передавать по значению.
Условные конструкции
Что быстрее: большое логическое выражение или цепочка из if'ов? При оптимальной конструкции логического выражения оно будет быстрее на ~20%. Но часто программистам трудно написать оптимальную булевскую конструкцию и поэтому цепочки из if побеждают. Возможно, имеет смысл сначала написать цепочку, а потом аккуратно сконвертировать её в булево выражение. Ради интереса можете попросить ИИ сконвертировать лесенку ниже в булеву конструкцию. Даже ИИ частенько ошибаются.
func (z *uint256) GtRaw(x *uint256) bool {
return z[3] > x[3] ||
(z[3] == x[3] && (z[2] > x[2] ||
(z[2] == x[2] && (z[1] > x[1] ||
(z[1] == x[1] && z[0] > x[0])))))
}
Лесенка из if работает медленнее.
func (z *uint256) GtRaw(x *uint256) bool {
if z[3] > x[3] {
return true
}
if z[3] == x[3] {
if z[2] > x[2] {
return true
}
if z[2] == x[2] {
if z[1] > x[1] {
return true
}
if z[1] == x[1] {
if z[0] > x[0] {
return true
}
return false
}
return false
}
return false
}
return false
}
Также нужно обращать внимание на вероятности. Если у нас есть цепочка из if'ов или булева функция, наиболее вероятный вариант мы должны располагать первым, менее вероятный — вторым и ещё менее вероятный — третьим. Потому что иначе мы часто будем делать бесполезную работу, и те проверки, которые проверяют маловероятные варианты, будут очень часто бесполезно тратить процессорное время.
Избегаем отрицаний в условиях
После замены во всех местах уже очень хорошо оптимизированной программы
if !булева_функция
на
if другая_булева_функция
увидел прирост около 20%. И это стало последней вишенкой на торте.
Низкоуровневые операции
Go умеет довольно эффективно работать с низкоуровневыми командами на уровне бит. Для этого нужно использовать модуль bits. Как правило, он компилируется в очень эффективные c точки зрения скорости инструкции.
Атомарные операции
Известный трюк состоит из использования атомарных операций. Это позволяет существенно ускорить многопоточные программы за счёт того, что можно обойтись без мьютексов.
Моя статья на Хабре на эту тему: «Go: жарим общие данные. Атомно, быстро и без мьютексов».
Константы и вычисления
Удивительное дело: иногда нам может показаться, что стоит заранее вычислить какую-то константу, чем получать её с помощью сдвига, но это работает не так.
Эти выражения будут работать одинаково быстро.
if z[3]&(1<<63) == 0 {
или
if z[3]&9223372036854775808 == 0 {
или
if z[3]&0x8000000000000000 == 0 {
Компилятор догадывается произвести сдвиг на этапе компиляции и о том, что константы имеют всего лишь один бит, неравный единице. В ассемблерном коде будет одна и та же инструкция BTQ $0x3f, DX (тест 63-го бита).
Смотрим получившийся ассемблерный код
go test -c -o asm.test -gcflags=all='-N -l'
go tool objdump -S -s '\.MyFunction' ./asm.test > file.asm
При просмотре получившегося кода удивляемся, как много вызовов Go-runtime происходит. Если компилятор не уверен, что вы не вылезете за пределы массива, то он поставит проверку границ CALL runtime.panicIndexU(SB) с переходом на панику.
Если работаете с динамически созданными переменными, то будут вызовы CMPL runtime.writeBarrier(SB), $0x0 и CALL runtime.gcWriteBarrier2(SB).
Число этих вывозов можно уменьшать, если из кода компилятору будет понятно, что они не нужны. Использовать range, len(array) в циклах. Это называется BCE (bounds-check elimination). Это тема на отдельную статью.
Типы данных
Удивительное дело, но логический тип bool может работать медленнее, чем тип int. Это очень странно, так как bool, казалось бы, предназначен для быстрых логических операций. Но если у вас есть функция, которая будет возвращать значение типа bool, она будет работать медленнее, чем функция, возвращающая значение типа int. Возможно, это не универсальная вещь, но в моём случае давшая прирост скорости.
// bool быстрее int в теории, но на практике нет
func (z *uint256) GtRaw(x *uint256) bool {
....
return res
}
...
if GtRaw(some_var) {
// действия
}
// int быстрее bool на практике
func (z *uint256) GtRawInt(x *uint256) int {
...
}
...
// Сравнения с нулём самые эффективные
if GtRawInt(some_var) != 0 {
// действия
}
Инлайнинг функций
Как правило, Go догадывается небольшие функции (примерно до 40-80 ассемблерных инструкций) делать инлайновыми. Но это легко проверяется с помощью отладчика Go. И если в каких-то случаях это не так, вы можете эту функцию вручную заинлайнить, включив код функции вместо её вызова. Директивы для 100% включения тела функции вместо её вызова нет.
Поэтому, горячие функции желательно писать маленькими.
Специализированные функции
Помогает также создание специализированных функций. Например, у вас может быть универсальная функция сдвига большого слова на N бит. Но если вы сделаете специализированную функцию, которая сдвигает на нужное число бит, это будет работать быстрее. Проверено на векторах для хранения 256-битных чисел.
Переменные и присваивание
Я стараюсь избегать объявление переменных в циклах, хотя это тоже может выглядеть как антипаттерн. То есть, если есть возможность, не разрушая логику, объявить переменную до цикла, лучше сделать именно так. Компилятору будет проще работать с такими переменными, и ему не нужно будет производить никакие особенные операции по их очистке внутри цикла.
Множественное присваивание
Рекомендую использовать множественное присваивание. Компилятор Go очень эффективно работает с ним, и оно получается значительно быстрее, чем последовательное присваивание — на десятки процентов.
Стандартный вариант:
func (z *uint256) Lsh4() {
z[0] = z[0]<<4
z[1] = (z[1]<<4) | (z[0]>>60)
z[2] = (z[2]<<4) | (z[1]>>60)
z[3] = (z[3]<<4) | (z[2]>>60)
}
Это будет быстрее:
func (z *uint256) Lsh4() {
// Мультиприсваивание для сдвига влево на 4 бита
z[0], z[1], z[2], z[3] = z[0]<<4, (z[1]<<4)|(z[0]>>60), (z[2]<<4)|(z[1]>>60), (z[3]<<4)|(z[2]>>60)
}
При оптимизации с помощью множественных присваиваний могут возникать огромные монстрообразные однострочники. Но такова цена за скорость. Поэтому я рекомендую около таких оптимизированных строк оставлять в закомментированном виде какие-то более понятные участки кода, которые объясняют, что происходит, чтобы тем, кто будет поддерживать код, было понятно, зачем это было сделано, с указанием, что такая оптимизация приводит к ускорению.
Метки и goto против for
Удивительное дело, но иногда метки могут работать быстрее циклов. В особенности если вам нужны циклы типа until, которых нативно нет в Go. Поэтому, если вы оптимизируете уже на уровне отдельных наносекунд, рассмотрите этот приём.
Да, можно сказать, что это антипаттерн, но если у вас стоит задача жёсткой оптимизации и вам важно выигрывать время, то это можно рассмотреть, когда уже другие возможности исчерпаны, когда у вас нет никаких аллокаций в циклах.
Инструменты оптимизации
Go обладает прекрасными возможностями, встроенными для тестирования. Мы можем получить значение в наносекундах исполнения функции, можем получить построчный вывод с указанием наносекунд на каждую строку, можем получить число аллокаций, которые выполняются в данной функции. Всё это очень помогает оптимизации программы.
Пожалуй, один из самых простых способов проверить, насколько эффективно вы используете Go, — это посмотреть на процент загрузки системы при запуске вашей программы. Если он не будет достигать 99 %, значит, можно оптимизировать дальше.
Инструментов довольно много. Поэтому о них будет следующая часть статьи.
Заключение
Можно, конечно, сказать, что на Go и не стоит пытаться писать высокопроизводительный код. Потому что придётся потратить существенные усилия на борьбу со сборщиком мусора и вызовами Go-runtime. Для этого есть C++ и Rust, у которых нет GC. Однако в Go существенно ниже порог входа, он имеет отличную инфраструктуру, быстрый компилятор и отличную поддержку сетевых функций.
Именно поэтому в криптосфере он широко используется — в ней зачастую требуется и высокая производительность вычислений, и отличная поддержка сетевых технологий.
Создание быстрых программ на Go потребует вдумчивого отношения к каждой строке и к каждому оператору. Но конечный результат может быть впечатляющим — моя оптимизированная функция инверсии оказалась в 3,4 раза быстрее, чем библиотечная из math.big. И всего на 3% медленнее, чем высокооптимизированная версия на C++. И, думаю, я ещё смогу улучшить её.
© 2025 ООО «МТ ФИНАНС»
Keeper22
Что люди только ни делают, лишь бы си не учить.
Kelbon
главное, что люди и менеджеры будут всерьёз считать, что они упростили, у них легче, надёжнее, а на С++ пришлось бы... Не знаю, ассемблер писать?
А на самом деле на С++ этот код был бы понятнее, логичнее и быстрее. И там не было бы хаков даже, просто компилятор там всё же поумнее и не надо бороться с гц
GospodinKolhoznik
Не надо бороться с гц, а надо писать свой гц, который будет бороться с утечками памяти.
Kelbon
вы видимо не знаете что такое деструктор
GospodinKolhoznik
Что такое деструктор знаю. А вот в какой момент его вызывать действительно не знаю.
Kelbon
невозможно знать что такое деструктор и не знать когда он вызывается. Благо сейчас легко приобщится, попросите у любой ЛЛМ объяснить как устроено RAII в С++
GospodinKolhoznik
Я что то заключил. Думал, что речь про free из си. А при чем здесь плюсы, речь же про си шла.