37.5 Template, virtual és std::function költsége

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.

37.5.1 A dispatch három formája és szerződése

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

37.5.2 Költségmodell: mi drága és mikor

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.

37.5.3 Kidolgozott példa: ugyanaz a redukció három formában

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.cpp

A 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.s

A 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.

37.5.4 Inlining és devirtualizáció: feltételek

A devirtualizáció nem varázslat, hanem feltételek teljesülése esetén végrehajtott transzformáció. A leggyakoribb esetek:

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”.

37.5.5 LTO, PGO és a modulhatáron átnyúló optimalizálás

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.

37.5.6 Instantiation, kódduzzadás és fordítási idő

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.

37.5.7 noexcept, move és allokáció

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.

37.5.8 Assembly, IR és mérés: bizonyíték, nem recept

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ő.

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

37.5.10 Összefoglaló

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ó.

37.5.11 Feladatok

  1. 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.

  2. 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.

  3. 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.

37.5.12 Megoldások

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.