A dispatch költsége nem egyetlen szám, hanem egy szerződésfüggő
modell eredménye. Ebben a részben ugyanazt a műveletet három megoldással
— statikus template, virtuális függvény és
std::function-alapú type erasure — hasonlítjuk össze, de
nem azért, hogy egyik mellett univerzálisan érveljünk, hanem hogy
megtanuljuk: mit kell megmérni, mit kell megnézni az assemblyben vagy az
IR-ben, és milyen feltételek mellett lesz egy állítás egyáltalán igaz.
Az előző részekben már rögzítettük a mérési keretet (Profilozás, benchmark és cost model (37.4)), a
fordítás idejű testreszabás mintáit (Customization
point, CRTP és policy design (27.5)) és a type erasure szerkezetét
(invoke, function wrapper és type erasure
(30.2)). Itt ezekre építünk, és a hangsúly a döntés indoklásán van:
API-szerződés, fordítási költség, bináris méret és futásidejű viselkedés
együtt.
A „zero-overhead” kifejezés a C++ egyik leggyakrabban félreértett
ígérete. Helyes olvasata: bizonyos feltételek mellett a használt
absztrakció nem jár olyan futásidejű többlettel, amit a kézi megoldás
elkerülne. Ezek a feltételek azonban nem automatikusak. Ha a fordító nem
látja a konkrét típust, ha a hívás modulhatáron megy át, ha a callable
nem ismert a hívás helyén, vagy ha a std::function éppen
allokál, akkor az absztrakciónak ára van. Ezt az árat nem tilos
megfizetni — gyakran ez a helyes tervezési döntés —, de tudni kell, hogy
mikor és mennyit fizetünk.
Dispatch alatt itt egy művelet konkrét implementációjának kiválasztását értjük egy hívási ponton. A három forma abban különbözik, hogy ez a kiválasztás mikor történik, és milyen információra van szükség hozzá.
Statikus (fordítás idejű) dispatch. A konkrét
függvényt vagy függvényobjektumot a fordító a típus alapján választja
ki; tipikus eszközei a függvénysablon, a CRTP, az overload-feloldás és
az if constexpr. Feltétele, hogy a hívási ponton a típus
ismert legyen, és a sablon definíciója látható legyen a fordítási
egységben (vagy explicit instantiációval elérhető legyen). Nincs
futásidejű indirekció, és a hívás jellemzően inline-olható. Ára a
kódduzzadás és a fordítási idő: minden új típus új instantiációt
jelent.
Virtuális (futásidejű) dispatch. A konkrét implementációt a dinamikus típus dönti el a vtable-en keresztül; a hívás pointeren vagy referencián keresztül történik. Feltétele, hogy az osztály rendelkezzen virtuális függvénnyel, a hívás pedig ne statikusan ismert típuson menjen át. Az objektum élettartamának érvényesnek kell lennie, törlésnél pedig a bázisnak virtuális destruktorral kell rendelkeznie. A költség egy indirekt hívás és a vptr betöltése; az inlining csak akkor lehetséges, ha a fordító devirtualizálni tud.
Type erasure. Egy egységes, futásidejű interfész
mögé rejtjük a konkrét típust; a std::function, a
std::any és a saját kézzel írt wrapperek tartoznak ide. A
std::function esetén a hívás egy belső, típusra szabott
invoker-függvényen keresztül megy, amelyre a wrapper egy
függvénypointerrel vagy egy kis tárolóban mutat. Feltétel, hogy a tárolt
callable másolható legyen (C++23-tól a
std::move_only_function oldja ezt a korlátot; a használat
előtt ellenőrizni kell a library támogatását). A költség az indirekt
hívás, a lehetséges allokáció és a másolás.
| Forma | Döntés ideje | Tipikus mechanizmus | Mit kell garantálni | Hol a költség |
|---|---|---|---|---|
| Statikus | fordítás | template, CRTP, overload | látható definíció, ismert típus | kódméret, fordítási idő |
| Virtuális | futás | vtable, indirekt hívás | érvényes objektum, virtuális destruktor | indirekció, inlining-korlát |
| Type erasure | futás | invoker-függvénypointer, SBO | másolhatóság, élettartam | indirekció, allokáció, másolás |
A dispatch-választás nem attól jó, hogy az egyik forma „gyorsabb”, hanem hogy a költségkomponensek közül melyik dominál az adott terhelésen. Öt komponenst érdemes külön követni.
Hívási overhead. Egy indirekt hívás önmagában néhány ciklus, de a valódi költség a branch target prediction viselkedésében van. Ha a hívás célpontja kiszámítható (mindig ugyanaz a típus), a prediktor jól teljesít; ha váltakozik, a hibás előrejelzés jóval többe kerülhet, mint maga a hívás. Ezért a homogenitás fontos: egy heterogén kollekció elemein végigmenő virtuális hívás más költségprofil, mint egyetlen típus ismételt hívása.
Inlining lehetősége. Az inlining nem csak a hívás elhagyását jelenti: lehetővé teszi a konstans-propagációt, a közös rész kiemelését és a vektorizálást. A statikus forma ezt alapból megengedi; a virtuális forma csak devirtualizáció után; a type erasure jellemzően blokkolja.
Adatelérés és lokalitás. A vptr betöltése és a vtable bejárása plusz memóriahozzáférést jelent. Kis objektumoknál ez arányaiban nagyobb lehet, nagy, ritkán hívott műveleteknél elhanyagolható.
Allokáció. A type erasure allokálhat a wrapper létrehozásakor, ha a callable nem fér be a kis pufferbe (SBO). A kis puffer mérete implementációfüggő: a libstdc++ és a libc++ eltérő méretekkel dolgozhat, és a szabvány erre nem ad garanciát. A hívási ciklusban történő konstrukció ezért drága lehet, még akkor is, ha maga a hívás olcsó.
Kódméret és fordítási idő. Minden template-instantiation új kódot jelent, ami növeli a bináris méretet és az I-cache nyomást, valamint a fordítási időt. A linker ugyan összevonhatja az azonos instantiationöket (vague linkage, COMDAT), de ez nem garantált minden esetben, és a fordítási időt nem csökkenti.
A döntéshez tehát először becsülni kell a művelet egységköltségét. Ha egy elemre jutó munka nagyságrendekkel nagyobb a hívás költségénél, a dispatch forma megválasztása másodlagos. Ha a művelet triviális és milliószor fut, a hívási overhead és az inlining válik dominánssá. Ez a cost model lényege: nem a forma, hanem a domináns komponens alapján döntünk.
A példa egy vektoron végzett összegzést mutat be, ahol az elemenkénti
műveletet x*x-re választjuk. A művelet szándékosan kicsi,
hogy a dispatch költsége láthatóvá váljon; nagyobb műveletnél a
különbség eltűnne.
// dispatch_demo.cpp
#include <cstddef>
#include <cstdio>
#include <functional>
#include <span>
#include <vector>
// 1) Statikus: sablon + függvényobjektum
struct square {
double operator()(double x) const noexcept { return x * x; }
};
template <class F>
double reduce_static(std::span<const double> v, F f) {
double acc = 0.0;
for (double x : v) acc += f(x);
return acc;
}
// 2) Virtuális: absztrakt interfész
struct transform {
virtual ~transform() = default;
virtual double apply(double) const = 0;
};
struct square_virtual final : transform {
double apply(double x) const override { return x * x; }
};
double reduce_virtual(std::span<const double> v, const transform& t) {
double acc = 0.0;
for (double x : v) acc += t.apply(x);
return acc;
}
// 3) Type erasure: std::function
double reduce_erased(std::span<const double> v,
const std::function<double(double)>& f) {
double acc = 0.0;
for (double x : v) acc += f(x);
return acc;
}
int main() {
std::vector<double> v(1'000'000, 1.0);
std::printf("%f %f %f\n",
reduce_static(v, square{}),
reduce_virtual(v, square_virtual{}),
reduce_erased(v, square{}));
}A fordításhoz rögzítsd a fordító kiadását és a nyelvi módot; a példa
C++20-at igényel a std::span miatt.
g++ -std=c++20 -O2 -Wall -Wextra -Wpedantic -o dispatch_demo dispatch_demo.cppA megfigyeléshez érdemes assemblyt is készíteni, és a ciklusokban keresni az indirekt hívás mintáját:
g++ -std=c++20 -O2 -S -masm=intel -o dispatch_demo.s dispatch_demo.cpp
grep -n "call" dispatch_demo.sA várt minta — nem mért eredmény — a következő. A
reduce_static esetében a square::operator()
beleolvad a ciklusba, és a hívás eltűnik. A reduce_virtual
belsejében a t.apply(x) indirekt hívás marad, kivéve ha a
reduce_virtual maga is beinline-olódik a
main-be, ahol a dinamikus típus statikusan ismert
(square_virtual), és a fordító devirtualizálhatja. A
reduce_erased esetében a std::function
operator()-a egy invoker-függvénypointeren keresztül hív,
amit a fordító jellemzően nem tud feloldani; a kis square
callable valószínűleg az SBO-ba kerül, de ez implementációfüggő, és nem
garantált.
A példa tanulsága nem az, hogy „a template gyorsabb”, hanem hogy a
három forma assemblyje eltérő szerkezetű, és a különbség a konkrét
hívási mintától függ. A reduce_virtual devirtualizációja
például attól függ, hogy a függvény beinline-olódik-e a hívóba, ami
viszont a méretétől és a fordító döntésétől függ.
A devirtualizáció nem varázslat, hanem feltételek teljesülése esetén végrehajtott transzformáció. A leggyakoribb esetek:
final osztály vagy final
metódus. A final kizárja a további felülírást, így
a vtable-bejegyzés statikusan ismertté válik. Ez a legegyszerűbb,
leginkább hordozható jelzés a fordítónak.-fno-semantic-interposition kapcsolóval engedik meg a
belsejük felé irányuló optimalizálást. Ez fordító- és verziófüggő
viselkedés, ezért a laborprofilban rögzíteni kell.Fontos különbség: a devirtualizáció nem garantált, és nem is mindig
előnyös. Ha a hívás helyén a dinamikus típus tényleg változékony, a
spekulatív devirtualizáció egy plusz típusellenőrzést és egy elágazást
épít be, ami rosszabb lehet, mint az eredeti indirekt hívás. Ezért a
mérés nem helyettesíthető azzal a feltételezéssel, hogy „a
final mindig gyorsít”.
A link-time optimization (LTO) lényege, hogy a
fordító a linkelés fázisában is látja a többi fordítási egységet, így a
template-instantiationök, a virtuális hívások és a kis függvények
átvihetők a modulhatáron. GCC-nél és Clangnél is elérhető
(-flto), Clangnél ThinLTO formában is
(-flto=thin). Az LTO költsége a linkelési idő és a
memóriaigény növekedése, valamint az, hogy a build reprodukálhatósága és
a diagnosztika bonyolultabb lehet.
A profile-guided optimization (PGO) a futásidejű
profilt visszavezeti a fordításba: a gyakori elágazások, a gyakori
virtuális célpontok és a gyakori inline-helyek súlyt kapnak. A tipikus
munkafolyamat GCC/Clang alatt -fprofile-generate a mérő
buildhez, majd -fprofile-use az optimalizált buildhez;
Clangnél az instrumentált változat -fprofile-instr-generate
/ -fprofile-instr-use. Ezek a kapcsolók és a profilfájl
formátuma verziófüggő, ezért a laborprofil részeként kell rögzíteni
őket, és a build-reprodukálhatóságot külön ellenőrizni.
Mindkét technika közös jellemzője, hogy a nyereség nagysága erősen terhelésfüggő. Egy olyan programban, ahol a dispatch a teljes futásidő töredékét teszi ki, az LTO és a PGO elsősorban a kódméretre és a fordítási időre lesz hatással, nem a futásidőre. Ezért a döntés előtt a cost modelben meg kell becsülni, mekkora a dispatch részaránya.
Minden template-instantiation egy külön függvényt vagy osztályt hoz létre a fordítási egységben. Ha ugyanazt a sablont több típussal használjuk, a kód mérete nő, és a fordítási idő is nő, mert a fejlécet minden fordítási egység újra feldolgozza és újrainstantiálja. A linker bizonyos esetekben összevonja az azonos instantiationöket (vague linkage, COMDAT), de ez nem garantálja, hogy a bináris méret csökken, és a fordítási időt sem érinti.
Kézi szabályozásra két eszköz áll rendelkezésre. Az explicit
instantiation
(template double reduce_static<square>(std::span<const double>, square);)
egy fordítási egységben állítja elő a kódot. Az
extern template deklaráció a többi
fordítási egységben letiltja az implicit instantiationt, így azok a
linker által biztosított példányt használják. Ez csökkenti a fordítási
időt és a kódduzzadást, de csak akkor működik, ha az explicit
instantiation pontosan egy helyen történik, és a használt típusok köre
ismert.
A fordítási idő mérésére GCC-nél a -ftime-report,
Clangnél a -ftime-trace használható; mindkettő verziófüggő,
és a kimenetük eltérő részletességű. A méréskor ügyelni kell arra, hogy
a gyorsítótár (ccache, inkrementális build) ne torzítsa az eredményt: a
template-instantiation költségét tiszta builddel érdemes mérni.
A noexcept hatása kettős. Egyrészt a fordító a
noexcept függvényeknél elhagyhatja a kivételkezelési
keretrendszert (landing pad, unwinder), ami kisebb kódot és jobb
optimalizálást eredményezhet. Másrészt a standard library viselkedését
befolyásolja: a std::vector újraallokációkor a
move_if_noexcept logikát használja, azaz csak akkor mozgat,
ha a move constructor noexcept, különben másol, hogy
megőrizze az erős kivételbiztonságot. Egy nem noexcept move
constructor tehát nem csak elméleti kockázat: a gyakorlatban másoláshoz
vezethet.
A std::function másolása a tárolt callable másolását
jelenti, ami allokálhat, ha a callable nem fér be az SBO-ba. Ezért a
std::function-t hívási ciklusban érdemes
const&-ként átadni, vagy egyáltalán elkerülni a wrapper
létrehozását a cikluson belül. Mozgatásnál a std::move
önmagában nem ad át erőforrást, csak kategóriakonverziót végez; a
tényleges átadás a move constructorban történik, és a moved-from
objektumnak továbbra is érvényes, destruálható állapotban kell
maradnia.
A C++23-as std::move_only_function csak mozgatható
callable-t tárol, így elkerülhető a másolás, de cserébe a wrapper nem
másolható. Ez a döntés is API-szerződés kérdése: ha a felhasználónak
másolható callback kell, akkor a std::function a helyes
választás, és a másolás költségét tudatosan kell vállalni.
A dispatch-választás helyes bizonyítéka nem egyetlen futásidejű szám, hanem a kód szerkezetének és a mérésnek az együttese. Három szintet érdemes megkülönböztetni.
Forráskód-szintű érvelés. A cost model megmondja, melyik komponens dominálhat. Ez szükséges, de nem elégséges: a fordító döntéseit nem ismerjük előre.
Assembly vagy IR. Az assembly megmutatja, hogy a
hívás megmaradt-e indirektként, történt-e inlining, van-e allokációs
hívás a ciklusban. Az IR (LLVM IR, GCC GIMPLE) közelebb van az
optimalizálási döntésekhez, de olvasása nagyobb rutint igényel. A
llvm-mca statikus elemzést ad egy adott függvényről, de ez
nem helyettesíti a teljes program mérését.
Futásidejű mérés. A perf stat
ciklusszámot, utasításszámot és branch-miss arányt ad; a
perf record megmutatja, hol töltődik az idő. A méréskor
ügyelni kell a konstans-hajtogatásra, a holt kód eliminálására, a
frekvencia-skálázásra és a gyorsítótár-melegítésre. A mikrob benchmark
eredménye önmagában nem általánosítható.
A három szint együtt ad döntési alapot. Az a megállapítás, hogy „a virtuális hívás lassabb a templatenél”, önmagában nem bizonyíték; az a megállapítás viszont igen, hogy „ezen a terhelésen, ezzel a compilerrel, ezzel a flagszettel a virtuális változat ciklusszáma X%-kal magasabb, és az assemblyben a ciklusban indirekt hívás maradt, míg a statikus változatban nem”. Az utóbbi állítás reprodukálható, ellenőrizhető és a laborprofilhoz köthető.
-O0-val. Az optimalizálatlan
build nem reprezentálja a produkciós viselkedést. Javítás: a mérést a
release flagszettel, rögzített compiler-verzióval végezni.virtual ~Base() = default;.std::function érték szerinti átadása hívási
ciklusban. Másolás és esetleges allokáció. Javítás:
const&, vagy a wrapper létrehozása a cikluson
kívül.final
nélkül. Javítás: a tényleges assembly ellenőrzése, és szükség
esetén final vagy LTO/PGO használata.extern template, és a
bináris méret rendszeres ellenőrzése.A dispatch forma megválasztása három szerződés együttes mérlegelése: az API mit garantál (másolhatóság, élettartam, ABI-stabilitás), a fordítási folyamat mit enged (inlining, LTO, PGO), és a terhelés mit tesz dominánssá (hívási overhead, allokáció, kódméret). A statikus forma a legtöbb inlining-lehetőséget adja, de kódduzzadással és fordítási idővel fizet. A virtuális forma stabil ABI-t és futásidejű rugalmasságot ad, de indirekcióval és korlátozott devirtualizációval. A type erasure egységes interfészt ad, de allokációval, másolással és indirekcióval. Egyik sem univerzálisan jobb; a döntés akkor jó, ha a cost model, az assembly és a mérés együtt támasztja alá, és a választás a laborprofilhoz kötve reprodukálható.
Cost model számítás. Egy reduce művelet elemenként 20 ns számítási időt igényel. Becsüld meg, mekkora lehet a dispatch hozzájárulása, ha a hívás kiszámítható, illetve ha váltakozó célpontú. Milyen esetben döntenél a virtuális forma mellett? Indokold a becslést, és jelöld, mely számok illusztratívak.
Kódolási feladat. A fenti
dispatch_demo.cpp-ben alakítsd át a
reduce_static-et úgy, hogy a square mellett
egy cube funktorral is használható legyen, és explicit
instantiationnel csökkentsd a kódduzzadást. Add meg a buildparancsot és
azt, hogy mit keresnél az assemblyben.
Diagnosztikai feladat. Adott egy hívási
ciklusban létrehozott std::function, amely egy nagy méretű,
érték szerint elkapott objektumot tárol. Azonosítsd a lehetséges
allokációt és a másolást, majd javítsd a kódot legalább két módon.
Mindkét javításnál nevezd meg az élettartam-szerződést és az ellenőrzési
feltételt.
1. megoldás. A 20 ns-os egységköltség kb. 60
ciklusnak felel meg 3 GHz-en (illusztratív számítás). Egy kiszámítható
indirekt hívás néhány ciklus, tehát a hozzájárulás néhány százalék;
ilyenkor a virtuális forma költsége elhanyagolható, és a döntést inkább
az ABI-stabilitás és a karbantarthatóság vezérli. Váltakozó célpontú
hívásnál a hibás branch-előrejelzés tipikusan tíz és húsz ciklus közötti
nagyságrendű lehet, ami a 60 ciklus jelentős részét veheti el; ilyenkor
a statikus forma vagy a homogén csoportosítás (pl. típus szerinti
szétválogatás) indokolt lehet. A számok illusztratívak, nem mérési
eredmények; a döntéshez a tényleges assembly és perf stat
szükséges. Ellenőrzési feltétel: a ciklusban megjelenő indirekt hívás és
a branch-miss arány együtt értelmezhető.
2. megoldás. Vedd fel a cube funktort a
square mellé, és a fejlécben deklaráld a két explicit
instantiationt:
// reduce.hpp
#pragma once
#include <span>
struct square { double operator()(double x) const noexcept { return x * x; } };
struct cube { double operator()(double x) const noexcept { return x * x * x; } };
template <class F>
double reduce_static(std::span<const double> v, F f);
extern template double reduce_static<square>(std::span<const double>, square);
extern template double reduce_static<cube>(std::span<const double>, cube);// reduce.cpp
#include "reduce.hpp"
template <class F>
double reduce_static(std::span<const double> v, F f) {
double acc = 0.0;
for (double x : v) acc += f(x);
return acc;
}
template double reduce_static<square>(std::span<const double>, square);
template double reduce_static<cube>(std::span<const double>, cube);A többi fordítási egység csak a deklarációt látja, így nem instantiál
újra. Az assemblyben azt keresed, hogy a
reduce_static<square> és
reduce_static<cube> külön szimbólumként jelenik meg,
és a hívási ciklusban nincs indirekt hívás. Ellenőrzési feltétel: a
nm kimenetében a két explicit instantiation szimbóluma
szerepel, és a forrásból készült objektumfájlokban nincs duplikált
definíció.
3. megoldás. A probléma kettős: a
std::function létrehozása a ciklusban másolja a nagy
callable-t, és ha az nem fér be az SBO-ba, allokál. Első javítás: a
std::function-t a cikluson kívül hozzuk létre, és
const&-ként adjuk át; ezzel a másolás és az allokáció a
ciklusból kikerül. Élettartam-szerződés: a wrappernek a ciklus teljes
ideje alatt érvényesnek kell lennie, és a benne tárolt callable nem
hivatkozhat a cikluson belül megszűnő objektumra. Második javítás: ha a
callable nagy és a típus fordítás időben ismert, térjünk át
template-alapú statikus dispatchre, ami megszünteti a wrappert és az
allokációt. Ha a callable csak mozgatható, C++23-tól
std::move_only_function használható; ezt a library
támogatásának ellenőrzése előzi meg. Ellenőrzési feltétel: az
assemblyben a ciklusban nincs allokációs hívás, és a wrapper
konstrukciója a cikluson kívül történik; a viselkedés a benchmarkban a
várt értéket adja, de ezt a laborban külön kell megmérni.