Powrót do strony głównej

Benchmarking kodu: dokładne pomiary CPU i cache

Rozdział o tworzeniu frameworka benchmarkingu z wysokodokładnymi pomiarami taktów CPU i licznikami perf. Przykłady dla x86, RISC-V, ARM. Analiza statystyczna i typowe błędy.

Dokładne benchmarki: od rdtsc do analizy perf
Advertisement 728x90

# Benchmarking i profilowanie: precyzyjne pomiary wydajności kodu

Optymalizacja kodu bez obiektywnych danych prowadzi do błędów. Przepisana funkcja haszująca niby dla poprawy lokalności cache spowolniła wykonanie o 15%. Intuicja zawodzi — potrzebne są precyzyjne pomiary czasu i zdarzeń procesora.

Framework benchmarkingu rozwiązuje problem: wielokrotne uruchomienia, analiza statystyczna, integracja z perf. Pozwala to wykrywać rzeczywiste wąskie gardła.

Wysokoprecyzyjne metody pomiaru czasu

Standardowa time() ma rozdzielczość 1 sekundy — niedopuszczalne dla mikrooptymalizacji.

Google AdInline article slot

clock_gettime()

struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
run_test();
clock_gettime(CLOCK_MONOTONIC, &end);

long ns = (end.tv_sec - start.tv_sec) * 1000000000L +
          (end.tv_nsec - start.tv_nsec);

Zalety: rozdzielczość nanosekundowa, odporność na korekty czasu systemowego, przenośność POSIX.

Liczniki taktów CPU (zalecane)

RISC-V:

static inline uint64_t rdcycle(void) {
    uint64_t cycles;
    asm volatile ("rdcycle %0" : "=r" (cycles));
    return cycles;
}

x86_64:

Google AdInline article slot
static inline uint64_t rdtsc(void) {
    uint32_t lo, hi;
    asm volatile ("rdtsc" : "=a" (lo), "=d" (hi));
    return ((uint64_t)hi << 32) | lo;
}

ARM64:

static inline uint64_t rdcycle(void) {
    uint64_t val;
    asm volatile("mrs %0, pmccntr_el0" : "=r"(val));
    return val;
}

Rozdzielczość — 1 takt, bez overheadu wywołań systemowych. Wady: specyfika architektury, wpływ na częstotliwości.

Analiza statystyczna wyników

Jeden pomiar jest bezużyteczny z powodu wariacji cache, przerw OS, rozgałęzień.

Google AdInline article slot

Podstawowa statystyka

#define ITERATIONS 1000
uint64_t times[ITERATIONS];
// ... wypełnienie times ...

uint64_t min = times[0], max = times[0], sum = 0;
for (int i = 0; i < ITERATIONS; i++) {
    if (times[i] < min) min = times[i];
    if (times[i] > max) max = times[i];
    sum += times[i];
}
uint64_t mean = sum / ITERATIONS;

Rozszerzona statystyka

Mediana jest odporna na wartości odstające, odchylenie standardowe pokazuje stabilność:

qsort(times, ITERATIONS, sizeof(uint64_t), compare_uint64);
uint64_t median = times[ITERATIONS / 2];

double variance = 0;
for (int i = 0; i < ITERATIONS; i++) {
    double diff = (double)times[i] - (double)mean;
    variance += diff * diff;
}
double stddev = sqrt(variance / ITERATIONS);

Kluczowe metryki:

  • Minimum: najlepszy przypadek (ciepły cache)
  • Mediana: typowa wydajność
  • Stddev: zmienność
  • Maksimum: najgorszy przypadek

Framework benchmarkingu

Uniwersalny interfejs do wielokrotnego użycia:

typedef struct {
    const char *name;
    void (*setup)(void);
    void (*run)(void);
    void (*teardown)(void);
} benchmark_t;

void benchmark_run(benchmark_t *bench, int iterations);

Implementacja obejmuje:

  • Rozgrzewający pomiar
  • Wielokrotne pomiary
  • Raport statystyczny

Przykład:

int array[1000];

void setup_array(void) {
    for (int i = 0; i < 1000; i++) {
        array[i] = i;
    }
}

void test_sequential_access(void) {
    volatile int sum = 0;
    for (int i = 0; i < 1000; i++) {
        sum += array[i];
    }
}

benchmark_t bench = {
    .name = "Sequential Array Access",
    .setup = setup_array,
    .run = test_sequential_access,
    .teardown = NULL
};

benchmark_run(&bench, 1000);

Analiza cache z perf

perf stat śledzi sprzętowe liczniki:

$ perf stat -e cache-references,cache-misses ./program
  1,234,567 cache-references
     12,345 cache-misses # 1.00% miss rate

Przydatne zdarzenia:

  • cache-references/misses: wszystkie poziomy
  • L1-dcache-loads/misses: L1 dane
  • LLC-loads/misses: ostatni poziom

Porównanie struktur: tablica — 1,2K misses, lista powiązana — 45K misses (37x gorzej).

Integracja perf z frameworkiem

typedef struct {
    uint64_t cycles;
    uint64_t cache_references;
    uint64_t cache_misses;
    uint64_t l1_loads;
    uint64_t l1_misses;
} perf_counters_t;

Automatyczne zbieranie i agregacja liczników za pomocą perf_event_open.

Typowe problemy i rozwiązania

Optymalizacje kompilatora

// Źle: pętla jest usuwana
int sum = 0;
for (...) sum += array[i];

// Dobrze
volatile int sum = 0;

Zimny vs ciepły cache

Pierwszy pomiar jest wolniejszy. Rozwiązanie: rozgrzewka + osobna metryka.

Overhead pomiarów

rdcycle() zajmuje ~10 taktów. Odejmować lub robić długie testy.

Szum systemowy

  • Wiele iteracji
  • Mediana zamiast średniej
  • cpupower frequency-set -g performance
  • taskset -c 0
  • nice -n -20

Co ważne

  • Liczniki CPU: rdtsc/rdcycle — złoty standard precyzji
  • Statystyka: mediana + stddev pewniejsza niż średnia
  • Zdarzenia perf: cache-misses wykrywają problemy lokalności
  • Rozgrzewka cache: obowiązkowa dla realistycznych pomiarów
  • Volatile: zapobiega optymalizacji martwego kodu

— Editorial Team

Advertisement 728x90

Czytaj dalej