In een hobbyprojectje genereer ik dynamisch bytecode, waarbij optioneel ook code voor bounds checking gegenereerd kan worden. Natuurlijk verwachtte ik dat die extra checks overhead zouden opleveren waardoor de code trager loopt. In de praktijk blijkt soms (niet altijd) het tegenovergestelde het geval! Dat is erg vreemd omdat mét bounds checking alleen maar meer code uitgevoerd moet worden, dus nu wil ik (vooral uit nieuwsgierigheid) weten hoe dit precies komt.
Om uit te zoeken waar dat aan ligt, heb ik geprobeerd de code te profilen met oprofile, waarbij ik wel kan zien hoe vaak elke instructie uitgevoerd wordt, maar helaas niet hoe elke instructie precies uitgevoerd wordt in de processor pipeline (daar is helaas mijn CPU te oud voor).
Voor een relatief simpel maar wel representatief voorbeeld is de gegenereerde code als volgt:
Met bounds checking wordt dit echter:
Het tweede fragment bevat precies dezelfde instructies als het eerste, plus een aantal conditionele jumps (jnc, hier weergegeven als jae). De code die daarop volgt heb ik weggelaten omdat alle jumps altijd genomen worden (wat natuurlijk erg fijn is voor branch prediction). Ik heb in het eerste fragment witregels toegevoegd zodat de regelnummering overeenkomt met het tweede; verder betekent dat niets.
De eerste kolom is het aantal samples per instructie, de tweede ook maar dan als percentage van het totaal. De derde en vierde kolom zijn de geheugenadressen en de bijbehorende instructies. Onderaan heb ik het totaal neergezet, waaraan je ook kunt zien dat het tweede fragment ongeveer 16% sneller is dan de eerste, hoewel in beide gevallen de lus ongeveer 98% van de totale executietijd in beslag neemt.
Wat opvalt is dat in het eerste fragment de samples redelijk netjes verdeeld zijn over all instructies. In het tweede fragment zijn er echter instructies die slechts een handjevol samples hebben, waarbij het dus lijkt alsof instructies per twee of per drie gepaird worden (zie bijvoorbeeld regel 16-18 of 20-22). Dit kan te maken hebben met out-of-order-execution, want overlappende instructies kunnen uitgevoerd worden, maar moeten in volgorde met maximaal drie tegelijk geretired worden.
In eerste instantie vermoedde ik dat het iets met branch target alignment te maken had, maar zelfs als ik een nop instructie toevoeg ergens bovenaan (waardoor het eerste fragment 8-byte aligned wordt en het tweede fragment op een oneven adres terecht komt) blijft het verschil bestaan.
De vraag bij dit hele verhaal is dus: hoe is het te verklaren dat het sneller is om meer (niet eens andere!) instructies uit te voeren, en hoe kan ik in het eerste geval code genereren die minstens zo efficiënt is als in het tweede geval?
Om uit te zoeken waar dat aan ligt, heb ik geprobeerd de code te profilen met oprofile, waarbij ik wel kan zien hoe vaak elke instructie uitgevoerd wordt, maar helaas niet hoe elke instructie precies uitgevoerd wordt in de processor pipeline (daar is helaas mijn CPU te oud voor).
Voor een relatief simpel maar wel representatief voorbeeld is de gegenereerde code als volgt:
GAS:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| 358 4.4778 : 400697: subb $0x1,(%rax) 449 5.6160 : 40069a: movzbq (%rax),%rcx 525 6.5666 : 40069e: add %cl,0x1(%rax) 731 9.1432 : 4006a1: movb $0x0,(%rax) 858 10.7317 : 4006a4: add $0xffffffffffffffff,%rax 858 10.7317 : 4006a8: movzbq (%rax),%rcx 299 3.7398 : 4006ac: add %cl,0x1(%rax) 486 6.0788 : 4006af: movb $0x0,(%rax) 602 7.5297 : 4006b2: add $0xffffffffffffffff,%rax 401 5.0156 : 4006b6: movzbq (%rax),%rcx 451 5.6410 : 4006ba: add %cl,0x1(%rax) 382 4.7780 : 4006bd: movb $0x0,(%rax) 394 4.9281 : 4006c0: addb $0x1,(%rax) 386 4.8280 : 4006c3: add $0x3,%rax 313 3.9149 : 4006c7: cmpb $0x0,(%rax) 358 4.4778 : 4006ca: jne 400697 ---- ------- 7851 98.1987 |
Met bounds checking wordt dit echter:
GAS:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| 2 0.0300 : 40072e: subb $0x1,(%rax) 503 7.5526 : 400731: jae 400740 : 400733: .. 384 5.7658 : 400740: movzbq (%rax),%rcx 25 0.3754 : 400744: add %cl,0x1(%rax) : 400747: jae 400753 : 400749: .. 1366 20.5105 : 400753: movb $0x0,(%rax) 7 0.1051 : 400756: add $0xffffffffffffffff,%rax : 40075a: movzbq (%rax),%rcx 488 7.3273 : 40075e: add %cl,0x1(%rax) 17 0.2553 : 400761: jae 40076d : 400763: .. 664 9.9700 : 40076d: movb $0x0,(%rax) 4 0.0601 : 400770: add $0xffffffffffffffff,%rax 634 9.5195 : 400774: movzbq (%rax),%rcx : 400778: add %cl,0x1(%rax) : 40077b: jae 400787 : 40077d: .. 719 10.7958 : 400787: movb $0x0,(%rax) 3 0.0450 : 40078a: addb $0x1,(%rax) : 40078d: jae 40079c : 40078f: .. 870 13.0631 : 40079c: add $0x3,%rax 10 0.1502 : 4007a0: cmpb $0x0,(%rax) 879 13.1982 : 4007a3: jne 40072e ---- ------- 6575 98.7239 |
Het tweede fragment bevat precies dezelfde instructies als het eerste, plus een aantal conditionele jumps (jnc, hier weergegeven als jae). De code die daarop volgt heb ik weggelaten omdat alle jumps altijd genomen worden (wat natuurlijk erg fijn is voor branch prediction). Ik heb in het eerste fragment witregels toegevoegd zodat de regelnummering overeenkomt met het tweede; verder betekent dat niets.
De eerste kolom is het aantal samples per instructie, de tweede ook maar dan als percentage van het totaal. De derde en vierde kolom zijn de geheugenadressen en de bijbehorende instructies. Onderaan heb ik het totaal neergezet, waaraan je ook kunt zien dat het tweede fragment ongeveer 16% sneller is dan de eerste, hoewel in beide gevallen de lus ongeveer 98% van de totale executietijd in beslag neemt.
Wat opvalt is dat in het eerste fragment de samples redelijk netjes verdeeld zijn over all instructies. In het tweede fragment zijn er echter instructies die slechts een handjevol samples hebben, waarbij het dus lijkt alsof instructies per twee of per drie gepaird worden (zie bijvoorbeeld regel 16-18 of 20-22). Dit kan te maken hebben met out-of-order-execution, want overlappende instructies kunnen uitgevoerd worden, maar moeten in volgorde met maximaal drie tegelijk geretired worden.
In eerste instantie vermoedde ik dat het iets met branch target alignment te maken had, maar zelfs als ik een nop instructie toevoeg ergens bovenaan (waardoor het eerste fragment 8-byte aligned wordt en het tweede fragment op een oneven adres terecht komt) blijft het verschil bestaan.
De vraag bij dit hele verhaal is dus: hoe is het te verklaren dat het sneller is om meer (niet eens andere!) instructies uit te voeren, en hoe kan ik in het eerste geval code genereren die minstens zo efficiënt is als in het tweede geval?