Почему бенчмарки важны
Когда вы утверждаете, что ваша система работает за 0.35 наносекунд, вам нужны доказательства. Без бенчмарков это просто маркетинговое заявление. С бенчмарками — это инженерный факт.
В MEMORIA бенчмарки решают три задачи:
- Верификация claims — подтверждение, что заявленная скорость достижима
- Поиск узких мест — выявление медленных операций
- Регрессионное тестирование — гарантия, что новые изменения не ухудшили производительность
Нельзя оптимизировать то, что нельзя измерить. Бенчмарки — это не роскошь, а необходимость для любой высоконагруженной системы.
Методология Go benchmark testing
Go имеет встроенную поддержку бенчмаркинга через пакет testing. Функция бенчмарка начинается с Benchmark и принимает *testing.B:
func BenchmarkUserArena_ReadBalance(b *testing.B) {
// Подготовка
initTestGlobals()
peerID := setupTestPeerIDWithSuffix('R')
arena := setupTestArena(peerID, 1234567890)
// Сброс таймера после подготовки
b.ResetTimer()
// Цикл бенчмарка
for i := 0; i < b.N; i++ {
_ = arena.ReadBalance()
}
}Go
Ключевые моменты:
b.N— количество итераций, подбирается автоматическиb.ResetTimer()— сбрасывает таймер после подготовки_ =— предотвращает оптимизацию компилятора (dead code elimination)
Запуск бенчмарков:
go test -bench=. -benchmem -benchtime=100ms -count=2Command
Флаги:
-bench=.— запустить все бенчмарки-benchmem— показать аллокации памяти-benchtime=100ms— минимальное время на бенчмарк-count=2— запустить каждый бенчмарк дважды для стабильности
Микробенчмарки: измеряем наносекунды
Начнём с самых быстрых операций — чтения баланса:
func BenchmarkUserArena_ReadBalance(b *testing.B) {
initTestGlobals()
peerID := setupTestPeerIDWithSuffix('R')
arena := setupTestArena(peerID, 1234567890)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = arena.ReadBalance()
}
}Go
Результат:
Что это значит:
- 336M ops/sec — 336 миллионов операций в секунду
- 0.35 ns/op — 0.35 наносекунды на операцию
- 0 B/op — ноль байт аллоцировано
- 0 allocs/op — ноль аллокаций
Для сравнения, обновление баланса:
func BenchmarkUserArena_UpdateBalance(b *testing.B) {
initTestGlobals()
peerID := setupTestPeerIDWithSuffix('U')
arena := setupTestArena(peerID, 0)
b.ResetTimer()
for i := 0; i < b.N; i++ {
arena.UpdateBalance(int64(i))
}
}Go
И P2P-перевод:
func BenchmarkUserArena_CreateOutgoingTransfer(b *testing.B) {
initTestGlobals()
fromPeer := setupTestPeerIDWithSuffix('F')
toPeer := setupTestPeerIDWithSuffix('T')
fromArena := setupTestArena(fromPeer, 1_000_000)
_ = setupTestArena(toPeer, 0)
var reqID [8]byte
b.ResetTimer()
for i := 0; i < b.N; i++ {
if i%1000 == 0 {
fromArena.UpdateBalance(1_000_000)
}
binary.BigEndian.PutUint64(reqID[:], uint64(i))
_ = fromArena.CreateOutgoingTransfer(toPeer, 100, reqID)
}
}Go
Подводные камни бенчмаркинга
1. Dead code elimination
Компилятор Go может оптимизировать код, который не используется. Если вы не сохраняете результат, компилятор может вообще не выполнять операцию:
- Компилятор может удалить вызов
- Результат будет 0 ns/op
- Бенчмарк бесполезен
- Сохраняем результат в
_ - Компилятор не может оптимизировать
- Реальные цифры
// ❌ ПЛОХО - компилятор может удалить вызов
for i := 0; i < b.N; i++ {
arena.ReadBalance()
}
// ✅ ХОРОШО - результат используется
for i := 0; i < b.N; i++ {
_ = arena.ReadBalance()
}Go
2. Подготовка данных
Если подготовка занимает время, она исказит результаты. Используйте b.ResetTimer():
func BenchmarkVerifyCache_GetSet(b *testing.B) {
// Подготовка (не учитывается в бенчмарке)
initTestGlobals()
shard := &verifyCaches[0]
// Сброс таймера
b.ResetTimer()
// Измеряем только это
for i := 0; i < b.N; i++ {
key := uint64(i)
shard.Set(key, true)
_ = shard.Get(key)
}
}Go
3. Периодический сброс состояния
Некоторые операции изменяют состояние (например, переводы списывают баланс). Нужно периодически сбрасывать:
func BenchmarkUserArena_CreateOutgoingTransfer(b *testing.B) {
initTestGlobals()
fromPeer := setupTestPeerIDWithSuffix('F')
toPeer := setupTestPeerIDWithSuffix('T')
fromArena := setupTestArena(fromPeer, 1_000_000)
_ = setupTestArena(toPeer, 0)
var reqID [8]byte
b.ResetTimer()
for i := 0; i < b.N; i++ {
// Каждые 1000 итераций восполняем баланс
if i%1000 == 0 {
fromArena.UpdateBalance(1_000_000)
}
binary.BigEndian.PutUint64(reqID[:], uint64(i))
_ = fromArena.CreateOutgoingTransfer(toPeer, 100, reqID)
}
}Go
4. Избегание StopTimer/StartTimer
Вызовы b.StopTimer() и b.StartTimer() в каждой итерации добавляют overhead. Лучше делать периодический сброс:
- StopTimer/StartTimer в каждой итерации
- Добавляет ~100 ns overhead
- Искажает результаты
- Периодический сброс каждые 1000 итераций
- Минимальный overhead
- Точные результаты
Интерпретация результатов
Вот реальные результаты бенчмарков MEMORIA:
Что означают колонки:
- BenchmarkName-8 — имя бенчмарка и количество CPU ядер
- 336672242 — количество итераций (b.N)
- 0.3535 ns/op — среднее время на операцию
- 0 B/op — байт аллоцировано на операцию
- 0 allocs/op — количество аллокаций на операцию
Ключевой вывод: все операции имеют 0 аллокаций. Это подтверждает zero-allocation hot path.
Интеграционные тесты
Микробенчмарки измеряют отдельные функции. Интеграционные тесты проверяют полные сценарии:
func TestIntegration_FullTransferCycle(t *testing.T) {
initTestGlobals()
w := setupTestWorker()
defer w.udpConn.Close()
fromPeer := setupTestPeerIDWithSuffix('A')
toPeer := setupTestPeerIDWithSuffix('B')
fromArena := setupTestArena(fromPeer, 10000)
toArena := setupTestArena(toPeer, 0)
initialFromBalance := fromArena.ReadBalance()
initialToBalance := toArena.ReadBalance()
// ПОЛНЫЙ ЦИКЛ ТРАНСФЕРА
var reqID [8]byte
binary.LittleEndian.PutUint64(reqID[:], 99999)
// 1. Создаём исходящий трансфер
ok1 := fromArena.CreateOutgoingTransfer(toPeer, 500, reqID)
if !ok1 {
t.Fatal("CreateOutgoingTransfer failed")
}
// 2. Обрабатываем входящий трансфер
ok2 := toArena.ProcessIncomingTransfer(fromPeer, 500, reqID)
if !ok2 {
t.Fatal("ProcessIncomingTransfer failed")
}
// 3. Проверяем балансы
if fromArena.ReadBalance() != initialFromBalance-500 {
t.Errorf("From balance mismatch")
}
if toArena.ReadBalance() != initialToBalance+500 {
t.Errorf("To balance mismatch")
}
cleanupTestArena(fromPeer)
cleanupTestArena(toPeer)
}Go
Этот тест проверяет:
- Создание исходящего перевода
- Обработку входящего перевода
- Корректность обновления балансов
- Запись транзакции в ring buffer
Стресс-тесты и endurance
Стресс-тесты проверяют систему под высокой нагрузкой:
func TestStress_MemoryPressure(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
initTestGlobals()
const testUserCount = 100_000
for i := 0; i < testUserCount; i++ {
peerID := setupTestPeerIDWithSuffix(byte(i % 256))
_ = setupTestArena(peerID, 1000)
}
runtime.GC()
var m runtime.MemStats
runtime.ReadMemStats(&m)
t.Logf("🧠 Memory after %d users: Alloc=%.1f MB, Sys=%.1f MB",
testUserCount,
float64(m.Alloc)/1_048_576,
float64(m.Sys)/1_048_576)
if m.Alloc > 2*1024*1024*1024 {
t.Errorf("Memory usage exceeds 2GB limit")
}
}Go
Endurance тесты проверяют утечки памяти:
func TestEndurance_Long(t *testing.T) {
if testing.Short() {
t.Skip("Skipping long endurance test")
}
initTestGlobals()
duration := 5 * time.Minute
endTime := time.Now().Add(duration)
arenas := make([]*UserArena, 100)
for i := range arenas {
peerID := setupTestPeerIDWithSuffix(byte(i))
arenas[i] = setupTestArena(peerID, 100_000)
}
runtime.GC()
var mStart, mEnd runtime.MemStats
runtime.ReadMemStats(&mStart)
go func() {
for time.Now().Before(endTime) {
arena := arenas[mrand.Intn(len(arenas))]
if arena != nil {
arena.AddBalance(1)
}
time.Sleep(time.Millisecond)
}
}()
<-time.After(duration)
runtime.GC()
runtime.ReadMemStats(&mEnd)
allocGrowth := float64(mEnd.Alloc-mStart.Alloc) / 1_048_576
threshold := float64(MEMORY_LEAK_THRESHOLD_MB_PER_HOUR) * duration.Hours()
if allocGrowth > threshold {
t.Errorf("Memory leak detected: %.1f MB growth", allocGrowth)
}
}Go
Регрессионное тестирование
Чтобы гарантировать, что новые изменения не ухудшили производительность, мы используем регрессионные бенчмарки:
func BenchmarkRegression_Transfer(b *testing.B) {
initTestGlobals()
fromPeer := setupTestPeerIDWithSuffix('F')
toPeer := setupTestPeerIDWithSuffix('T')
fromArena := setupTestArena(fromPeer, 1_000_000)
_ = setupTestArena(toPeer, 0)
var reqID [8]byte
b.ResetTimer()
for i := 0; i < b.N; i++ {
if i%1000 == 0 {
fromArena.UpdateBalance(1_000_000)
}
binary.BigEndian.PutUint64(reqID[:], uint64(i))
_ = fromArena.CreateOutgoingTransfer(toPeer, 100, reqID)
}
}
func BenchmarkRegression_Snapshot(b *testing.B) {
initTestGlobals()
peerID := setupTestPeerIDWithSuffix('R')
arena := setupTestArena(peerID, 1_000_000)
w := setupTestWorker()
defer w.udpConn.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = w.buildArenaSnapshot(arena, arena.ReadBalance())
}
}Go
Эти бенчмарки запускаются в CI/CD pipeline. Если производительность ухудшается более чем на 10%, сборка падает.
Инструменты и автоматизация
benchstat — сравнение бенчмарков
Инструмент benchstat сравнивает два набора бенчмарков:
# Сохраняем старые результаты
go test -bench=. -benchmem > old.txt
# Вносим изменения
# Сохраняем новые результаты
go test -bench=. -benchmem > new.txt
# Сравниваем
benchstat old.txt new.txtCommand
Результат:
pprof — профилирование
Для глубокого анализа используем pprof:
# Запускаем бенчмарк с профилированием
go test -bench=BenchmarkUserArena_ReadBalance -cpuprofile=cpu.prof
# Анализируем
go tool pprof cpu.profCommand
Это показывает, какие функции потребляют больше всего CPU.
race detector — поиск гонок
Для проверки race conditions:
go test -race -run=TestConcurrentTransfers_RaceCommand
Тест на гонки:
func TestConcurrentTransfers_Race(t *testing.T) {
initTestGlobals()
arenas := make([]*UserArena, 100)
for i := range arenas {
peerID := setupTestPeerIDWithSuffix(byte(i % 256))
arenas[i] = setupTestArena(peerID, 1_000_000)
}
done := make(chan bool, 10)
for goroutineID := 0; goroutineID < 10; goroutineID++ {
go func(gid int) {
startIdx := gid * 10
endIdx := startIdx + 10
myArenas := arenas[startIdx:endIdx]
for j := 0; j < 1000; j++ {
from := myArenas[mrand.Intn(len(myArenas))]
to := myArenas[mrand.Intn(len(myArenas))]
if from != nil && to != nil && from != to {
var reqID [8]byte
binary.LittleEndian.PutUint64(reqID[:], uint64(gid*1000+j))
_ = from.CreateOutgoingTransfer(to.peerID, 100, reqID)
}
}
done <- true
}(goroutineID)
}
for i := 0; i < 10; i++ {
<-done
}
}Go
Выводы
Бенчмаркинг в MEMORIA — это не разовая акция, а непрерывный процесс:
- Микробенчмарки измеряют отдельные операции с точностью до наносекунд
- Интеграционные тесты проверяют полные сценарии
- Стресс-тесты выявляют проблемы под нагрузкой
- Endurance тесты находят утечки памяти
- Регрессионные бенчмарки гарантируют стабильность производительности
Бенчмарки — это не просто цифры. Это инструмент инженерного мышления. Они заставляют задавать вопросы: почему это так медленно? можно ли быстрее? не стало ли хуже после изменений? Без бенчмарков вы летите вслепую. С бенчмарками — вы контролируете каждый наносекунд своего кода.
Бонус: взгляд под капот через assembly
Для самых критичных функций мы заглядываем в assembly-вывод компилятора Go, чтобы убедиться, что оптимизации работают на уровне машинных инструкций. Команда для получения дизассемблера:
go tool compile -S memo.go > assembly_dump.txtCommand
Вот как выглядит реальный ассемблер для функции ReadBalance — той самой, что работает за 0.35 ns:
TEXT main.(*UserArena).ReadBalance(SB), NOSPLIT|NOFRAME
MOVQ 8(AX), DX // Загрузка указателя на ping/pong
MOVL 12(AX), AX // Чтение active flag
TESTL AX, AX // Проверка: 0 или 1?
JEQ ping_slot // Если 0 — идём в ping
MOVQ 128(DX), AX // pong slot
RET
ping_slot:
MOVQ (DX), AX // ping slot
RETAssembly
Обратите внимание на ключевые детали:
- NOSPLIT|NOFRAME — функция не создаёт стековый фрейм и не проверяет stack overflow. Это экономит ~5 ns на каждый вызов
- Всего 6 инструкций — MOVQ, MOVL, TESTL, JEQ, MOVQ, RET. Меньше инструкций = меньше тактов CPU
- Нет вызовов runtime — никаких
runtime.morestack,runtime.gcWriteBarrier - Условный переход — JEQ предсказывается процессором (branch prediction) с точностью ~99%, так как active flag меняется редко
Сравним с функцией, которая не оптимизирована — getCPUPercent:
TEXT main.getCPUPercent(SB), ABIInternal, $248-0
LEAQ -120(SP), R12
CMPQ R12, 16(R14)
JLS morestack // ← Проверка стека!
PUSHQ BP
MOVQ SP, BP
SUBQ $248, SP // ← Большой стековый фрейм!
// ... 682 байта инструкций ...
CALL unix.Getrusage(SB) // ← Системный вызов!
CALL time.Now(SB) // ← Ещё один!
DIVSD X1, X0 // ← Плавающая точка
RETAssembly
Разница колоссальная:
- Размер: 682 байта
- Стековый фрейм: 248 байт
- Системные вызовы: 2
- Время: ~1000 ns
- Частота вызовов: раз в 10 сек
- Размер: ~30 байт
- Стековый фрейм: 0 байт
- Системные вызовы: 0
- Время: 0.35 ns
- Частота вызовов: миллионы/сек
Assembly-анализ помогает находить скрытые проблемы:
- Неожиданные вызовы runtime —
runtime.gcWriteBarrier,runtime.growslice,runtime.newobject - Стековые аллокации — большие
SUBQ $N, SPозначают, что функция создаёт локальные переменные на стеке - Потеря inlining — если функция не инлайнится, она становится
CALL, что стоит ~5 ns - Невыровненный доступ —
MOVQк невыровненному адресу работает в 2-4 раза медленнее
Не нужно читать весь ассемблер. Ищите только три вещи: вызовы runtime (лишние аллокации), большие стековые фреймы (лишняя память) и системные вызовы в горячем пути (лишние задержки). Если ничего из этого нет — функция оптимизирована хорошо.