Próbowałem zmierzyć różnicę w używaniu a for
i a foreach
podczas uzyskiwania dostępu do list typów wartości i typów referencyjnych.
Do profilowania użyłem następującej klasy.
public static class Benchmarker
{
public static void Profile(string description, int iterations, Action func)
{
Console.Write(description);
// Warm up
func();
Stopwatch watch = new Stopwatch();
// Clean up
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
watch.Start();
for (int i = 0; i < iterations; i++)
{
func();
}
watch.Stop();
Console.WriteLine(" average time: {0} ms", watch.Elapsed.TotalMilliseconds / iterations);
}
}
Użyłem double
dla mojego typu wartości. Stworzyłem tę „fałszywą klasę”, aby przetestować typy referencyjne:
class DoubleWrapper
{
public double Value { get; set; }
public DoubleWrapper(double value)
{
Value = value;
}
}
W końcu uruchomiłem ten kod i porównałem różnice czasu.
static void Main(string[] args)
{
int size = 1000000;
int iterationCount = 100;
var valueList = new List<double>(size);
for (int i = 0; i < size; i++)
valueList.Add(i);
var refList = new List<DoubleWrapper>(size);
for (int i = 0; i < size; i++)
refList.Add(new DoubleWrapper(i));
double dummy;
Benchmarker.Profile("valueList for: ", iterationCount, () =>
{
double result = 0;
for (int i = 0; i < valueList.Count; i++)
{
unchecked
{
var temp = valueList[i];
result *= temp;
result += temp;
result /= temp;
result -= temp;
}
}
dummy = result;
});
Benchmarker.Profile("valueList foreach: ", iterationCount, () =>
{
double result = 0;
foreach (var v in valueList)
{
var temp = v;
result *= temp;
result += temp;
result /= temp;
result -= temp;
}
dummy = result;
});
Benchmarker.Profile("refList for: ", iterationCount, () =>
{
double result = 0;
for (int i = 0; i < refList.Count; i++)
{
unchecked
{
var temp = refList[i].Value;
result *= temp;
result += temp;
result /= temp;
result -= temp;
}
}
dummy = result;
});
Benchmarker.Profile("refList foreach: ", iterationCount, () =>
{
double result = 0;
foreach (var v in refList)
{
unchecked
{
var temp = v.Value;
result *= temp;
result += temp;
result /= temp;
result -= temp;
}
}
dummy = result;
});
SafeExit();
}
Wybrałem Release
i Any CPU
opcje, uruchomiłem program i otrzymałem następujące czasy:
valueList for: average time: 483,967938 ms
valueList foreach: average time: 477,873079 ms
refList for: average time: 490,524197 ms
refList foreach: average time: 485,659557 ms
Done!
Następnie wybrałem opcje Release i x64, uruchomiłem program i otrzymałem następujące czasy:
valueList for: average time: 16,720209 ms
valueList foreach: average time: 15,953483 ms
refList for: average time: 19,381077 ms
refList foreach: average time: 18,636781 ms
Done!
Dlaczego wersja 64-bitowa jest o wiele szybsza? Spodziewałem się pewnej różnicy, ale nie czegoś tak dużego.
Nie mam dostępu do innych komputerów. Czy mógłbyś uruchomić to na swoich komputerach i przekazać mi wyniki? Używam programu Visual Studio 2015 i mam procesor Intel Core i7 930.
Oto SafeExit()
metoda, dzięki której możesz samodzielnie skompilować / uruchomić:
private static void SafeExit()
{
Console.WriteLine("Done!");
Console.ReadLine();
System.Environment.Exit(1);
}
Zgodnie z żądaniem, double?
zamiast my DoubleWrapper
:
Dowolny procesor
valueList for: average time: 482,98116 ms
valueList foreach: average time: 478,837701 ms
refList for: average time: 491,075915 ms
refList foreach: average time: 483,206072 ms
Done!
x64
valueList for: average time: 16,393947 ms
valueList foreach: average time: 15,87007 ms
refList for: average time: 18,267736 ms
refList foreach: average time: 16,496038 ms
Done!
Last but not least: utworzenie x86
profilu daje mi prawie takie same rezultaty używaniaAny CPU
.
źródło
double
sięfloat
,long
czyint
i masz podobne wyniki.Odpowiedzi:
Mogę to odtworzyć w 4.5.2. Brak RyuJIT tutaj. Dezasemblacje x86 i x64 wyglądają rozsądnie. Sprawdzanie zakresu i tak dalej są takie same. Ta sama podstawowa struktura. Brak rozwijania pętli.
x86 używa innego zestawu instrukcji float. Wydajność tych instrukcji wydaje się być porównywalna z instrukcją x64, z wyjątkiem podziału :
Operacja dzielenia sprawia, że wersja 32-bitowa jest wyjątkowo wolna. Odkomentowanie podziału wyrównuje wydajność w dużym stopniu (32 bity w dół z 430 ms do 3,25 ms).
Peter Cordes zwraca uwagę, że opóźnienia instrukcji dwóch jednostek zmiennoprzecinkowych nie są tak różne. Być może niektóre z pośrednich wyników to zdenormalizowane liczby lub NaN. Może to spowodować powolną ścieżkę w jednej z jednostek. A może wartości różnią się między dwiema implementacjami z powodu dokładności 10-bajtowej vs. 8-bajtowej.
Peter Cordes zwraca również uwagę, że wszystkie pośrednie wyniki to NaN ... Usunięcie tego problemu (
valueList.Add(i + 1)
tak, aby żaden dzielnik nie był zerowy) w większości wyrównuje wyniki. Najwyraźniej 32-bitowy kod w ogóle nie lubi operandów NaN. Załóżmy wydrukować niektóre wartości pośrednich:if (i % 1000 == 0) Console.WriteLine(result);
. Potwierdza to, że dane są teraz rozsądne.Podczas testów porównawczych musisz porównać realistyczne obciążenie pracą. Ale kto by pomyślał, że niewinny podział może zepsuć twój punkt odniesienia ?!
Spróbuj po prostu zsumować liczby, aby uzyskać lepszy punkt odniesienia.
Division i modulo są zawsze bardzo powolne. Jeśli zmodyfikujesz
Dictionary
kod BCL, aby po prostu nie używać operatora modulo do obliczania mierzalnej poprawy wydajności indeksu zasobnika. Taki jest powolny podział.Oto kod 32-bitowy:
64-bitowy kod (ta sama struktura, szybki podział):
Nie jest to wektoryzowane pomimo użycia instrukcji SSE.
źródło
fdiv
ma opóźnienie 7-27 cykli (i taką samą wzajemną przepustowość).divsd
wynosi 7-22 cykli.addsd
przy opóźnieniu 3c, przepustowości 1 / c. Division to jedyna niepotokowa jednostka wykonawcza w procesorach Intel / AMD. C # JIT nie wektoryzuje pętli dla x86-64 (zdivPd
).MXCSR
).NaN
Mogę pomyśleć, że różne traktowanie denormali lub sów wyjaśnia czynnik 26 perf diff. C # może ustawić denormals-are-zero w MXCSR.valueList[i] = i
zaczynając odi=0
, tak działa pierwsza iteracja pętli0.0 / 0.0
. Zatem każda operacja w całym benchmarku jest wykonywana za pomocąNaN
s. Ten podział wygląda coraz mniej niewinnie! Nie jestem ekspertem w zakresie wydajności zNaN
s, ani różnicy między x87 i SSE w tym przypadku, ale myślę, że to wyjaśnia różnicę 26x perf. Założę się, że twoje wyniki będą znacznie bliższe między 32 a 64 bitami, jeśli zainicjujeszvalueList[i] = i+1
.double
byłoby dość rzadkie. Jednym z głównych wzorców użycia typu 80-bitowego było umożliwienie sumowania wielu liczb bez konieczności ścisłego zaokrąglania wyników aż do samego końca. Zgodnie z tym schematem przepełnienia po prostu nie stanowią problemu.valueList[i] = i
, zaczynając odi=0
, tak jak w pierwszej iteracji pętli0.0 / 0.0
. Zatem każda operacja w całym benchmarku jest wykonywana za pomocąNaN
s.Jak pokazał @usr w danych wyjściowych deasemblacji , wersja 32-bitowa wykorzystywała zmiennoprzecinkowe x87, podczas gdy 64-bitowa korzystała ze zmiennoprzecinkowych SSE.
Nie jestem ekspertem w zakresie wydajności z
NaN
s, ani różnicy między x87 i SSE w tym przypadku, ale myślę, że to wyjaśnia różnicę 26x perf. Założę się, że twoje wyniki będą znacznie bliższe między 32 a 64 bitami, jeśli zainicjujeszvalueList[i] = i+1
. (aktualizacja: usr potwierdził, że dzięki temu wydajność 32- i 64-bitowa była dość bliska.)Podział jest bardzo powolny w porównaniu z innymi operacjami. Zobacz moje komentarze dotyczące odpowiedzi @ usr. Zobacz także http://agner.org/optimize/, gdzie znajdziesz mnóstwo świetnych informacji na temat sprzętu oraz optymalizacji ASM i C / C ++, niektóre z nich dotyczą C #. Ma tabele instrukcji dotyczące opóźnień i przepustowości dla większości instrukcji dla wszystkich najnowszych procesorów x86.
Jednak 10B x87
fdiv
nie jest dużo wolniejsze niż podwójna precyzja 8B SSE2divsd
dla wartości normalnych. IDK na temat różnic w perf z NaN, nieskończoności lub denormali.Mają jednak różne kontrolki dla tego, co dzieje się z NaN i innymi wyjątkami FPU. Słowo kontrolne FPU x87 jest oddzielona od rejestru kontrolnego zaokrąglenie / wyjątkiem SSE (MXCSR). Jeśli x87 otrzymuje wyjątek CPU dla każdego działu, ale SSE nie, to łatwo wyjaśnia współczynnik 26. A może po prostu jest tak duża różnica w wydajności podczas obsługi NaN. Sprzęt nie jest zoptymalizowany do przechodzenia
NaN
poNaN
.IDK, jeśli SSE kontroluje unikanie spowolnień z denormali, wejdzie tutaj w grę, ponieważ wierzę, że
result
będzie przezNaN
cały czas. IDK, jeśli C # ustawia flagę denormals-are-zero w MXCSR lub flagę flush-to-zero-(która zapisuje zera w pierwszej kolejności, zamiast traktować denormals jako zero podczas odczytu z powrotem).Znalazłem artykuł Intela o kontrolkach zmiennoprzecinkowych SSE, porównując go ze słowem kontrolnym x87 FPU. Nie ma jednak wiele do powiedzenia
NaN
. Kończy się tym:IDK, jeśli to pomaga komukolwiek z dzieleniem przez zero.
for vs. foreach
Może być interesujące przetestowanie treści pętli, która ma ograniczoną przepustowość, a nie jest tylko pojedynczym łańcuchem zależności przenoszonym w pętli. W obecnej sytuacji cała praca zależy od wcześniejszych wyników; CPU nie ma nic do zrobienia równolegle (poza bounds-check następnym ładowaniem tablicy, gdy działa łańcuch mul / div).
Możesz zauważyć większą różnicę między metodami, jeśli „rzeczywista praca” zajęła więcej zasobów wykonawczych procesora. Ponadto w przypadku Intel przed Sandybridge istnieje duża różnica między dopasowaniem pętli w buforze pętli 28 uop lub nie. Jeśli nie, otrzymasz instrukcje dekodowania wąskich gardeł, zwł. gdy średnia długość instrukcji jest dłuższa (co zdarza się w przypadku SSE). Instrukcje dekodujące do więcej niż jednego uop również ograniczają przepustowość dekodera, chyba że występują we wzorcu, który jest przyjemny dla dekoderów (np. 2-1-1). Tak więc pętla z większą liczbą instrukcji dotyczących narzutu pętli może stanowić różnicę między dopasowaniem pętli w 28-wejściowej pamięci podręcznej uop, czy nie, co jest wielką sprawą w Nehalem, a czasami jest pomocne w Sandybridge i później.
źródło
NaN
że w praktyce są naprawdę rzadkie? Zostawiłem wszystkie rzeczy dotyczące denormali i link do materiałów Intela, głównie dla dobra czytelników, nie dlatego, że myślałem, że będzie to naprawdę miało duży wpływ na ten konkretny przypadek.Mamy obserwację, że 99,9% wszystkich operacji zmiennoprzecinkowych będzie dotyczyło NaN, co jest co najmniej bardzo nietypowe (odkryte najpierw przez Petera Cordesa). Mamy inny eksperyment usr, który wykazał, że usunięcie instrukcji dzielenia prawie całkowicie znika różnicę czasu.
Faktem jest jednak, że NaN są generowane tylko dlatego, że pierwszy podział oblicza 0,0 / 0,0, co daje początkowy NaN. Jeśli podział nie zostanie wykonany, wynik zawsze będzie wynosił 0,0, a zawsze będziemy obliczać 0,0 * temp -> 0,0, 0,0 + temp -> temp, temp - temp = 0,0. Zatem usunięcie podziału nie tylko usunęło podziały, ale także usunęło NaN. Spodziewałbym się, że naN są w rzeczywistości problemem i że jedna implementacja radzi sobie z NaN bardzo wolno, podczas gdy druga nie ma problemu.
Warto byłoby rozpocząć pętlę od i = 1 i ponownie zmierzyć. Cztery operacje skutkują * temp, + temp, / temp, - temp efektywnie dodając (1 - temp), więc nie mielibyśmy żadnych nietypowych liczb (0, nieskończoność, NaN) dla większości operacji.
Jedynym problemem może być to, że dzielenie zawsze daje wynik w postaci liczby całkowitej, a niektóre implementacje dzielenia mają skróty, gdy poprawny wynik nie używa wielu bitów. Na przykład, podzielenie 310,0 / 31,0 daje 10,0 jako pierwsze cztery bity z resztą 0,0, a niektóre implementacje mogą przestać oceniać pozostałe 50 lub więcej bitów, podczas gdy inne nie. Jeśli istnieje istotna różnica, rozpoczęcie pętli z wynikiem = 1,0 / 3,0 spowodowałoby różnicę.
źródło
Może być kilka powodów, dla których to działa szybciej w wersji 64-bitowej na twoim komputerze. Powodem, dla którego zapytałem, którego procesora używasz, był fakt, że kiedy 64-bitowe procesory pojawiły się po raz pierwszy, AMD i Intel miały różne mechanizmy do obsługi 64-bitowego kodu.
Architektura procesora:
Architektura procesora Intela była czysto 64-bitowa. Aby wykonać kod 32-bitowy, instrukcje 32-bitowe musiały zostać przekonwertowane (wewnątrz procesora) na instrukcje 64-bitowe przed wykonaniem.
Architektura procesora AMD polegała na zbudowaniu 64-bitowej architektury tuż nad architekturą 32-bitową; to znaczy, że była to zasadniczo architektura 32-bitowa z rozszerzeniami 64-bitowymi - nie było procesu konwersji kodu.
Było to oczywiście kilka lat temu, więc nie mam pojęcia, czy / jak zmieniła się technologia, ale zasadniczo można oczekiwać, że 64-bitowy kod będzie działał lepiej na maszynie 64-bitowej, ponieważ procesor jest w stanie pracować z podwójną ilością bity na instrukcję.
.NET JIT
Argumentuje się, że .NET (i inne języki zarządzane, takie jak Java) mogą przewyższać języki takie jak C ++ ze względu na sposób, w jaki kompilator JIT jest w stanie zoptymalizować kod zgodnie z architekturą procesora. W związku z tym może się okazać, że kompilator JIT wykorzystuje coś w architekturze 64-bitowej, co prawdopodobnie nie było dostępne lub wymagało obejścia, gdy jest wykonywane w wersji 32-bitowej.
Uwaga:
Zamiast używać DoubleWrapper, czy rozważałeś użycie
Nullable<double>
lub skróconą składnię:double?
- Chciałbym sprawdzić, czy ma to jakikolwiek wpływ na twoje testy.Uwaga 2: Niektórzy ludzie mylą moje komentarze na temat architektury 64-bitowej z IA-64. Dla wyjaśnienia, w mojej odpowiedzi 64-bitowy odnosi się do x86-64, a 32-bitowy odnosi się do x86-32. Nic tutaj nie odnosi się do IA-64!
źródło