Hoofdcategorieën

Nieuwe reactie in topic: Optimaliseren van code (if statements)

Let op:
  • Reageer ontopic, plaats geen onzinnige berichten en ga niet flamen of uitlokken (trollen).
  • Zie je iets dat niet door de beugel kan, attendeer dan een moderator via een topicreport maar post hierover niet in het topic, dat werkt alleen averechts. Zie ook de policy die wij op dit forum hanteren.
  • Lees je eigen bericht even door voor je het post.

Insert message
 

Let op! Het laatste bericht in deze discussie is meer dan 2 weken oud!

 

Smilies: :) :( ;) >:) :> :P :9 :o :*) :'( 8) :+ :D _/-\o_ :9~ O+ :O }:O :/ :| :X :? 8)7 |:( O-) :z ;( meer »

Page navigation

Laatste reacties:

 
quote:
prometheus345479 schreef op donderdag 22 mei 2008 @ 10:15:
Verder worden COS en SIN in mijn code evenvaak aangeroepen. Dus als een call naar COS sin zou aanroepen, dan zou de count van SIN 2 maal de count van COS moeten zijn.
Als sin direct cos zou aanroepen ja. Waarschijnlijker is het dan echter dat sin en cos beide proxies zijn die uiteindelijk een implementatie van een cosinusfunctie aanroepen, waarbij sin dus meer overhead heeft dan cos. Ik beweer niet dat het zo is, ik zeg alleen dat het zou kunnen. Maar het lijkt me raar dat sin daardoor 9x zo duur is als cos. In mijn eigen arbitrary precision implementatie van sin en cos verschillen ze niet zoveel van elkaar, en ook op de FPU van m'n CPU (2.4 GHz Core 2 Quad) performen ze gelijkwaardig (0.38 sec voor 10 miljoen aanroepen)
 
 
Als je over low-level optimalisaties wil gaan kun je er ook deze eens op na slaan:
http://lwn.net/Articles/250967/

Bovendien denk ik dat een goeie for-lus net wel bevorderlijk is voor de prestaties. De compiler kan namelijk aannames maken:
C++:
1
2
3
for (int i = 0i < size; ++i)
{
}

Als de compiler hier redeneert dat i < size altijd waar is (en dus een instructie genereert waarin altijd de "true" code geprefereerd wordt, dan heb je altijd:
- 1 enkele pipeline flush door een foute branch
- size - 1 goeie branches.
Dit rendeert vanaf size = 2.

Je kan in veel gevallen de compiler bij if-statements ook een beetje helpen met dit soort aanwijzingen.
Bij gcc kun je bvb __likely() en __unlikely() gebruiken.

Ook kan het in veel gevallen een voordeel zijn om ipv
C++:
1
2
3
if (a || b || c)
{
}

deze
C++:
1
2
3
if (a | b | c)
{
}

te gaan gebruiken (tenzij evaluatie van b en c 'duur' is)

De reden is dat de eerste code gelijk is aan
C++:
1
2
3
4
5
6
7
8
9
if (a)
{
  if (b)
  {
    if (c)
    {
    }
  }
}

Het zijn van die micro-optimalisatie die in hotspots wel wat kunnen opleveren.
Maar zoals anderen al aangaven, roer eerst eens maar eens met de pollepel in de pot voor je met een koffie-lepel begint. Die sinus-berekening is al een goeie eerste start.
 
 
quote:
H!GHGuY schreef op vrijdag 23 mei 2008 @ 22:35:
Ook kan het in veel gevallen een voordeel zijn om ipv
C++:
1
2
3
if (a || b || c)
{
}

deze
C++:
1
2
3
if (a | b | c)
{
}

te gaan gebruiken (tenzij evaluatie van b en c 'duur' is)

De reden is dat de eerste code gelijk is aan
C++:
1
2
3
4
5
6
7
8
9
if (a)
{
  if (b)
  {
    if (c)
    {
    }
  }
}

euh, nee? Het onderste stukje code is gelijkwaardig aan &&, niet || - bij de || wordt immers de code uitgevoerd als ook maar een van de drie statements true zijn, niet als a, b en c true zijn. Of ik begrijp je redenering niet helemaal.
 
 
Dus hij bedoelt && en & ipv || en |, waarbij het bij de || en | ook wel opgaat maar het equivalent in if-statements iets anders is. Dat maakt z'n punt toch niet minder valide?

Bij de || zou het zoiets zijn:
C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bool ok = true;

if (!a)
{
    if (!b)
    {
        if (!c)
            ok = false;
    }
}

if (ok)
{
    // doeIets
}

 
 
juist ja, klein foutje van mijn kant. er moet idd && staan waar nu || staat
 
 
Welke van de volgende 2 snippets zou het snelste zijn?
code:
1
2
3
4
5
if(statement) {
    x = 1;
} else {
    x = 2;
}

of
code:
1
2
3
4
x = 2;
if(statement) {
    x = 1;
}

Ik heb gelezen dat optie 2 sneller is, maar maakt die extra toewijzing of een else veel uit? (In het kader van optimalisatie).
 
 
Tja, tenzij dit in een loop met gruwelijk veel iteraties zit is het nog niets 2s denkwerk waard. Bovendien zal menig compiler dezelfde output geven voor beide inputs.
 
 
quote:
Spiked schreef op maandag 23 juni 2008 @ 16:29:
Welke van de volgende 2 snippets zou het snelste zijn?

[...code...]

Ik heb gelezen dat optie 2 sneller is, maar maakt die extra toewijzing of een else veel uit? (In het kader van optimalisatie).
Waar heb je dat gelezen dan? En voor welke taal?
Wat machinecode betreft kan het feitelijk beide kanten op, en in een taal met complexe typen kan het afhangen van de kosten van de assignment. Maar over het algemeen is het een totaal oninteressante vraag (zoals de meeste vragen in de topic trouwens)
quote:
Voutloos schreef op maandag 23 juni 2008 @ 16:40:
Bovendien zal menig compiler dezelfde output geven voor beide inputs.
Daar kun je feitelijk geen zinnig woord over zeggen zonder meer informatie te hebben over bijv. taal en het type van 'x'.
 
 
Op de ARM is het antwoord simpel: De eerste kost je twee conditional moves (MOVEQ/MOVNE) terwijl de tweede je een conditional en een unconditional move kost (MOV/MOVEQ). De MOVNE is niet duurder dan de MOV, en soms goedkoper. Dus op ARM is de eerste sneller.
 
 
quote:
MSalters schreef op maandag 23 juni 2008 @ 20:51:
Op de ARM is het antwoord simpel: De eerste kost je twee conditional moves (MOVEQ/MOVNE) terwijl de tweede je een conditional en een unconditional move kost (MOV/MOVEQ). De MOVNE is niet duurder dan de MOV, en soms goedkoper. Dus op ARM is de eerste sneller.
Lijkt me dan meer aan je compiler liggen. De eerste kun je prima optimizen naar een enkele Jump-if-equal. Als 'ie niet matched krijg je een fallthrough naar de else case.
 
 
Optimizen naar een conditional jump? Are you mad? :)

Instructies als conditional moves e.d. bestaan niet voor niets. Die hebben namelijk geen last van branch mispredictions en complete pipeline flushes als gevolg daarvan.
 
 
quote:
prometheus345479 schreef op zondag 18 mei 2008 @ 13:03:
[...]

Het probleem is dat bij Fourier optics, je lens een blokfunctie is (waar een turbulent golffront doorheen gestuurd kan worden). De benodigde nyquist sampling is dus eigenlijk oneindig, om de blokfunctie te kunnen sampelen. Het bleek dat de fouten die optraden vanwege het samplen te groot waren bij een kleine matrix. De fout neemt toe naarmate je dichter bij de rand van de matrix zit. (hoge spatial frequencies). Dit probleem is gewoon inherrent aan de discrete fourier transformatie.

Dus nu simuleren we een grote matrix (256x256) en zoomen daarna in op het centrale gedeelte (32x32), dat voldoende nauwkeurig is. Ook de double precisie is nodig, om de grote range aan waarden allemaal mee te kunnen nemen.
Feitelijk heb je last van het Gibbs fenomeen. Nu las ik laatst dat er variaties op de fourier transformatie zijn die het probleem van die oscilaties kunnen onderdrukken. Ze zullen allicht duurder zijn dan een DFT, maar als je dan wel met kleinere matrices kan werken kan het uiteindelijk wel voordeliger zijn. Ik zou zeggen zoek daar eens naar.
 
 
Als je if-statements vooral bedoeld zijn om fout-codes af te vangen dan zou je hier nog een kleine snelheidswinst kunnen maken door ze om te zetten naar switch()-statements.

Voor referentie: http://www.blackwasp.co.uk/SpeedTestIfElseSwitch.aspx
 
 
Ja en nee. Een if-statement in pure ASM is niets meer dan een "cmp" (compare) instructie gevolgd door een jump instructie, bijvoorbeeld "je" (jump equal). Bij een jump wordt de "locatie register" aangepast aan het nieuwe adres, zijnde EIP, wat vrij weinig overhead heeft. Bij een switch statement wordt er gebruik gemaakt van een zogenaamde jump-tabel, wat in principe een serie addressen is (in sommige gevallen zelfs absolute addressen). Bij meer complexe switch-statements zijn er zelfs twee vertaal-tabellen, ééntje om de eigenlijke case uit te vinden, en de tweede om het adres van deze case te achterhalen. Een voorbeeld hiervan is;
.
C++:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
unsigned char ucWaarde = 123;
switch (ucWaarde)
{
    case 15610:
        printf ("Case 1");
        break;

    case 151925:
        printf ("Case 2");
        break;
  
    case 99123:
        printf ("Case 3");

    default:
        printf ("Default");
        break;
}

.
In dat geval wordt er eerst een array gemaakt met de waardes, een array die in totaal 123 (hoogste waarde) bytes zal bevatten. De inhoud zal beginnen met [0,1,0,0,0,1,1,0...], waarbij index 0 de default-switch is, index 1 de eerste case, enzovoorts. De assembly code die hierbij gemaakt wordt moet rekening houden met een aantal dingen: als de waarde hoger dan 123 of lager dan 1 is, zal sowieso de default switch uitgevoerd moeten worden. Verder moet de case-index uit deze eerste translation array opgehaald worden waarna een 2e array gelezen kan worden om het adres van de case uit te vinden. In Assembly zal dit ongeveer gelijk zijn aan;
.
assembly:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    mov eax, 123 ; de case index
    cmp eax, 0
    jbe _default
    cmp eax, 124
    jae _default
    movsx al, __caseIndex[eax]
    jmp __caseAddress[al*4]

_case1:
    ....
    jmp _end
_case2:
    ....
    jmp _end
_case3:
    ....
_default:
    ....

_end
    ; rest van je code

Dit, samen met de nodige jump en compare instructies, heeft veel meer overhead dan de standaard if-constructie, welke in Assembly taal vrijwel gelijk zal staan aan het volgende. Hierbij wil ik opmerken dat bij echt hele "complexe" switch tabellen, waarbij de waardes meer dan 1000-2000 uit elkaar liggen, de assembler er vaak een grote serie if-compares van maakt.
.
assembly:
1
2
3
4
5
    cmp eax, 123
    jne _false
    .... ; Code gedeelte voor true
_false:
    .... ; Rest van je code

Uiteindelijk wil ik nog toevoegen dat simpele switches wel degelijk sneller zijn; als je switch goede aaneensluitende waardes bevat, 1-8 bijvoorbeeld, dan wordt er maar één jump gedaan. Dit is ook het geval als het opvolgende waardes zijn, bijvoorbeeld 1,2,4,8,16 etcetera, daar kennen compilers vaak handige trucjes voor. Wat sneller is heeft echt heel veel te maken met de situatie waarin je het gebruikt, gezien een switch naar drie verschillende dingen kan assembleren. Als je echt voor snelheid wilt gaan kan je nog gaan nadenken over inline Assembly, het is moeilijker, maar je kan code wel zeer veel efficienter maken. Eén van de redenen dat de eerste Rollercoaster Tycoon vrij veel tegelijk kon doen op de oudere systemen; het spel bestond voor 99% uit handgeschreven Assembly.

Sorry als m'n verhaal hier en daar wat onduidelijk is, ben nogal bezig met WOGgen op het moment :P

Edit;
Nog wat algemene tips voor optimalisatie; een belangrijk punt zijn for-loops en de vergelijkwaarde in het tweede deel. Iets wat vaak gedaan wordt is het volgende:
.
C++:
1
2
3
4
5
6
7
8
std::list <intlistCijfers;
listCijfers.push_back (5);
listCijfers.push_back (10);

for (int i = 0i < listCijfers.size(); i++)
{
...
}

Sommige assemblers (let op: niet allemaal meer) hebben de neiging om na iedere run van de loop opnieuw de size() methode van de list aan te roepen, wat echt veel tijd in beslag kan nemen bij grotere loops. In gevallen als deze, zet de waarde van de size() functie in een tweede locale variable:
C++:
1
2
3
4
for (int i = 0j = listCijfers.size (); i < ji++)
{

}

In het meest efficiente geval zal de assembler de "j" in het EDI register zetten, waardoor je inplaats van een collectie calls, mov's en hertellingen van de lijst enkel één compare instructie hebt, met lijsten van miljoenen cijfertjes kan dat echt seconden gaan schelen.
 
 
quote:
Peter schreef op donderdag 24 juli 2008 @ 12:30:
Ja en nee. Een if-statement in pure ASM is niets meer dan een "cmp" (compare) instructie gevolgd door een jump instructie, bijvoorbeeld "je" (jump equal). Bij een jump wordt de "locatie register" aangepast aan het nieuwe adres, zijnde EIP, wat vrij weinig overhead heeft.
Het spijt me, maar met die laatst gequootte zin aan het begin van je post kan ik niet anders dan je hele post in twijfel trekken - het geeft namelijk aan dat je niet echt ervaring hebt met "moderne" (zeg vanaf de 386) x86 architectuur.

Een gewone jump naar een vast adres is idd vrij goedkoop. Een conditionele jump echter niet. Instructies worden namelijk niet een voor een afgerond, maar gaan door verschillende fases (fetch, decode, execute, etc) die parallel worden uitgevoerd. Dat betekent dat als de conditional jump gedecode en geexecute wordt, mogelijk nog niet bekend is wat het resultaat van de compare is omdat het flags register nog niet is geüpdate. De CPU gaat dan maar "gokken" (branch prediction adhv resultaten uit het verleden) of hij wel moet springen of niet, zodat hij wel alvast nieuwe instructies kan fetchen, decoden, etc. terwijl hij degene die momenteel in de pipeline zitten kan afronden. Gokt hij mis, dan betekent dat dat op het moment hij erachter komt alle nieuwe instructies die al in de pipeline zitten gecanceled moeten worden, en moet hij de hele pipeline weer vullen met instructies vanaf het andere adres. Op CPU's met een lange pipeline (zoals de P4) is dit érg kostbaar.

Met dit verhaal in je achterhoofd kun je ook wel bedenken dat jump tables niet zo heel erg optimaal meer zijn als op die oude simpele CPU's. Hij moet namelijk het adres uitrekenen aan de hand van een aantal condities en daarnaartoe jumpen. Als dat adres nog niet volledig achterhaald is op het moment van de jump instruction heb je hetzelfde verhaal, maar dan nog erger - in feite is dit altijd een branch misprediction met een pipeline flush als resultaat, omdat hij simpelweg niets heeft om te predicten.

Beide gevallen zijn natuurlijk te voorkomen door te zorgen dat de instructies die zorgen voor de flags danwel het adres al lang en breed zijn uitgevoerd voordat de jump plaats vindt, en de compiler zal je hierbij helpen. Dit is echter lang niet altijd mogelijk - er moet natuurlijk wel werk te doen zijn :).

En daarom bestaan er dus ook instructies zoals conditional moves, die geen invloed hebben op de instruction flow waardoor de CPU gewoon zo efficient mogelijk door kan gaan met het uitvoeren van instructies. Een stukje code als
C++:
1
2
if (b > 5)
    a = 10;

kan dan worden vertaald naar
asm:
1
2
cmp b, 5    ; vergelijk b met 5
cmovg a, 10     ; als groter, dan schrijf 10 naar a

Je ziet ook steeds vaker gebeuren dat bij het doen van veel berekeningen (met SSE bijvoorbeeld) if-statements helemaal niet als zodanig worden geassembleerd. De compiler genereert code die beide takken altijd uitvoert en vervolgens combineert door de een te vermenigvuldigen met 0 en de ander met 1, afhankelijk van een bepaalde conditie. Door het streaming karakter van SSE is dit veel voordeliger dan bijvoorbeeld in een algoritme vroeg bepaalde condities te controleren waardoor de berekening niet plaats zou hoeven vinden (de zgn. early-outs) - ze kosten namelijk op zich weinig cycles, maar hebben wel een lange pipeline.

(leesvoer)
 
 
Dergelijke conditional moves zijn een prima oplossing, het grote probleem hierbij is dat er maar amper assemblers zijn dit dit goed aankunnen - mogelijkerwijs dat de platform specifieke high-performance compilers van Intel hier mee om kunnen gaan, maar de gebruikelijke cl.exe kan dit zeer zeker niet. Vanuit dat opzicht is inline assembly gebruiken gewoon een hele goede optie als je dat efficient kan.

Meestal werk ik met x86 asm, vrij recent, ondanks dat nog niet de nieuwere instruction sets er bij mij goed inzitten. Verder komt daarbij dat moderne asm ook niet niet zo heel veel voorkomt, veel productiegames/applicaties worden nog steeds gemaakt met Visual Studio 2003, gezien niet iedereen even blij is met de extra requirements voor 2005 en verder. Toch bedankt voor je verhaal :)
 
 
VS doet dat idd niet helaas. Waarom weet ik niet precies, in het voorbeeld dat ik gaf zou het prima kunnen. Echter, als de cmov een memory operand heeft dan haalt hij dat geheugen op ongeacht de conditie, waardoor het in een constructie als de volgende al niet meer gebruikt kan worden:
C++:
1
2
3
4
5
int * p;
int a;
// ...
if (p)
    a = *p;

Als hier een cmov gebruikt zou worden, dan zou je een exception krijgen als de pointer null is. gcc en de intel compiler maken vziw wel gebruik van de cmov instructie (maar dan uiteraard niet in dit geval).

Wij zijn tijdens de ontwikkeling van Tomb Raider: Underworld overigens overgestapt naar VS 2005. Temeer omdat dat ook een requirement is voor de Xbox 360 SDK ;)
 
 
Indien je zelf kan voorspellen dat code een bepaald pad volgt (bv een null check op een pointer die eigenlijk nooit true kan zijn), en je werkt / kan werken met gcc kun je __builtin_expect() gebruiken. Op die manier zet gcc hetgeen wat het minst waarschijnlijk is in een branch blok, en de rest is sequentieel.

Indien je berekeningen gebruikt met de zelfde input (en dus ook output), zou je kunnen overwegen dit tegaan cachen. Floating point + context switches waren in het verleden altijd een slechte combinatie. Zaken zoals het gebruik van SSE kan ook een rol spelen, desnoods via een implementatie van bepaalde delen in assembler.
 
 
Dat kan met VC++ ook, met __assume(). Maar dan zal hij niet eens branchen - van de pointer wordt dan immers aangenomen dat hij niet 0 is, dus hoeft hij ook niets te controleren. Het is wel handig om het in een macro te wrappen zodat je in debug builds ook assert op dezelfde expressie (en eigenlijk zou je alle asserts dan zo moeten maken, want het is een win-win situatie: extra controle in debug builds, en betere performance in release builds)

De oude x87 FP stack mag sowieso wel sterven. Wij targetten CPU's met minstens SSE2 - alle floating point operaties kunnen dan middels SSE code gerealiseerd worden, die een stuk rapper zijn en minder last hebben van allerlei stalls.
 

VNU Media logo Powered by True

© 1998 - 2009 Tweakers.net - Alle rechten voorbehouden

Uitgever van: