Odpowiadając na inne pytanie przepełnienia stosu ( to ) natknąłem się na interesujący pod-problem. Jaki jest najszybszy sposób na posortowanie tablicy 6 liczb całkowitych?
Ponieważ pytanie jest bardzo niskie:
- nie możemy zakładać, że biblioteki są dostępne (a samo połączenie ma swój koszt), tylko zwykłe C
- aby uniknąć opróżniania potoku instrukcji (co ma bardzo wysoki koszt), powinniśmy prawdopodobnie zminimalizować rozgałęzienia, skoki i wszelkie inne rodzaje przerywania przepływu sterowania (takie jak te ukryte za punktami sekwencji w
&&
lub||
). - Pokój jest ograniczony, a minimalizacja rejestrów i wykorzystanie pamięci to problem, najlepiej w miejscu sortowanie jest prawdopodobnie najlepsze.
Naprawdę to pytanie jest rodzajem golfa, w którym celem nie jest zminimalizowanie długości źródła, ale czas wykonania. Nazywam to kodem „Zeninga”, jak użyto w tytule książki Zen of Code optimization autorstwa Michaela Abrasha i jego kontynuacji .
Jeśli chodzi o to, dlaczego jest to interesujące, istnieje kilka warstw:
- przykład jest prosty i łatwy do zrozumienia i zmierzenia, nie wymaga dużej znajomości języka C.
- pokazuje efekty wyboru dobrego algorytmu dla problemu, ale także efekty kompilatora i podstawowego sprzętu.
Oto moja referencyjna (naiwna, niezoptymalizowana) implementacja i mój zestaw testowy.
#include <stdio.h>
static __inline__ int sort6(int * d){
char j, i, imin;
int tmp;
for (j = 0 ; j < 5 ; j++){
imin = j;
for (i = j + 1; i < 6 ; i++){
if (d[i] < d[imin]){
imin = i;
}
}
tmp = d[j];
d[j] = d[imin];
d[imin] = tmp;
}
}
static __inline__ unsigned long long rdtsc(void)
{
unsigned long long int x;
__asm__ volatile (".byte 0x0f, 0x31" : "=A" (x));
return x;
}
int main(int argc, char ** argv){
int i;
int d[6][5] = {
{1, 2, 3, 4, 5, 6},
{6, 5, 4, 3, 2, 1},
{100, 2, 300, 4, 500, 6},
{100, 2, 3, 4, 500, 6},
{1, 200, 3, 4, 5, 600},
{1, 1, 2, 1, 2, 1}
};
unsigned long long cycles = rdtsc();
for (i = 0; i < 6 ; i++){
sort6(d[i]);
/*
* printf("d%d : %d %d %d %d %d %d\n", i,
* d[i][0], d[i][6], d[i][7],
* d[i][8], d[i][9], d[i][10]);
*/
}
cycles = rdtsc() - cycles;
printf("Time is %d\n", (unsigned)cycles);
}
Surowe wyniki
Ponieważ liczba wariantów staje się duża, zebrałem je wszystkie w pakiecie testowym, który można znaleźć tutaj . Rzeczywiste zastosowane testy są nieco mniej naiwne niż te pokazane powyżej, dzięki Kevin Stock. Możesz go skompilować i wykonać we własnym środowisku. Jestem bardzo zainteresowany zachowaniem różnych architektur docelowych / kompilatorów. (OK, podajcie odpowiedzi, daję +1 każdemu autorowi nowego zestawu wyników).
Dałem odpowiedź Danielowi Stutzbachowi (do gry w golfa) rok temu, ponieważ był on u źródła najszybszego rozwiązania w tym czasie (sieci sortowania).
Linux 64 bity, gcc 4.6.1 64 bity, Intel Core 2 Duo E8400, -O2
- Bezpośrednie wywołanie funkcji biblioteki qsort: 689,38
- Naiwna implementacja (sortowanie wstawek): 285,70
- Sortowanie wtrąceniowe (Daniel Stutzbach): 142.12
- Wstawianie sortowania rozwinięte: 125,47
- Ranga: 102,26
- Ranga Zamówienie z rejestrami: 58,03
- Sortowanie sieci (Daniel Stutzbach): 111,68
- Sorting Networks (Paul R): 66.36
- Sortowanie sieci 12 z funkcją Fast Swap: 58,86
- Sorting Networks 12 zmieniona kolejność Zamień: 53,74
- Sorting Networks 12 uporządkowana prosta zamiana: 31.54
- Zmieniona sieć sortowania z szybką wymianą: 31,54
- Zmieniona sieć sortowania z szybką wymianą V2: 33.63
- Sortowanie bąbelkowe (Paolo Bonzini): 48,85
- Unrolled Insertion Sort (Paolo Bonzini): 75.30
Linux 64 bity, gcc 4.6.1 64 bity, Intel Core 2 Duo E8400, -O1
- Bezpośrednie wywołanie funkcji bibliotecznej qsort: 705.93
- Naiwna implementacja (sortowanie wstawek): 135,60
- Sortowanie wtrąceniowe (Daniel Stutzbach): 142.11
- Wstawianie Sortowanie rozwinięte: 126,75
- Porządek rangi: 46,42
- Porządek z rejestrami: 43,58
- Sortowanie sieci (Daniel Stutzbach): 115,57
- Sortowanie sieci (Paul R): 64,44
- Sortowanie sieci 12 z funkcją Fast Swap: 61,98
- Sorting Networks 12 zmieniona kolejność Zamień: 54,67
- Sorting Networks 12 uporządkowana prosta zamiana: 31.54
- Zmieniona sieć sortowania z szybką wymianą: 31,24
- Zmiana kolejności sortowania w / fast swap V2: 33.07
- Sortowanie bąbelkowe (Paolo Bonzini): 45,79
- Unrolled Insertion Sort (Paolo Bonzini): 80,15
Dołączyłem zarówno wyniki -O1, jak i -O2, ponieważ zaskakująco w przypadku kilku programów O2 jest mniej wydajne niż O1. Zastanawiam się, jaką konkretną optymalizację ma ten efekt?
Komentarze do proponowanych rozwiązań
Sortowanie wtrąceniowe (Daniel Stutzbach)
Zgodnie z oczekiwaniami minimalizacja oddziałów jest rzeczywiście dobrym pomysłem.
Sorting Networks (Daniel Stutzbach)
Lepsze niż sortowanie przez wstawianie. Zastanawiałem się, czy głównym efektem nie było uniknięcie pętli zewnętrznej. Spróbowałem, sprawdzając niepoprawne wstawianie, i rzeczywiście otrzymujemy mniej więcej te same liczby (kod jest tutaj ).
Sortowanie sieci (Paul R)
Najlepsze do tej pory. Rzeczywisty kod, którego użyłem do testowania, jest tutaj . Nie wiem jeszcze, dlaczego jest prawie dwa razy szybszy niż inna implementacja sieci sortującej. Przekazywanie parametrów? Szybki maks?
Sortowanie sieci 12 SWAP z funkcją Fast Swap
Jak zasugerował Daniel Stutzbach, połączyłem jego sieć sortowania 12 swapów z bezgałęziową szybką zamianą (kod jest tutaj ). Jest rzeczywiście szybszy, najlepszy do tej pory z niewielkim marginesem (około 5%), jak można się spodziewać przy 1 zamianie mniejszej.
Interesujące jest również zauważenie, że zamiana bez rozgałęzień wydaje się znacznie (4 razy) mniej wydajna niż prosta przy użyciu architektury PPC.
Calling Library qsort
Aby podać kolejny punkt odniesienia, próbowałem również, jak sugerowano, po prostu wywołać bibliotekę qsort (kod jest tutaj ). Zgodnie z oczekiwaniami jest znacznie wolniejszy: 10 do 30 razy wolniejszy ... jak stało się to oczywiste dzięki nowemu pakietowi testowemu, głównym problemem wydaje się być początkowe obciążenie biblioteki po pierwszym wywołaniu i nie jest tak źle porównywane z innymi wersja. Jest tylko 3 do 20 razy wolniejszy na moim Linuksie. W niektórych architekturach wykorzystywanych do testów przez inne wydaje się to nawet szybsze (jestem naprawdę zaskoczony, ponieważ biblioteka qsort używa bardziej złożonego API).
Kolejność rang
Rex Kerr zaproponował inną całkowicie inną metodę: dla każdego elementu tablicy oblicz bezpośrednio jego ostateczną pozycję. Jest to wydajne, ponieważ obliczanie kolejności rang nie wymaga rozgałęzienia. Wadą tej metody jest to, że zajmuje ona trzy razy więcej pamięci niż tablica (jedna kopia tablicy i zmiennych do przechowywania zamówień rang). Wyniki wydajności są bardzo zaskakujące (i interesujące). W mojej referencyjnej architekturze z 32-bitowym systemem operacyjnym i Intel Core2 Quad E8300 liczba cykli była nieco poniżej 1000 (jak sortowanie sieci z rozgałęziającą wymianą). Ale po skompilowaniu i uruchomieniu na moim 64-bitowym pudełku (Intel Core2 Duo) działało znacznie lepiej: stało się jak dotąd najszybsze. W końcu znalazłem prawdziwy powód. Mój 32-bitowy box używa gcc 4.4.1, a mój 64bits box gcc 4.4.
aktualizacja :
Jak pokazują powyższe dane, efekt ten został jeszcze wzmocniony przez późniejsze wersje gcc, a Kolejność Ranków stała się dwa razy szybsza niż jakakolwiek inna opcja.
Sorting Networks 12 z uporządkowaną zamianą
Niesamowita wydajność propozycji Rex Kerr z gcc 4.4.3 sprawiła, że zastanawiałem się: jak program z 3-krotnie większym zużyciem pamięci może być szybszy niż bezgałęziowe sieci sortujące? Moja hipoteza była taka, że miał mniej zależności w rodzaju odczytu po zapisie, co pozwala na lepsze wykorzystanie superskalarnego harmonogramu instrukcji x86. To dało mi pomysł: zmiana kolejności zamian w celu zminimalizowania zależności odczytu po zapisie. Mówiąc prościej: kiedy musisz SWAP(1, 2); SWAP(0, 2);
poczekać na zakończenie pierwszej wymiany, zanim wykonasz drugą, ponieważ oba mają dostęp do wspólnej komórki pamięci. Gdy to zrobisz, SWAP(1, 2); SWAP(4, 5);
procesor może wykonywać oba równolegle. Wypróbowałem to i działa zgodnie z oczekiwaniami, sieci sortujące działają około 10% szybciej.
Sortowanie sieci 12 za pomocą prostej wymiany
Rok po tym, jak oryginalny post Steinar H. Gunderson zasugerował, abyśmy nie próbowali przechytrzyć kompilatora i utrzymać kod wymiany w prosty sposób. To naprawdę dobry pomysł, ponieważ wynikowy kod jest o około 40% szybszy! Zaproponował także zamianę zoptymalizowaną ręcznie przy użyciu wbudowanego kodu zestawu x86, który wciąż może oszczędzić trochę więcej cykli. Najbardziej zaskakujące (jak mówi tomy o psychologii programisty) jest to, że rok temu nikt z nich nie próbował tej wersji wymiany. Kod, którego użyłem do testowania, jest tutaj . Inni sugerowali inne sposoby napisania szybkiej zamiany w C, ale daje ona takie same wyniki jak prosta z przyzwoitym kompilatorem.
Kod „najlepszy” jest teraz następujący:
static inline void sort6_sorting_network_simple_swap(int * d){
#define min(x, y) (x<y?x:y)
#define max(x, y) (x<y?y:x)
#define SWAP(x,y) { const int a = min(d[x], d[y]); \
const int b = max(d[x], d[y]); \
d[x] = a; d[y] = b; }
SWAP(1, 2);
SWAP(4, 5);
SWAP(0, 2);
SWAP(3, 5);
SWAP(0, 1);
SWAP(3, 4);
SWAP(1, 4);
SWAP(0, 3);
SWAP(2, 5);
SWAP(1, 3);
SWAP(2, 4);
SWAP(2, 3);
#undef SWAP
#undef min
#undef max
}
Jeśli uważamy, że nasz zestaw testowy (i tak, jest dość słaby, jego zaletą jest to, że jest krótki, prosty i łatwy do zrozumienia, co mierzymy), średnia liczba cykli wynikowego kodu dla jednego rodzaju jest mniejsza niż 40 cykli ( Wykonanych jest 6 testów). Dzięki temu każda zamiana trwa średnio 4 cykle. Nazywam to niezwykle szybko. Czy są możliwe inne ulepszenia?
__asm__ volatile (".byte 0x0f, 0x31; shlq $32, %%rdx; orq %%rdx, %0" : "=a" (x) : : "rdx");
że rdtsc umieszcza odpowiedź w EDX: EAX, podczas gdy GCC oczekuje jej w jednym 64-bitowym rejestrze. Możesz zobaczyć błąd kompilując w -O3. Zobacz także poniżej mój komentarz do Paula R na temat szybszego SWAP.
CMP EAX, EBX; SBB EAX, EAX
0 lub 0xFFFFFFFF w EAX
zależności od tego, czy EAX
jest odpowiednio większy czy mniejszy EBX
. SBB
to „odejmij z pożyczeniem”, odpowiednikiem ADC
(„dodaj z przeniesieniem”); bit stanu, o którym mówisz, to bit przeniesienia. Z drugiej strony, pamiętam to ADC
i SBB
miałem straszne opóźnienia i przepustowość na Pentium 4 vs. ADD
i SUB
, i nadal były dwa razy wolniejsze na Core CPU. Od 80386 dostępne są również instrukcje SETcc
warunkowego przechowywania i CMOVcc
warunkowego przenoszenia, ale są one również powolne.
x-y
ix+y
nie spowoduje niedomiaru lub nadmiaru?