Programoptimering

Optimering kan ske på flera nivåer. Typiskt sett har de högre nivåerna större inverkan och är svårare att ändra senare i ett projekt, och kräver betydande ändringar eller en fullständig omskrivning om de behöver ändras. Optimering kan därför typiskt sett ske genom förfining från högre till lägre nivå, där de första vinsterna är större och uppnås med mindre arbete, och där senare vinster är mindre och kräver mer arbete. I vissa fall beror dock den totala prestandan på prestandan hos delar av ett program på mycket låg nivå, och små ändringar i ett sent skede eller ett tidigt beaktande av detaljer på låg nivå kan ha en mycket stor inverkan. Vanligtvis tas viss hänsyn till effektivitet under hela projektet – även om detta varierar avsevärt – men större optimeringar betraktas ofta som en förfining som ska göras sent, om ens någonsin. I projekt som löper över en längre tid finns det vanligtvis optimeringscykler, där förbättringar på ett område avslöjar begränsningar på ett annat, och dessa begränsas vanligtvis när prestandan är acceptabel eller när vinsterna blir för små eller för kostsamma.

Eftersom prestanda är en del av specifikationen för ett program – ett program som är ovanligt långsamt är olämpligt för ändamålet: ett videospel med 60 Hz (bilder per sekund) är acceptabelt, men 6 bilder per sekund är oacceptabelt hackigt – är prestanda ett övervägande från början, för att se till att systemet kan leverera tillräcklig prestanda, och tidiga prototyper måste ha ungefär godtagbar prestanda för att man skall kunna lita på att det slutgiltiga systemet (med optimering) kommer att uppnå godtagbar prestanda. Detta utelämnas ibland i tron att optimering alltid kan göras senare, vilket resulterar i prototypsystem som är alldeles för långsamma – ofta med en storleksordning eller mer – och system som i slutändan misslyckas på grund av att de arkitektoniskt sett inte kan uppnå sina prestandamål, t.ex. Intel 432 (1981), eller system som kräver flera års arbete för att uppnå acceptabla prestanda, t.ex. Java (1995), som uppnådde acceptabla prestanda först med HotSpot (1999). Graden av förändring av prestanda mellan prototyp och produktionssystem, och hur lätt den kan optimeras, kan vara en betydande källa till osäkerhet och risk.

KonstruktionsnivåRedigera

På den högsta nivån kan konstruktionen optimeras för att på bästa sätt utnyttja de tillgängliga resurserna, med tanke på mål, begränsningar och förväntad användning/belastning. Den arkitektoniska utformningen av ett system påverkar på ett överväldigande sätt dess prestanda. Till exempel skulle ett system som är bundet till nätverkslatens (där nätverkslatens är den viktigaste begränsningen för den totala prestandan) optimeras för att minimera antalet nätverksresor, och helst göra en enda begäran (eller inga begäranden, som i ett push-protokoll) i stället för att göra flera tur-och-retur-resor. Valet av utformning beror på målen: när man utformar en kompilator, om snabb kompilering är den viktigaste prioriteringen, är en kompilator med ett pass snabbare än en kompilator med flera pass (om man utgår från samma arbete), men om hastigheten på den utgående koden är målet, uppfyller en långsammare kompilator med flera pass målet bättre, även om den tar längre tid i sig själv. Valet av plattform och programmeringsspråk sker på denna nivå, och för att ändra dem krävs ofta en fullständig omskrivning, även om ett modulsystem kan tillåta omskrivning av endast en del av komponenterna – till exempel kan ett Pythonprogram skriva om prestandakritiska avsnitt i C. I ett distribuerat system sker valet av arkitektur (klient-server, peer-to-peer osv.) på konstruktionsnivå och kan vara svårt att ändra, i synnerhet om alla komponenter inte kan bytas ut samtidigt (t.ex, gamla klienter).

Algoritmer och datastrukturerRedigera

Med tanke på den övergripande utformningen kommer ett bra val av effektiva algoritmer och datastrukturer och ett effektivt genomförande av dessa algoritmer och datastrukturer härnäst. Efter designen påverkar valet av algoritmer och datastrukturer effektiviteten mer än någon annan aspekt av programmet. Generellt sett är datastrukturer svårare att ändra än algoritmer, eftersom ett antagande om datastruktur och dess prestandaförutsättningar används i hela programmet, även om detta kan minimeras genom användning av abstrakta datatyper i funktionsdefinitioner och genom att hålla de konkreta datastrukturdefinitionerna begränsade till ett fåtal ställen.

För algoritmer består detta i första hand av att se till att algoritmerna är konstanta O(1), logaritmiska O(log n), linjära O(n), eller i vissa fall loglinjära O(n log n) i indata (både i utrymme och tid). Algoritmer med kvadratisk komplexitet O(n2) misslyckas med att skala, och även linjära algoritmer orsakar problem om de anropas upprepade gånger, och ersätts vanligtvis med konstant eller logaritmisk om det är möjligt.

Bortom den asymptotiska tillväxtredningen spelar de konstanta faktorerna roll: en asymptotiskt långsammare algoritm kan vara snabbare eller mindre (eftersom den är enklare) än en asymptotiskt snabbare algoritm när de båda ställs inför en liten indata, vilket kan vara det fall som förekommer i verkligheten. Ofta ger en hybridalgoritm den bästa prestandan, på grund av att denna avvägning förändras med storleken.

En allmän teknik för att förbättra prestandan är att undvika arbete. Ett bra exempel är användningen av en snabb väg för vanliga fall, vilket förbättrar prestandan genom att undvika onödigt arbete. Till exempel att använda en enkel textlayoutalgoritm för latinsk text och endast byta till en komplex layoutalgoritm för komplexa skrifter, t.ex. devanagari. En annan viktig teknik är caching, särskilt memotisering, som undviker överflödiga beräkningar. På grund av cachingens betydelse finns det ofta många nivåer av caching i ett system, vilket kan orsaka problem på grund av minnesanvändning och korrekthetsproblem på grund av föråldrade cacher.

KällkodsnivåRedigera

Bortsett från generella algoritmer och deras implementering på en abstrakt maskin, kan konkreta val på källkodsnivå göra en betydande skillnad. På tidiga C-kompilatorer var till exempel while(1) långsammare än for(;;) för en ovillkorlig slinga, eftersom while(1) utvärderade 1 och sedan hade ett villkorligt hopp som testade om det var sant, medan for (;;) hade ett ovillkorligt hopp . Vissa optimeringar (som den här) kan numera utföras av optimerande kompilatorer. Detta beror på källspråket, målmaskinspråket och kompilatorn och kan vara både svårt att förstå eller förutsäga och ändras med tiden; detta är ett viktigt ställe där förståelse för kompilatorer och maskinkod kan förbättra prestandan. Loop-invariant kodrörelse och returvärdesoptimering är exempel på optimeringar som minskar behovet av hjälpvariabler och kan till och med resultera i snabbare prestanda genom att undvika rundgångsoptimeringar.

Build levelEdit

Mellan käll- och kompileringsnivå kan direktiv och build flags användas för att ställa in prestandainställningarna i källkoden respektive kompilatorn, t.ex. genom att använda preprocessordefinitioner för att inaktivera obehövliga programvarufunktioner, optimera för specifika processormodeller eller maskinvarufunktioner eller förutsäga förgrening, till exempel. Källkodsbaserade programvarudistributionssystem som BSD:s Ports och Gentoos Portage kan dra nytta av denna form av optimering.

KompileringsnivåRedigera

Användning av en optimerande kompilator tenderar att säkerställa att det körbara programmet är optimerat minst lika mycket som kompilatorn kan förutsäga.

AssembleringsnivåEdit

På lägsta nivå kan skrivande av kod med hjälp av ett assembleringsspråk, som är utformat för en viss hårdvaruplattform, ge den mest effektiva och kompakta koden om programmeraren drar nytta av hela repertoaren av maskininstruktioner. Många operativsystem som används på inbyggda system har traditionellt skrivits i assemblerkod av denna anledning. Program (utom mycket små program) skrivs sällan från början till slut i assembler på grund av den tid och de kostnader det innebär. De flesta kompileras ner från ett högnivåspråk till assembler och optimeras för hand därifrån. När effektivitet och storlek är mindre viktiga kan stora delar skrivas i ett högnivåspråk.

Med modernare optimerande kompilatorer och den större komplexiteten hos de senaste CPU:erna är det svårare att skriva effektivare kod än vad kompilatorn genererar, och få projekt behöver detta ”ultimata” optimeringssteg.

En stor del av den kod som skrivs idag är avsedd att köras på så många maskiner som möjligt. Som en följd av detta drar programmerare och kompilatorer inte alltid fördel av de effektivare instruktioner som tillhandahålls av nyare CPU:er eller egenheter hos äldre modeller. Dessutom kan assemblerkod som är inställd för en viss processor utan att använda sådana instruktioner fortfarande vara suboptimal på en annan processor, som förväntar sig en annan inställning av koden.

Typiskt sett använder programmerare idag snarare än att skriva i assemblerspråk en disassembler för att analysera resultatet från en kompilator och ändra källkoden på hög nivå så att den kan kompileras effektivare, eller förstå varför den är ineffektiv.

KörtidRedigera

Just-in-time-kompilatorer kan producera skräddarsydd maskinkod baserad på data från körtid, till priset av kompileringsoverhead. Denna teknik går tillbaka till de tidigaste motorerna för reguljära uttryck och har fått stor spridning med Java HotSpot och V8 för JavaScript. I vissa fall kan adaptiv optimering utföra körtidsoptimering som överstiger statiska kompilatorers kapacitet genom att dynamiskt justera parametrar i enlighet med den faktiska indata eller andra faktorer.

Profilstyrd optimering är en teknik för optimering av kompilering i förväg (AOT) som bygger på körtidsprofiler, och liknar en statisk ”genomsnittsfall”-analog till den dynamiska tekniken för adaptiv optimering.

Självmodifierande kod kan ändra sig själv som svar på körtidsförhållanden för att optimera koden; detta var vanligare i assemblerprogram.

Vissa CPU-konstruktioner kan utföra vissa optimeringar vid körtiden. Några exempel är Out-of-order execution, Speculative execution, Instruction pipelines och Branch predictors. Kompilatorer kan hjälpa programmet att dra nytta av dessa CPU-funktioner, till exempel genom schemaläggning av instruktioner.

Plattformberoende och oberoende optimeringarRedigera

Kodeoptimering kan också grovt kategoriseras som plattformsberoende och plattformsoberoende tekniker. Medan de sistnämnda är effektiva på de flesta eller alla plattformar använder plattformsberoende tekniker specifika egenskaper hos en plattform eller är beroende av parametrar som beror på den enskilda plattformen eller till och med på den enskilda processorn. Det kan därför bli nödvändigt att skriva eller producera olika versioner av samma kod för olika processorer. När det gäller optimering på kompileringsnivå är till exempel plattformsoberoende tekniker generiska tekniker (t.ex. loop unrolling, minskning av funktionsanrop, minneseffektiva rutiner, minskning av villkor etc.) som påverkar de flesta CPU-arkitekturer på samma sätt. Ett bra exempel på plattformsoberoende optimering har visats med inre for-slingor, där det observerades att en slinga med en inre for-slinga utför fler beräkningar per tidsenhet än en slinga utan den eller en med en inre while-slinga. Generellt sett tjänar dessa till att minska den totala instruktionsvägslängden som krävs för att slutföra programmet och/eller minska den totala minnesanvändningen under processen. Å andra sidan omfattar plattformsberoende tekniker schemaläggning av instruktioner, parallellitet på instruktionsnivå, parallellitet på datanivå, tekniker för cacheoptimering (dvs. parametrar som skiljer sig åt mellan olika plattformar) och den optimala schemaläggningen av instruktioner kan skilja sig åt till och med på olika processorer med samma arkitektur.