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.
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.
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.
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.
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.
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.
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.
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”.
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 20Az é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.
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.
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.
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.
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.