Podczas uruchamiania pętli sumy nad tablicą w Rust zauważyłem ogromny spadek wydajności, gdy CAPACITY
> = 240. CAPACITY
= 239 jest około 80 razy szybszy.
Czy istnieje specjalna optymalizacja kompilacji, którą Rust robi dla „krótkich” tablic?
Kompilowany z rustc -C opt-level=3
.
use std::time::Instant;
const CAPACITY: usize = 240;
const IN_LOOPS: usize = 500000;
fn main() {
let mut arr = [0; CAPACITY];
for i in 0..CAPACITY {
arr[i] = i;
}
let mut sum = 0;
let now = Instant::now();
for _ in 0..IN_LOOPS {
let mut s = 0;
for i in 0..arr.len() {
s += arr[i];
}
sum += s;
}
println!("sum:{} time:{:?}", sum, now.elapsed());
}
arrays
performance
rust
llvm-codegen
Guy Korland
źródło
źródło
Odpowiedzi:
Podsumowanie : poniżej 240 LLVM całkowicie rozwija wewnętrzną pętlę, co pozwala zauważyć, że może zoptymalizować pętlę powtarzania, przełamując twój test porównawczy.
Znalazłeś magiczny próg, powyżej którego LLVM przestaje dokonywać pewnych optymalizacji . Próg wynosi 8 bajtów * 240 = 1920 bajtów (twoja tablica jest tablicą
usize
s, dlatego długość jest mnożona przez 8 bajtów, przy założeniu, że CPU x86-64). W tym teście, jedna szczególna optymalizacja - przeprowadzona tylko dla długości 239 - odpowiada za ogromną różnicę prędkości. Ale zacznijmy powoli:(Cały kod w tej odpowiedzi jest kompilowany
-C opt-level=3
)Ten prosty kod utworzy z grubsza taki zestaw, jakiego można się spodziewać: pętla sumująca elementy. Jeśli jednak zmienisz
240
na239
, emitowany zestaw różni się bardzo. Zobacz to w Godbolt Compiler Explorer . Oto niewielka część zestawu:To się nazywa rozwijanie pętli : LLVM wkleja ciało pętli przez pewien czas, aby uniknąć konieczności wykonywania wszystkich tych „instrukcji zarządzania pętlą”, tj. Zwiększania zmiennej pętli, sprawdzania, czy pętla się zakończyła i przejścia do początku pętli .
Jeśli się zastanawiasz:
paddq
podobne instrukcje są instrukcjami SIMD, które umożliwiają sumowanie wielu wartości równolegle. Co więcej, dwa 16-bajtowe rejestry SIMD (xmm0
ixmm1
) są używane równolegle, aby równoległość procesora na poziomie instrukcji mogła w zasadzie wykonać dwie z tych instrukcji jednocześnie. W końcu są od siebie niezależni. Na koniec oba rejestry są sumowane, a następnie sumowane poziomo do wyniku skalarnego.Współczesne procesory głównego nurtu x86 (nie Atom o niskiej mocy) naprawdę potrafią wykonać 2 obciążenia wektorowe na zegar, gdy trafią w pamięć podręczną L1d, a
paddq
przepustowość wynosi również co najmniej 2 na zegar, z 1 opóźnieniem cyklu na większości procesorów. Zobacz https://agner.org/optimize/, a także niniejsze pytania i odpowiedzi dotyczące wielu akumulatorów w celu ukrycia opóźnień (FP FMA dla produktu kropkowego) i wąskiego gardła w zakresie przepustowości.LLVM rozwija niektóre małe pętle, gdy nie jest w pełni rozwijane, i nadal używa wielu akumulatorów. Tak więc zwykle wąskie gardła przepustowości frontonu i opóźnienia nie są dużym problemem dla pętli generowanych przez LLVM, nawet bez pełnego rozwinięcia.
Ale rozwijanie pętli nie jest odpowiedzialne za różnicę wydajności wynoszącą współczynnik 80! Przynajmniej nie rozwijania pętli sam. Rzućmy okiem na rzeczywisty kod testu porównawczego, który umieszcza jedną pętlę w drugiej:
( W Godbolt Compiler Explorer )
Zestaw
CAPACITY = 240
wygląda normalnie: dwie zagnieżdżone pętle. (Na początku funkcji jest trochę kodu do inicjalizacji, który zignorujemy.) W przypadku 239 wygląda to jednak zupełnie inaczej! Widzimy, że pętla inicjująca i pętla wewnętrzna zostały rozwinięte: jak dotąd się spodziewano.Ważną różnicą jest to, że dla 239 LLVM był w stanie stwierdzić, że wynik wewnętrznej pętli nie zależy od zewnętrznej pętli! W rezultacie LLVM emituje kod, który w zasadzie najpierw wykonuje tylko wewnętrzną pętlę (obliczając sumę), a następnie symuluje zewnętrzną pętlę, sumując
sum
kilka razy!Najpierw widzimy prawie taki sam zespół jak powyżej (zespół reprezentujący wewnętrzną pętlę). Następnie widzimy to (skomentowałem, aby wyjaśnić zgromadzenie; komentarze z
*
są szczególnie ważne):Jak widać tutaj, wynik pętli wewnętrznej jest pobierany, dodawany tak często, jak pętla zewnętrzna biegnie, a następnie wraca. LLVM może przeprowadzić tę optymalizację tylko dlatego, że zrozumiał, że wewnętrzna pętla jest niezależna od zewnętrznej.
Oznacza to, że środowisko wykonawcze zmienia się z
CAPACITY * IN_LOOPS
naCAPACITY + IN_LOOPS
. A to odpowiada za ogromną różnicę wydajności.Dodatkowa uwaga: czy możesz coś z tym zrobić? Nie całkiem. LLVM musi mieć takie magiczne progi, ponieważ bez nich optymalizacje LLVM mogą trwać wiecznie na pewnym kodzie. Ale możemy również zgodzić się, że ten kod był bardzo sztuczny. W praktyce wątpię, by nastąpiła tak ogromna różnica. Różnica wynikająca z pełnego rozwinięcia pętli zwykle nie wynosi nawet w tych przypadkach 2. Więc nie musisz się martwić o rzeczywiste przypadki użycia.
Ostatnia uwaga na temat idiomatycznego kodu Rust:
arr.iter().sum()
jest lepszym sposobem na podsumowanie wszystkich elementów tablicy. A zmiana tego w drugim przykładzie nie prowadzi do zauważalnych różnic w emitowanym zestawie. Powinieneś używać krótkich i idiomatycznych wersji, chyba że zmierzyłeś, że to obniża wydajność.źródło
sum
bezpośrednio na nie lokalnym,s
działał znacznie wolniej.for i in 0..arr.len() { sum += arr[i]; }
Instant
zapobieganie?Oprócz odpowiedzi Lukasa, jeśli chcesz użyć iteratora, spróbuj tego:
Dzięki @Chris Morgan za sugestię dotyczącą wzorca zasięgu.
Zoptymalizowany montaż jest bardzo dobra:
źródło
(0..CAPACITY).sum::<usize>() * IN_LOOPS
, co daje ten sam wynik.rustc
brakuje mi możliwości zmniejszenia siły. Jednak w tym konkretnym kontekście wydaje się, że jest to pętla czasowa i celowo chcesz, aby nie była optymalizowana. Chodzi o to, aby powtórzyć obliczenia tyle razy od zera i podzielić przez liczbę powtórzeń. W języku C (nieoficjalnym) idiomem tego jest zadeklarowanie licznika pętli jakovolatile
np. Licznika BogoMIPS w jądrze Linuksa. Czy istnieje sposób na osiągnięcie tego w Rust? Być może, ale nie wiem. Wywołanie zewnętrznegofn
może pomóc.volatile
wymusza synchronizację pamięci. Zastosowanie go do licznika pętli wymusza jedynie rzeczywiste przeładowanie / zapisanie wartości licznika pętli. Nie wpływa bezpośrednio na korpus pętli. Dlatego lepszym sposobem jego użycia jest zwykle przypisanie rzeczywistego ważnego wynikuvolatile int sink
lub po pętli (jeśli istnieje zależność przenoszona przez pętlę) lub po każdej iteracji, aby umożliwić kompilatorowi zoptymalizować licznik pętli w dowolny sposób, ale wymusić zmaterializować pożądany wynik w rejestrze, aby mógł go zapisać.