Forsida

Temaer

Sjangere

Raskeste måte å bytte to tall

Snarveier:
[ « Forrige ] [ Neste » ]

Tema: Vitenskap; sjanger: Artikler
Skrevet av Andreas Nordal den 12. oktober 2009 kl 03:47:21; Kommentarer: 8

Å bytte om verdiene på to tall er en problemstilling som man ofte kommer over når man programmerer. Det er en viktig del av mange algoritmer, særlig sorteringsalgoritmer, og selvfølgelig en masse andre som man ikke skulle tro hadde noe med bytting av tall å gjøre. Dessuten er det en typisk operasjon som dataprogrammet kanskje gjør veldig mange ganger. Dette er så enkelt og viktig at det fins liten unnskyldning for å gjøre det feil.

Testprogram

Jeg har testet de 4 metodene i C/C++ som jeg syns var mest aktuelle (i håp om å finne den raskeste), samt 3 assembly-modifikasjoner av disse. Til sammen 7 tester nummerert fra 0 til 6. Følgende kildekode i C/C++ vil, som vi straks skal se, kunne kompileres til 4 forskjellige programmer.

/* testbenk.c */

#include <stdio.h>
#include <inttypes.h>
#ifdef __cplusplus
# include <algorithm> //std::swap
#endif


int main(){
#ifdef SWAP
        int a=666, b=13;
        register int32_t i=0;
        do{//Bytt om a og b 2^32 ganger
# if SWAP==0
                a ^= b;
                b ^= a;
                a ^= b;
# elif SWAP==1
#  ifndef __cplusplus
#  error "Dette er C++"
#  endif

                std::swap(a, b);
# elif SWAP==2
                int temp = a;
                a = b;
                b = temp;
# elif SWAP==3
                register int temp = a;
                a = b;
                b = temp;
# endif
        }while(++i);
        printf("%d %d\n", a, b);
#endif //defined SWAP
        return 0;
}

Gransking av assembly

Når vi kompilerer, la oss gå veien om assembly, og titte på hva den stakkars prosessoren plages med:

gcc -S -DSWAP=0 testbenk.c -o swap0.s
g++ -S -DSWAP=1 testbenk.c -o swap1.s
gcc -S -DSWAP=2 testbenk.c -o swap2.s
gcc -S -DSWAP=3 testbenk.c -o swap3.s

Her ser vi utdrag av hva kildekoden koker ned til av instruksjoner i hvert av de 4 tilfellene. Merk at assemblykode er forskjellig fra maskin til maskin; disse utdragene er fra den første testen (se resultater). Det som har med tallbytting å gjøre er markert med feit skrift.

Utdrag fra swap0.s:

        movl    $666, -8(%rbp)
        movl    $13, -4(%rbp)
        movl    $0, -20(%rbp)
.L2:
        movl    -4(%rbp), %eax
        xorl    %eax, -8(%rbp)
        movl    -8(%rbp), %eax
        xorl    %eax, -4(%rbp)
        movl    -4(%rbp), %eax
        xorl    %eax, -8(%rbp)

        addl    $1, -20(%rbp)
        cmpl    $0, -20(%rbp)
        jne     .L2

Utdrag fra swap1.s:

_ZSt4swapIiEvRT_S1_:
        pushq   %rbp
        movq    %rsp, %rbp
        movq    %rdi, -24(%rbp)
        movq    %rsi, -32(%rbp)
        movq    -24(%rbp), %rax
        movl    (%rax), %eax
        movl    %eax, -4(%rbp)
        movq    -32(%rbp), %rax
        movl    (%rax), %edx
        movq    -24(%rbp), %rax
        movl    %edx, (%rax)
        movq    -32(%rbp), %rdx
        movl    -4(%rbp), %eax
        movl    %eax, (%rdx)

        leave
        ret
        movl    $666, -4(%rbp)
        movl    $13, -8(%rbp)
        movl    $0, -20(%rbp)
.L4:
        leaq    -8(%rbp), %rsi
        leaq    -4(%rbp), %rdi

        call    _ZSt4swapIiEvRT_S1_
        addl    $1, -20(%rbp)
        cmpl    $0, -20(%rbp)
        setne   %al
        testb   %al, %al
        jne     .L4

Utdrag fra swap2.s:

        movl    $666, -12(%rbp)
        movl    $13, -8(%rbp)
        movl    $0, -20(%rbp)
.L2:
        movl    -12(%rbp), %eax
        movl    %eax, -4(%rbp)
        movl    -8(%rbp), %eax
        movl    %eax, -12(%rbp)
        movl    -4(%rbp), %eax
        movl    %eax, -8(%rbp)

        addl    $1, -20(%rbp)
        cmpl    $0, -20(%rbp)
        jne     .L2

Utdrag fra swap3.s:

        movl    $666, -8(%rbp)
        movl    $13, -4(%rbp)
        movl    $0, -20(%rbp)
.L2:
        movl    -8(%rbp), %edx
        movl    -4(%rbp), %eax
        movl    %eax, -8(%rbp)
        movl    %edx, -4(%rbp)

        addl    $1, -20(%rbp)
        cmpl    $0, -20(%rbp)
        jne     .L2

Optimalisering av assembly

swap4.s: Når det fornuftstridig viste seg under testene at såkalt registerswap var treigere enn vanlig temp-swap, tydet det på at koden i swap3.s ikke var optimal. Like fullt, det er mulig å se at swap3.s burde ha vært raskere enn swap2.s. Et bevis for dette er at ved å gjøre om "-8(%rbp)" til "-12(%rbp)" og "-4(%rbp)" til "-8(%rbp)" i swap3.s, og å variere bruken av registere litt i swap2.s, oppnår vi at den eneste forskjellen mellom disse 2 filene er 2 linjer ekstra i swap2.s, samtidig som virkemåten til begge programmene er bevart:

; swap2.s (modifisert)
movl    -12(%rbp), %edx
movl    %edx, -4(%rbp)
movl    -8(%rbp), %eax
movl    %eax, -12(%rbp)
movl    -4(%rbp), %edx
movl    %edx, -8(%rbp)
; swap3.s (modifisert) aka swap4.s
movl    -12(%rbp), %edx

movl    -8(%rbp), %eax
movl    %eax, -12(%rbp)

movl    %edx, -8(%rbp)

Den modifiserte swap3.s ble lagret som swap4.s.


5: En skulle kanskje tro at xchg (exchange) hørtes ut som en ideell instruksjon for vårt formål. Med utgangspunkt i swap4.s lagde jeg swap5.s:

; swap5.s
movl    -12(%rbp), %eax
xchgl   -8(%rbp), %eax
movl    %eax, -12(%rbp)

6: Ved hjelp av "inc" i stedet for "add" og å bruke registeret eax som tellevariabel, ble swap4.s forbedret til swap6.s:

        movl    $666, -12(%rbp)
        movl    $13, -8(%rbp)
        movl    $0, %eax
.L2:
        movl    -12(%rbp), %edx
        movl    -8(%rbp), %ebx
        movl    %ebx, -12(%rbp)
        movl    %edx, -8(%rbp)
        incl    %eax
        cmpl    $0, %eax
        jne     .L2

Testprosedyre

gcc -s swap0.s -o swap0
g++ -s swap1.s -o swap1
gcc -s swap2.s -o swap2
gcc -s swap3.s -o swap3
gcc -s swap4.s -o swap4
gcc -s swap5.s -o swap5
gcc -s swap6.s -o swap6
gcc -s swap7.s -o swap7

Hadde det ikke vært for at vi må bruke g++ i ett tilfelle:

for i in *.s; do gcc -s $i -o ${i%.?}; done

bash$ time ./swap1
666 13 #Uinteressant: Programmet virker.

real 0m27.270s #Uinteressant: Så lang tid det faktisk tok.
user 0m27.226s #Interessant: CPU-tid i user-mode.
sys 0m0.040s #Interessant: CPU-tid i protected-mode.

Kjøretiden ble målt som i eksempelet over. Som antydet, er det summen av CPU-tid som teller, altså user + sys. Når det står flere tall i samme celle i tabellen, er det fordi jeg har testet flere ganger. Tiden er i sekunder.

Resultater

Maskin0: XOR-swap1: std::swap2: temp-swap3: register­swap (gcc)4: register­swap (fiksa)5: xchg-swap6: Så bra jeg kan i assembly
ny laptop: Intel Core 2 Duo T8100 2,1GHz (2 kjerner), GCC 4.3.2, Linux 2.6.27.29 x86_6434,226s 34,066s27,266s 27,306s11,629s 11,805s12,485s 12,565s9,737s 9,781s72,569s 72,817s9,465s 9,397s
gammel stasjonær: AMD Athlon XP 2600+ 1,9GHz, GCC 4.3.0, Linux 2.6.27.25 i68639,19s47,20s14,16s16,17s12,41s49,47s8.93s
ny server: Intel Xeon 3,2 GHz (4 prosessorer), GCC 4.2.4, Linux 2.6.24-24 i68620,48s 20,35s40,77s 40,44s10,61s 10,72s8,18s 8,26s6,92s 7,13s
gammel server: Intel Pentium III Copper­mine 936MHz (2 prosessorer), GCC 4.1.1, Linux 2.6.18 i68687,34s 87,38s111,22s 109,07s36,85s 36,83s23,39s 23,79s18,41s 18,40s
gammel laptop: Intel Pentium 4 2,0GHz, GCC 4.3.2, Linux 2.6.27 i686116,471s 116,219s105,799s 100,218s40,050s 37,802s28,274s 27,65022,333s 21,786s
server: Intel Xeon 2,83 GHz, GCC 4.2.4, Linux 2.6.2428,12s23,88s10,16s8,74s7,71s

Diskusjon

0 (XOR-swap): Så elegant, men akk så treigt. XOR-swap fører til stans i prosessorens samlebånd, fordi hver instruksjon må vente på resultatet av den forrige. Grunnen til at moderne prosessorer kan ha klokkefrekvenser over et par hundre MHz er bruken av samlebånd.

1 (std::swap): Et funksjonskall tar ekstra tid, og indirekte adressering gir lang kode. Programmet swap1 ble forresten 8 byte større enn hver av de andre. Std::swap er rett og slett dømt til å være treig.

2-4 (temp-swap vs registerswap): Jeg kan ikke kåre noen vinner mellom disse 2. Selv om all fornuft sier at det skal være raskere å bruke et register til mellomlagring framfor å bruke RAM, stemte det ikke med mitt program i C. Jeg har vist at dette skyldes GCCs ugunstige plassering av variablene på stakken, og at registerswap faktisk ble raskere enn temp-swap ved å plassere variablene som i temp-swap. Ikke vet jeg om fenomenet skyldes cache-kollisjon eller hva det er. Det ser ut til å være reproduserbart på flere maskiner, men det hele kan jo være en tilfeldighet ved akkurat mitt program.

5 (xchg): Er dette en ubrukelig instruksjon?

6 (Så bra jeg kan i assembly): Dette vil funke i alle fall på i386.

Kommentarer (8)

Stig Magnus Halvorsen - 13. oktober 2009 kl 11:25:08

Hva med å legge tallene i en array i C++ for å så kjøre en sorteringsfunskjon, deretter skrive til skjerm? Et slikt program lagde jeg i Java og den var mye mindre innviklet enn den C++-koden du skrev (Ikke at jeg kritiserer C++, liker det best selv)
Andreas Nordal - 14. oktober 2009 kl 00:28:10

Jeg tenkte jeg skulle holde meg til det enkle. Jeg er interessert i å finne et fasitsvar.
Andreas Nordal - 14. oktober 2009 kl 00:30:25

Hva syns du om datamaskinene mine?
Stig Magnus Halvorsen - 14. oktober 2009 kl 21:38:01

Hver og en av de er jo ganske gamle, men at du har fått tak i så mange er imponerende. Skolepcer?
Helt ålreite tider =)
Andreas Nordal - 15. oktober 2009 kl 23:27:03

Gamle du liksom. Det er 2 gamle maskiner i testen, de med Pentium-prosessorer. 10 prosessorkjerner i 5 testmaskiner så langt: Dette er bra saker.
Det heter "hver av dem".
Peter Cordes - 2. desember 2009 kl 06:48:31

I don't speak Norwegian, so I had to use google translate. Hopefully you can read my comments in English.

I found this page from http://en.wikipedia.org/wiki/XOR_swap_algorithm#cite_note-2

The results are pretty much meaningless because you used gcc without optimization. With optimization, the "register" keyword should have no effect. The asm for the xor swap loads the variable from the stack frame before xoring with a memory operand as the destination. If you need to swap two values that are in memory, but you only have one free register to play with, it's probably fastest to spill another register (e.g. mov %eax, -8(%rbp)), then load both values, and store them to each other's old location. You get the swap for free. xor swap would require 3 read-modify-write memory cycles, which is completely ridiculous. Have a look at some instruction timings on http://www.agner.org/optimize/

If you're swapping local variables (not through points) like you're trying to test here, it's always going to be faster to use a temporary. gcc will optimize it away. gcc knows where each value is, so when the value is needed, it will use whatever register the current value of a or b is in. (The only time it actually might have to issue a mov instruction is if the swap only happens in a if() block, or similar, and needs to put the values into the correct locations to set up for following code.) That's why the only way you were able to get gcc to not optimize it away entirely was to leave out -O2, I imagine.

Maybe you could test the different swap methods with a loop that has an if( something ) in it, where "something" can't be optimized away. maybe an extern const int that's set to 1 in another file. don't use a random number or anything, because the branch mis-prediction stalls would be way longer than even xor swap.

std::swap should inline to the same code as using a temp variable, with optimization. Without, I'm not surprised it sucks.

BTW, if you're testing on some machines that support amd64 and some that only support ia32, you should probably show your asm sources in ia32. (so use gcc -m32).

I updated wiki with some changes.

Have fun playing with asm :)
Peter Cordes - 2. desember 2009 kl 19:34:11

Forgot to mention:

The fastest way to do the loop-exit test is
addl $1, %eax
jne .L4

Yes, add 1 is better than inc, even though it takes more bytes, because inc only writes some of the flags. Most CPUs will have a partial-flag stall if you test the flags right after.

inc and then cmp against zero is just a waste of time. But it is much better than gcc's non-optimized code that uses memory operands.

I was playing around with xchg vs. 3 mov instructions. xchg seems to be a tiny bit faster on its own (on 65nm Core2, conroe) 2.2s vs. 2.3s for my unrolled loop with a different loop count, so times aren't comparable to yours. But mov would usually be a better choice because you can mix the mov instructions in with previous instructions.

Now I can't reproduce the difference, and both versions run at 2.35s on my 2.4GHz E6600 CPU. (cpufreq-set -g performance -c0; ... -c1 to make sure CPUs are at full speed. Didn't bother with taskset to pin it to one CPU with realtime priority, though, and I didn't pull out oprofile to see what the limiting factor is, because I'm lazy.)

have fun with this code:

--- xchg-test.S ---
#include <sys/syscall.h>
# gcc -DUSE_XCHG=0 -m32 -nostdlib xchg-test.S && objdump -d a.out && time ./a.out

# for minimal binary size, use gcc -c && ld -s, not gcc (collect2).
# objcopy -j .text -O elf64-x86-64 a.out b.out to keep only the .text section

.text
.align 4096 # page boundary
.globl _start
_start:
# xor %ecx, %ecx
mov $-1000000000, %ecx

# mov $-11, %ecx
# dec %ecx # uncomment to alter number of loops by one

mov $1, %eax # values to swap
mov $2, %ebx
# .align 16
.Lloop:
#if USE_XCHG
xchg %eax, %ebx
xchg %eax, %ebx
xchg %eax, %ebx
addl $1, %ecx
# inc %ecx
#else
mov %eax, %edx ; mov %ebx, %eax ; mov %edx, %ebx
mov %eax, %edx ; mov %ebx, %eax ; mov %edx, %ebx


mov %eax, %edx ;
mov %ebx, %eax ;
mov %edx, %ebx
# addl $1, %ecx # with some unrolling, best place for this is at the end, it turns out. maybe avoids a ROB read port stall for the flags?
inc %ecx # actually inc seems faster in this loop
#endif
jne .Lloop

mov %eax, %edi # first arg: exit status in %rdi (%ebx on ia32)
mov $SYS_exit, %eax # syscall number from syscall.h
#ifdef __amd64__
syscall # amd64 way
#else
int $0x80 # ia32 way
#endif

# .byte 0x0F, 0xB9
ud2 # die with SIGILL if it returns
------

The loop is unrolled to do three swaps, since the loop overhead is huge otherwise.

--
peter@cordes.ca
Andreas Nordal - 3. desember 2009 kl 01:06:43

Thank you Peter, for excellent comments and for sharing your insight.

I agree that using gcc with optimizations wold be (probably more) interesting. However, from similar tests I had done before, -O3 might even optimize away loops entirely. For the sake of control, I figured that testing without optimizations would be safest. Your suggestion of using an extern variable, sounds like a good idea.

Your assembly ran in around 11.6 seconds on my Core 2 Duo laptop, with %ecx initially set to 0. You are not swapping variables in RAM, which was my goal (having practiced writing sorting algorithms this semester), but you are also swapping 3 times more in each iteration.

Your assembly gave me something to think about. I didn't know you could drop the cmp instruction. Haven't thought about unrolling loops either, only linked lists. Cool.

Din kommentar

Navn:
[url] og [url tekst] kan brukes for å lage lenker.
Gjenta: VIRKER IKKE