W sytuacjach, w których wydajność ma największe znaczenie, kompilator C najprawdopodobniej nie wygeneruje najszybszego kodu w porównaniu do tego, co można zrobić z ręcznie dostrojonym językiem asemblera. Zwykle wybieram ścieżkę najmniejszego oporu - w przypadku małych procedur, takich jak ta, po prostu piszę kod ASM i wiem, ile cykli zajmie wykonanie. Możesz być w stanie bawić się kodem C i zmusić kompilator do wygenerowania dobrego wyniku, ale możesz stracić dużo czasu na dostrajanie wyjścia w ten sposób. Kompilatory (zwłaszcza firmy Microsoft) przeszły długą drogę w ciągu ostatnich kilku lat, ale nadal nie są tak inteligentne jak kompilator między uszami, ponieważ pracujesz nad swoją konkretną sytuacją, a nie tylko ogólnym przypadkiem. Kompilator może nie korzystać z pewnych instrukcji (np. LDM), które mogą to przyspieszyć, i to ” jest mało prawdopodobne, aby był wystarczająco inteligentny, aby rozwinąć pętlę. Oto sposób na zrobienie tego, który obejmuje 3 pomysły, o których wspomniałem w moim komentarzu: rozwijanie pętli, pobieranie wstępne z pamięci podręcznej i korzystanie z instrukcji wielokrotnego ładowania (ldm). Liczba cykli instrukcji wynosi około 3 zegarów na element tablicy, ale nie uwzględnia opóźnień pamięci.
Teoria działania: konstrukcja procesora ARM wykonuje większość instrukcji w jednym cyklu zegara, ale instrukcje są wykonywane w potoku. Kompilatory C będą próbowały wyeliminować opóźnienia potoków, przeplatając inne instrukcje pomiędzy. W przypadku przedstawienia ciasnej pętli, takiej jak oryginalny kod C, kompilator będzie miał trudności z ukryciem opóźnień, ponieważ wartość odczytana z pamięci musi zostać natychmiast porównana. Mój kod poniżej zmienia się między 2 zestawami 4 rejestrów, aby znacznie zmniejszyć opóźnienia samej pamięci i potoku pobierającego dane. Ogólnie rzecz biorąc, gdy pracujesz z dużymi zestawami danych, a Twój kod nie wykorzystuje większości lub wszystkich dostępnych rejestrów, nie uzyskujesz maksymalnej wydajności.
; r0 = count, r1 = source ptr, r2 = comparison value
stmfd sp!,{r4-r11} ; save non-volatile registers
mov r3,r0,LSR #3 ; loop count = total count / 8
pld [r1,#128]
ldmia r1!,{r4-r7} ; pre load first set
loop_top:
pld [r1,#128]
ldmia r1!,{r8-r11} ; pre load second set
cmp r4,r2 ; search for match
cmpne r5,r2 ; use conditional execution to avoid extra branch instructions
cmpne r6,r2
cmpne r7,r2
beq found_it
ldmia r1!,{r4-r7} ; use 2 sets of registers to hide load delays
cmp r8,r2
cmpne r9,r2
cmpne r10,r2
cmpne r11,r2
beq found_it
subs r3,r3,#1 ; decrement loop count
bne loop_top
mov r0,#0 ; return value = false (not found)
ldmia sp!,{r4-r11} ; restore non-volatile registers
bx lr ; return
found_it:
mov r0,#1 ; return true
ldmia sp!,{r4-r11}
bx lr
Aktualizacja:
w komentarzach jest wielu sceptyków, którzy uważają, że moje doświadczenie jest anegdotyczne / bezwartościowe i wymaga dowodu. Użyłem GCC 4.8 (z Android NDK 9C) do wygenerowania następującego wyjścia z optymalizacją -O2 (wszystkie optymalizacje włączone, w tym rozwijanie pętli ). Skompilowałem oryginalny kod C przedstawiony w powyższym pytaniu. Oto, co wyprodukowało GCC:
.L9: cmp r3, r0
beq .L8
.L3: ldr r2, [r3, #4]!
cmp r2, r1
bne .L9
mov r0, #1
.L2: add sp, sp, #1024
bx lr
.L8: mov r0, #0
b .L2
Wyjście GCC nie tylko nie rozwija pętli, ale także marnuje zegar na straganie po LDR. Wymaga co najmniej 8 zegarów na element tablicy. Dobrze radzi sobie z używaniem adresu, aby wiedzieć, kiedy wyjść z pętli, ale w tym kodzie nigdzie nie można znaleźć wszystkich magicznych rzeczy, które kompilatory są w stanie zrobić. Nie uruchomiłem kodu na platformie docelowej (nie mam takiej), ale każdy, kto ma doświadczenie w wydajności kodu ARM, może zobaczyć, że mój kod jest szybszy.
Aktualizacja 2:
Dałem szansę programowi Microsoft Visual Studio 2013 SP2 na lepsze wykorzystanie kodu. Był w stanie użyć instrukcji NEON do wektoryzacji mojej inicjalizacji tablicy, ale liniowe wyszukiwanie wartości napisane przez OP okazało się podobne do tego, co wygenerowało GCC (zmieniłem nazwy etykiet, aby uczynić je bardziej czytelnymi):
loop_top:
ldr r3,[r1],#4
cmp r3,r2
beq true_exit
subs r0,r0,#1
bne loop_top
false_exit: xxx
bx lr
true_exit: xxx
bx lr
Jak powiedziałem, nie posiadam dokładnego sprzętu OP, ale będę testować wydajność na nVidia Tegra 3 i Tegra 4 w 3 różnych wersjach i wkrótce opublikuję wyniki tutaj.
Aktualizacja 3:
Uruchomiłem swój kod i skompilowany przez Microsoft kod ARM na Tegra 3 i Tegra 4 (Surface RT, Surface RT 2). Uruchomiłem 1000000 iteracji pętli, która nie znajduje dopasowania, więc wszystko jest w pamięci podręcznej i jest łatwe do zmierzenia.
My Code MS Code
Surface RT 297ns 562ns
Surface RT 2 172ns 296ns
W obu przypadkach mój kod działa prawie dwa razy szybciej. Większość nowoczesnych procesorów ARM prawdopodobnie da podobne wyniki.