Programma-optimalisatie

Optimalisatie kan op een aantal niveaus plaatsvinden. De hogere niveaus hebben doorgaans een grotere impact en zijn later in het project moeilijker te wijzigen; als ze moeten worden gewijzigd, zijn aanzienlijke wijzigingen of een volledige herschrijving nodig. Optimalisatie kan dus typisch verlopen via verfijning van hoger naar lager, waarbij de initiële winst groter is en bereikt wordt met minder werk, en de latere winst kleiner is en meer werk vereist. In sommige gevallen hangt de totale prestatie echter af van de prestatie van zeer laag-niveau delen van een programma, en kleine veranderingen in een laat stadium of vroege beschouwing van laag-niveau details kunnen een grote impact hebben. Doorgaans wordt gedurende een project enige aandacht besteed aan efficiency – hoewel dit sterk varieert – maar belangrijke optimalisatie wordt vaak beschouwd als een verfijning die laat moet worden uitgevoerd, als dat al gebeurt. Bij langer lopende projecten zijn er meestal optimalisatiecycli, waarbij verbetering op een bepaald gebied beperkingen op een ander gebied aan het licht brengt, en deze cycli worden meestal stopgezet wanneer de prestaties aanvaardbaar zijn of de winst te gering of te kostbaar wordt.

De prestaties maken deel uit van de specificatie van een programma – een programma dat ongewoon traag is, is niet geschikt voor het beoogde doel: een videospelletje met 60 Hz (beelden per seconde) is aanvaardbaar, maar 6 beelden per seconde is onaanvaardbaar hakkerig – de prestaties worden vanaf het begin in aanmerking genomen om ervoor te zorgen dat het systeem voldoende prestaties kan leveren, en vroege prototypen moeten ongeveer aanvaardbare prestaties leveren om erop te kunnen vertrouwen dat het uiteindelijke systeem (na optimalisatie) aanvaardbare prestaties zal leveren. Dit wordt soms nagelaten in de overtuiging dat optimalisatie later altijd nog kan, wat resulteert in prototypesystemen die veel te traag zijn – vaak met een orde van grootte of meer – en systemen die uiteindelijk mislukken omdat ze architectonisch niet in staat zijn hun prestatiedoelstellingen te halen, zoals de Intel 432 (1981); of systemen waar jaren aan gewerkt moet worden om tot aanvaardbare prestaties te komen, zoals Java (1995), dat pas met HotSpot (1999) aanvaardbare prestaties bereikte. De mate waarin de prestaties veranderen tussen prototype en productiesysteem, en hoe vatbaar deze zijn voor optimalisatie, kan een belangrijke bron van onzekerheid en risico zijn.

OntwerpniveauEdit

Op het hoogste niveau kan het ontwerp worden geoptimaliseerd om zo goed mogelijk gebruik te maken van de beschikbare middelen, gegeven doelen, beperkingen, en verwacht gebruik/belasting. Het architectonisch ontwerp van een systeem is van grote invloed op de prestaties. Bijvoorbeeld, een systeem dat netwerklatentie-gebonden is (waarbij netwerklatentie de belangrijkste beperking op de totale prestatie is) zou worden geoptimaliseerd om netwerkreizen te minimaliseren, idealiter door een enkel verzoek te doen (of geen verzoeken, zoals in een push-protocol) in plaats van meerdere rondreizen. De keuze van het ontwerp hangt af van de doelen: bij het ontwerpen van een compiler, als snel compileren de belangrijkste prioriteit is, is een één-pass compiler sneller dan een multi-pass compiler (uitgaande van hetzelfde werk), maar als snelheid van uitvoercode het doel is, vervult een langzamere multi-pass compiler het doel beter, ook al duurt het zelf langer. De keuze van platform en programmeertaal gebeurt op dit niveau, en het veranderen ervan vereist vaak een volledige herschrijving, hoewel een modulair systeem het herschrijven van slechts enkele componenten kan toestaan – bijvoorbeeld, een Python programma kan prestatie-kritische secties in C herschrijven. In een gedistribueerd systeem, gebeurt de keuze van architectuur (client-server, peer-to-peer, enz.) op het ontwerp-niveau, en kan moeilijk te veranderen zijn, vooral als alle componenten niet synchroon kunnen worden vervangen (b.v., oude clients).

Algoritmen en datastructurenEdit

Gegeven een globaal ontwerp, een goede keuze van efficiënte algoritmen en datastructuren, en efficiënte implementatie van deze algoritmen en datastructuren komt daarna. Na het ontwerp heeft de keuze van algoritmen en gegevensstructuren meer invloed op de efficiëntie dan enig ander aspect van het programma. Over het algemeen zijn gegevensstructuren moeilijker te veranderen dan algoritmen, omdat een veronderstelling van een gegevensstructuur en zijn prestatieveronderstellingen in het hele programma worden gebruikt, hoewel dit kan worden geminimaliseerd door abstracte gegevenstypen in functiedefinities te gebruiken, en de concrete definities van gegevensstructuren tot een paar plaatsen beperkt te houden.

Voor algoritmen bestaat dit er hoofdzakelijk in ervoor te zorgen dat algoritmen constant O(1), logaritmisch O(log n), lineair O(n), of in sommige gevallen log-lineair O(n log n) zijn in de invoer (zowel in ruimte als in tijd). Algoritmen met kwadratische complexiteit O(n2) zijn niet schaalbaar, en zelfs lineaire algoritmen veroorzaken problemen als ze herhaaldelijk worden aangeroepen, en worden meestal vervangen door constante of logaritmische indien mogelijk.

Naast de asymptotische volgorde van groei zijn de constante factoren van belang: een asymptotisch langzamer algoritme kan sneller of kleiner (want eenvoudiger) zijn dan een asymptotisch sneller algoritme als ze beide te maken hebben met een kleine invoer, wat het geval kan zijn dat zich in werkelijkheid voordoet. Vaak zal een hybride algoritme de beste prestaties leveren, doordat deze afweging verandert met de grootte.

Een algemene techniek om de prestaties te verbeteren is het vermijden van werk. Een goed voorbeeld is het gebruik van een snel pad voor veel voorkomende gevallen, waardoor de prestaties verbeteren door het vermijden van onnodig werk. Bijvoorbeeld door een eenvoudig tekstopmaakalgoritme te gebruiken voor Latijnse tekst, en pas over te schakelen op een complex opmaakalgoritme voor complexe schriften, zoals Devanagari. Een andere belangrijke techniek is caching, met name memoization, waarmee overbodige berekeningen worden vermeden. Vanwege het belang van caching, zijn er vaak vele niveaus van caching in een systeem, wat problemen kan veroorzaken door geheugengebruik, en correctheidsproblemen door stale caches.

Broncode niveauEdit

Naast algemene algoritmen en hun implementatie op een abstracte machine, kunnen concrete keuzes op broncode niveau een significant verschil maken. Bijvoorbeeld, op vroege C compilers, while(1) was langzamer dan for(;;) voor een onvoorwaardelijke lus, omdat while(1) 1 evalueerde en dan een voorwaardelijke sprong had die testte of het waar was, terwijl for (;;) een onvoorwaardelijke sprong had . Sommige optimalisaties (zoals deze) kunnen tegenwoordig worden uitgevoerd door optimaliserende compilers. Dit hangt af van de brontaal, de doelmachinetaal, en de compiler, en kan zowel moeilijk te begrijpen of te voorspellen zijn als in de loop van de tijd veranderen; dit is een belangrijke plaats waar begrip van compilers en machinecode de prestaties kan verbeteren. Loop-invariante code motion en retourwaarde-optimalisatie zijn voorbeelden van optimalisaties die de behoefte aan hulpvariabelen verminderen en zelfs tot snellere prestaties kunnen leiden doordat rotonde-optimalisaties worden vermeden.

Build levelEdit

Tussen het bron- en compileerniveau kunnen directives en build flags worden gebruikt om prestatie-opties in respectievelijk de broncode en de compiler af te stemmen, zoals het gebruik van preprocessor defines om onnodige softwarefuncties uit te schakelen, het optimaliseren voor specifieke processormodellen of hardwaremogelijkheden, of het voorspellen van vertakkingen, bijvoorbeeld. Broncode-gebaseerde software distributiesystemen zoals BSD’s Ports en Gentoo’s Portage kunnen hun voordeel doen met deze vorm van optimalisatie.

CompileerniveauEdit

Het gebruik van een optimaliserende compiler heeft de neiging om ervoor te zorgen dat het uitvoerbare programma minstens zoveel geoptimaliseerd is als de compiler kan voorspellen.

AssemblageniveauEdit

Op het laagste niveau kan het schrijven van code met behulp van een assembleertaal, ontworpen voor een bepaald hardwareplatform, de meest efficiënte en compacte code opleveren als de programmeur gebruik maakt van het volledige repertoire van machine-instructies. Veel besturingssystemen die op embedded systemen worden gebruikt, zijn om deze reden traditioneel in assembler-code geschreven. Programma’s (met uitzondering van zeer kleine programma’s) worden zelden van begin tot eind in assembler-code geschreven vanwege de tijd en de kosten die dit met zich meebrengt. De meeste programma’s worden gecompileerd van een “high level” taal naar assemblercode en van daaruit met de hand geoptimaliseerd. Wanneer efficiëntie en omvang minder belangrijk zijn, kunnen grote delen in een high-level taal worden geschreven.

Met modernere optimaliserende compilers en de grotere complexiteit van recente CPU’s, is het moeilijker om efficiëntere code te schrijven dan wat de compiler genereert, en weinig projecten hebben deze “ultieme” optimalisatiestap nodig.

Veel code die tegenwoordig wordt geschreven, is bedoeld om op zoveel mogelijk machines te draaien. Als gevolg daarvan maken programmeurs en compilers niet altijd gebruik van de efficiëntere instructies van nieuwere CPU’s of de eigenaardigheden van oudere modellen. Bovendien kan assemblagecode die voor een bepaalde processor is afgestemd zonder dergelijke instructies te gebruiken, nog steeds suboptimaal zijn op een andere processor, waarvoor een andere afstemming van de code wordt verwacht.

In plaats van in assembleertaal te schrijven, gebruiken programmeurs tegenwoordig meestal een disassembler om de uitvoer van een compiler te analyseren en de high-level broncode te wijzigen, zodat deze efficiënter kan worden gecompileerd, of te begrijpen waarom deze inefficiënt is.

Run-timeEdit

Just-in-time compilers kunnen aangepaste machinecode produceren op basis van run-time gegevens, ten koste van compilatie-overhead. Deze techniek dateert van de eerste reguliere expressie-engines, en is wijdverbreid geworden met Java HotSpot en V8 voor JavaScript. In sommige gevallen kan adaptieve optimalisatie run-time optimalisatie uitvoeren die de mogelijkheden van statische compilers te boven gaat, door parameters dynamisch aan te passen aan de feitelijke invoer of andere factoren.

Profile-guided optimization is een ahead-of-time (AOT) compilatie-optimalisatietechniek op basis van run-time profielen, en is vergelijkbaar met een statisch “average case” analoog van de dynamische techniek van adaptieve optimalisatie.

Zelfmodificerende code kan zichzelf wijzigen in reactie op runtime-omstandigheden om code te optimaliseren; dit was gebruikelijker in assembleertaalprogramma’s.

Sommige CPU-ontwerpen kunnen sommige optimalisaties tijdens runtime uitvoeren. Enkele voorbeelden zijn Out-of-order execution, Speculative execution, Instruction pipelines, en Branch predictors. Compilers kunnen het programma helpen van deze CPU-functies te profiteren, bijvoorbeeld door instructieplanning.

Platformafhankelijke en -onafhankelijke optimalisatiesEdit

Code-optimalisatie kan ook grofweg worden gecategoriseerd als platformafhankelijke en platformonafhankelijke technieken. Terwijl de laatste effectief zijn op de meeste of alle platformen, maken platform-afhankelijke technieken gebruik van specifieke eigenschappen van één platform, of vertrouwen op parameters die afhankelijk zijn van het ene platform of zelfs van de enkele processor. Daarom kan het nodig zijn verschillende versies van dezelfde code voor verschillende processoren te schrijven of te produceren. In het geval van optimalisatie op compile-niveau zijn platformonafhankelijke technieken bijvoorbeeld generieke technieken (zoals loop unrolling, vermindering van het aantal functie-aanroepen, geheugenefficiënte routines, vermindering van condities, enz. Een goed voorbeeld van platform-onafhankelijke optimalisatie werd getoond met inner for loop, waarbij werd waargenomen dat een loop met een inner for loop meer berekeningen per tijdseenheid uitvoert dan een loop zonder deze of een met een inner while loop. In het algemeen dienen deze om de totale lengte van het instructiepad dat nodig is om het programma te voltooien te verminderen en/of het totale geheugengebruik tijdens het proces te verminderen. Anderzijds omvatten platformafhankelijke technieken instructieplanning, parallellisme op instructieniveau, parallellisme op gegevensniveau, technieken voor cache-optimalisatie (d.w.z. parameters die per platform verschillen) en de optimale instructieplanning kan zelfs op verschillende processoren van dezelfde architectuur verschillend zijn.