Oto przykład z prawdziwego świata: Stałe punkty mnożą się na starych kompilatorach.
Są one przydatne nie tylko na urządzeniach bez zmiennoprzecinkowych, ale świecą, jeśli chodzi o precyzję, ponieważ zapewniają 32 bity precyzji z przewidywalnym błędem (liczba zmiennoprzecinkowa ma tylko 23 bity i trudniej jest przewidzieć utratę precyzji). tj. jednolita absolutna precyzja w całym zakresie, zamiast zbliżonej do jednakowej dokładności względnej ( float
).
Nowoczesne kompilatory ładnie optymalizują ten przykład w punkcie stałym, więc dla bardziej nowoczesnych przykładów, które wciąż wymagają kodu specyficznego dla kompilatora, zobacz
C nie ma operatora pełnego mnożenia (wynik 2N-bitowy z wejść N-bitowych). Zwykłym sposobem wyrażenia tego w C jest rzutowanie danych wejściowych na szerszy typ i nadzieję, że kompilator rozpozna, że górne bity danych wejściowych nie są interesujące:
// on a 32-bit machine, int can hold 32-bit fixed-point integers.
int inline FixedPointMul (int a, int b)
{
long long a_long = a; // cast to 64 bit.
long long product = a_long * b; // perform multiplication
return (int) (product >> 16); // shift by the fixed point bias
}
Problem z tym kodem polega na tym, że robimy coś, czego nie można bezpośrednio wyrazić w języku C. Chcemy pomnożyć dwie liczby 32-bitowe i uzyskać wynik 64-bitowy, z którego zwracamy środkowy 32-bitowy. Jednak w C ten mnożnik nie istnieje. Wszystko, co możesz zrobić, to podwyższyć liczby całkowite do 64-bitowych i zrobić 64 * 64 = 64 pomnożenie.
x86 (i ARM, MIPS i inne) mogą jednak wykonać mnożenie w pojedynczej instrukcji. Niektóre kompilatory ignorowały ten fakt i generowały kod, który wywołuje funkcję biblioteki wykonawczej w celu wykonania mnożenia. Przesunięcie o 16 jest również często wykonywane przez procedurę biblioteczną (również x86 może wykonywać takie przesunięcia).
Pozostaje nam jedno lub dwa wywołania biblioteczne tylko dla pomnożenia. Ma to poważne konsekwencje. Przesunięcie jest nie tylko wolniejsze, ale rejestry muszą być zachowywane w wywołaniach funkcji, a także nie pomaga wstawianie i rozwijanie kodu.
Jeśli przepiszesz ten sam kod w (wbudowanym) asemblerze, możesz uzyskać znaczne przyspieszenie.
Ponadto: korzystanie z ASM nie jest najlepszym sposobem na rozwiązanie problemu. Większość kompilatorów pozwala na użycie niektórych instrukcji asemblera w postaci wewnętrznej, jeśli nie można ich wyrazić w C. Kompilator VS.NET2008 na przykład wyświetla 32 * 32 = 64-bitowy mul jako __emul, a 64-bitowe przesunięcie jako __ll_rshift.
Używając funkcji wewnętrznych, możesz przepisać funkcję w taki sposób, aby kompilator C miał szansę zrozumieć, co się dzieje. Pozwala to na wstawianie kodu, przydzielanie rejestru, wspólną eliminację podwyrażeń i stałą propagację. W ten sposób uzyskasz ogromną poprawę wydajności w stosunku do ręcznie napisanego kodu asemblera.
Dla porównania: Rezultat końcowy dla mulda punktu stałego dla kompilatora VS.NET to:
int inline FixedPointMul (int a, int b)
{
return (int) __ll_rshift(__emul(a,b),16);
}
Różnica wydajności podziału na punkty stałe jest jeszcze większa. Miałem ulepszenia do współczynnika 10 dla ciężkiego kodu stałego punktu dzielącego, pisząc kilka linii asm.
Korzystanie z Visual C ++ 2013 daje ten sam kod asemblera na oba sposoby.
gcc4.1 z 2007 roku ładnie optymalizuje również czystą wersję C. (Eksplorator kompilatora Godbolt nie ma zainstalowanych wcześniejszych wersji gcc, ale prawdopodobnie nawet starsze wersje GCC mogłyby to zrobić bez wewnętrznych elementów).
Zobacz source + asm dla x86 (32-bit) i ARM w eksploratorze kompilatorów Godbolt . (Niestety nie ma żadnych kompilatorów wystarczająco starych, aby wygenerować zły kod z prostej wersji w czystym C.)
Nowoczesne procesory mogą robić rzeczy, C nie ma dla operatorów w ogóle , jak popcnt
i nieco skanowania do znalezienia pierwszego lub ostatniego zestawu trochę . (POSIX ma ffs()
funkcję, ale jej semantyka nie pasuje do x86 bsf
/ bsr
. Zobacz https://en.wikipedia.org/wiki/Find_first_set ).
Niektóre kompilatory czasami rozpoznają pętlę, która zlicza liczbę ustawionych bitów w liczbie całkowitej i kompilują ją do popcnt
instrukcji (jeśli jest włączona w czasie kompilacji), ale o wiele bardziej niezawodne jest używanie jej __builtin_popcnt
w GNU C lub na x86, jeśli jesteś tylko celowanie w sprzęt z SSE4.2: _mm_popcnt_u32
z<immintrin.h>
.
Lub w C ++, przypisz do std::bitset<32>
i użyj .count()
. (Jest to przypadek, w którym język znalazł sposób na przenośne udostępnienie zoptymalizowanej implementacji popcount poprzez standardową bibliotekę, w sposób, który zawsze kompiluje się do czegoś poprawnego i może wykorzystać wszystko, co obsługuje cel.) Zobacz także https : //en.wikipedia.org/wiki/Hamming_weight#Language_support .
Podobnie, ntohl
można skompilować do bswap
(x86 32-bitowa zamiana bajtów dla konwersji endian) na niektórych implementacjach C, które go mają.
Innym ważnym obszarem wewnętrznym lub ręcznie pisanym asmem jest ręczna wektoryzacja z instrukcjami SIMD. Kompilatory nie są złe z takimi prostymi pętlami dst[i] += src[i] * 10.0;
, ale często źle działają lub wcale nie powodują automatycznej wektoryzacji, gdy sprawy stają się bardziej skomplikowane. Na przykład jest mało prawdopodobne, aby uzyskać coś takiego jak Jak wdrożyć atoi za pomocą SIMD? generowane automatycznie przez kompilator z kodu skalarnego.