37.4 Profilozás, benchmark és cost model

A helyesség bizonyítása után a teljesítmény az a terület, ahol a legkönnyebb önmagunkat becsapni. Ez az alfejezet ezért nem optimalizációs trükköket ad, hanem azt a fegyelmet, amellyel egy állítás – „ez a változat gyorsabb” – mérhetővé, reprodukálhatóvá és visszavezethetővé tehető műveleti és erőforrás-költségre. Kiindulópontunk az Object layout, EBO és adatközpontú tervezés (37.3) objektumelrendezése: a layout dönti el, hány cache vonalat érint egy bejárás, és ez az, amit a mérésnek látnia kell. Az ABI- és optimalizálásdiagnosztikai labor (14.6) ABI- és optimalizálásdiagnosztikai labor adja a fordító viselkedésének hátterét, a Tárolóválasztási és invalidációs labor (28.6) tárolóválasztási labor pedig azt a kérdést, amelyre a profilozás visszavezet: melyik művelet hova került.

37.4.1 A mérés mint szerződés: benchmark, profilozás, cost model

Három fogalmat érdemes élesen szétválasztani, mert a gyakorlatban összemosódnak. A benchmark rögzített workloadon végzett, ismételhető mérés, amelynek célja egy eldöntendő összehasonlítás: A változat gyorsabb-e B-nél azon a profilon, amely számunkra releváns. A profilozás (profiling) futás közbeni megfigyelés, amelynek célja nem az ítélet, hanem a magyarázat: hol megy el az idő és az erőforrás. A cost model (költségmodell) pedig az a leképezés, amely egy forrásszintű szerkezetet – egy ciklus, egy allokáció, egy virtuális hívás, egy syscall – közelítő költséghez rendel. A cost model nem mérési eredmény, hanem előrejelzés, amelyet a mérésnek kell igazolnia vagy cáfolnia.

Egy benchmark állítás csak akkor érvényes, ha a következő feltételek mind rögzítettek: (1) a workload pontos összetétele, (2) a build – compiler és verziója, -std, optimalizálási szint, target, -DNDEBUG megléte, (3) a futtatási környezet – CPU típus, frekvencia-governor, konténer CPU-limitje, a hoszt terheltsége, (4) a mért szakasz határa – beleszámít-e az inicializálás, a teardown, a fájl megnyitása, (5) az ismétlésszám és a statisztika módja. Ha bármelyik hiányzik, az eredmény nem hamis, hanem értelmezhetetlen: nem tudjuk, mire vonatkozik.

Fontos korlát: nincs univerzális benchmark. Egy std::vector versus std::list mérés csak a konkrét elemszámra, elemtípusra, bejárási mintára és cache-méretre érvényes. Ezért a mérési jegyzőkönyv mindig a profilt rögzíti, nem általános következtetést.

37.4.2 Workload, inputeloszlás és warmup

A workload az a bemenet, amelyen a program viselkedése releváns. Egy rekordfeldolgozó pipeline esetében legalább négy paramétert kell rögzíteni: a rekordok száma, a rekordhossz eloszlása (nem csak az átlag!), az egyedi kulcsok száma (kardinalitás), és a bemenet rendezettsége. Ezek külön-külön mozgatják a költséget: a nagy kardinalitás hash-táblát növel, a hosszú rekordok allokációt és memóriahierarchia-járást okoznak, a rendezett bemenet kihasználhatja a predikciót és a localityt.

A warmup (bemelegítés) első iterációinak költsége szisztematikusan eltér a többiétől: laphibák a lazán lefoglalt memórián, az allokátor belső arénáinak kialakulása, a dinamikus loader lazy bindingja, a CPU frekvencia felrámpázása. A warmupot a mért szakaszon kívül kell futtatni, és a mért szakasznak ugyanazt a bemenetet kell kapnia. Ha a warmupot csak „az első pár iteráció eldobásával” kezeljük, akkor is rögzíteni kell, hány iterációt dobtunk el, mert ez a szám befolyásolja az eredményt.

37.4.3 A fordító kicselezése: holt kód, konstans folding, mérési kontroll

Az optimalizáló az as-if szabály szerint mindent megtehet, ami a megfigyelhető viselkedést nem változtatja. Ha egy számítás eredményét senki nem használja, a hívás eltűnik (dead-code elimination); ha a bemenet fordítási időben ismert, a számítás előre elvégezhető (constant folding), és a ciklus összeomolhat. Ilyenkor a mérés nem azt méri, amit hiszünk.

A megoldás nem a volatile – az megakadályozza az optimalizálást, tehát épp azt a kódot méri, amit a valóságban nem futtatunk –, hanem egy mérési gát (optimization barrier), amely a fordító számára láthatatlanná teszi, hogy az érték felhasználatlan, a hardver számára viszont nem generál műveletet. GCC/Clang alatt ez egy üres inline assembly blokk:

template <class T>
inline void keep(T const& v) {
#if defined(__GNUC__) || defined(__clang__)
    asm volatile("" : : "r,m"(v) : "memory");
#else
    volatile T sink = v; (void)sink;   // hordozható, de konzervatívabb tartalék
#endif
}

Ez fordítóbővítmény, nem ISO C++; a #else ág a hordozható visszaesés. A keep() hívása önmagában nem garantál semmit, ha a hívó kód más módon is kiejthető – a gátat a cikluson kívül, az eredményre kell alkalmazni. Ugyanez a probléma az allokációs számlálónál: ha a mért kód egy std::string-et hoz létre, de az SSO (small string optimization) miatt rövid szövegnél nem történik allokáció, akkor a számláló nulla lesz – ez nem hiba, hanem információ, de csak akkor, ha a rekordhossz-elooszlás ismert.

37.4.4 A költségek szétválasztása: CPU, allokáció, memória, syscall

A hasznos mérés nem egy számot ad, hanem dimenziókat bont fel, mert a beavatkozások különböző dimenziókra hatnak. Egy rekordfeldolgozó pipeline esetében ez a következő tábla:

Dimenzió Mérték Forrás Mit magyaráz
CPU user s getrusage ru_utime tényleges számítás
CPU sys s ru_stime kernel-átlépések, syscallok
wall s std::chrono::steady_clock teljes átfutás
allokáció db, byte operator new számláló memóriakezelési költség
csúcs RSS kB ru_maxrss memórialábnyom
syscall db strace -c I/O és kernel-határ

A wall − user − sys különbség közelíti a várakozást: alvás, I/O-ra várás, futásra várakozás. Ha ez a szám nagy, a szűk keresztmetszet nem a CPU. A user/sys arány pedig megmutatja, hogy a költség a felhasználói kódban vagy a kernelben keletkezik – utóbbi tipikusan sok kis read/write vagy gettimeofday-jellegű hívás jele.

37.4.5 Eszközök és jogosultságok: perf és callgrind

A perf Linux-oldali, hardveres PMU-számlálókat és szoftveres eseményeket használ; perf stat összesít, perf record mintát vesz (sampling), és a mintákból attribútumot rendel a szimbólumokhoz. Működése jogosultságfüggő: a kernel.perf_event_paranoid sysctl, a konténer seccomp-profilja, a CAP_PERFMON/CAP_SYS_ADMIN megléte és a virtualizált PMU mind befolyásolják, hogy egyáltalán kapunk-e számlálót. Konténerben gyakran csak szoftveres események (task-clock, page-faults) érhetők el. Ha a számláló nem elérhető, azt kell jelenteni – adatot kitalálni nem lehet.

A callgrind a Valgrind dinamikus bináris instrumentációján alapul, ezért determinisztikus utasításszámot ad, ami kiválóan alkalmas két algoritmus összehasonlítására zajos gépen is. Cserébe nagyságrendekkel lassabb, és nem valós időt mér, hanem modellt. Mindkét eszköz használatát a laborprofilban rögzíteni kell (verzió, jogosultság, konfiguráció), mert a képességek verzió- és környezetfüggők.

37.4.6 Várakozás elkülönítése: syscall, IO, CPU

A wall-CPU szétválasztás önmagában még nem mondja meg, melyik syscall a bűnös. Erre a strace -c -f ad számlálókat hívásonként és időnként (az idő-kiemelés maga is torzít, ezt jelezni kell). A tipikus mintázat: sok kis read/recv hívás – pufferelési hiba; sok futex – szálankénti versengés; sok laphiba (-R opcióval mérve) – nem előre lefoglalt memória, vagy mmap/növekedő vektor.

37.4.7 Latency-eloszlás, áteresztés és a CI benchmark zaja

Az átlag önmagában félrevezet. A pipeline-oknál a farok számít: az a kérés, amelyik a p99-be esik, a felhasználó számára a lassú kérés. Ezért minden mérésnél legalább mediánt és p95/p99-et kell rögzíteni, ideális esetben hisztogramot. Az áteresztés (N/T) és a latency különböző kérdés: a batch feldolgozás áteresztésre optimalizál, az interaktív szolgáltatás farok-latencyre.

A CI-ban a zaj nagy: megosztott runner, más konténerek, változó CPU-frekvencia, hipervizitor-zaj. A védekezés nem a varázsszám, hanem a mérés-tervezés: abszolút érték helyett arány a baseline-hoz képest, ugyanabban a futásban mérve a két változatot (A/B interleaving), elegendő ismétlés, medián az ismétlések között, és a szórás (MAD) jelentése. Ha a különbség kisebb, mint a zaj, a helyes következtetés: „nem mutatható ki hatás”.

37.4.8 Kidolgozott példa: rekordfeldolgozó pipeline mérése

A példa egy key,value\n sorokból álló blobból számol összeget. A tulajdonos a main-ben élő blob; a process std::string_view-ként kölcsönzi a szeletet, és nem tárolja tovább, így nincs dangling (blob a reserve után nem allokál újra). Az allokációk a parse_line-ban keletkeznek.

// pipeline_bench.cpp
// g++     -std=c++20 -O2 -DNDEBUG pipeline_bench.cpp -o pipeline_bench
// clang++ -std=c++20 -O2 -DNDEBUG pipeline_bench.cpp -o pipeline_bench
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <new>
#include <string>
#include <string_view>
#include <sys/resource.h>

static std::atomic<std::size_t> g_allocs{0};

void* operator new(std::size_t n) {
    g_allocs.fetch_add(1, std::memory_order_relaxed);
    if (void* p = std::malloc(n)) return p;
    throw std::bad_alloc{};
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }

template <class T>
inline void keep(T const& v) {
#if defined(__GNUC__) || defined(__clang__)
    asm volatile("" : : "r,m"(v) : "memory");
#else
    volatile T sink = v; (void)sink;
#endif
}

struct Record { std::string_view key; std::int64_t value; };
struct Stats  { std::int64_t sum = 0; std::size_t n = 0; };

static bool parse_line(std::string_view line, Record& out) {
    auto comma = line.find(',');
    if (comma == std::string_view::npos) return false;
    out.key = line.substr(0, comma);
    std::string tail{line.substr(comma + 1)};   // itt allokálunk (SSO-tól függően nem)
    char* end = nullptr;
    long long v = std::strtoll(tail.c_str(), &end, 10);
    if (end == tail.c_str()) return false;
    out.value = v;
    return true;
}

static Stats process(std::string_view blob) {
    Stats s{};
    std::size_t pos = 0;
    while (pos < blob.size()) {
        auto nl = blob.find('\n', pos);
        if (nl == std::string_view::npos) nl = blob.size();
        Record r{};
        if (parse_line(blob.substr(pos, nl - pos), r)) { s.sum += r.value; ++s.n; }
        pos = (nl == blob.size()) ? blob.size() : nl + 1;
    }
    return s;
}

int main(int argc, char** argv) {
    const int iters = (argc > 1) ? std::atoi(argv[1]) : 200;

    std::string blob;
    blob.reserve(1u << 20);
    char buf[64];
    for (int i = 0; i < 20000; ++i) {
        int n = std::snprintf(buf, sizeof buf, "key%03d,%d\n", i % 512, i);
        blob.append(buf, static_cast<std::size_t>(n));
    }
    keep(blob.size());

    Stats warm{};
    for (int i = 0; i < 20; ++i) warm = process(blob);
    keep(warm.sum);

    struct rusage r0{}, r1{};
    getrusage(RUSAGE_SELF, &r0);
    const auto t0 = std::chrono::steady_clock::now();
    const auto a0 = g_allocs.load(std::memory_order_relaxed);

    std::int64_t acc = 0;
    for (int i = 0; i < iters; ++i) acc += process(blob).sum;
    keep(acc);

    const auto t1 = std::chrono::steady_clock::now();
    const auto a1 = g_allocs.load(std::memory_order_relaxed);
    getrusage(RUSAGE_SELF, &r1);

    auto dt = [](timeval a, timeval b) {
        return double(b.tv_sec - a.tv_sec) + double(b.tv_usec - a.tv_usec) * 1e-6;
    };
    const double wall = std::chrono::duration<double>(t1 - t0).count();
    const double user = dt(r0.ru_utime, r1.ru_utime);
    const double sys  = dt(r0.ru_stime, r1.ru_stime);
    const double recs = double(warm.n) * iters;

    std::printf("iterations    = %d\n", iters);
    std::printf("records       = %.0f\n", recs);
    std::printf("wall_s        = %.6f\n", wall);
    std::printf("user_s        = %.6f\n", user);
    std::printf("sys_s         = %.6f\n", sys);
    std::printf("wait_s        = %.6f\n", wall - user - sys);
    std::printf("allocs        = %zu\n", a1 - a0);
    std::printf("allocs/record = %.4f\n", double(a1 - a0) / recs);
    std::printf("ns/record     = %.2f\n", wall * 1e9 / recs);
    std::printf("maxrss_kb     = %ld\n", r1.ru_maxrss);
    return 0;
}

A kiegészítő dimenziók külön parancsokkal mérhetők; mindegyik jogosultságfüggő, a laborprofilban rögzítendő:

./pipeline_bench 200
perf stat -d ./pipeline_bench 200
strace -c -f ./pipeline_bench 200
valgrind --tool=callgrind ./pipeline_bench 20

Az értelmezés iránya: ha az allocs/record értéke tartósan nagy, a parse_line allokációja a jelölt, és a javítás (allokációmentes parse) ebben a dimenzióban látszik majd; ha a sys_s arány nő, a kernel-határ felé kell nézni; ha a wait_s nagy, a CPU nem a szűk keresztmetszet. A fenti kód kimenete itt nem szerepel mért értékként – a számokat a saját laborodban kell megkapnod, és a jegyzőkönyvbe a build- és környezeti profil mellé kell tenni.

37.4.9 Tipikus hibák és javításuk

A leggyakoribb hiba az -O0-n mért benchmark: az nem a kiadott binárist méri. A második a std::chrono::system_clock használata steady_clock helyett – az előbbi ugorhat. A harmadik a parse_line allokációjának elfelejtése az elemzésben, mert „az std::string_view nem allokál” – igaz, de a std::string igen. A negyedik a operator new számláló túlbecslése: a libstdc++/libc++ belső allokációit is számolja, és az over-aligned operator new túlterhelése nélkül a nagy igazítású allokációk kimaradnak. Az ötödik a konténer CPU-limitje mellett abszolút másodpercek összehasonlítása két különböző futásból.

37.4.10 Összefoglaló

A teljesítmény-állítás szerződés: rögzített workload, rögzített build, rögzített környezet, rögzített mért szakasz, megismételt mérés. A benchmark ítélet, a profilozás magyarázat, a cost model előrejelzés – és a három csak együtt érvényes. A költséget dimenziókra kell bontani (CPU user/sys, wall, allokáció, RSS, syscall), mert a beavatkozások különböző dimenziókba hatnak. A fordítót mérési gáttal kell távol tartani, az eszközök jogosultságfüggését jelenteni kell, a farok-latencyet pedig nem szabad átlaggal helyettesíteni.

37.4.11 Feladatok

1. feladat (kódolási). Írd át a parse_line-t allokációmentesre (a számot közvetlenül a std::string_view-ből olvasva), és mérd meg a különbséget a pipeline_bench harness-szel. Mely dimenziók változnak, és melyek nem?

2. feladat (magyarázó–diagnosztikai). Egészítsd ki a harness-t úgy, hogy iterációnként mérje a wall time-ot, és számoljon mediánt, p95-öt és p99-et. Indokold, miért lehet a medián és az átlag lényegesen eltérő, és milyen esetben hazudik az átlag.

3. feladat (mérés-tervezési). Készíts CI-barát összehasonlítást -O2 és -O2 -march=native között. Add meg a mérési tervet: hány ismétlés, milyen statisztika, hogyan kezeled a runner zaját, és mit írsz a jegyzőkönyvbe, ha a hatás a zaj alatt marad.

37.4.12 Megoldások

1. megoldás. Az allokációmentes változat a számjegyeket közvetlenül a nézetből dolgozza fel:

static bool parse_line(std::string_view line, Record& out) {
    auto comma = line.find(',');
    if (comma == std::string_view::npos) return false;
    out.key = line.substr(0, comma);
    auto num = line.substr(comma + 1);
    if (num.empty()) return false;
    std::int64_t v = 0;
    for (char c : num) {
        if (c < '0' || c > '9') return false;
        v = v * 10 + (c - '0');
    }
    out.value = v;
    return true;
}

Indoklás: a std::string tail{...} konstrukció a rövid rekordoknál SSO miatt egyáltalán nem allokál, hosszúaknál igen. Ezért az allocs/record csökkenése függ a rekordhossz-eloszlástól – ha a bemenet végig rövid, a javulás kicsi vagy nulla, ami nem azt jelenti, hogy a változtatás rossz, csak azt, hogy ezen a profilon nem mérhető. Ellenőrzési feltétel: allocs/record a hosszú rekordos bemeneten csökken, a sum és a feldolgozott rekordszám viszont azonos marad a két változatban (helyességi kapu), és a wait_s nem nő. A ns/record csak ezek után értelmezhető.

2. megoldás. Iterációnkénti minta vételéhez a ciklus belsejébe kell az időmérés, steady_clock-kal:

std::vector<double> samples;
samples.reserve(static_cast<std::size_t>(iters));
for (int i = 0; i < iters; ++i) {
    const auto a = std::chrono::steady_clock::now();
    acc += process(blob).sum;
    const auto b = std::chrono::steady_clock::now();
    samples.push_back(std::chrono::duration<double>(b - a).count());
}
keep(acc);
std::sort(samples.begin(), samples.end());
auto q = [&](double p) {
    std::size_t i = static_cast<std::size_t>(p * double(samples.size() - 1));
    return samples[i];
};
std::printf("p50=%.6f p95=%.6f p99=%.6f max=%.6f\n",
            q(0.50), q(0.95), q(0.99), samples.back());

Indoklás: az átlag érzékeny a kiugró értékekre, és a farokról semmit nem mond. Ha a futásidő eloszlása jobbra ferdült (néhány kiugróan lassú iteráció, például laphiba vagy allokátor-újranövelés miatt), az átlag felfelé húz, miközben a medián stabil. Az átlag akkor hazudik igazán, amikor a döntést „a tipikus esetre” akarjuk hozni, de a felhasználói élményt a farok határozza meg. Ellenőrzési feltétel: p50 ≤ p95 ≤ p99 ≤ max, és az iters növelésével a medián konvergál, a p99 pedig nem esik a medián alá. Megjegyzés: az iterációnkénti now() hívás maga is költség, ezért a rekordonkénti ns itt már nem összevethető az 1. feladat teljes-futásos mérésével – ez tudatos trade-off, nem hiba.

3. megoldás. A terv a következő. Egy CI-futáson belül mindkét binárist lefordítjuk ugyanazzal a compilerrel és ugyanazzal a stdlib-verzióval, majd váltogatva futtatjuk őket: A, B, A, B, … Ez kiszűri a lassú driftet (termikus vagy szomszéd-konténer hatás). Ismétlés: legalább 15 váltott pár, mindegyikből a medián ns/record értéket vesszük, és a páronkénti arányok mediánját jelentjük. Statisztika: a két medián aránya, mellé a páronkénti arányok MAD-ja. Jegyzőkönyv: compiler és verzió, -std=c++20, -O2, -DNDEBUG, -march=native pontos jelentése az adott targeten (ez implementációfüggő, a laborprofilban rögzítendő), a CPU-modell, a runner típusa, az ismétlésszám és a statisztika. Ha az arány mediánja a MAD sávján belül van, a helyes megfogalmazás: „a két build között ezen a profilon nem mutatható ki különbség”; ezt nem szabad „azonos teljesítmény”-ként megfogalmazni. -march=native esetén plusz kockázat, hogy a CI-runner CPU-ja eltér a deploy célétól, ezért a natív buildet a cél hardveren kell ellenőrizni, nem a runneren.