Door Sijmen J. Mulder
Bij het denken over performance moet onderscheid worden gemaakt tussen efficiëntie en snelheid. Efficiëntie is het minimaliseren van het werk dat gedaan moet worden.
Een voorbeeld van verbeterde efficiëntie in dit project was het beperken van het aantal doorgerekende tijdstappen in berekeningen. Het gebruiken van arrays in plaats van dictionaries in resultaatobjecten was een snelheidsverbetering. Technisch gezien was daarbij ook sprake van vermeden werk, namelijk dictionarylookups, maar dat laat vooral zien dat dit onderscheid verschuift op basis van het abstractieniveau.
Bij het verbeteren van performance kijken we naar beide aspecten, elk met de tools die daarvoor geschikt zijn.
De primaire meetmethode hier gebruikt is profiling. Hierbij wordt het programma aangepast of door een extern programma geïnspecteerd om gedetailleerde metingen te doen naar hoe vaak en hoe lang delen van de code (modules, functies) uitgevoerd worden.
De belangrijkste profilingmethoden zijn:
instrumentatie, waarbij de compiler of runtime meetcode injecteert;
sampling, waarbij de profiler, een apart programma, geregeld momentopnames maakt van de stack van het programma en registreert welke functie of regel code wordt uitgevoerd; en
performancecounters, waarbij waardes uit bijvoorbeeld het besturingssystem of processor worden uitgelezen.
Profiling beperkt zich niet tot processorgebruik. Het kan ook worden gebruik voor bijvoorbeeld geheugenallocaties of I/O.
De resultaten van profiling kunnen primair op twee verschillende manieren gegeven worden: top-down en bottom-up. Top-down uitvoer ziet er bijvoorbeeld zo uit:
- main() (100%) - load() (34%) - ... - run() (55%) - foo() (10%) - bar() (40%) - baz() (15%) - report() (11%) - ..
Deze profile laat een duidelijke hot path zien naar de
bar()
functie. Wellicht kan deze efficienter worden
gemaakt.
Als de uitvoer bottom-up wordt weergeven, zou dat er zo uit kunnen zien:
- alloc() (14%) - pow() (8%) - print() (2%) - ...
Deze uitvoer wijst op iets heel anders: er wordt veel tijd besteed aan allocaties en machtsverheffingen. Het kan lonen om uit te zoeken waarom dat is en of dat vermeden kan worden, of dat de functies zelf efficienter worden gemaakt.
Wat dit voorbeeld ook laat zien is dat de twee benaderingen verschillende pijnpunten identificeren. Top-down profiles geven vooral aanknopingspunten voor efficiëntieverbetering op hogere niveaus van abstractie, terwijl bottom-up profiles ‘papercuts’ (verborgen kosten) op lager niveau laat zien die uit de top-down profiles niet zichtbaar zijn.
Andere verborgen kosten worden een niveau lager gemaakt: op het niveau van processorinstructies, geheugentoegang, contextswitches enz. Deze zijn niet zichtbaar op het niveau van functies en hoger waarop profiling meestal gebeurt. Toch kan de impact van deze zaken op kritieke codepaden groot zijn.
Een aantal belangrijke lowlevel performance-aspecten zijn:
Cache. De processor heeft een kleine hoeveelheid cache die meerdere ordes van grootte sneller is dan het RAM. Code en data wordt in deze cache bewaard, waardoor het loont om compact code en datastructuren met hoge lokaliteit te hebben. Hoge lokaliteit betekent dat alle gebruikte data dicht bij elkaar staat.
Cachelijnen. Bij het ophalen van data uit het geheugen wordt een hele cachelijn binnengehaald, normaal 64 bytes. Bij hoge lokaliteit betekent dit dat er meer relevante data wordt opgehaald en er minder cachemissers plaatsvinden.
Branching. De processor heeft een pijplijn, of lopende band, van instructies die worden uitgevoerd. Bij een branch (zoals if, while) kan het zijn dat de hele pijplijn moet worden geleegd. Het is daarom beter om in kritieke codesecties branching te vermijden.
Virtuele methodes. Voor het uitvoeren van virtuele methodecalls, waaronder ook interfaces vallen, moet er een lookup in een functietabel (vtable) worden gedaan om de daadwerkelijke implementatie te vinden.
Contextswitches. Bij elke wissel naar een andere thread of proces moeten alle caches en registers worden gewist. Dit is kostbaar.
Bovenstaande geeft al wat aanknopingspunten voor de keuze van code- en datastructuren. Meten is weten, maar een paar nuttige vuistregels kunnen zijn:
Beperk indirectie, zoals gebruik van interfaces en virtuele methodes.
Gebruik datastructuren met hoge lokaliteit. Het kan helpen om operatiespecifieke datastructuren te gebruiken met alleen de relevante velden.
Werk op arrays, niet op enkele objecten. Dit beperkt de overhead van (virtuele) methodecalls en zorgt voor hogere lokaliteit.
In het algemeen is dat data oriented programming-benadering, die aansluit bij deze vuistregels, het meest geschikt voor kritieke code.
Maar: meten is weten, en de eerste stap moet altijd efficiëntie zijn. Code die niet wordt uitgevoerd is het snelst. Lowlevel optimalisatietechnieken zijn pas nodig als er op hoog niveau geen grote winst meer te halen is.
Zoals genoemd is meten weten. Bij optimalisatie is het daarom het beste om een sectie code te nemen, bijvoorbeeld een representatieve set berekeningen, en die als benchmark te gebruiken. Profile deze benchmark, probeer verbeteringen, en profile opnieuw. “Beproeft alle dingen; behoud het goede.”
Merk op dat just-in-time-compilatie, caching, paging, branchprediction, enz. ervoor kan zorgen dat de eerste aantal runs niet representatief zijn. Het systeem moet opwarmen. Daarnaast is er altijd een marge van willekeur. Gebruik geen voldoende groot aantal iteraties en het liefst een benchmarkframework zoals BenchmarkDotNet.
Instrumentatie en sampling kan uitstekend met de ingebouwde profiler van Visual Studio (Alt+F2).
Voor handmatige profiling kan de klasse
System.Diagnostics.Stopwatch
worden gebruikt. Deze heeft
hogere precisie dan System.DateTime
.
Voor inzicht gebaseerd op performancecounters van de processor is Intel VTune een aanrader. Deze laat zaken als cachemissers en pagefaults zien, gekoppeld aan de code in kwestie.