Wczoraj znalazłem artykuł Christopha Nahra zatytułowany ".NET Struct Performance", w którym porównano kilka języków (C ++, C #, Java, JavaScript) dla metody, która dodaje dwie struktury punktowe ( double
krotki).
Jak się okazało, wykonanie wersji C ++ zajmuje około 1000 ms (iteracje 1e9), podczas gdy C # nie może zejść poniżej ~ 3000 ms na tej samej maszynie (i działa jeszcze gorzej na x64).
Aby samemu to przetestować, wziąłem kod C # (i nieco uprościłem, aby wywołać tylko metodę, w której parametry są przekazywane przez wartość) i uruchomiłem go na maszynie i7-3610QM (3,1 GHz doładowanie dla pojedynczego rdzenia), 8 GB pamięci RAM, Win8. 1, przy użyciu .NET 4.5.2, wersja RELEASE 32-bitowa (x86 WoW64, ponieważ mój system operacyjny jest 64-bitowy). To jest wersja uproszczona:
public static class CSharpTest
{
private const int ITERATIONS = 1000000000;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Point AddByVal(Point a, Point b)
{
return new Point(a.X + b.Y, a.Y + b.X);
}
public static void Main()
{
Point a = new Point(1, 1), b = new Point(1, 1);
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
}
Z Point
definicją po prostu:
public struct Point
{
private readonly double _x, _y;
public Point(double x, double y) { _x = x; _y = y; }
public double X { get { return _x; } }
public double Y { get { return _y; } }
}
Uruchomienie go daje wyniki podobne do tych w artykule:
Result: x=1000000001 y=1000000001, Time elapsed: 3159 ms
Pierwsza dziwna obserwacja
Ponieważ metoda powinna być wbudowana, zastanawiałem się, jak działałby kod, gdybym całkowicie usunął struktury i po prostu wstawił całość razem:
public static class CSharpTest
{
private const int ITERATIONS = 1000000000;
public static void Main()
{
// not using structs at all here
double ax = 1, ay = 1, bx = 1, by = 1;
Stopwatch sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
{
ax = ax + by;
ay = ay + bx;
}
sw.Stop();
Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
ax, ay, sw.ElapsedMilliseconds);
}
}
I uzyskał praktycznie ten sam wynik (właściwie 1% wolniej po kilku próbach), co oznacza, że JIT-ter wydaje się wykonywać dobrą robotę optymalizując wszystkie wywołania funkcji:
Result: x=1000000001 y=1000000001, Time elapsed: 3200 ms
Oznacza to również, że benchmark nie wydaje się mierzyć żadnej struct
wydajności i wydaje się mierzyć tylko podstawową double
arytmetykę (po optymalizacji wszystkiego innego).
Dziwne rzeczy
Teraz czas na dziwną część. Jeśli po prostu dodam kolejny stoper poza pętlą (tak, zawęziłem to do tego szalonego kroku po kilku próbach), kod działa trzy razy szybciej :
public static void Main()
{
var outerSw = Stopwatch.StartNew(); // <-- added
{
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Result: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
outerSw.Stop(); // <-- added
}
Result: x=1000000001 y=1000000001, Time elapsed: 961 ms
To niedorzeczne! I to nie Stopwatch
jest tak, że daje mi złe wyniki, ponieważ wyraźnie widzę, że kończy się po jednej sekundzie.
Czy ktoś może mi powiedzieć, co się tutaj dzieje?
(Aktualizacja)
Oto dwie metody w tym samym programie, które pokazują, że przyczyną nie jest JIT:
public static class CSharpTest
{
private const int ITERATIONS = 1000000000;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static Point AddByVal(Point a, Point b)
{
return new Point(a.X + b.Y, a.Y + b.X);
}
public static void Main()
{
Test1();
Test2();
Console.WriteLine();
Test1();
Test2();
}
private static void Test1()
{
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Test1: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
private static void Test2()
{
var swOuter = Stopwatch.StartNew();
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
swOuter.Stop();
}
}
Wynik:
Test1: x=1000000001 y=1000000001, Time elapsed: 3242 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 974 ms
Test1: x=1000000001 y=1000000001, Time elapsed: 3251 ms
Test2: x=1000000001 y=1000000001, Time elapsed: 972 ms
Oto pastebin. Musisz go uruchomić w wersji 32-bitowej na platformie .NET 4.x (w kodzie znajduje się kilka elementów sprawdzających, aby to zapewnić).
(Aktualizacja 4)
Po komentarzach @ usr na temat odpowiedzi @Hansa sprawdziłem zoptymalizowany demontaż dla obu metod i są one raczej różne:
To wydaje się pokazywać, że różnica może wynikać z dziwnego działania kompilatora w pierwszym przypadku, a nie z wyrównania podwójnego pola?
Ponadto, jeśli dodam dwie zmienne (całkowite przesunięcie 8 bajtów), nadal otrzymuję to samo przyspieszenie - i nie wydaje się, że jest to związane z wyrównaniem pola, o którym wspomniał Hans Passant:
// this is still fast?
private static void Test3()
{
var magical_speed_booster_1 = "whatever";
var magical_speed_booster_2 = "whatever";
{
Point a = new Point(1, 1), b = new Point(1, 1);
var sw = Stopwatch.StartNew();
for (int i = 0; i < ITERATIONS; i++)
a = AddByVal(a, b);
sw.Stop();
Console.WriteLine("Test2: x={0} y={1}, Time elapsed: {2} ms",
a.X, a.Y, sw.ElapsedMilliseconds);
}
GC.KeepAlive(magical_speed_booster_1);
GC.KeepAlive(magical_speed_booster_2);
}
źródło
double
zmiennymi lokalnymi , bezstruct
s, więc wykluczyłem nieefektywność układu struktury / wywołania metody.Odpowiedzi:
Aktualizacja 4 wyjaśnia problem: w pierwszym przypadku JIT przechowuje obliczone wartości (
a
,b
) na stosie; w drugim przypadku JIT przechowuje je w rejestrach.W rzeczywistości
Test1
działa powoli, ponieważStopwatch
. Napisałem następujący minimalny test porównawczy oparty na BenchmarkDotNet :Wyniki na moim komputerze:
Jak możemy zobaczyć:
WithoutStopwatch
działa szybko (boa = a + b
korzysta z rejestrów)WithStopwatch
działa powoli (ponieważa = a + b
używa stosu)WithTwoStopwatches
znów działa szybko (boa = a + b
używa rejestrów)Zachowanie JIT-x86 zależy od dużej ilości różnych warunków. Z jakiegoś powodu pierwszy stoper wymusza na JIT-x86 użycie stosu, a drugi pozwala na ponowne użycie rejestrów.
źródło
Stopwatch
faktycznie działa szybciej . Ale jeśli zmienisz kolejność, w jakiej są wywoływane wMain
metodzie, druga metoda zostanie zoptymalizowana.Istnieje bardzo prosty sposób, aby zawsze uzyskać „szybką” wersję programu. Projekt> Właściwości> karta Kompilacja, usuń zaznaczenie opcji „Preferuj 32-bitowe”, upewnij się, że wybrana platforma docelowa to AnyCPU.
Naprawdę nie preferujesz wersji 32-bitowej, niestety zawsze jest domyślnie włączona dla projektów C #. W przeszłości zestaw narzędzi programu Visual Studio działał znacznie lepiej z procesami 32-bitowymi, starym problemem, który rozwiązał Microsoft. Czas na usunięcie tej opcji, VS2015 w szczególności rozwiązał kilka ostatnich rzeczywistych przeszkód w 64-bitowym kodzie z zupełnie nowym jitterem x64 i uniwersalną obsługą Edytuj + Kontynuuj.
Dość gadania, odkryłeś znaczenie dopasowania zmiennych. Procesor bardzo o to dba. Jeśli zmienna jest źle wyrównana w pamięci, procesor musi wykonać dodatkową pracę, aby przetasować bajty, aby uzyskać je we właściwej kolejności. Istnieją dwa różne problemy związane z niewspółosiowością, z których jeden polega na tym, że bajty nadal znajdują się w pojedynczej linii pamięci podręcznej L1, co kosztuje dodatkowy cykl, aby przesunąć je do właściwej pozycji. I ten bardzo zły, ten, który znalazłeś, w którym część bajtów znajduje się w jednej linii pamięci podręcznej, a część w drugiej. Wymaga to dwóch oddzielnych dostępów do pamięci i sklejania ich ze sobą. Trzy razy wolniej.
double
Ilong
typy są kłopoty decydentów w procesie 32-bitowym. Mają rozmiar 64-bitowy. I może w ten sposób zostać źle wyrównany o 4, CLR może zagwarantować tylko 32-bitowe wyrównanie. Nie stanowi to problemu w procesie 64-bitowym, wszystkie zmienne są wyrównane do 8. Również podstawowy powód, dla którego język C # nie może obiecać, że są atomowe . I dlaczego tablice double są przydzielane w stosie dużych obiektów, gdy mają więcej niż 1000 elementów. LOH zapewnia gwarancję wyrównania równą 8. I wyjaśnia, dlaczego dodanie zmiennej lokalnej rozwiązało problem. Odniesienie do obiektu ma 4 bajty, więc podwójna zmienna została przesunięta o 4, teraz wyrównując. Przez przypadek.32-bitowy kompilator C lub C ++ wykonuje dodatkową pracę, aby upewnić się, że double nie może zostać źle wyrównany. Nie jest to do końca prosty problem do rozwiązania, stos może być źle wyrównany po wprowadzeniu funkcji, biorąc pod uwagę, że jedyną gwarancją jest to, że jest wyrównana do 4. Prolog takiej funkcji wymaga dodatkowej pracy, aby dostosować ją do 8. Ta sama sztuczka nie działa w zarządzanym programie, moduł odśmiecania pamięci bardzo dba o to, gdzie dokładnie znajduje się zmienna lokalna w pamięci. Jest to konieczne, aby mógł odkryć, że obiekt w stercie GC nadal zawiera odwołania. Nie może poprawnie poradzić sobie z przesunięciem takiej zmiennej o 4, ponieważ stos był nieprawidłowo wyrównany przy wprowadzaniu metody.
Jest to również podstawowy problem związany z drganiami .NET, które nie obsługują łatwo instrukcji SIMD. Mają znacznie silniejsze wymagania dotyczące wyrównania, których procesor również nie może rozwiązać samodzielnie. SSE2 wymaga wyrównania 16, AVX wymaga wyrównania 32. Nie można tego uzyskać w kodzie zarządzanym.
Na koniec należy zauważyć, że dzięki temu wydajność programu C # działającego w trybie 32-bitowym jest bardzo nieprzewidywalna. Kiedy uzyskujesz dostęp do double lub long, które jest przechowywane jako pole w obiekcie, wówczas perf może drastycznie się zmienić, gdy moduł odśmiecania pamięci kompaktuje stertę. Które przenosi obiekty w pamięci, takie pole może teraz nagle zostać źle wyrównane. Oczywiście bardzo przypadkowe, może być niezłe drapanie w głowę :)
Cóż, nie ma prostych poprawek, ale jeden, 64-bitowy kod to przyszłość. Usuń wymuszanie jitter, o ile Microsoft nie zmieni szablonu projektu. Może kolejna wersja, kiedy poczują się pewniej Ryujit.
źródło
Zawęziło to trochę (wydaje się, że wpływa tylko na 32-bitowe środowisko wykonawcze CLR 4.0).
Zwróć uwagę, że umieszczenie znaku
var f = Stopwatch.Frequency;
robi różnicę.Wolno (2700 ms):
Szybki (800 ms):
źródło
Stopwatch
również drastycznie zmienia prędkość. Zmiana sygnatury metody naTest1(bool warmup)
i dodanie warunku naConsole
wyjściu:if (!warmup) { Console.WriteLine(...); }
również ma ten sam efekt (natknąłem się na to podczas budowania moich testów w celu odtworzenia problemu).Wygląda na to, że w Jitter jest jakiś błąd, ponieważ zachowanie jest jeszcze dziwniejsze. Rozważ następujący kod:
Będzie to działać w
900
ms, tak samo jak w przypadku zewnętrznego stopera. Jeśli jednak usuniemyif (!warmup)
warunek, będzie działał w3000
ms. Jeszcze dziwniejsze jest to, że następujący kod również zostanie uruchomiony w900
ms:Uwaga Usunąłem
a.X
ia.Y
odniesienia z danychConsole
wyjściowych.Nie mam pojęcia, co się dzieje, ale dla mnie to dość buggy i nie ma związku z posiadaniem zewnętrznego
Stopwatch
lub nie, problem wydaje się nieco bardziej uogólniony.źródło
a.X
ia.Y
, kompilator prawdopodobnie będzie mógł zoptymalizować prawie wszystko wewnątrz pętli, ponieważ wyniki operacji są nieużywane.a.X
ia.Y
nie sprawia, że działa szybciej niż wtedy, gdy dołączaszif (!warmup)
warunek lub OPouterSw
, co oznacza, że nie optymalizuje niczego, po prostu eliminuje każdy błąd, który powoduje, że kod działa z nieoptymalną prędkością (3000
ms zamiast900
ms).warmup
było to prawdą, ale w tym przypadku linia nie jest jeszcze wydrukowane, więc w przypadku, gdy ma się wydrukowanym faktycznie odniesieńa
. Niemniej jednak lubię mieć pewność, że zawsze odwołuję się do wyników obliczeń gdzieś pod koniec metody, gdy wykonuję testy porównawcze.