Programoptimering

Optimering kan ske på en række niveauer. Typisk har de højere niveauer større indvirkning og er vanskeligere at ændre senere i et projekt, idet de kræver betydelige ændringer eller en komplet omskrivning, hvis de skal ændres. Optimering kan således typisk foregå via raffinering fra højere til lavere niveau, hvor de første gevinster er større og opnås med mindre arbejde, mens senere gevinster er mindre og kræver mere arbejde. I nogle tilfælde afhænger den samlede ydelse imidlertid af ydelsen af dele af et program på meget lavt niveau, og små ændringer på et sent tidspunkt eller tidlig hensyntagen til detaljer på lavt niveau kan have en meget stor virkning. Typisk tages der i et projekt i nogen grad hensyn til effektiviteten – selv om dette varierer betydeligt – men større optimering betragtes ofte som en forfinelse, der skal foretages sent, hvis overhovedet. I længerevarende projekter er der typisk tale om optimeringscyklusser, hvor forbedring af et område afslører begrænsninger på et andet område, og disse optimeringscyklusser afbrydes typisk, når ydeevnen er acceptabel eller gevinsterne bliver for små eller dyre.

Da ydeevne er en del af specifikationen af et program – et program, der er ualmindeligt langsomt, er ikke egnet til formålet: et videospil med 60 Hz (billeder pr. sekund) er acceptabelt, men 6 billeder pr. sekund er uacceptabelt hakkende – er ydeevne en overvejelse fra starten for at sikre, at systemet er i stand til at levere tilstrækkelig ydeevne, og tidlige prototyper skal have en nogenlunde acceptabel ydeevne, for at der kan være tillid til, at det endelige system (med optimering) vil opnå acceptabel ydeevne. Dette udelades nogle gange i den tro, at optimering altid kan foretages senere, hvilket resulterer i prototypesystemer, der er alt for langsomme – ofte med en størrelsesorden eller mere – og systemer, der i sidste ende er fiaskoer, fordi de arkitektonisk ikke kan nå deres præstationsmål, som f.eks. Intel 432 (1981), eller systemer, der kræver flere års arbejde for at opnå acceptabel ydeevne, som f.eks. Java (1995), der først opnåede acceptabel ydeevne med HotSpot (1999). Graden af ændringer i ydeevnen mellem prototype og produktionssystem, og hvor let den kan optimeres, kan være en væsentlig kilde til usikkerhed og risiko.

DesignniveauRediger

På det højeste niveau kan designet optimeres for at udnytte de tilgængelige ressourcer bedst muligt i betragtning af mål, begrænsninger og forventet brug/belastning. Det arkitektoniske design af et system påvirker i altovervejende grad dets ydeevne. F.eks. vil et system, der er bundet af netværkslatens (hvor netværkslatens er den vigtigste begrænsning for den samlede ydelse), blive optimeret til at minimere antallet af netværksrejser, idet der ideelt set kun skal foretages en enkelt anmodning (eller ingen anmodninger, som i en push-protokol) i stedet for flere rundrejser. Valget af design afhænger af målene: Ved udformning af en compiler er en one-pass compiler hurtigere end en multi-pass compiler, hvis hurtig kompilering er den vigtigste prioritet (under forudsætning af samme arbejde), men hvis hastigheden af outputkoden er målet, opfylder en langsommere multi-pass compiler bedre dette mål, selv om den selv tager længere tid. Valg af platform og programmeringssprog sker på dette niveau, og hvis de ændres, kræver det ofte en fuldstændig omskrivning, selv om et modulært system måske kun tillader omskrivning af nogle komponenter – f.eks. kan et Python-program omskrive ydelseskritiske afsnit i C. I et distribueret system sker valget af arkitektur (klient-server, peer-to-peer osv.) på designniveau og kan være vanskeligt at ændre, især hvis alle komponenter ikke kan udskiftes synkront (f.eks, gamle klienter).

Algoritmer og datastrukturerRediger

Givet et overordnet design kommer dernæst et godt valg af effektive algoritmer og datastrukturer og en effektiv implementering af disse algoritmer og datastrukturer. Efter designet påvirker valget af algoritmer og datastrukturer effektiviteten mere end noget andet aspekt af programmet. Generelt er datastrukturer vanskeligere at ændre end algoritmer, da en datastrukturforudsætning og dens præstationsforudsætninger anvendes i hele programmet, selv om dette kan minimeres ved at anvende abstrakte datatyper i funktionsdefinitioner og holde de konkrete datastrukturdefinitioner begrænset til få steder.

For algoritmer består dette primært i at sikre, at algoritmer er konstante O(1), logaritmiske O(log n), lineære O(n) eller i visse tilfælde log-lineære O(n log n) i input (både i rum og tid). Algoritmer med kvadratisk kompleksitet O(n2) kan ikke skaleres, og selv lineære algoritmer giver problemer, hvis de kaldes gentagne gange, og erstattes typisk med konstante eller logaritmiske, hvis det er muligt.

Bortset fra den asymptotiske vækstorden har de konstante faktorer betydning: En asymptotisk langsommere algoritme kan være hurtigere eller mindre (fordi den er enklere) end en asymptotisk hurtigere algoritme, når de begge står over for et lille input, hvilket kan være det tilfælde, der forekommer i virkeligheden. Ofte vil en hybridalgoritme give den bedste ydeevne, fordi denne afvejning ændrer sig med størrelsen.

En generel teknik til at forbedre ydeevnen er at undgå arbejde. Et godt eksempel er brugen af en hurtig vej for almindelige tilfælde, hvilket forbedrer ydeevnen ved at undgå unødvendigt arbejde. For eksempel ved at bruge en simpel tekstlayoutalgoritme til latinsk tekst og kun skifte til en kompleks layoutalgoritme til komplekse skrifter, f.eks. devanagari. En anden vigtig teknik er caching, især memoisering, som gør det muligt at undgå overflødige beregninger. På grund af cachingens betydning er der ofte mange niveauer af caching i et system, hvilket kan give problemer på grund af hukommelsesforbrug og korrekthedsproblemer på grund af forældede caches.

Kildetekstens niveauRediger

Bortset fra generelle algoritmer og deres implementering på en abstrakt maskine kan konkrete valg på kildetekstens niveau gøre en betydelig forskel. På tidlige C-compilere var while(1) f.eks. langsommere end for(;;) for en ubetinget løkke, fordi while(1) evaluerede 1 og derefter havde et betinget spring, som testede, om det var sandt, mens for (;;) havde et ubetinget spring . Nogle optimeringer (som f.eks. denne) kan i dag udføres af optimerende compilere. Dette afhænger af kildesproget, målmaskinesproget og compileren og kan både være vanskeligt at forstå eller forudsige og ændrer sig over tid; dette er et vigtigt sted, hvor forståelse af compilere og maskinkode kan forbedre ydeevnen. Loop-invariant kodebevægelse og returværdioptimering er eksempler på optimeringer, der reducerer behovet for hjælpevariabler og kan endda resultere i hurtigere ydelse ved at undgå omgangsoptimeringer.

Build-niveauRediger

Mellem kildekode- og kompilerniveauet kan direktiver og build-flag bruges til at indstille ydelsesindstillinger i henholdsvis kildekoden og kompileren, f.eks. ved hjælp af præprocessordefinitioner til at deaktivere unødvendige softwarefunktioner, optimering til bestemte processormodeller eller hardwarekapaciteter eller forudsigelse af forgrening. Kildebaserede softwaredistributionssystemer som BSD’s Ports og Gentoo’s Portage kan drage fordel af denne form for optimering.

Compile levelEdit

Brug af en optimerende compiler har en tendens til at sikre, at det eksekverbare program er optimeret mindst lige så meget, som compileren kan forudsige.

AssembleringsniveauRediger

På det laveste niveau kan det at skrive kode ved hjælp af et assembler-sprog, der er designet til en bestemt hardwareplatform, give den mest effektive og kompakte kode, hvis programmøren udnytter hele repertoiret af maskininstruktioner. Mange operativsystemer, der anvendes på indlejrede systemer, er traditionelt blevet skrevet i assemblerkode af denne grund. Programmer (bortset fra meget små programmer) skrives sjældent fra start til slut i assembler på grund af den tid og de omkostninger, der er forbundet hermed. De fleste programmer kompileres ned fra et højniveausprog til assembler og optimeres derfra i hånden. Når effektivitet og størrelse er mindre vigtige, kan store dele skrives i et højniveausprog.

Med mere moderne optimerende compilere og den større kompleksitet i nyere CPU’er er det sværere at skrive mere effektiv kode end den, som compileren genererer, og kun få projekter har brug for dette “ultimative” optimeringstrin.

Meget kode, der skrives i dag, er beregnet til at kunne køre på så mange maskiner som muligt. Som følge heraf udnytter programmører og compilere ikke altid de mere effektive instruktioner, der leveres af nyere CPU’er, eller de særheder, der findes i ældre modeller. Desuden kan assemblerkode, der er tunet til en bestemt processor uden brug af sådanne instruktioner, stadig være suboptimal på en anden processor, der forventer en anden tuning af koden.

Typisk vil programmører i dag i stedet for at skrive i assembler-sprog bruge en disassembler til at analysere output fra en compiler og ændre kildekoden på højt niveau, så den kan kompileres mere effektivt, eller forstå, hvorfor den er ineffektiv.

Run timeEdit

Just-in-time compilere kan producere tilpasset maskinkode baseret på run-time data, på bekostning af overhead ved kompilering. Denne teknik stammer fra de tidligste motorer til regulære udtryk og er blevet udbredt med Java HotSpot og V8 til JavaScript. I nogle tilfælde kan adaptiv optimering være i stand til at udføre løbetidsoptimering, der overstiger statiske kompilatorers kapacitet, ved dynamisk at justere parametre i henhold til det faktiske input eller andre faktorer.

Profil-styret optimering er en ahead-of-time (AOT) kompileringsoptimeringsteknik baseret på løbetidsprofiler og svarer til en statisk “average case”-analog til den dynamiske teknik for adaptiv optimering.

Selvmodificerende kode kan ændre sig selv som reaktion på køretidsbetingelser for at optimere koden; dette var mere almindeligt i assemblerprogrammer.

Nogle CPU-designs kan udføre nogle optimeringer på køretid. Nogle eksempler omfatter Out-of-order execution, Speculative execution, Instruction pipelines og Branch predictors. Compilere kan hjælpe programmet med at udnytte disse CPU-funktioner, f.eks. gennem instruktionsplanlægning.

Platformafhængige og uafhængige optimeringerRediger

Kodeoptimering kan også groft sagt kategoriseres som platformsafhængige og platformsuafhængige teknikker. Mens de sidstnævnte er effektive på de fleste eller alle platforme, anvender platformsafhængige teknikker specifikke egenskaber for en platform eller er afhængige af parametre, der afhænger af den enkelte platform eller endog af den enkelte processor. Det kan derfor være nødvendigt at skrive eller producere forskellige versioner af den samme kode til forskellige processorer. I forbindelse med optimering på kompileringsniveau er platformsuafhængige teknikker f.eks. generiske teknikker (f.eks. loop unrolling, reduktion af funktionskald, hukommelseseffektive rutiner, reduktion af betingelser osv. Et godt eksempel på platformsuafhængig optimering er blevet vist med indre for-loop, hvor det blev observeret, at en loop med en indre for-loop udfører flere beregninger pr. tidsenhed end en loop uden den eller en med en indre while-loop. Generelt tjener disse til at reducere den samlede instruktionsvejslængde, der er nødvendig for at gennemføre programmet, og/eller reducere det samlede hukommelsesforbrug under processen. På den anden side omfatter platformsafhængige teknikker instruktionsplanlægning, parallelitet på instruktionsniveau, parallelitet på dataniveau og cache-optimeringsteknikker (dvs. parametre, der varierer mellem forskellige platforme), og den optimale instruktionsplanlægning kan være forskellig, selv på forskellige processorer med samme arkitektur.