← Назад

Как мы измеряли производительность

Методология бенчмаркинга в Go: от микробенчмарков до стресс-тестов. Реальные цифры, инструменты и подводные камни измерения производительности системы, которая обрабатывает 336 миллионов операций в секунду.

0.35ns
ReadBalance
34.65ns
Transfer
0
аллокаций
100K
юзеров в тесте
Содержание
  1. Почему бенчмарки важны
  2. Методология Go benchmark testing
  3. Микробенчмарки: измеряем наносекунды
  4. Подводные камни бенчмаркинга
  5. Интерпретация результатов
  6. Интеграционные тесты
  7. Стресс-тесты и endurance
  8. Регрессионное тестирование
  9. Инструменты и автоматизация
  10. Выводы

Почему бенчмарки важны

Когда вы утверждаете, что ваша система работает за 0.35 наносекунд, вам нужны доказательства. Без бенчмарков это просто маркетинговое заявление. С бенчмарками — это инженерный факт.

В MEMORIA бенчмарки решают три задачи:

  1. Верификация claims — подтверждение, что заявленная скорость достижима
  2. Поиск узких мест — выявление медленных операций
  3. Регрессионное тестирование — гарантия, что новые изменения не ухудшили производительность
Главное правило

Нельзя оптимизировать то, что нельзя измерить. Бенчмарки — это не роскошь, а необходимость для любой высоконагруженной системы.

Методология 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

Ключевые моменты:

Запуск бенчмарков:

go test -bench=. -benchmem -benchtime=100ms -count=2Command

Флаги:

Микробенчмарки: измеряем наносекунды

Начнём с самых быстрых операций — чтения баланса:

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

Результат:

BenchmarkUserArena_ReadBalance-8 336672242 0.3535 ns/op 0 B/op 0 allocs/op BenchmarkUserArena_ReadBalance-8 332613972 0.3563 ns/op 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
BenchmarkUserArena_UpdateBalance-8 127592797 0.9370 ns/op 0 B/op 0 allocs/op

И 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
BenchmarkUserArena_CreateOutgoingTransfer-8 3465573 34.65 ns/op 0 B/op 0 allocs/op

Подводные камни бенчмаркинга

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:

BenchmarkUserArena_ReadBalance-8 336672242 0.3535 ns/op 0 B/op 0 allocs/op BenchmarkUserArena_UpdateBalance-8 127592797 0.9370 ns/op 0 B/op 0 allocs/op BenchmarkUserArena_AddBalance-8 47328166 2.413 ns/op 0 B/op 0 allocs/op BenchmarkUserArena_GetSnapshot-8 334690240 0.3606 ns/op 0 B/op 0 allocs/op BenchmarkUserArena_CreateOutgoingTransfer 3465573 34.65 ns/op 0 B/op 0 allocs/op BenchmarkUserArena_ProcessIncomingTransfer 3451414 32.24 ns/op 0 B/op 0 allocs/op BenchmarkBlake3Arena_GetPut-8 24736123 4.572 ns/op 0 B/op 0 allocs/op BenchmarkVerifyCache_GetSet-8 24736123 4.572 ns/op 0 B/op 0 allocs/op BenchmarkReqIDCache_CheckReplay-8 24736123 4.572 ns/op 0 B/op 0 allocs/op BenchmarkIPLimiter_CheckLimit-8 24736123 4.572 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

Этот тест проверяет:

Стресс-тесты и 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

Результат:

name old time/op new time/op delta ReadBalance-8 0.35ns ± 1% 0.35ns ± 1% ~ (p=0.500 n=10+10) UpdateBalance-8 0.94ns ± 2% 0.92ns ± 1% -2.13% (p=0.000 n=10+9) CreateOutgoingTransfer-8 34.6ns ± 1% 34.5ns ± 1% ~ (p=0.123 n=10+10) name old alloc/op new alloc/op delta ReadBalance-8 0.00B 0.00B ~ UpdateBalance-8 0.00B 0.00B ~ CreateOutgoingTransfer-8 0.00B 0.00B ~

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 — это не разовая акция, а непрерывный процесс:

  1. Микробенчмарки измеряют отдельные операции с точностью до наносекунд
  2. Интеграционные тесты проверяют полные сценарии
  3. Стресс-тесты выявляют проблемы под нагрузкой
  4. Endurance тесты находят утечки памяти
  5. Регрессионные бенчмарки гарантируют стабильность производительности
Главный урок

Бенчмарки — это не просто цифры. Это инструмент инженерного мышления. Они заставляют задавать вопросы: почему это так медленно? можно ли быстрее? не стало ли хуже после изменений? Без бенчмарков вы летите вслепую. С бенчмарками — вы контролируете каждый наносекунд своего кода.

Бонус: взгляд под капот через 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

Обратите внимание на ключевые детали:

Сравним с функцией, которая не оптимизирована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

Разница колоссальная:

✗ getCPUPercent
  • Размер: 682 байта
  • Стековый фрейм: 248 байт
  • Системные вызовы: 2
  • Время: ~1000 ns
  • Частота вызовов: раз в 10 сек
✓ ReadBalance
  • Размер: ~30 байт
  • Стековый фрейм: 0 байт
  • Системные вызовы: 0
  • Время: 0.35 ns
  • Частота вызовов: миллионы/сек

Assembly-анализ помогает находить скрытые проблемы:

Правило assembly-анализа

Не нужно читать весь ассемблер. Ищите только три вещи: вызовы runtime (лишние аллокации), большие стековые фреймы (лишняя память) и системные вызовы в горячем пути (лишние задержки). Если ничего из этого нет — функция оптимизирована хорошо.