InfoC adventi naptár
Duff's device
Tegyük fel, hogy egy megadott méretű memóriaterületet kell másolnunk.
Példaprogramként szerepeljen itt egyszerűen egy sztring, aminek előre tudjuk
a hosszát. count
a másolandó bájtok száma; to
a cél mutató,
from
a forrás mutató. Minden bájt átmásolása (*to=*from
)
után mindkét pointert meg kell növelni eggyel (to++, from++
), hogy a következő
bájtokra mutassanak. Mindezt annyiszor, amennyi a másolandó bájtok száma:
#include <stdio.h> #include <string.h> int main() { char f[]="hellohellohellohellohellohellohello", t[40]; int count; char *to=t, *from=f; for (count=strlen(f)+1; count>0; count--) *to++=*from++; printf("%s\n", t); return 0; }
A program „hasznos” részei a ciklus törzsébe,
a „haszontalan” részei pedig a fejlécbe kerültek. Egy bájt másolásához
a számunka fontos művelet az értékadás, és a két pointer növelése. Tulajdonképp
haszontalan a count
változó csökkentése, amelyet minden bájt másolása
után elvégzünk; ennek maradandó nyoma nem marad a memóriában, csak azért van
rá szükség, hogy tudjuk, hányszor kell végrehajtani a ciklus belsejében lévő
kódrészletet. Ugyancsak haszontalan az eredmény szempontjából az összehasonlítás
(count>0
), amelyet úgyszint minden karakter után megcsinálunk;
és az ugrás, hogy a ciklus törzse után vissza kell mindig ugrani a feltétel
ellenőrzéséhez. Az érdekesség kedvéért álljon itt az optimalizálás nélkül
generált assembly kód. Nem olyan vészes, meg lehet érteni a működést, ha
megsejtjük, mit jelentenek az utasítások:
idegzetűeknek
call strlen ; strlen hívása (call) addl $1, %eax ; 1 hozzáadása (addition) movl %eax, -100(%rbp) ; az eredmény írása a count változóba jmp .L2 ; ugrás a feltétel ellenőrzéséhez (jump) .L3: movq -120(%rbp), %rax ; from pointer a memóriából movzbl (%rax), %edx ; mutatott bájt beolvasása movq -112(%rbp), %rax ; to pointer movb %dl, (%rax) ; bájt írása addq $1, -112(%rbp) ; to növelése addq $1, -120(%rbp) ; from növelése subl $1, -100(%rbp) ; count csökkentése (subtraction) .L2: cmpl $0, -100(%rbp) ; count = 0? (compare) jg .L3 ; ugrás ha nagyobb (jump if greater)
Valami ilyesmi. Látható, hogy a ciklus megszervezése miatt sok a járulékos művelet minden egyes bájt másolásához. Az ugrás, hogy a ciklustörzs újabb végrehajtásához annak elejére kell lépnie a processzornak, különösen kártékony. A mai processzorok képesek egyszerre több utasítást feldolgozni párhuzamosan; amikor azonban a kódban egy ugrás van, akkor a félig már feldolgozott (memóriából már beolvasott, esetleg már dekódolt) utasításokat el kell dobni.
Ha a ciklus törzsét leírnánk egymás után annyiszor, ahányszor kell, akkor ezeket a járulékos műveleteket meg tudnánk spórolni:
… *to++=*from++; *to++=*from++; *to++=*from++; *to++=*from++; *to++=*from++; …
Ez az assemblyben programozásnál közismert technika (loop unrolling vagy loop unwinding).
Ha nem ismerjük előre
a másolandó bájtok számát, ez akkor is könnyen megoldható: a bájtmásolások
sorozatának közepére ugorva. Érdekes ilyen szempontból a C nyelv,
ugyanis ez abban is megvalósítható, ha elhagyjuk a switch()
-ből
a break
-eket, amelyik
kiugrana a vezérlési szerkezet közepéből. A switch()
lényegében
egy táblázatos goto
-nak felel meg, ahol az egyes címkéket
a case
kulcsszavak határozzák meg.
A lenti kód count=0…10
értékeire működik helyes adatmásolóként:
switch (count) { case 10: *to++=*from++; case 9: *to++=*from++; case 8: *to++=*from++; case 7: *to++=*from++; case 6: *to++=*from++; case 5: *to++=*from++; case 4: *to++=*from++; case 3: *to++=*from++; case 2: *to++=*from++; case 1: *to++=*from++; case 0: /* itt mar nem kell semmit */ }
Általánosítás
A C-ben a switch()-case
szerkezetet ténylegesen egy táblazatos
goto
. Olyannyira, hogy a switch()
szerkezetesen belül bármilyen, szintaktikailag helyes
utasítássorozat szerepelhet, amelyben az egyes utasítások közé szúrhatóak a case
-ek.
Még azt is megengedi a nyelv, hogy egy ciklus belsejébe beugorjunk egy goto
utasítással. És még azt is, hogy egy ciklus belsejébe ugorjunk egy case
-es címkével!
Tom Duffnak a következő ötlete támadt ezzel kapcsolatban. A fenti, maximum tíz bájtot másolni
képes kódrészlet a következő módon általánosítható:
#include <stdio.h> #include <string.h> int main() { char f[]="hellohellohellohellohellohellohello", t[40]; int count=strlen(f)+1; register char *to=t, *from=f; register int n=(count+7)/8; switch (count%8) { do { case 0: *to++=*from++; case 7: *to++=*from++; case 6: *to++=*from++; case 5: *to++=*from++; case 4: *to++=*from++; case 3: *to++=*from++; case 2: *to++=*from++; case 1: *to++=*from++; } while(--n>0); } printf("%s\n", t); return 0; }
A fenti kódrészlet úgy működik, hogy a másolásokat 8-as csoportokra osztja. Az n
változó tárolja, hogy hányszor kell végrehajtani a 8-as másolást (vagyis hányszor 8 bájtról van
szó). Ha a másolandó terület mérete nem osztható 8-cal, akkor pedig a
switch(count%8)
segítségével a másolás belsejére ugrik; oda, hogy pont annyi bájt kerüljön át pluszba,
amekkora a nyolcas csoportokhoz képest a maradék. A register
kulcsszóval arra lehet utalni a
fordítónak, hogy azt a változót esetleg érdemes a processzor egyik regiszterébe tenni, mivel
gyakran használja a program.
Jó ez? Igen is meg nem is. Elvileg gyorsabb, hiszen a másolásokhoz tartozó járulékos műveletek számát nyolcadára csökkentettük. A kód olvashatósága viszont romlott. Továbbá az egyes fordítók eltérő hatékonysággal képesek kezelni a szokatlan kódot. Van, amelyik lassabban futó assembly kódot generál. Legtöbbször jobb ezt éppen ezért a fordítóra bízni: megfelelő optimalizálási beállítások mellett a jó fordítók automatikusan használják az ilyen trükköket.