Efficiënte C++-code schrijven
Introductie
C++ is een ongebruikelijke taal: complex, moeilijk te beheersen en in sommige opzichten controversieel. Toch is het in veel toepassingen, vooral waar prestaties cruciaal zijn (zoals bij game-programmering), vaak de beste of zelfs de enige keuze. Dit komt door een unieke eigenschap die de taal tot een soort compromis maakt. Het is high-level: het ondersteunt objectgeoriënteerd programmeren en het gemakkelijke gebruik of de creatie van aangepaste typen en datastructuren, zoals vectors, strings en andere STL-containers. Tegelijkertijd is het low-level genoeg om ons toegang te geven tot de hardware zelf, of liever gezegd tot het besturingssysteem: er staat geen virtuele machine of framework in de weg.
We moeten de toewijzing en vrijgave van geheugen zelf beheren, maar dat betekent ook dat er geen garbage collector is die dit op onvoorspelbare momenten op zijn eigen manier doet. Bovendien bestaat er een enorm aantal bibliotheken voor C++ (en voor C, waarmee het gedeeltelijk achterwaarts compatibel is), en zijn er compilers beschikbaar voor veel platforms.
In zekere zin zou men zelfs kunnen zeggen dat native code weer in trek is. Hoewel software steeds complexer wordt en hardware steeds sneller, hebben we nog steeds efficiënte code nodig in onze programma's. Sommigen zeggen dat een snellere processor of meer RAM goedkoper is dan de tijd van een goede programmeur. Maar wat als een programma, eenmaal geschreven, jarenlang moet draaien of op miljoenen machines wordt geïnstalleerd? Prestaties zijn belangrijk in zowel computing-clusters en datacenters, waar de kosten voor stroom en koeling enorm kunnen zijn, als in de kleinste apparaten, zoals smartphones en tablets, waar we de langst mogelijke batterijduur willen.
Er zijn ook toepassingen waarin code zonder compromissen op een specifieke snelheid moet draaien. Denk hierbij aan programma's die data in real-time verwerken, zoals games (waar een daling in FPS, frames per second, de indruk van vloeiende animatie vernietigt en unpleasant stuttering veroorzaakt) of real-time verwerking van mediastromen bij een specifieke bitrate. Ook kunnen we niet altijd willekeurig hoge hardware-eisen stellen. Gameconsoles hebben bijvoorbeeld een vaste processorsnelheid en een vaste hoeveelheid RAM. Zelfs van een pc kunnen we niet de nieuwste componenten eisen als we een simpel casual spel schrijven in plaats van een nieuwe versie van Crysis.
Data-Oriented Design
Objectgeoriënteerd programmeren (OOP) lijkt een remedie te zijn voor de moeilijkheden waar programmeurs en teams voor staan bij het schrijven van grote systemen op een gestructureerde, "ouderwetse" manier. In OOP bestaat code uit klassen, die niet alleen data (velden) en de procedures die daarop opereren (methoden) groeperen, maar bovenal dienen als abstracties van concepten uit de echte wereld of uit het probleemdomein. Klassen zouden, althans in theorie, zo onafhankelijk en herbruikbaar mogelijk moeten zijn.
Objectgeoriënteerd programmeren kan echter op twee manieren worden begrepen: conceptueel, door na te denken over de filosofie achter de elementen, of technisch, door het te behandelen als een mechanisme van de programmeertaal voor het eigen gemak. Het is ook waardevol om te begrijpen hoe alles "onder de motorkap" werkt en welke eigenschappen en beperkingen daaruit voortvloeien. Alleen de eerste benadering volgen kan ons te ver weg brengen van het niveau waarop een programmeur moet blijven wanneer hij streeft naar het schrijven van efficiënte code.
Wat is dan de oplossing? Voorstanders van een benadering genaamd Data-Oriented Design (DOD) suggereren een ietwat andere manier van denken. Deze term is de laatste jaren populair geworden, vooral onder game-programmeurs. Het houdt in dat men zich tijdens het ontwerp en het coderen richt op de data die worden opgeslagen: de indeling in het geheugen en het ontwerp van geschikte datastructuren, en pas daarna op de algoritmen die daarop opereren. Dit lijkt misschien op een terugkeer naar het oude idee van gestructureerd programmeren, maar het sluit het gebruik van klassen en alle voordelen van OOP niet uit. Het is eerder een manier van denken die dichter bij de hardware blijft en de beschikbare mechanismen van de taal gebruikt om code te creëren die niet alleen "elegant" is, maar ook simpel en efficiënt.
Kijk naar Figuur 1. Het laat symbolisch zien hoe data in het geheugen is geplaatst. Links staan structuren die ver uit elkaar liggen en via pointers met elkaar verbonden zijn. Dit gebeurt wanneer we veel kleine objecten van verschillende klassen gebruiken die naar elkaar verwijzen. Code die op deze manier is geschreven, kan om twee redenen inefficiënt zijn:
- Cache misses: Er treden frequente cache-missers op bij het doorlopen van dergelijke data omdat we via pointers "springen".
- Parallelisatie: Het is moeilijk om code te paralleliseren die op dergelijke objecten opereert. Als we alleen de publieke methoden van de klassen hebben (vaak virtueel) en we gaan ervan uit dat we niet weten wat er binnenin gebeurt (of binnen al hun afgeleide klassen), dan kunnen we ze per definitie niet veilig op meerdere threads draaien. We weten niet naar welke andere objecten ze tijdens die operaties verwijzen. Het gebruik van threads vereist bovendien vaak het beschermen van gedeelde data met mutexes (kritieke secties), wat de code effectief serialiseert en voorkomt dat deze volledig parallel draait.
De rechterkant van de figuur toont daarentegen het idee van een regelmatige datastructuur (zoals een array) die we verwerken door opeenvolgende operaties uit te voeren: sorteren, specifieke velden bijwerken, elementen verwijderen of elk element omzetten naar een andere vorm. Als de data "transparant" is en we precies weten welke operatie we willen uitvoeren, en als het toepassen ervan op elk element van de collectie onafhankelijk is van de andere elementen en de rest van het programma, dan kunnen we het algoritme eenvoudig paralleliseren, bijvoorbeeld door het bereik van elementen over verschillende threads te verdelen.
Het blindelings volgen van objectgeoriënteerd programmeren brengt ook andere valkuilen met zich mee. Sommige mensen wikkelen instinctief elke functionaliteit die ze gebruiken, zoals een bibliotheek, in een eigen wrapper die de interface moet vereenvoudigen of (subjectief) eleganter moet maken. Zo'n extra laag, vooral wanneer deze eigen logica introduceert of virtuele methoden gebruikt, voegt runtime-overhead toe. Ik stel voor om er een gewoonte van te maken om jezelf elke keer af te vragen of we in dit specifieke geval bepaalde functies en klassen simpelweg direct kunnen gebruiken, zonder een extra abstractielaag toe te voegen.
Er is een soortgelijke tendens om alles zo algemeen en universeel mogelijk te maken. Het definiëren van een interface die volledig uit virtuele methoden bestaat, met de belofte aan onszelf dat we de implementatie later kunnen vervangen zonder de interface te wijzigen, is verleidelijk. Maar hebben we dat hier en nu echt nodig? Ook design patterns worden soms overmatig gebruikt als kant-en-klare oplossingen die dieper nadenken over de code vervangen. In feite zijn eenvoudige oplossingen vaak het best. Als we zo direct mogelijk proberen uit te drukken welke data een programma moet opslaan en welke operaties het op die data moet uitvoeren, zal de code tegelijkertijd simpel, elegant, leesbaar en efficiënt zijn. Dit spreekt de populaire opvatting tegen dat optimalisatie betekent dat code gecompliceerd en onleesbaar wordt.
Geheugen en Cache
Het lijkt misschien alsof elke processorinstructie in één cyclus data leest van specifieke adressen in het RAM, een operatie uitvoert (zoals een optelling) en het resultaat terugschrijft naar het geheugen. In de praktijk is het niet zo simpel. De complexe CISC-instructies die de x86-code vormen, worden in de processor vertaald naar microcode en uitgevoerd in meerdere stappen die meer of minder tijd in beslag kunnen nemen. De operatie zelf kan simpel zijn, maar het lezen of schrijven van data in het geheugen kost extra tijd.
In het verleden, in de jaren 80, grepen processors inderdaad direct toegang tot het RAM en konden ze dergelijke operaties in enkele cycli uitvoeren. Tegenwoordig stijgt de snelheid waarmee een processor berekeningen kan uitvoeren echter veel sneller dan de prestaties van het RAM. Dit gat blijft groeien, en de tijd die nodig is om een stukje data — zelfs een enkele byte — uit het hoofdgeheugen van een moderne computer te lezen, is inmiddels gelijk aan honderden processorcycli! Onthoud dat bandbreedte (bandwidth), de snelheid van dataoverdracht (gemeten in bits per seconde), verschilt van latentie (latency), de tijd die nodig is voordat de opgevraagde data zijn bestemming bereikt (gemeten in fracties van een seconde).
Computerontwerpers zoeken natuurlijk naar oplossingen voor dit probleem. Daarom is de cache gecreëerd: processorgeheugen dat onlangs gebruikte data vasthoudt en sneller toegankelijk is dan het hoofd-RAM. We kunnen spreken van een volledige geheugenhiërarchie waarvan de opeenvolgende niveaus een toenemende capaciteit hebben, maar een afnemende snelheid. Beschouw bijvoorbeeld een processor die draait op 3 GHz. Grofweg kan deze 3 miljard operaties per seconde uitvoeren, dus één simpele operatie (zoals een optelling) duurt ongeveer 0,33 ns. Als toegang tot een waarde in de L1-cache 1 ns duurt, is die vertraging gelijk aan het uitvoeren van 3 instructies (3 cycli). Tabel 1 geeft geschatte cijfers voor de geheugenhiërarchie in een dergelijk voorbeeld van een computersysteem.
Tabel 1: Geheugenhiërarchie
| Geheugentype | Toegangstijd | Capaciteit |
|---|---|---|
| Processorregisters | 0,33 ns (1 cyclus) | - |
| L1 cache | 1 ns (3 cycli) | 32 KB |
| L2 cache | 4,7 ns (14 cycli) | 6 MB |
| RAM | 83 ns (250 cycli) | 8 GB |
| Harde schijf | 15 ms (45 miljoen cycli) | 1 TB |
| Internet | 80 ms (240 miljoen cycli) | - |
In een typisch computersysteem beheren we de cache niet direct. Deze wordt automatisch beheerd om de toegang tot onlangs gebruikte data te versnellen. Hoe kunnen we dan profiteren van deze snelheid? We hebben een basisbegrip nodig van hoe het werkt. Wanneer er toegang wordt gezocht tot data in het RAM die voorheen niet was gebruikt, wordt deze gelezen en gebruikt voor berekening, maar komt deze ook in de cache terecht. De data wordt niet byte voor byte overgedragen: er wordt een hele cache line overgedragen, bijvoorbeeld 64 bytes breed. De hele lijn wordt uit het RAM gelezen en in de cache bewaard, zodat toegang tot een van die bytes snel is totdat deze wordt vervangen door andere data. Dit wordt een cache hit genoemd. Het tegenovergestelde is een cache miss, wat toegang tot het hoofdgeheugen vereist en veel langer duurt.
Wat betekent dit voor ons? Om te profiteren van de cache moeten we data in het geheugen zo ordenen dat waarden die vaak samen worden gebruikt, naast elkaar liggen. Dan is de kans groot dat die waarden al in de cache zitten wanneer de code ze nodig heeft. Goede praktijken zijn daarom:
- Gebruik datastructuren die waarden in een aaneengesloten geheugengebied plaatsen, zoals een eenvoudige array of
std::vector. - Begrijp hoe de taal die je gebruikt data in het geheugen ordent (groottes van datatypen, alignment, padding) en houd hier rekening mee bij het schrijven van code.
- Pak data in de kleinst mogelijke typen (zoals 8-bit of 16-bit getallen en bit-velden). Zelfs de extra instructies die nodig zijn om dergelijke data uit te pakken, kosten minder tijd dan de geheugentoegang na een cache-miss.
Een slechte praktijk is daarentegen het gebruik van veel kleine objecten die afzonderlijk zijn toegewezen en verspreid liggen door het geheugen. Evenzo kunnen datastructuren die worden doorlopen door via pointers te "springen" — zoals gekoppelde lijsten (linked lists), bomen en grafen — last hebben van slechte prestaties door frequente cache-missers.
Overweeg bijvoorbeeld een collectie objecten die we in een programma moeten opbouwen, in het geheugen moeten houden, in gesorteerde volgorde moeten doorlopen en snel moeten kunnen doorzoeken. Om de elementen gesorteerd te houden, zouden we een binaire boom of een soortgelijke structuur kunnen kiezen, zoals de STL-containers std::set of std::map. Maar deze structuren wijzen elk element dynamisch toe op de heap en koppelen ze met pointers, waardoor constructie en traversal traag worden. De asymptotische complexiteit van het zoeken is goed, maar de constante factor kan de prestaties slecht maken om de eerder beschreven redenen.
Soms kunnen we een andere oplossing vinden die theoretisch niet slechter is, maar in de praktijk sneller. Als we de structuur één keer opbouwen en daarna geen elementen meer hoeven in te voegen of te verwijderen, gebruik dan een gewone array. Nadat alle elementen zijn toegevoegd, sorteren we deze één keer. We kunnen elementen in de gesorteerde array dan in logaritmische tijd vinden met een binaire zoekopdracht. Het doorlopen zal veel sneller zijn dankzij het goede gebruik van de cache, omdat opeenvolgende elementen naast elkaar in het geheugen liggen.
Prestaties van verschillende operaties
De eerder beschreven geheugenhiërarchie kan worden uitgebreid naar andere soorten operaties. Stel je een "prestatiepiramide" voor (Figuur 2). Dit is geen strikte classificatie, maar een manier om geselecteerde operaties te illustreren. Een operatie op een lager niveau is minstens een orde van grootte trager dan een operatie op het niveau erboven. Om efficiënte code te schrijven, moeten we ons hiervan bewust zijn en, waar mogelijk, voorkomen dat we operaties van de lagere niveaus te vaak uitvoeren. De niveaus vertegenwoordigen:
- Arithmetic instructions: Rekeninstructies zoals optellen, aftrekken en vermenigvuldigen, samen met bitwise-operaties, zijn de snelste berekeningen die een processor kan uitvoeren.
- Transcendental instructions: Complexere wiskundige operaties zoals sinus, cosinus, logaritme, worteltrekken en exponentiële functies, evenals deling, zijn veel trager. Het is daarom raadzaam deze te vermijden. In plaats van herhaaldelijk te delen door een getal, kunnen we bijvoorbeeld één keer de reciproke waarde berekenen en vervolgens met die waarde vermenigvuldigen.
- RAM-toegang: Zoals besproken kan toegang tot het RAM honderden processorcycli kosten (bij een cache-miss). Dit gebeurt onder andere bij het doorlopen van datastructuren die zijn opgebouwd uit kleine, verspreide objecten, bij het aanroepen van virtuele methoden, of zelfs bij het springen naar een ver weg gelegen, lang niet gebruikt deel van de code.
- Dynamische geheugenallocatie: Geheugentoewijzing via de systeem-allocator is een kostbare operatie. Het is raadzaam het aantal allocaties te verminderen, bijvoorbeeld door bestaande buffers te hergebruiken, veel items in één aaneengesloten geheugengebied (een array) op te slaan, of zelfs een aangepaste allocator te schrijven die stukjes uit een vooraf gealloceerde pool uitdeelt.
- Systeemresources: Het creëren en gebruiken van systeemresources zoals threads, bestanden en diverse handles (inclusief WinAPI-handles) kan over het algemeen als kostbaar worden beschouwd. Systeemoproepen (vooral die naar de kernel moeten) duren langer dan oproepen naar onze eigen functies. Operaties op resources kunnen bovendien een onbekend aantal geheugentoegangen en allocaties inhouden. Het is daarom raadzaam om input en output uit te voeren door zoveel mogelijk data in één oproep te lezen of schrijven, in plaats van functies zoals
freadoffwritevoor individuele bytes aan te roepen. - I/O (Input/Output): Alle input en output — het lezen en schrijven van data naar of vanuit de wereld buiten het RAM, zoals een harde schijf, SSD, CD/DVD of netwerk — is onvergelijkbaar trager dan de eerder beschreven operaties. Het kan een onvoorspelbare hoeveelheid tijd in beslag nemen. Het is daarom het best om dergelijke operaties op de achtergrond uit te voeren, asynchroon of op een aparte worker-thread.
AOS en SOA
Particle systems zijn een populair effect in 3D-graphics. Kort gezegd slaan ze een collectie deeltjes en hun eigenschappen op (zoals huidige positie, snelheid en kleur), om deze vervolgens in elk animatieframe bij te werken en te renderen. Zoals u kunt vermoeden, moeten we particle-structuren niet dynamisch als afzonderlijke objecten alloceren, maar opslaan in een array. Laten we kijken hoe we dit verder kunnen optimaliseren in Listing 1.
De eerste benadering, genaamd AOS (Array of Structures), lijkt intuïtief. We definiëren een structuur die één deeltje vertegenwoordigt en alle parameters beschrijft. De klasse die verantwoordelijk is voor het hele particle-effect slaat dan het aantal deeltjes en een pointer naar een array van deze structuren op.
Listing 1. AOS versus SOA in een particle-systeem
// AOS - Array of Structures
struct Particle
{
vec3 Position;
vec3 Velocity;
vec4 Color;
};
class ParticleSystem
{
Particle* Particles;
size_t Count;
};
// SOA - Structure of Arrays
class ParticleSystem
{
vec3* Positions;
vec3* Velocities;
vec4* Colors;
size_t Count;
};
Een andere benadering is mogelijk: SOA (Structure of Arrays). Hier slaat de particle-system klasse direct arrays op die individuele parameters van opeenvolgende deeltjes bevatten. Er is een array van posities, een array van snelheden, een array van kleuren, enzovoort, allemaal met hetzelfde aantal elementen.
Afhankelijk van het specifieke geval kan deze benadering efficiënter zijn omdat het data groepeert op gebruikspatroon en "hot data" (wat we nu nodig hebben en wat in de cache komt) scheidt van "cold data". Dit gebeurt bijvoorbeeld wanneer we alle deeltjes willen doorlopen en hun posities willen bijwerken op basis van hun snelheden. In de tweede benadering liggen die waarden dichter bij elkaar in het geheugen en worden ze niet afgewisseld met kleuren die we op dat moment niet nodig hebben.
C++ Taalfuncties
Het schrijven van efficiënte code is niet alleen een kwestie van het ontwerpen van datastructuren en algoritmen op een hoog niveau. Zelfs bij het schrijven van individuele functies kunnen we veel doen om valkuilen te vermijden die de prestaties onnodig schaden. C++ biedt veel gemakken, maar stelt ons ook in staat om te kiezen of we deze gebruiken of code schrijven zoals we dat in C zouden doen.
In veel gevallen is het daarom waardevol om sommige functies volledig uit te schakelen, zoals exception handling of RTTI (Run-Time Type Information). Hoewel het onderwerp controversieel is, wordt er soms gezegd dat deze mechanismen een negatief effect hebben op de prestaties. Dat betekent niet dat ze waardeloos zijn, maar we kunnen ons afvragen of ons programma ze echt nodig heeft en ze anders uitschakelen in de compiler-opties.
De bewering dat we niets beter kunnen schrijven dan de makers van de compiler, de standaardbibliotheek of een andere bewezen bibliotheek, is onjuist. Hun code is zeker geschreven door uitstekende programmeurs, maar bij het schrijven van ons eigen programma kennen we de specifieke casus: de vereisten en beperkingen. RTTI, foutafhandeling en vele andere taken kunnen daarom soms beter zelf worden geïmplementeerd op een manier die past bij de behoeften van het project. Geheugenallocatie is hiervan het beste voorbeeld. De systeem-allocator is goed op de manier waarop elke algemene allocator goed kan zijn. Maar als we in een deel van een programma objecten van een bekende, vaste grootte moeten alloceren en we een bovengrens weten voor hun aantal, dan is een aangepaste allocator gebaseerd op een vooraf gealloceerde pool van vrije cellen (free list) ongetwijfeld efficiënter.
Hetzelfde geldt voor de STL. Het is een zeer goede bibliotheek die prestaties vaak verkiest boven veiligheid (bijvoorbeeld door geen grenzen te controleren bij toegang tot een vector-element met de [] operator in Release-configuratie). Toch vereist het gebruik ervan kennis van de interne implementatie. De containers list, set, multiset, map en multimap alloceren elk element dynamisch als een apart object in het geheugen, wat de prestaties schaadt. Daarentegen slaat std::vector zijn elementen op in een aaneengesloten geheugengebied, waardoor we het voor het gemak kunnen gebruiken in plaats van dynamisch gealloceerde arrays zonder prestatieverlies. Sommige zaken kunnen echter nog beter worden geschreven, zoals de programmeurs bij Electronic Arts deden toen zij EASTL creëerden, een STL-vervanger geoptimaliseerd voor game-programmering.
Compileroptimalisaties
Het is goed om enigszins sceptisch te blijven over de optimalisaties die door de compiler worden uitgevoerd. Neem als eerste voorbeeld het dereferencen van een overgedragen pointer. We weten dat het C++ trefwoord volatile wordt gebruikt om te voorkomen dat een waarde wordt geoptimaliseerd op een manier die de compiler toestaat de waarde in een processorregister te houden in plaats van deze elke keer van de oorspronkelijke geheugenlocatie op te halen. We gebruiken dit wanneer een waarde onverwacht kan veranderen, bijvoorbeeld door hardware of een andere thread.
Maar dat is slechts een deel van het verhaal. Zelfs zonder dat trefwoord kan de compiler soms niet zeker weten of een waarde onverwacht zal veranderen, waardoor hij deze niet in een register kan houden. Dit kan gebeuren in de functie in Listing 2. Deze eenvoudige code telt de vector die als laatste parameter wordt meegegeven op bij elke vector in een array. De constante vector s zou in registers of op de stack bewaard kunnen worden. Maar zonder de context te kennen (bijvoorbeeld wanneer de functie niet inline is), kan de compiler er niet zeker van zijn dat pointer s niet naar dezelfde geheugenlocatie wijst als een van de elementen in array tab, waardoor een loop-iteratie de waarde zou veranderen. Hij moet daarom elke keer de waarde ophalen waar s naar wijst. Dit wordt pointer aliasing genoemd.
Listing 2. Pointer aliasing
void AddVectorToArray(vec3* tab, size_t n, const vec3* s)
{
for(size_t i = 0; i < n; ++i)
tab[i] += *s;
}
Het expliciet kopiëren van vector s naar een lokale variabele, het doorgeven ervan by value, of het gebruik van het niet-standaard trefwoord __restrict zou hier helpen. Dat laatste vertelt de compiler dat pointer aliasing niet voorkomt in de huidige scope.
Een ander voorbeeld is het declareren van variabelen binnen een loop. De compiler handelt variabelen van simpele typen zoals int zeer goed af, maar kan niet voor ons redeneren over het gebruik van complexere constructies. Laten we experimenteren met een variabele van het type std::string. Listing 3 is de eerste versie van de code. Twee miljoen keer bouwt de loop een string op uit een prefix, een nummer en een suffix, en kopieert deze vervolgens naar een globale variabele. Op mijn computer kost deze code gemiddeld 0,36 seconden.
Listing 3. Een string declareren binnen de loop
for(int i = 0; i < 2000000; ++i)
{
std::string s;
s += "ITER_";
char sz[16];
itoa(i, sz, 10);
s += sz;
s += ".txt";
memcpy(g_Buf, s.c_str(), s.length() + 1);
}
Nu voeren we een eenvoudige optimalisatie uit. In de nieuwe versie in Listing 4 wordt de string vóór de loop gedeclareerd en aan het begin van elke iteratie simpelweg geleegd door clear aan te roepen. Op mijn computer kost deze code gemiddeld 0,27 seconden.
Listing 4. Een string declareren buiten de loop
std::string s;
for(int i = 0; i < 2000000; ++i)
{
s.clear();
s += "ITER_";
char sz[16];
itoa(i, sz, 10);
s += sz;
s += ".txt";
memcpy(g_Buf, s.c_str(), s.length() + 1);
}
Door een eenvoudige wijziging — het aanpassen van twee regels code zonder de logica te veranderen — hebben we 25% van de executietijd bespaard. Hoe is dat mogelijk? Het antwoord ligt in het interne ontwerp van de STL string-klasse. In de Visual C++ 2012-implementatie kan een dergelijk object, net als een vector, groeien door zijn interne opslag opnieuw toe te wijzen wanneer dat nodig is, maar hij krimpt nooit. Het verwijderen van elementen of het volledig leegmaken ervan zet simpelweg de huidige lengte op nul, terwijl het gealloceerde blok geheugen hetzelfde blijft. Het hergebruiken van het object vereist dus geen nieuwe allocatie als de nieuwe inhoud niet langer is dan voorheen. In de vorige code werd daarentegen in elke loop-iteratie een nieuw object gemaakt, moest het nodige geheugen worden gealloceerd, en werd het vernietigd bij de sluitende accolade.
Ondanks dit alles voert de compiler veel slimme optimalisaties uit. We kunnen deze controleren met switches. Iedereen kent waarschijnlijk de switch voor het algemene optimalisatieniveau, /O2. Deze is standaard ingeschakeld in de Release-configuratie van Visual Studio. Het is ook de moeite waard om enkele andere in te schakelen in de projectinstellingen (na het lezen van de documentatie):
- Optimization > Enable Intrinsic Functions = Yes (
/Oi) - Code Generation > Enable Enhanced Instruction Set = Streaming SIMD Extensions 2 (
/arch:SSE2) - Code Generation > Floating Point Model = Fast (
/fp:fast)
Voortijdige optimalisatie?
Veel mensen stellen zich voor dat de mythische "optimalisatie" betekent dat geselecteerde routines worden herschreven in een moeilijke, onleesbare assembly-taal. Tegenwoordig is dit zelden nodig. Compilers zijn goed in het optimaliseren van code op het niveau van individuele instructies. Het is nog steeds waardevol om genoeg assembly te kennen om af en toe de door de compiler gegenereerde code te inspecteren en te begrijpen.
Degenen die niet met de prestaties van hun code willen of durven omgaan, verschuilen zich vaak achter een citaat van Donald Knuth, die naar verluidt zei dat "voortijdige optimalisatie de wortel van alle kwaad is". Zijn volledige uitspraak was echter: "We should forget about small efficiencies, say about 97% of the time. Premature optimization is the root of all evil" (Knuth, Donald (december 1974). “Structured Programming with go to Statements.” ACM Journal Computing Surveys 6 (4): 268). Dit verwees simpelweg naar iets wat hij op de vorige pagina had gedaan: het versnellen van loop-code met 12% via een goto-statement. Het punt is niet dat we optimalisatie volledig moeten negeren. We moeten altijd nadenken over prestaties bij het schrijven van code, van het ontwerp tot goede gewoonten bij het gebruik van taalconstructies.
Sommigen zeggen dat optimalisatie iets is wat we doen wanneer het programma al geschreven is: we draaien een profiler, vinden een functie die 30% van de totale executietijd in beslag neemt, en proberen deze te optimaliseren. Soms is er echter geen enkele functie, of kleine groep functies, die kritiek is voor de prestaties. Verschillende delen van het programma nemen elk een klein percentage van de tijd in beslag, terwijl het programma als geheel traag is omdat het over het algemeen slecht is geschreven. We zijn niet verder geholpen als we ontdekken dat het optimaliseren van een tijdrovende functie zou vereisen dat de datastructuren opnieuw worden ontworpen en een groot deel van het programma opnieuw wordt geschreven. Daarom is het de moeite waard om aandacht te besteden aan de prestaties van onze code in ons dagelijkse programmeerwerk.
Bronnen / Verder lezen
- Daniel Collin, “Introduction to Data-Oriented Design”
- Tony Albrecht, “Pitfalls of Object Oriented Programming”
- Gustavo Duarte, “What Your Computer Does While You Wait”
- Mick West, “MatureOptimization”, GameDeveloper Magazine, 2006
Groetjes,