Udostępnij za pośrednictwem


Pisanie szybszego kodu zarządzanego: dowiedz się, jakie są koszty

 

Jan Gray
Zespół ds. wydajności środowiska Microsoft CLR

Czerwiec 2003 r.

Dotyczy:
   Microsoft® .NET Framework

Podsumowanie: W tym artykule przedstawiono model kosztów niskiego poziomu dla czasu wykonywania kodu zarządzanego na podstawie mierzonych czasów operacji, dzięki czemu deweloperzy mogą podejmować lepsze świadome decyzje dotyczące kodowania i pisać szybszy kod. (30 stron drukowanych)

Pobierz CLR Profiler. (330 KB)

Treść

Wprowadzenie (i zobowiązanie)
W kierunku modelu kosztów dla kodu zarządzanego
Jakie są koszty dotyczące elementów w kodzie zarządzanym
Konkluzja
Zasoby

Wprowadzenie (i zobowiązanie)

Istnieją niezliczone sposoby implementacji obliczeń, a niektóre są znacznie lepsze niż inne: prostsze, czystsze, łatwiejsze do utrzymania. Niektóre sposoby są płonące szybko, a niektóre są zadziwiająco powolne.

Nie popełniaj powolnych i grubych kodów na świecie. Czy nie pogardzujesz takim kodem? Kod, który działa w pasujących i uruchamianych? Kod, który blokuje interfejs użytkownika przez kilka sekund? Kod, który kołysa procesor CPU lub ogranicza dysk?

Nie rób tego. Zamiast tego, wstać i zobowiązać się wraz ze mną:

"Obiecuję, że nie wyślem powolnego kodu. Szybkość jest funkcją, o której mi zależy. Każdego dnia zwracam uwagę na wydajność kodu. Będę regularnie i metodycznie mierzyć jego szybkość i rozmiar. Będę uczyć się, kompilować lub kupować narzędzia, które muszę wykonać. To moja odpowiedzialność".

(Naprawdę.) Więc czy obiecałeś? Dobre dla Ciebie.

Jak więc pisać najszybszy, najciśniejszy dzień kodu w dzień i na dzień? Jest to kwestia świadomego wybierania frugalnego sposobu w preferencjach ekstrawaganckiego, wzdętego sposobu, ponownie i ponownie, i kwestia myślenia przez konsekwencje. Każda strona kodu przechwytuje dziesiątki takich małych decyzji.

Ale nie możesz dokonać inteligentnych wyborów między alternatywami, jeśli nie wiesz, jakie rzeczy kosztują: nie możesz napisać wydajnego kodu, jeśli nie wiesz, jakie rzeczy kosztują.

Było łatwiej w dobrych starych dniach. Dobrzy programiści języka C wiedzieli. Każdy operator i operacja w języku C, czy jest to przypisanie, liczba całkowita lub zmiennoprzecinkowa, wyłudanie lub wywołanie funkcji, zamapowane mniej więcej jeden do jednego do pojedynczej operacji maszyny pierwotnej. Prawdą jest, że czasami kilka instrukcji maszynowych było wymaganych do umieszczenia odpowiednich operandów w odpowiednich rejestrach, a czasami pojedyncza instrukcja może przechwycić kilka operacji C (znany *dest++ = *src++;), ale zwykle można napisać (lub odczytać) wiersz kodu C i wiedzieć, gdzie czas się dzieje. Zarówno w przypadku kodu, jak i danych kompilator języka C był WYWIWYG — "to, co piszesz, to co otrzymujesz". (Wyjątek to, i to, wywołania funkcji. Jeśli nie wiesz, jakie są koszty funkcji, nie wiesz, jak to zrobić.

W 1990 roku, aby cieszyć się wieloma korzyściami inżynierii oprogramowania i produktywności abstrakcji danych, programowania obiektowego i ponownego używania kodu, przemysł oprogramowania komputerowego dokonał przejścia z języka C do języka C++.

Język C++ jest nadzbiorem języka C i jest "opłacany zgodnie z rzeczywistym użyciem" — nowe funkcje nie kosztują nic, jeśli ich nie używasz — więc wiedza na temat programowania w języku C, w tym model kosztów wewnętrznych, ma bezpośrednie zastosowanie. Jeśli zajmiesz trochę działającego kodu C i ponownie skompilujesz go dla języka C++, czas wykonywania i obciążenie przestrzeni nie powinny się znacznie zmieniać.

Z drugiej strony język C++ wprowadza wiele nowych funkcji języka, w tym konstruktorów, destruktorów, nowych, usuwania, pojedynczych, wielu i wirtualnych dziedziczenia, rzutów, funkcji składowych, funkcji wirtualnych, przeciążonych operatorów, wskaźników do elementów członkowskich, tablic obiektów, obsługi wyjątków i kompozycji tego samego, co wiąże się z nietrygalnymi ukrytymi kosztami. Na przykład funkcje wirtualne kosztują dwa dodatkowe pośrednie elementy na wywołanie i dodaj do każdego wystąpienia ukryte pole wskaźnika tabeli wirtualnej. Możesz też rozważyć, że ten nieszkodliwy kod:

{ complex a, b, c, d; … a = b + c * d; }

kompiluje się w około trzynaście niejawnych wywołań funkcji składowych (miejmy nadzieję, że wciśnięty).

Dziewięć lat temu omówiliśmy ten temat w moim artykule C++: Under the Hood. Napisałem:

"Ważne jest, aby zrozumieć, jak jest implementowany język programowania. Taka wiedza rozwia strach i zastanawia się: "Co na ziemi robi tutaj kompilator?"; daje pewność, że używa nowych funkcji; zapewnia szczegółowe informacje podczas debugowania i uczenia się innych funkcji językowych. Daje to również poczucie względnych kosztów różnych wyborów kodowania, które są niezbędne do pisania najbardziej wydajnego dnia kodu na dzień."

Teraz przyjrzymy się podobnemu kodowi zarządzanemu. W tym artykule omówiono niskich czasu i kosztów przestrzeni zarządzanego wykonywania, dzięki czemu możemy sprawić, że inteligentne kompromisy w naszym codziennym kodowaniu.

I zachowaj nasze obietnice.

Dlaczego kod zarządzany?

W przypadku zdecydowanej większości deweloperów kodu natywnego kod zarządzany jest lepszą, wydajniejszą platformą do uruchamiania oprogramowania. Usuwa ona całe kategorie błędów, takich jak uszkodzenia stert i błędy indeksu tablicy poza granicami, które często prowadzą do frustrujących sesji debugowania późnym wieczorem. Obsługuje nowoczesne wymagania, takie jak bezpieczny kod mobilny (za pośrednictwem zabezpieczeń dostępu do kodu) i usługi sieci Web XML, a w porównaniu do starzejącego się systemu Win32/COM/ATL/MFC/VB, program .NET Framework jest odświeżającym projektem czystego łupka, w którym można uzyskać więcej pracy z mniejszym nakładem pracy.

W przypadku społeczności użytkowników kod zarządzany umożliwia bogatsze, bardziej niezawodne aplikacje — lepsze życie dzięki lepszemu oprogramowaniu.

Co to jest wpis tajny do pisania szybszego kodu zarządzanego?

Tylko dlatego, że możesz zrobić więcej z mniejszym nakładem pracy, nie jest licencją na uprowadzenie odpowiedzialności za kod mądry. Po pierwsze, musisz przyznać to sobie: "Jestem nowicjuszem". Jesteś nowym użytkownikiem. Jestem też nowicjuszem. Wszyscy jesteśmy babes w zarządzanej ziemi kodu. Wszyscy wciąż uczymy się lin , w tym tego, jakie rzeczy kosztują.

Jeśli chodzi o bogaty i wygodny program .NET Framework, to jak jesteśmy dziećmi w sklepie cukierków. "Wow, nie muszę robić wszystko, co żmudne strncpy rzeczy, mogę tylko "+" ciągi razem! Wow, mogę załadować megabajt XML w kilku wierszach kodu! Whoo-hoo!"

To wszystko jest tak łatwe. Tak łatwo, rzeczywiście. Tak łatwo spalić megabajty pamięci RAM analizowania zestawów informacji XML tylko w celu ściągnięcia kilku elementów z nich. W języku C lub C++ było tak bolesne, że myślisz dwa razy, być może utworzysz maszynę stanu na niektórych interfejsach API przypominających program SAX. Za pomocą programu .NET Framework wystarczy załadować cały zestaw informacji w jednym gulp. Może nawet robisz to w całym. Być może aplikacja nie wydaje się już tak szybka. Może ma zestaw roboczy wielu megabajtów. Być może powinieneś pomyśleć dwa razy o tym, co te łatwe metody kosztują...

Niestety, moim zdaniem bieżąca dokumentacja platformy .NET Framework nie opisuje odpowiednio wpływu na wydajność typów i metod platformy — nawet nie określa metod, które mogą tworzyć nowe obiekty. Modelowanie wydajności nie jest łatwym przedmiotem okładek ani dokumentów; ale mimo to, "nie wiedząc" sprawia, że znacznie trudniejsze dla nas do podejmowania świadomych decyzji.

Ponieważ wszyscy jesteśmy tutaj nowicjuszami, a ponieważ nie wiemy, jakie są jakiekolwiek koszty, a ponieważ koszty nie są wyraźnie udokumentowane, co należy zrobić?

Zmierz ją. Wpis tajny polega na mierzeniu i czujności. Wszyscy będziemy musieli dostać się do nawyku mierzenia kosztów rzeczy. Jeśli pójdziemy do kłopotów z pomiarem kosztów rzeczy, to nie będziemy tymi, którzy przypadkowo nazywają nową metodę, która kosztuje dziesięć razy, co zakładaliśmy, to kosztuje.

(Aby uzyskać lepszy wgląd w wydajność bazową biblioteki BCL (biblioteki klas bazowych) lub samej CLR, rozważ przyjrzenie się udostępnionego źródłowego interfejsu wiersza polecenia, czyli Wirnik. Kod wirnika współdzieli linię krwi z programem .NET Framework i CLR. To nie jest ten sam kod w całym, ale mimo to obiecuję, że przemyślane badanie Wirnika da ci nowe szczegółowe informacje na temat dzieje pod maską CLR. Ale pamiętaj, aby najpierw przejrzeć licencję SSCLI!)

Wiedza

Jeśli aspirujesz do bycia kierowcą taksówki w Londynie, najpierw musisz zarobić The Knowledge. Studenci studiują przez wiele miesięcy, aby zapamiętać tysiące małych ulic w Londynie i nauczyć się najlepszych tras od miejsca do miejsca. I wychodzą codziennie na skutery, aby zwiadować i wzmocnić swoją naukę książki.

Podobnie, jeśli chcesz być deweloperem kodu zarządzanego o wysokiej wydajności, musisz uzyskać Wiedzy o zarządzanym kodzie. Musisz dowiedzieć się, co kosztuje każda operacja niskiego poziomu. Musisz dowiedzieć się, jakie funkcje, takie jak delegaty i koszt zabezpieczeń dostępu kodu. Musisz poznać koszty używanych typów i metod oraz tych, których piszesz. Nie ma to wpływu na wykrycie, które metody mogą być zbyt kosztowne dla twojej aplikacji— dlatego ich unikaj.

Wiedza nie znajduje się w żadnej książce, niestety. Musisz wyjść na skuter i eksplorować — czyli korbę csc, ildasm, debuger VS.NET, CLR Profiler, profiler, niektóre czasomierze wydajności i tak dalej, i zobaczyć, co kosztuje kod w czasie i przestrzeni.

W kierunku modelu kosztów dla kodu zarządzanego

Na bok rozważmy model kosztów dla kodu zarządzanego. Dzięki temu będziesz w stanie przyjrzeć się metodzie liścia i powiedzieć na pierwszy rzut oka, które wyrażenia i instrukcje są bardziej kosztowne; i będziesz w stanie dokonać mądrzejszych wyborów podczas pisania nowego kodu.

(Nie dotyczy to kosztów przejściowych wywoływania metod lub metod programu .NET Framework. To będzie musiał czekać na inny artykuł w innym dniu.)

Wcześniej stwierdziłem, że większość modelu kosztów języka C nadal ma zastosowanie w scenariuszach języka C++. podobnie większość modelu kosztów C/C++ nadal ma zastosowanie do kodu zarządzanego.

Jak to może być? Znasz model wykonywania środowiska CLR. Kod pisze się w jednym z kilku języków. Kompilujesz go w formacie CIL (Common Intermediate Language), spakowany do zestawów. Uruchamiasz główny zestaw aplikacji, a następnie uruchamiasz kod CIL. Ale czy kolejność wielkości jest wolniejsza, jak interpretery kodu bajtowego starego?

Kompilator just in time

Nie, to nie jest. ClR używa kompilatora JIT (just in time) do kompilowania każdej metody w wzorniku CIL do natywnego kodu x86, a następnie uruchamia kod macierzysty. Chociaż istnieje niewielkie opóźnienie kompilacji JIT każdej metody, ponieważ jest wywoływana po raz pierwszy, każda metoda o nazwie uruchamia czysty kod natywny bez konieczności interpretacji.

W przeciwieństwie do tradycyjnego procesu kompilacji off-line języka C++ czas spędzony w kompilatorze JIT jest opóźnieniem "zegara ściany", w twarzy każdego użytkownika, więc kompilator JIT nie ma luksusu wyczerpujących przebiegów optymalizacji. Mimo to lista optymalizacji, które wykonuje kompilator JIT, jest imponująca:

  • Stałe składanie
  • Propagacja stałej i kopiowania
  • Wspólna eliminacja podexpressionu
  • Ruch pętli kodu wariancji
  • Eliminacja martwego magazynu i martwego kodu
  • Rejestrowanie alokacji
  • Inlining metody
  • Wyrejestrowywanie pętli (małe pętle z małymi ciałami)

Wynik jest porównywalny z tradycyjnym kodem natywnym —co najmniej w tym samymballpark.

Jeśli chodzi o dane, użyjesz kombinacji typów wartości lub typów referencyjnych. Typy wartości, w tym typy całkowite, typy zmiennoprzecinkowe, wyliczenia i struktury, zwykle żyją na stosie. Są one tak małe i szybkie, jak lokalne i struktury są w języku C/C++. Podobnie jak w przypadku języka C/C++, prawdopodobnie należy unikać przekazywania dużych struktur jako argumentów metody lub zwracanych wartości, ponieważ obciążenie związane z kopiowaniem może być zbyt kosztowne.

Typy odwołań i typy wartości pól są aktywne w stercie. Są one adresowane przez odwołania do obiektów, które są po prostu wskaźnikami maszyn, podobnie jak wskaźniki obiektów w języku C/C++.

Więc jitted zarządzany kod może być szybki. W przypadku kilku wyjątków, które omówimy poniżej, jeśli masz poczucie, że koszt niektórych wyrażeń w natywnym kodzie języka C nie pójdzie daleko źle modelując jego koszt jako równoważny w kodzie zarządzanym.

Powinienem również wspomnieć NGEN, narzędzie, które "przed czasem" kompiluje CIL do natywnych zestawów kodu. Chociaż NGEN'ing zestawów nie ma obecnie znacznego wpływu (dobrego lub złego) na czas wykonywania, może zmniejszyć całkowity zestaw roboczy dla udostępnionych zestawów, które są ładowane do wielu obiektów AppDomain i procesów. (System operacyjny może współużytkować jedną kopię kodu NGEN we wszystkich klientach; podczas gdy kod jitted zwykle nie jest obecnie współużytkowany w domenach aplikacji lub procesach. Ale zobacz również LoaderOptimizationAttribute.MultiDomain.)

Automatyczne zarządzanie pamięcią

Najważniejsze odejście kodu zarządzanego (z natywnego) to automatyczne zarządzanie pamięcią. Przydzielasz nowe obiekty, ale moduł odśmiecywania pamięci CLR (GC) automatycznie zwalnia je, gdy staną się niedostępne. Funkcja GC działa teraz i ponownie, często nieupowiadująco, zazwyczaj zatrzymując aplikację tylko w milisekundach lub dwóch — od czasu do czasu dłużej.

W kilku innych artykułach omówiono wpływ na wydajność modułu odśmiecającego śmieci i nie będziemy ich tutaj recapitulate. Jeśli aplikacja jest zgodna z zaleceniami w tych innych artykułach, całkowity koszt odzyskiwania pamięci może być nieznaczny, kilka procent czasu wykonywania, konkurencyjny lub lepszy od tradycyjnych new obiektów C++ i delete. Amortyzowany koszt tworzenia i późniejszego automatycznego odzyskiwania obiektu jest wystarczająco niski, że można utworzyć wiele milionów małych obiektów na sekundę.

Jednak alokacja obiektów nadal nie jest bezpłatna. Obiekty zajmują miejsce. Zwiększenie alokacji obiektów prowadzi do częstszych cykli odzyskiwania pamięci.

Co gorsza, niepotrzebne zachowywanie odwołań do bezużytecznych grafów obiektów utrzymuje je przy życiu. Czasami widzimy skromne programy z godnymi ubolewania zestawami roboczymi 100+ MB, których autorzy zaprzeczają swojej winie i zamiast tego przypisują ich słabą wydajność do niektórych tajemniczych, niezidentyfikowanych (a tym samym niezmienialnych) problemu z samym kodem zarządzanym. To tragiczne. Ale potem godzina badania z CLR Profiler i zmiany w kilku wierszach kodu przecinają ich użycie stert przez współczynnik dziesięciu lub więcej. Jeśli masz problem z dużym zestawem roboczym, pierwszym krokiem jest wyszukanie w lustrze.

Dlatego nie twórz obiektów niepotrzebnie. Tylko dlatego, że automatyczne zarządzanie pamięcią rozprasza wiele złożoności, kłopotów i błędów alokacji obiektów i zwalniania, ponieważ jest tak szybkie i tak wygodne, naturalnie mamy tendencję do tworzenia coraz większej liczby obiektów, tak jakby rosły na drzewach. Jeśli chcesz napisać naprawdę szybki kod zarządzany, utwórz obiekty przemyślane i odpowiednio.

Dotyczy to również projektu interfejsu API. Istnieje możliwość zaprojektowania typu i jego metod, aby wymagać od klientów tworzenia nowych obiektów z dzikim porzuceniem. Nie rób tego.

Jakie są koszty dotyczące elementów w kodzie zarządzanym

Teraz rozważmy koszt czasu różnych operacji kodu zarządzanego niskiego poziomu.

Tabela 1 przedstawia przybliżony koszt różnych operacji kodu zarządzanego niskiego poziomu w nanosekundach na spoczynku 1,1 GHz Pentium-III komputera z systemem Windows XP i .NET Framework w wersji 1.1 ("Everett"), zebranych z zestawem prostych pętli chronometrażu.

Sterownik testowy wywołuje każdą metodę testową, określając liczbę iteracji do wykonania, automatycznie skalowaną w celu iteracji między 218 a 230 iteracji, w razie potrzeby do wykonania każdego testu przez co najmniej 50 ms. Mówiąc ogólnie, jest to wystarczająco długie, aby obserwować kilka cykli odzyskiwania pamięci generacji 0 w teście, który wykonuje intensywną alokację obiektów. W tabeli przedstawiono wyniki średnio ponad 10 prób, a także najlepszą (minimalną ilość czasu) próbną dla każdego tematu testowego.

Każda pętla testu jest wyrejestrowana od 4 do 64 razy w razie potrzeby, aby zmniejszyć obciążenie pętli testowej. Sprawdzono kod macierzysty wygenerowany dla każdego testu, aby upewnić się, że kompilator JIT nie optymalizuje testu — na przykład w kilku przypadkach zmodyfikowałem test w celu utrzymania wyników pośrednich na żywo podczas i po pętli testowej. Podobnie wprowadzono zmiany, aby wykluczyć typowe eliminacje podrażenia w kilku testach.

Tabeli 1 Czasy pierwotne (średnia i minimalna) (ns)

Avg Min Prymitywny Avg Min Prymitywny Avg Min Prymitywny
0.0 0.0 Kontrola 2.6 2.6 nowy valtype L1 0.8 0.8 isinst w górę 1
1.0 1.0 Dodaj int 4.6 4.6 nowy valtype L2 0.8 0.8 isinst w dół 0
1.0 1.0 Subt int 6.4 6.4 nowy valtype L3 6.3 6.3 isinst w dół 1
2.7 2.7 Int mul 8.0 8.0 nowy valtype L4 10.7 10.6 isinst (w górę 2) w dół 1
35.9 35.7 Int div 23.0 22.9 nowy valtype L5 6.4 6.4 isinst w dół 2
2.1 2.1 Przesunięcie int 22.0 20.3 nowy reftyp L1 6.1 6.1 isinst w dół 3
2.1 2.1 długie dodawanie 26.1 23.9 nowy reftyp L2 1.0 1.0 pobieranie pola
2.1 2.1 długi sub 30.2 27.5 nowy reftyp L3 1.2 1.2 pobierz rekwizyt
34.2 34.1 długie mul 34.1 30.8 nowy reftyp L4 1.2 1.2 ustaw pole
50.1 50.0 długi div 39.1 34.4 nowy reftyp L5 1.2 1.2 ustaw rekwizyt
5.1 5.1 długa zmiana 22.3 20.3 nowy reftype pusty ctor L1 0.9 0.9 pobierz to pole
1.3 1.3 dodawanie zmiennoprzecinkowe 26.5 23.9 nowy reftype pusty ctor L2 0.9 0.9 pobierz ten rekwizyt
1.4 1.4 sub zmiennoprzecinkowy 38.1 34.7 nowy reftype pusty ctor L3 1.2 1.2 ustaw to pole
2.0 2.0 float mul 34.7 30.7 nowy reftype pusty ctor L4 1.2 1.2 ustaw ten rekwizyt
27.7 27.6 zmiennoprzecinkowe div 38.5 34.3 nowy reftype pusty ctor L5 6.4 6.3 pobieranie wirtualnego rekwizytu
1.5 1.5 double add 22.9 20.7 nowy reftyp ctor L1 6.4 6.3 ustawianie rekwizytu wirtualnego
1.5 1.5 podwaja 27.8 25.4 nowy reftyp ctor L2 6.4 6.4 bariera zapisu
2.1 2.0 podwójna mul 32.7 29.9 nowy reftyp ctor L3 1.9 1.9 ładowanie tablicy int elem
27.7 27.6 podwójne div 37.7 34.1 nowy ctor reftype L4 1.9 1.9 store int array elem
0.2 0.2 wywołanie statyczne w zesłoniętym 43.2 39.1 nowy reftyp ctor L5 2.5 2.5 ładowanie tablicy obj elem
6.1 6.1 wywołanie statyczne 28.6 26.7 nowy reftyp ctor no-inl L1 16.0 16.0 store obj array elem
1.1 1.0 Wywołanie wystąpienia w trybie wbudowanym 38.9 36.5 nowy reftyp ctor no-inl L2 29.0 21.6 skrzynka int
6.8 6.8 wywołanie wystąpienia 50.6 47.7 nowy reftype ctor no-inl L3 3.0 3.0 rozpakuj skrzynkę odbiorczą (int)
0.2 0.2 inlined to wywołanie inst 61.8 58.2 nowy reftyp ctor no-inl L4 41.1 40.9 deleguj wywołanie
6.2 6.2 to wywołanie wystąpienia 72.6 68.5 nowy reftype ctor no-inl L5 2.7 2.7 sum array 1000
5.4 5.4 połączenie wirtualne 0.4 0.4 rzutu 1 2.8 2.8 sum array 10000
5.4 5.4 to połączenie wirtualne 0.3 0.3 rzutu 0 2.9 2.8 sum array 100000
6.6 6.5 wywołanie interfejsu 8.9 8.8 rzutu 1 5.6 5.6 sum array 1000000
1.1 1.0 inst itf instance call (wywołanie wystąpienia inst itf) 9.8 9.7 rzutowanie (w górę 2) w dół 1 3.5 3.5 lista sum 1000
0.2 0.2 to wywołanie wystąpienia itf 8.9 8.8 rzutowanie 2 6.1 6.1 lista sum 10000
5.4 5.4 inst itf wirtualne wywołanie 8.7 8.6 rzutowanie 3 22.0 22.0 lista sum 100000
5.4 5.4 to wywołanie wirtualne itf       21.5 21.4 lista sum 1000000

Zastrzeżenie: proszę nie przyjmować tych danych zbyt dosłownie. Testowanie czasu jest obarczone niebezpieczeństwo nieoczekiwanych efektów drugiej kolejności. Prawdopodobieństwo wystąpienia może spowodować umieszczenie wytrąconego kodu lub niektórych kluczowych danych, tak aby obejmowały wiersze pamięci podręcznej, zakłócały działanie czegoś innego lub co cię posiada. To trochę jak zasada niepewności: czasy i różnice czasu 1 nanosekundy lub tak są w granicach obserwowalnych.

Inne zastrzeżenie: te dane są istotne tylko w przypadku małych scenariuszy kodu i danych, które mieszczą się całkowicie w pamięci podręcznej. Jeśli "gorące" części aplikacji nie mieszczą się w pamięci podręcznej mikroukładu, może być inny zestaw wyzwań związanych z wydajnością. Mamy o wiele więcej do powiedzenia na temat pamięci podręcznych pod koniec papieru.

A kolejne zastrzeżenie: jedno z wzniosłych zalet wysyłki składników i aplikacji jako zestawów CIL jest to, że program może automatycznie szybciej co sekundę i szybciej co rok — "szybciej co sekundę", ponieważ środowisko uruchomieniowe może (teoretycznie) ponownie dostroić skompilowany kod JIT podczas uruchamiania programu; i "szybciej w ciągu roku", ponieważ wraz z każdą nową wersją środowiska uruchomieniowego lepiej, inteligentniej, szybciej algorytmy mogą podjąć nową kłutę przy optymalizacji kodu. Jeśli więc kilka z tych terminów wydaje się mniej niż optymalne na platformie .NET 1.1, weź pod uwagę, że powinny one poprawić się w kolejnych wersjach produktu. Wynika to z tego, że każda sekwencja kodu natywnego zgłoszona w tym artykule może ulec zmianie w przyszłych wersjach programu .NET Framework.

Zastrzeżenia na bok, dane zapewniają rozsądne poczucie odwagi dla bieżącej wydajności różnych typów pierwotnych. Liczby mają sens i potwierdzają moje twierdzenie, że większość jitted kodu zarządzanego działa "blisko maszyny", podobnie jak w przypadku skompilowanego kodu natywnego. Pierwotna liczba całkowita i operacje zmiennoprzecinkowe są szybkie, wywołania metod różnych rodzajów mniej, ale (ufaj mi) nadal porównywalne z natywnym językiem C/C++; a jednak widzimy również, że niektóre operacje, które są zwykle tanie w kodzie natywnym (rzuty, macierze i magazyny pól, wskaźniki funkcji (delegaty)) są teraz droższe. Dlaczego? Zobaczmy.

Operacje arytmetyczne

tabeli 2 czasy operacji arytmetycznych (ns)

Avg Min Prymitywny Avg Min Prymitywny
1.0 1.0 int add 1.3 1.3 dodawanie zmiennoprzecinkowe
1.0 1.0 subt int 1.4 1.4 sub zmiennoprzecinkowy
2.7 2.7 int mul 2.0 2.0 float mul
35.9 35.7 int div 27.7 27.6 zmiennoprzecinkowe div
2.1 2.1 przesunięcie int      
2.1 2.1 długie dodawanie 1.5 1.5 double add
2.1 2.1 długi sub 1.5 1.5 podwaja
34.2 34.1 długie mul 2.1 2.0 podwójna mul
50.1 50.0 długi div 27.7 27.6 podwójne div
5.1 5.1 długa zmiana      

W starych czasach matematyka zmiennoprzecinkowa była być może liczbą wielkości wolniejszy niż matematyka całkowita. Jak pokazano w tabeli 2, w przypadku nowoczesnych jednostek zmiennoprzecinkowych potoków wydaje się, że jest niewiele lub nie ma różnicy. To niesamowite, że przeciętny komputer notebook jest teraz gigaflop klasy maszyny (w przypadku problemów, które mieszczą się w pamięci podręcznej).

Przyjrzyjmy się wierszowi jitted kodu z liczby całkowitej i liczby zmiennoprzecinkowych dodajmy testy:

Dezasemblacja 1 Do dodania i liczby zmiennoprzecinkowej dodaj

int add               a = a + b + c + d + e + f + g + h + i;
0000004c 8B 54 24 10      mov         edx,dword ptr [esp+10h] 
00000050 03 54 24 14      add         edx,dword ptr [esp+14h] 
00000054 03 54 24 18      add         edx,dword ptr [esp+18h] 
00000058 03 54 24 1C      add         edx,dword ptr [esp+1Ch] 
0000005c 03 54 24 20      add         edx,dword ptr [esp+20h] 
00000060 03 D5            add         edx,ebp 
00000062 03 D6            add         edx,esi 
00000064 03 D3            add         edx,ebx 
00000066 03 D7            add         edx,edi 
00000068 89 54 24 10      mov         dword ptr [esp+10h],edx 

float add            i += a + b + c + d + e + f + g + h;
00000016 D9 05 38 61 3E 00 fld         dword ptr ds:[003E6138h] 
0000001c D8 05 3C 61 3E 00 fadd        dword ptr ds:[003E613Ch] 
00000022 D8 05 40 61 3E 00 fadd        dword ptr ds:[003E6140h] 
00000028 D8 05 44 61 3E 00 fadd        dword ptr ds:[003E6144h] 
0000002e D8 05 48 61 3E 00 fadd        dword ptr ds:[003E6148h] 
00000034 D8 05 4C 61 3E 00 fadd        dword ptr ds:[003E614Ch] 
0000003a D8 05 50 61 3E 00 fadd        dword ptr ds:[003E6150h] 
00000040 D8 05 54 61 3E 00 fadd        dword ptr ds:[003E6154h] 
00000046 D8 05 58 61 3E 00 fadd        dword ptr ds:[003E6158h] 
0000004c D9 1D 58 61 3E 00 fstp        dword ptr ds:[003E6158h] 

W tym miejscu widzimy, że kod jitted jest zbliżony do optymalnego. W przypadku int add kompilator zarejestrował nawet pięć zmiennych lokalnych. W przypadku dodawania zmiennoprzecinkowego byłem zobowiązany do tworzenia zmiennych a przez h statycznych klas w celu pokonania wspólnej eliminacji podexpressionu.

Wywołania metody

W tej sekcji przyjrzymy się kosztom i implementacjom wywołań metod. Temat testowy to klasa T implementowanie interfejsu I, z różnymi metodami. Zobacz Listę 1.

Lista 1 Metody wywołania metod testowych

interface I { void itf1();… void itf5();… }
public class T : I {
    static bool falsePred = false;
    static void dummy(int a, int b, int c, …, int p) { }

    static void inl_s1() { } …    static void s1()     { if (falsePred) dummy(1, 2, 3, …, 16); } …    void inl_i1()        { } …    void i1()            { if (falsePred) dummy(1, 2, 3, …, 16); } …    public virtual void v1() { } …    void itf1()          { } …    virtual void itf5()  { } …}

Rozważmy tabelę 3. Wydaje się, , do pierwszego przybliżenia, metoda jest podkreślina (abstrakcja nie kosztuje nic) lub nie (koszty abstrakcji >5X operacji całkowitej). Wydaje się, że nie ma znaczącej różnicy w nieprzetworzonym koszcie wywołania statycznego, wywołania wystąpienia, wywołania wirtualnego lub wywołania interfejsu.

3 godziny wywołań metody (ns)

Avg Min Prymitywny Wywoływane Avg Min Prymitywny Wywoływane
0.2 0.2 wywołanie statyczne w zesłoniętym inl_s1 5.4 5.4 połączenie wirtualne v1
6.1 6.1 wywołanie statyczne s1 5.4 5.4 to połączenie wirtualne v1
1.1 1.0 Wywołanie wystąpienia w trybie wbudowanym inl_i1 6.6 6.5 wywołanie interfejsu itf1
6.8 6.8 wywołanie wystąpienia i1 1.1 1.0 inst itf instance call (wywołanie wystąpienia inst itf) itf1
0.2 0.2 inlined to wywołanie inst inl_i1 0.2 0.2 to wywołanie wystąpienia itf itf1
6.2 6.2 to wywołanie wystąpienia i1 5.4 5.4 inst itf wirtualne wywołanie itf5
        5.4 5.4 to wywołanie wirtualne itf itf5

Jednak te wyniki są nierepresywne najlepszych przypadków, efekt działania ciasnych pętli chronometrażu miliony razy. W tych przypadkach testowych lokacje wywołań metody wirtualnej i interfejsu są monomorficzne (np. dla lokacji wywołania, metoda docelowa nie zmienia się w czasie), więc połączenie buforowania mechanizmów wysyłania metody wirtualnej i metody interfejsu (tabelę metody i wskaźniki mapy interfejsu i wpisy) i spektakularnie zapewniania przewidywania gałęzi umożliwia procesorowi wykonywanie nierealicznie skutecznego zadania wywołującego te w inny sposób trudne do przewidzenia, Gałęzie zależne od danych. W praktyce chybienie pamięci podręcznej danych dla dowolnego z danych mechanizmu wysyłania lub błędnejpredykcji gałęzi (jeśli jest to obowiązkowa pojemność lub lokacja wywołań polimorficznych), może i spowolni wywołania wirtualne i interfejsu przez dziesiątki cykli.

Przyjrzyjmy się bliżej każdemu z tych czasów wywołań metody.

W pierwszym przypadku wbudowanym wywołaniu statycznym, nazywamy serią pustych metod statycznych s1_inl() itp. Ponieważ kompilator całkowicie odchodzi od wszystkich wywołań, kończymy czas pustej pętli.

Aby zmierzyć przybliżony koszt wywołania metody statycznej , robimy metody statyczne s1() itp., tak duże, że nie są one opłacalne do wbudowanego elementu wywołującego.

Zauważ, że musimy nawet użyć jawnej fałszywej zmiennej predykatu falsePred. Jeśli napisaliśmy

static void s1() { if (false) dummy(1, 2, 3, …, 16); }

Kompilator JIT wyeliminowałby wywołanie nieaktywne, aby dummy i w tekście całej treści metody (teraz pustej) tak jak poprzednio. W tym przypadku niektóre z 6,1 ns czasu wywołania muszą być przypisywane do (false) testu predykatu i skoku w ramach wywoływanej metody statycznej s1. (W ten sposób lepszym sposobem wyłączenia inlining jest atrybut CompilerServices.MethodImpl(MethodImplOptions.NoInlining)).

To samo podejście było używane w przypadku wywołania wystąpienia wbudowanego i chronometrażu wywołania wystąpienia regularnego. Ponieważ jednak specyfikacja języka C# gwarantuje, że każde wywołanie odwołania do obiektu null zgłasza wyjątek NullReferenceException, każda witryna wywołania musi upewnić się, że wystąpienie nie ma wartości null. Odbywa się to przez wyłudanie odwołania do wystąpienia; jeśli jest wartość null, wygeneruje błąd, który zostanie przekształcony w ten wyjątek.

W dezasemblacji 2 używamy zmiennej statycznej t jako wystąpienia, ponieważ podczas używania zmiennej lokalnej

    T t = new T();

kompilator wciągnął wyewidencjonowanie wystąpienia o wartości null z pętli.

dezasemblacja lokacji metody dezasemblowania 2 wystąpienia z wystąpieniem o wartości null "check"

               t.i1();
00000012 8B 0D 30 21 A4 05 mov         ecx,dword ptr ds:[05A42130h] 
00000018 39 09             cmp         dword ptr [ecx],ecx 
0000001a E8 C1 DE FF FF    call        FFFFDEE0 

Przypadki wywołania tego wystąpienia i to wywołanie wystąpienia są takie same, z wyjątkiem wystąpienia this; w tym miejscu sprawdzanie wartości null zostało wyliczone.

Dezasemblacja 3 Ta wywołania metody wystąpienia

               this.i1();
00000012 8B CE            mov         ecx,esi
00000014 E8 AF FE FF FF   call        FFFFFEC8

Wywołania metody wirtualnej działają tak jak w tradycyjnych implementacjach języka C++. Adres każdej nowo wprowadzonej metody wirtualnej jest przechowywany w nowym miejscu w tabeli metod typu. Tabela metod pochodnych każdego typu jest zgodna z tabelą metod pochodnych i rozszerza ją o typ podstawowy, a każda metoda wirtualna zastępuje adres metody wirtualnej typu podstawowego adresem metody wirtualnej typu pochodnego w odpowiednim miejscu w tabeli metod pochodnych.

W lokacji wywołania wywołania metody wirtualnej wywołanie metody wirtualnej wiąże się z dwoma dodatkowymi obciążeniami w porównaniu z wywołaniem wystąpienia, jednym w celu pobrania adresu tabeli metody (zawsze znalezionego w *(this+0)), a drugiego w celu pobrania odpowiedniego adresu metody wirtualnej z tabeli metod i wywołania go. Zobacz Dezasemblacji 4.

Dezasemblacja 4 lokacja wywołania metody wirtualnej

               this.v1();
00000012 8B CE            mov         ecx,esi 
00000014 8B 01            mov         eax,dword ptr [ecx] ; fetch method table address
00000016 FF 50 38         call        dword ptr [eax+38h] ; fetch/call method address

Na koniec doszliśmy do metody interfejsu wywołania (Dezasemblacji 5). Nie mają one dokładnego odpowiednika w języku C++. Każdy dany typ może implementować dowolną liczbę interfejsów, a każdy interfejs logicznie wymaga własnej tabeli metod. Aby wysłać metodę interfejsu, wyszukujemy tabelę metod, jej mapę interfejsu, wpis interfejsu w tej mapie, a następnie wywołujemy pośredni poprzez odpowiedni wpis w sekcji interfejsu tabeli metod.

dezasemblacja lokacji metody dezasemblowania 5 interfejsu

               i.itf1();
00000012 8B 0D 34 21 A4 05 mov        ecx,dword ptr ds:[05A42134h]; instance address
00000018 8B 01             mov        eax,dword ptr [ecx]         ; method table addr
0000001a 8B 40 0C          mov        eax,dword ptr [eax+0Ch]     ; interface map addr
0000001d 8B 40 7C          mov        eax,dword ptr [eax+7Ch]     ; itf method table addr
00000020 FF 10             call       dword ptr [eax]             ; fetch/call meth addr

Pozostała część pierwotnych chronometrażu, wywołanie wystąpienia itf, to wystąpienie itf wywołuje, inst itf wirtualne wywołanie, to wywołanie wirtualne itf podkreślić ideę, że za każdym razem, gdy metoda pochodnego typu implementuje metodę interfejsu, pozostaje wywoływana za pośrednictwem lokacji wywołania metody wystąpienia.

Na przykład w przypadku testowego to wystąpienie itf wywołuje, wywołanie implementacji metody interfejsu za pośrednictwem odwołania wystąpienia (nie interfejsu) metoda interfejsu jest pomyślnie wznawiana, a koszt jest kierowany do 0 ns. Nawet implementacja metody interfejsu jest potencjalnie wbudowana podczas wywoływania jej jako metody wystąpienia.

Wywołania metod jeszcze do jitted

W przypadku wywołań metod statycznych i wystąpień (ale nie wywołań metod wirtualnych i interfejsu) kompilator JIT obecnie generuje różne sekwencje wywołań metody w zależności od tego, czy metoda docelowa została już wyzwolona w czasie, gdy jego lokacja wywołania jest wyzwolona.

Jeśli metoda wywoływana (metoda docelowa) nie została jeszcze wytrącona, kompilator emituje wywołanie pośrednie za pośrednictwem wskaźnika, który jest najpierw inicjowany przy użyciu "wstępnego wycinku". Pierwsze wywołanie metody docelowej pojawia się w wycinku, który wyzwala kompilację JIT metody, generowanie kodu natywnego i aktualizowanie wskaźnika w celu rozwiązania nowego kodu natywnego.

Jeśli obiekt wywoływany został już jitted, jego natywny adres kodu jest znany, więc kompilator emituje bezpośrednie wywołanie do niego.

Tworzenie nowego obiektu

Tworzenie nowego obiektu składa się z dwóch faz: alokacji obiektu i inicjowania obiektu.

W przypadku typów referencyjnych obiekty są przydzielane na stertę zbieraną przez śmieci. W przypadku typów wartości, niezależnie od tego, czy stos-rezydent, czy osadzony w innym odwołaniu lub typie wartości, obiekt typu wartości znajduje się na pewnym stałym przesunięcie ze struktury otaczającej — nie jest wymagana alokacja.

W przypadku typowych małych obiektów typu referencyjnego alokacja sterty jest bardzo szybka. Po każdym wyrzucaniu pamięci, z wyjątkiem obecności przypiętych obiektów, obiekty na żywo z sterty generacji 0 są kompaktowane i promowane do generacji 1, a więc alokator pamięci ma ładne duże ciągłe wolne areny pamięci do pracy. Większość alokacji obiektów wiąże się tylko ze wzrostem wskaźnika i sprawdzaniem granic, co jest tańsze niż typowy alokator listy bezpłatnej C/C++ (malloc/operator new). Moduł odśmiecanie pamięci uwzględnia nawet rozmiar pamięci podręcznej maszyny, aby spróbować zachować obiekty 0. generacji w szybkim miejscu w hierarchii pamięci podręcznej/pamięci.

Ponieważ preferowanym stylem kodu zarządzanego jest przydzielanie większości obiektów z krótkim okresem istnienia i szybkie odzyskiwanie ich, uwzględniamy również (w kosztach czasowych) zamortyzowany koszt odzyskiwania pamięci tych nowych obiektów.

Zwróć uwagę, że moduł odśmiecania pamięci nie spędza czasu na żałobie martwych obiektów. Jeśli obiekt jest martwy, GC nie widzi go, nie chodzi, nie daje mu myśli nanosekundy. GC dotyczy tylko dobrobytu życia.

(Wyjątek: finalizowalne obiekty nieaktywne są specjalnym przypadkiem. GC śledzi te i specjalnie promuje martwe obiekty finalizowalne do następnej generacji oczekujące na finalizację. Jest to kosztowne, a w najgorszym przypadku może przechodnio podwyższyć poziom dużych martwych grafów obiektów. W związku z tym nie należy finalizują obiektów, chyba że są ściśle konieczne; a jeśli musisz, rozważ użycie Wzorzec usuwania, wywołując GC.SuppressFinalizer, gdy jest to możliwe. Jeśli nie jest to wymagane przez metodę Finalize, nie przechowuj odwołań z obiektu finalizowalnego do innych obiektów.

Oczywiście zamortyzowany koszt GC dużego obiektu krótkotrwałego jest większy niż koszt małego obiektu krótkotrwałego. Każda alokacja obiektów przybliża nas do następnego cyklu odzyskiwania pamięci; większe obiekty robią to znacznie wcześniej, że małe. Wcześniej (lub później) nadejdzie moment wyliczania. Cykle GC, szczególnie kolekcje generacji 0, są bardzo szybkie, ale nie są wolne, nawet jeśli zdecydowana większość nowych obiektów nie żyje: aby odnaleźć (oznaczyć) obiekty na żywo, najpierw należy wstrzymać wątki, a następnie przejść stosy i inne struktury danych w celu zbierania odwołań do obiektów głównych w stercie.

(Być może znaczniej, mniej większych obiektów mieści się w tej samej ilości pamięci podręcznej, co mniejsze obiekty. Efekty chybienia pamięci podręcznej mogą łatwo zdominować efekty długości ścieżki kodu).

Po przydzieleniu miejsca dla obiektu pozostaje zainicjowanie go (konstruowanie). ClR gwarantuje, że wszystkie odwołania do obiektów są wstępnie inicjowane do wartości null, a wszystkie pierwotne typy skalarne są inicjowane do wartości 0, 0,0, false itp. (w związku z tym nie jest konieczne nadmiarowe wykonywanie tych czynności w konstruktorach zdefiniowanych przez użytkownika. Oczywiście możesz swobodnie. Należy jednak pamiętać, że kompilator JIT obecnie niekoniecznie optymalizuje nadmiarowe magazyny.

Oprócz zerowania pól wystąpienia clR inicjuje (tylko typy referencyjne) wewnętrzne pola implementacji obiektu: wskaźnik tabeli metody i wyraz nagłówka obiektu, który poprzedza wskaźnik tabeli metody. Tablice uzyskują również pole Długość, a tablice obiektów uzyskują pola Długość i typ elementu.

Następnie CLR wywołuje konstruktor obiektu, jeśli istnieje. Konstruktor każdego typu, niezależnie od tego, czy jest generowany przez użytkownika, czy kompilator, najpierw wywołuje konstruktor typu podstawowego, a następnie uruchamia inicjację zdefiniowaną przez użytkownika, jeśli istnieje.

Teoretycznie może to być kosztowne w scenariuszach głębokiego dziedziczenia. W przypadku rozszerzenia języka D rozszerzenie języka C rozszerzenie B rozszerza A (rozszerza obiekt System.Object), a następnie inicjowanie E zawsze spowoduje naliczenie pięciu wywołań metody. W praktyce rzeczy nie są tak złe, ponieważ kompilator wyprzedaje (w nicość) wywołania pustych konstruktorów typów podstawowych.

Odwołując się do pierwszej kolumny tabeli 4, zwróć uwagę, że możemy utworzyć i zainicjować strukturę D z czterema polami int w około 8 godzinach dodawania. Dezasemblacji 6 to wygenerowany kod z trzech różnych pętli chronometrażu, tworząc wartości A, C i E. (W ramach każdej pętli modyfikujemy każde nowe wystąpienie, dzięki czemu kompilator JIT nie optymalizuje wszystkiego).

tabeli 4 wartości i czasu tworzenia obiektu typu odwołania (ns)

Avg Min Prymitywny Avg Min Prymitywny Avg Min Prymitywny
2.6 2.6 nowy valtype L1 22.0 20.3 nowy reftyp L1 22.9 20.7 nowy rt ctor L1
4.6 4.6 nowy valtype L2 26.1 23.9 nowy reftyp L2 27.8 25.4 nowy rt ctor L2
6.4 6.4 nowy valtype L3 30.2 27.5 nowy reftyp L3 32.7 29.9 nowy rt ctor L3
8.0 8.0 nowy valtype L4 34.1 30.8 nowy reftyp L4 37.7 34.1 nowy rt ctor L4
23.0 22.9 nowy valtype L5 39.1 34.4 nowy reftyp L5 43.2 39.1 nowy rt ctor L5
      22.3 20.3 nowy pusty ctor rt L1 28.6 26.7 nowy rt no-inl L1
      26.5 23.9 nowy pusty ctor rt L2 38.9 36.5 nowy rt no-inl L2
      38.1 34.7 nowy pusty ctor L3 rt 50.6 47.7 nowy rt no-inl L3
      34.7 30.7 nowy pusty ctor L4 rt 61.8 58.2 nowy rt no-inl L4
      38.5 34.3 nowy pusty ctor rt L5 72.6 68.5 nowy rt no-inl L5

Dezasembleracja 6 Konstrukcja obiektu typu wartość

               A a1 = new A(); ++a1.a;
00000020 C7 45 FC 00 00 00 00 mov     dword ptr [ebp-4],0 
00000027 FF 45 FC         inc         dword ptr [ebp-4] 

               C c1 = new C(); ++c1.c;
00000024 8D 7D F4         lea         edi,[ebp-0Ch] 
00000027 33 C0            xor         eax,eax 
00000029 AB               stos        dword ptr [edi] 
0000002a AB               stos        dword ptr [edi] 
0000002b AB               stos        dword ptr [edi] 
0000002c FF 45 FC         inc         dword ptr [ebp-4] 

               E e1 = new E(); ++e1.e;
00000026 8D 7D EC         lea         edi,[ebp-14h] 
00000029 33 C0            xor         eax,eax 
0000002b 8D 48 05         lea         ecx,[eax+5] 
0000002e F3 AB            rep stos    dword ptr [edi] 
00000030 FF 45 FC         inc         dword ptr [ebp-4] 

Następne pięć chronometrażu (nowy reftyp L1, ... nowy typ reftype L5) są przeznaczone dla pięciu poziomów dziedziczenia typów odwołań A, ..., E, sans konstruktorów zdefiniowanych przez użytkownika:

    public class A     { int a; }
    public class B : A { int b; }
    public class C : B { int c; }
    public class D : C { int d; }
    public class E : D { int e; }

Porównując czasy typu odwołania do czasów typu wartości, widzimy, że amortyzowana alokacja i koszt zwolnienia każdego wystąpienia wynosi około 20 ns (20 X czasu dodawania) na maszynie testowej. To szybkie — przydzielanie, inicjowanie i odzyskiwanie około 50 milionów obiektów krótkotrwałych na sekundę, trwałe. W przypadku obiektów o rozmiarze co najmniej pięciu pól alokacja i kolekcja jest uwzględniana tylko przez połowę czasu tworzenia obiektu. Zobacz Dezasemblacji 7.

Dezasembleracja 7 konstrukcji obiektu referencyjnego

               new A();
0000000f B9 D0 72 3E 00   mov         ecx,3E72D0h 
00000014 E8 9F CC 6C F9   call        F96CCCB8 

               new C();
0000000f B9 B0 73 3E 00   mov         ecx,3E73B0h 
00000014 E8 A7 CB 6C F9   call        F96CCBC0 

               new E();
0000000f B9 90 74 3E 00   mov         ecx,3E7490h 
00000014 E8 AF CA 6C F9   call        F96CCAC8 

Ostatnie trzy zestawy pięciu chronometrażu przedstawiają różnice w tym odziedziczonym scenariuszu budowy klasy.

  1. New rt empty ctor L1, ..., new rt empty ctor L5: Każdy typ A, ..., E ma pusty konstruktor zdefiniowany przez użytkownika. Wszystkie są wbudowane, a wygenerowany kod jest taki sam jak powyżej.

  2. New rt ctor L1, ..., new rt ctor L5: Każdy typ A, ..., E ma konstruktor zdefiniowany przez użytkownika, który ustawia zmienną wystąpienia na 1:

        public class A     { int a; public A() { a = 1; } }
        public class B : A { int b; public B() { b = 1; } }
        public class C : B { int c; public C() { c = 1; } }
        public class D : C { int d; public D() { d = 1; } }
        public class E : D { int e; public E() { e = 1; } }
    

Kompilator tworzy wbudowany zestaw zagnieżdżonych wywołań konstruktora klasy bazowej do lokacji new. (Dezasemblacji 8).

Dezasemblacji 8 głęboko wbudowanych konstruktorów dziedziczone

               new A();
00000012 B9 A0 77 3E 00   mov         ecx,3E77A0h 
00000017 E8 C4 C7 6C F9   call        F96CC7E0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 

               new C();
00000012 B9 80 78 3E 00   mov         ecx,3E7880h 
00000017 E8 14 C6 6C F9   call        F96CC630 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 

               new E();
00000012 B9 60 79 3E 00   mov         ecx,3E7960h 
00000017 E8 84 C3 6C F9   call        F96CC3A0 
0000001c C7 40 04 01 00 00 00 mov     dword ptr [eax+4],1 
00000023 C7 40 08 01 00 00 00 mov     dword ptr [eax+8],1 
0000002a C7 40 0C 01 00 00 00 mov     dword ptr [eax+0Ch],1 
00000031 C7 40 10 01 00 00 00 mov     dword ptr [eax+10h],1 
00000038 C7 40 14 01 00 00 00 mov     dword ptr [eax+14h],1 
  1. New rt no-inl L1, ..., new rt no-inl L5: Każdy typ A, ..., E ma konstruktor zdefiniowany przez użytkownika, który został celowo napisany jako zbyt kosztowny dla wbudowanego. Ten scenariusz symuluje koszt tworzenia złożonych obiektów z hierarchiami głębokiego dziedziczenia i leniwymi konstruktorami.

      public class A     { int a; public A() { a = 1; if (falsePred) dummy(…); } }
      public class B : A { int b; public B() { b = 1; if (falsePred) dummy(…); } }
      public class C : B { int c; public C() { c = 1; if (falsePred) dummy(…); } }
      public class D : C { int d; public D() { d = 1; if (falsePred) dummy(…); } }
      public class E : D { int e; public E() { e = 1; if (falsePred) dummy(…); } }
    

Ostatnie pięć chronometrażów w tabeli 4 pokazuje dodatkowe obciążenie wywołania zagnieżdżonych konstruktorów bazowych.

Interlude: CLR Profiler Demo

Teraz na krótki pokaz CLR Profiler. Profiler CLR, wcześniej znany jako Profiler alokacji, używa interfejsów API profilowania CLR do zbierania danych zdarzeń, szczególnie wywołania, powrotu i alokacji obiektów oraz zdarzeń odzyskiwania pamięci podczas uruchamiania aplikacji. (CLR Profiler jest "inwazyjnym" profilerem, co oznacza, że niestety spowalnia profilowane aplikacje znacznie.) Po zebraniu zdarzeń użyjesz środowiska CLR Profiler do eksplorowania alokacji pamięci i zachowania GC aplikacji, w tym interakcji między hierarchicznym grafem wywołań i wzorcami alokacji pamięci.

Narzędzie CLR Profiler warto się nauczyć, ponieważ w przypadku wielu aplikacji kodu zarządzanego "zakwestionowanych pod kątem wydajności" zrozumienie profilu alokacji danych zapewnia krytyczne informacje niezbędne do zmniejszenia zestawu roboczego, a tym samym zapewnienia szybkich i oszczędnych składników i aplikacji.

ClR Profiler może również ujawnić, które metody przydzielają więcej miejsca do magazynowania niż oczekiwano, i mogą wykryć przypadki, w których przypadkowo zachowasz odwołania do bezużytecznych grafów obiektów, które w przeciwnym razie mogą zostać odzyskane przez GC. (Typowy wzorzec projektowania problemu to pamięć podręczna oprogramowania lub tabela odnośników elementów, które nie są już potrzebne lub są bezpieczne do ponownego utworzenia później. Jest to tragiczne, gdy pamięć podręczna przechowuje grafy obiektów przy życiu ich użyteczności. Zamiast tego pamiętaj, aby odwołać się do obiektów, które nie są już potrzebne.

Rysunek 1 to widok osi czasu sterta podczas wykonywania sterownika testu chronometrażu. Wzorzec piły wskazuje alokację wielu tysięcy wystąpień obiektów C (magenta), D (purpurowa) i E (niebieski). Co kilka milisekund, żuć kolejne ok. 150 KB pamięci RAM w nowym stercie obiektu (generacja 0), a moduł odśmiecanie pamięci działa krótko, aby go odzyskać i promować wszystkie obiekty na żywo do generacji 1. To niezwykłe, że nawet w tym inwazyjnym (powolnym) środowisku profilowania, w przedziale od 100 ms (od 2,8 s do 2,9s), przechodzimy ok. 8 generacji cykli GC. Następnie o 2,977 s, co sprawia, że miejsce dla innego wystąpienia E, moduł odśmiecania pamięci wykonuje odzyskiwanie pamięci generacji 1, który zbiera i kompaktuje stertę generacji 1 — a więc piła kontynuuje, z niższego adresu początkowego.

Rysunek1 Widok linii czasowej profilera CLR

Zwróć uwagę, że większy obiekt (E większy niż D większy niż C), tym szybciej stos generacji 0 wypełnia się i częstsze cykl GC.

Rzutowanie i kontrole typów wystąpień

Fundamentem fundamentu bezpiecznego, bezpiecznego, weryfikowalnego zarządzanego kodu jest bezpieczeństwo typu. Gdyby można było rzutować obiekt na typ, który nie jest, byłoby proste, aby naruszyć integralność CLR i tak mieć go na łasce niezaufanego kodu.

tabeli 5 rzutów i isinst Times (ns)

Avg Min Prymitywny Avg Min Prymitywny
0.4 0.4 rzutu 1 0.8 0.8 isinst w górę 1
0.3 0.3 rzutu 0 0.8 0.8 isinst w dół 0
8.9 8.8 rzutu 1 6.3 6.3 isinst w dół 1
9.8 9.7 rzutowanie (w górę 2) w dół 1 10.7 10.6 isinst (w górę 2) w dół 1
8.9 8.8 rzutowanie 2 6.4 6.4 isinst w dół 2
8.7 8.6 rzutowanie 3 6.1 6.1 isinst w dół 3

Tabela 5 przedstawia obciążenie tych obowiązkowych kontroli typów. Rzutowanie z typu pochodnego na typ podstawowy jest zawsze bezpieczne i wolne; podczas gdy rzutowanie z typu podstawowego do typu pochodnego musi być sprawdzane pod kątem typu.

Rzutowanie (zaznaczone) konwertuje odwołanie do obiektu na typ docelowy lub zgłasza InvalidCastException.

Natomiast instrukcja isinst CIL służy do implementowania słowa kluczowego as języka C#:

bac = ac as B;

Jeśli ac nie jest B lub pochodzi z B, wynik jest null, a nie wyjątek.

Lista 2 przedstawia jedną z pętli chronometrażu rzutowania, a dezasemblacja 9 pokazuje wygenerowany kod dla jednego rzutowania na typ pochodny. Aby wykonać rzutowanie, kompilator emituje bezpośrednie wywołanie procedury pomocniczej.

Lista 2 Pętla do testowania chronometrażu rzutowania

public static void castUp2Down1(int n) {
    A ac = c; B bd = d; C ce = e; D df = f;
    B bac = null; C cbd = null; D dce = null; E edf = null;
    for (n /= 8; --n >= 0; ) {
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
        bac = (B)ac; cbd = (C)bd; dce = (D)ce; edf = (E)df;
    }
}

Dezasemblacja 9 w dół

               bac = (B)ac;
0000002e 8B D5            mov         edx,ebp 
00000030 B9 40 73 3E 00   mov         ecx,3E7340h 
00000035 E8 32 A7 4E 72   call        724EA76C 

Właściwości

W kodzie zarządzanym właściwość jest parą metod, metody getter właściwości i zestaw właściwości, które działają jak pole obiektu. Metoda get_ pobiera właściwość; metoda set_ aktualizuje właściwość do nowej wartości.

Poza tym właściwości zachowują się i kosztują, podobnie jak w przypadku zwykłych metod wystąpień i metod wirtualnych. Jeśli używasz właściwości do zwykłego pobierania lub przechowywania pola wystąpienia, zwykle jest on wbudowany, tak jak w przypadku dowolnej małej metody.

Tabela 6 przedstawia czas potrzebny do pobrania (i dodania) oraz do przechowywania zestawu pól i właściwości wystąpień liczb całkowitych. Koszt pobierania lub ustawiania właściwości jest rzeczywiście identyczny z bezpośrednim dostępem do pola bazowego, chyba że właściwość jest zadeklarowana wirtualnie, w tym przypadku koszt jest w przybliżeniu taki sam jak wywołanie metody wirtualnej. Nic dziwnego.

tabeli 6 pól i właściwości (ns)

Avg Min Prymitywny
1.0 1.0 pobieranie pola
1.2 1.2 pobierz rekwizyt
1.2 1.2 ustaw pole
1.2 1.2 ustaw rekwizyt
6.4 6.3 pobieranie wirtualnego rekwizytu
6.4 6.3 ustawianie rekwizytu wirtualnego

Bariery zapisu

Moduł odśmiecacz pamięci CLR dobrze wykorzystuje "hipotezę pokoleniową" —większość nowych obiektów umiera młodych— aby zminimalizować nakład pracy na zbieranie.

Sterta jest logicznie podzielona na pokolenia. Najnowsze obiekty żyją w generacji 0 (gen 0). Te obiekty nie przetrwały jeszcze kolekcji. Podczas kolekcji gen 0 GC określa, które obiekty 0 generacji są osiągalne z zestawu głównego GC, który zawiera odwołania do obiektów w rejestrach maszyn, na stosie, odwołania do obiektów statycznych klas itp. Przechodnio osiągalne obiekty są "aktywne" i promowane (skopiowane) do generacji 1.

Ponieważ całkowity rozmiar sterty może wynosić setki MB, podczas gdy rozmiar sterty 0 generacji może wynosić tylko 256 KB, ograniczając zakres śledzenia grafów obiektów GC do sterty generacji 0 jest optymalizacją niezbędną do osiągnięcia bardzo krótkich czasów wstrzymania kolekcji CLR.

Można jednak przechowywać odwołanie do obiektu 0. generacji w polu odwołania do obiektu 1. generacji lub 2. generacji. Ponieważ nie skanujemy obiektów gen 1 ani gen2 podczas kolekcji gen 0, jeśli jest to jedyne odwołanie do danego obiektu gen 0, ten obiekt może być błędnie odzyskany przez GC. Nie możemy pozwolić, aby tak się stało!

Zamiast tego wszystkie magazyny do wszystkich pól odwołań do obiektów w stercie powodują barierę zapisu. Jest to kod księgowy, który efektywnie zauważa magazyny odwołań do obiektów nowej generacji do pól starszych obiektów generacji. Takie stare pola odwołania do obiektów są dodawane do głównego zestawu GC kolejnych GC.

Obciążenie bariery zapisu dla każdego obiektu-odwołania do pola magazynu jest porównywalne z kosztem prostego wywołania metody (Tabela 7). Jest to nowy wydatek, który nie jest obecny w natywnym kodzie C/C++, ale zwykle jest to niewielka cena do zapłaty za bardzo szybką alokację obiektów i GC oraz wiele korzyści związanych z produktywnością automatycznego zarządzania pamięcią.

tabeli 7 — czas bariery zapisu (ns)

Avg Min Prymitywny
6.4 6.4 bariera zapisu

Bariery zapisu mogą być kosztowne w ciasnych pętlach wewnętrznych. Ale w nadchodzących latach możemy oczekiwać na zaawansowane techniki kompilacji, które zmniejszają liczbę barier zapisu podjętych i łączny koszt zamortyzowany.

Można pomyśleć, że bariery zapisu są niezbędne tylko w magazynach do pól referencyjnych obiektów typów referencyjnych. Jednak w ramach metody typu wartości magazynuje pola odwołania do obiektu (jeśli istnieją) również są chronione przez bariery zapisu. Jest to konieczne, ponieważ sam typ wartości może być czasami osadzony w typie odwołania znajdującym się w stercie.

Dostęp do elementu tablicy

Aby zdiagnozować i wykluczyć błędy poza granicami tablicy oraz uszkodzenia stert oraz chronić integralność samego środowiska CLR, sprawdzane są obciążenia elementów tablicy i magazyny, upewniając się, że indeks znajduje się w przedziale [0,tablica. Długość-1] włącznie lub zgłaszanie IndexOutOfRangeException.

Nasze testy mierzą czas ładowania lub przechowywania elementów tablicy int[] i tablicy A[]. (Tabela 8).

tabeli 8 czasu dostępu do tablicy (ns)

Avg Min Prymitywny
1.9 1.9 ładowanie tablicy int elem
1.9 1.9 store int array elem
2.5 2.5 ładowanie tablicy obj elem
16.0 16.0 store obj array elem

Sprawdzanie granic wymaga porównania indeksu tablicy z niejawną tablicą. Pole Długości. Jak pokazuje dezasembleracja 10, w zaledwie dwóch instrukcjach sprawdzamy, czy indeks nie jest mniejszy niż 0, ani większy niż lub równy tablicy. Długość — jeśli tak jest, odgałęziemy do sekwencji poza wierszem, która zgłasza wyjątek. To samo dotyczy obciążeń elementów tablicy obiektów, a w przypadku magazynów w tablicach ints i innych prostych typów wartości. (Load obj tablicy elem czas jest (nieznacznie) wolniejszy ze względu na niewielką różnicę w pętli wewnętrznej).

Dezasemblacja 10 załaduj element tablicy int

                          ; i in ecx, a in edx, sum in edi
               sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4] ; compare i and array.Length
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] 
…                         ; throw IndexOutOfRangeException
00000042 33 C9            xor         ecx,ecx 
00000044 E8 52 78 52 72   call        7252789B 

Dzięki optymalizacji jakości kodu kompilator JIT często eliminuje nadmiarowe kontrole granic.

Przypominając poprzednie sekcje, możemy oczekiwać, że magazyny elementów tablicy obiektów być znacznie droższe. Aby przechowywać odwołanie do obiektu w tablicy odwołań do obiektów, środowisko uruchomieniowe musi:

  1. sprawdzanie indeksu tablicy znajduje się w granicach;
  2. check object jest wystąpieniem typu elementu tablicy;
  3. wykonaj barierę zapisu (nie odwołując się do żadnego międzypokoleniowego odwołania do obiektu z tablicy).

Ta sekwencja kodu jest dość długa. Zamiast emitować go w każdej lokacji magazynu tablic obiektów, kompilator emituje wywołanie funkcji współużytkowanego pomocnika, jak pokazano w dezasemblacji 11. To wywołanie oraz te trzy akcje odpowiadają za dodatkowy czas potrzebny w tym przypadku.

Dezasemblacja 11 Przechowuj element tablicy obiektów

                          ; objarray in edi
                          ; obj      in ebx
               objarray[1] = obj;
00000027 53               push        ebx  
00000028 8B CF            mov         ecx,edi 
0000002a BA 01 00 00 00   mov         edx,1 
0000002f E8 A3 A0 4A 72   call        724AA0D7   ; store object array element helper

Boxing and Unboxing

Partnerstwo między kompilatorami platformy .NET a clR umożliwia korzystanie z typów wartości, w tym typów pierwotnych, takich jak int (System.Int32), do udziału w taki sposób, jakby były typami referencyjnymi, które mają być adresowane jako odwołania do obiektów. Ta dostępność — ten cukier składniowy — umożliwia przekazywanie typów wartości do metod jako obiektów, przechowywanych w kolekcjach jako obiektów itp.

Aby "pole" typ wartości, należy utworzyć obiekt typu odwołania, który przechowuje kopię jego typu wartości. Jest to koncepcyjnie takie samo, jak tworzenie klasy z nienazwanym polem wystąpienia tego samego typu co typ wartości.

Aby "rozpaść" typ wartości pola, to skopiować wartość z obiektu do nowego wystąpienia typu wartości.

Jak pokazano w tabeli 9 (w porównaniu z tabelą 4), amortyzowany czas potrzebny do spakowania int, a później do odzyskiwania pamięci, jest porównywalny z czasem potrzebnym do utworzenia wystąpienia małej klasy z jednym polem int.

tabela 9 Box and Unbox int Times (ns)

Avg Min Prymitywny
29.0 21.6 skrzynka int
3.0 3.0 rozpakuj skrzynkę odbiorczą (int)

Aby rozpatować poletowy obiekt int, wymaga jawnego rzutowania do int. Spowoduje to skompilowanie w porównaniu typu obiektu (reprezentowanego przez adres tabeli metody) i adresu tabeli metody int. Jeśli są równe, wartość zostanie skopiowana z obiektu. W przeciwnym razie zgłaszany jest wyjątek. Zobacz Dezasemblacji 12.

Dezasemblacji 12 Box i rozpakuj skrzynkę odbiorczą

box               object o = 0;
0000001a B9 08 07 B9 79   mov         ecx,79B90708h 
0000001f E8 E4 A5 6C F9   call        F96CA608 
00000024 8B D0            mov         edx,eax 
00000026 C7 42 04 00 00 00 00 mov         dword ptr [edx+4],0 

unbox               sum += (int)o;
00000041 81 3E 08 07 B9 79 cmp         dword ptr [esi],79B90708h ; "type == typeof(int)"?
00000047 74 0C            je          00000055 
00000049 8B D6            mov         edx,esi 
0000004b B9 08 07 B9 79   mov         ecx,79B90708h 
00000050 E8 A9 BB 4E 72   call        724EBBFE                   ; no, throw exception
00000055 8D 46 04         lea         eax,[esi+4]
00000058 3B 08            cmp         ecx,dword ptr [eax] 
0000005a 03 38            add         edi,dword ptr [eax]        ; yes, fetch int field

Delegatów

W języku C wskaźnik do funkcji jest typem danych pierwotnych, który dosłownie przechowuje adres funkcji.

Język C++ dodaje wskaźniki do funkcji składowych. Wskaźnik do funkcji składowej (PMF) reprezentuje wywołanie funkcji odroczonej składowej. Adres funkcji niewirtualnej składowej może być prostym adresem kodu, ale adres wirtualnej funkcji składowej musi zawierać określone wywołanie funkcji wirtualnej składowej — wyłusczenie takiego PMF jest wywołania funkcji wirtualnej.

Aby wyłudić odwołanie do C++ PMF, należy podać wystąpienie:

    A* pa = new A;
    void (A::*pmf)() = &A::af;
    (pa->*pmf)();

Wiele lat temu, w zespole deweloperów kompilatora Visual C++, kiedyś zadawaliśmy sobie pytanie, jakiego rodzaju beastie jest nagim wyrażeniem pa->*pmf (operator wywołania funkcji sans)? Nazwaliśmy go wskaźnikiem powiązanym z funkcją składową, ale ukryte wywołanie funkcji składowej jest tak samo trafne.

Wracając do ziemi kodu zarządzanego, obiekt delegata to tylko to — ukryte wywołanie metody. Obiekt delegata reprezentuje zarówno metodę do wywołania, jak i wystąpienie, na które ma być wywoływana — lub dla delegata do metody statycznej, wystarczy wywołać metodę statyczną.

(Zgodnie z naszą dokumentacją: Deklaracja delegata definiuje typ odwołania, który może służyć do hermetyzacji metody z określonym podpisem. Wystąpienie delegata hermetyzuje statyczną lub metodę wystąpienia. Delegaty są mniej więcej podobne do wskaźników funkcji w języku C++; jednak delegaty są bezpieczne i bezpieczne.

Typy delegatów w języku C# są typami pochodnymi multiemisjiDelegate. Ten typ udostępnia rozbudowane semantyki, w tym możliwość tworzenia listy wywołań par (object,method) do wywołania podczas wywoływania delegata.

Delegaci udostępniają również obiekt wywołania metody asynchronicznej. Po zdefiniowaniu typu delegata i utworzeniu wystąpienia, zainicjowanym za pomocą ukrytego wywołania metody, można wywołać go synchronicznie (składnię wywołania metody) lub asynchronicznie za pośrednictwem BeginInvoke. Jeśli BeginInvoke jest wywoływana, środowisko uruchomieniowe kolejkuje wywołanie i zwraca natychmiast do wywołującego. Metoda docelowa jest wywoływana później w wątku puli wątków.

Wszystkie te bogate semantyki nie są niedrogie. Porównując tabelę 10 i tabelę 3, zwróć uwagę, że wywołanie delegata jest ** około osiem razy wolniejsze niż wywołanie metody. Spodziewaj się, że poprawi się z upływem czasu.

10 czasu wywołania delegata (ns)

Avg Min Prymitywny
41.1 40.9 deleguj wywołanie

Braki pamięci podręcznej, błędy stron i architektura komputera

W "dobrych starych dniach", około 1983 r., procesory były powolne (~.5 milionów instrukcji/s), a stosunkowo mówiąc, pamięć RAM była wystarczająco szybka, ale mała (~300 ns czasu dostępu na 256 KB pamięci DRAM), a dyski były powolne i duże (ok. 25 ms czasu dostępu na 10 MB dysków). Mikroprocesory pc były skalarne CISCs, większość zmiennoprzecinkowa znajdowała się w oprogramowaniu i nie było żadnych pamięci podręcznych.

Po dwudziestu latach prawa Moore'a, Około 2003 procesory są szybkie (wydawanie do trzech operacji na cykl przy 3 GHz), pamięć RAM jest stosunkowo niska (około 100 ns czasu dostępu na 512 MB pamięci DRAM), a dyski są powolne i ogromny (ok. 10 ms czasu dostępu na 100 GB dysków). Mikroprocesory komputerów są obecnie poza kolejnością hiperprzeczystego przepływu danych hiperwątków RISC śledzenia pamięci podręcznej (uruchamianie dekodowanych instrukcji CISC) i istnieje kilka warstw pamięci podręcznych — na przykład pewna mikroprocesor zorientowana na serwer ma 32 KB pamięci podręcznej danych na poziomie 1 (być może 2 cykle opóźnienia), 512 KB L2 pamięci podręcznej danych i 2 MB L3 pamięci podręcznej danych (na przykład kilkanaście cykli opóźnienia), wszystko na chipie.

W dobrych starych dniach można, a czasami, zliczyć bajty napisanego kodu i zliczyć liczbę cykli potrzebnych do uruchomienia kodu. Obciążenie lub magazyn wziął około tej samej liczby cykli co dodanie. Nowoczesny procesor wykorzystuje przewidywanie gałęzi, spekulacje i wykonywanie poza kolejnością (przepływ danych) w wielu jednostkach funkcji w celu znalezienia równoległości na poziomie instrukcji, a więc poczynić postępy na kilku frontach jednocześnie.

Teraz nasze najszybsze komputery mogą wystawiać maksymalnie 9000 operacji na mikrosekundy, ale w tym samym mikrosekundach, ładować lub przechowywać tylko w liniach pamięci podręcznej DRAM ~10. W kręgach architektury komputera jest to nazywane trafienie pamięciściany. Pamięci podręczne ukrywają opóźnienie pamięci, ale tylko do punktu. Jeśli kod lub dane nie mieszczą się w pamięci podręcznej i/lub wykazują słabą lokalność odwołania, nasz 9000 operacji na mikrosekundy jet degenerates do 10 obciążenia na mikrosekundy.

A (nie pozwól, aby stało się to z Tobą), jeśli zestaw roboczy programu przekroczy dostęp do dostępnej pamięci RAM fizycznej, a program zacznie wykonywać twarde błędy strony, a następnie w każdej usłudze błędów strony 10 000 mikrosekund (dostęp do dysku), przegapimy możliwość, aby umożliwić użytkownikowi 90 milionów operacji bliżej ich odpowiedzi. To jest po prostu straszne, że ufam, że od tego dnia do przodu zajmiesz się pomiarem zestawu roboczego (vadump) i użyj narzędzi, takich jak CLR Profiler, aby wyeliminować niepotrzebne alokacje i nieumyślne przechowywanie grafów obiektów.

Ale co to wszystko musi zrobić ze znajomością kosztów elementów pierwotnych kodu zarządzanego?wszystko*.*

Przypominając tabelę 1, wielobusową listę czasów pierwotnych kodu zarządzanego, mierzoną na P-III 1,1 GHz, zauważają, że za każdym razem, nawet zamortyzowany koszt przydzielania, inicjowania i odzyskiwania pięciu obiektów pól z pięcioma poziomami jawnych wywołań konstruktora, jest szybszy niż pojedynczy dostęp do pamięci DRAM. Tylko jedno obciążenie, które pomija wszystkie poziomy pamięci podręcznej mikroukładu, może trwać dłużej niż prawie każda pojedyncza operacja kodu zarządzanego.

Dlatego jeśli pasjonujesz się szybkością kodu, należy rozważyć i zmierzyć hierarchii pamięci podręcznej/pamięci podczas projektowania i implementowania algorytmów i struktur danych.

Czas na prostą demonstrację: Czy szybciej jest sumować tablicę kropek, czy sumować równoważną połączoną listę kropek? Co, ile tak i dlaczego?

Pomyśl o tym przez chwilę. W przypadku małych elementów, takich jak ints, ślad pamięci na element tablicy jest jedną czwartą listy połączonej. (Każdy węzeł listy połączonej ma dwa wyrazy narzutu obiektu i dwa wyrazy pól (następny link i element int).) To zaszkodzi wykorzystaniu pamięci podręcznej. Wynik jeden dla podejścia do tablicy.

Jednak przechodzenie tablicy może spowodować naliczenie sprawdzenia granic tablicy na element. Już wiesz, że sprawdzanie granic zajmuje trochę czasu. Być może to porady dotyczące skali na rzecz połączonej listy?

dezasemblacji 13 Sum int array a sum int linked list

sum int array:            sum += a[i];
00000024 3B 4A 04         cmp         ecx,dword ptr [edx+4]       ; bounds check
00000027 73 19            jae         00000042 
00000029 03 7C 8A 08      add         edi,dword ptr [edx+ecx*4+8] ; load array elem
               for (int i = 0; i < m; i++)
0000002d 41               inc         ecx  
0000002e 3B CE            cmp         ecx,esi 
00000030 7C F2            jl          00000024 


sum int linked list:         sum += l.item; l = l.next;
0000002a 03 70 08         add         esi,dword ptr [eax+8] 
0000002d 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000030 03 70 08         add         esi,dword ptr [eax+8] 
00000033 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
00000036 03 70 08         add         esi,dword ptr [eax+8] 
00000039 8B 40 04         mov         eax,dword ptr [eax+4] 
               sum += l.item; l = l.next;
0000003c 03 70 08         add         esi,dword ptr [eax+8] 
0000003f 8B 40 04         mov         eax,dword ptr [eax+4] 
               for (m /= 4; --m >= 0; ) {
00000042 49               dec         ecx  
00000043 85 C9            test        ecx,ecx 
00000045 79 E3            jns         0000002A 

Odnosząc się do Dezasemblacji 13, mam ułożone pokład na rzecz przejścia listy połączonej, wyrejestrowanie go cztery razy, nawet usunięcie zwykłego wskaźnika null koniec listy sprawdzania. Każdy element w pętli tablicy wymaga sześciu instrukcji, natomiast każdy element w pętli listy połączonej wymaga tylko 11/4 = 2,75 instrukcji. Teraz, co załóżmy, że jest szybsze?

Warunki testowania: najpierw utwórz tablicę o milionach cali i prostą, tradycyjną połączoną listę miliona kropek (1 węzły listy M). Następnie czas, jak długo, na element, zajmuje dodanie pierwszych 1000, 10 000, 100 000, 100 000 i 1000 000 elementów. Powtarzaj każdą pętlę wiele razy, aby zmierzyć najbardziej pochlebne zachowanie pamięci podręcznej dla każdego przypadku.

Co jest szybsze? Po zgadaniu zapoznaj się z odpowiedziami: ostatnie osiem wpisów w tabeli 1.

Interesujący! Czasy są znacznie wolniejsze, ponieważ przywołyzowane dane rosną szybciej niż kolejne rozmiary pamięci podręcznej. Wersja tablicy jest zawsze szybsza niż wersja listy połączonej, mimo że wykonuje dwa razy więcej instrukcji; dla 100 000 elementów wersja tablicy jest siedem razy szybsza!

Dlaczego tak jest? Najpierw mniej połączonych elementów listy mieści się w dowolnym poziomie pamięci podręcznej. Wszystkie te nagłówki obiektów i linki tracą miejsce. Po drugie, nasz nowoczesny procesor przepływu danych poza kolejnością może potencjalnie powiększyć i poczynić postępy w kilku elementach w tablicy w tym samym czasie. Natomiast z połączoną listą, dopóki bieżący węzeł listy nie będzie w pamięci podręcznej, procesor nie może rozpocząć pobierania następnego linku do węzła po tym.

W przypadku 100 000 elementów procesor wydaje (średnio) (22-3,5)/22 = 84% czasu twiddling kciuków oczekujących na odczyt wiersza pamięci podręcznej węzła listy z pamięci DRAM. To brzmi źle, ale rzeczy mogą być znacznie gorsze. Ponieważ połączone elementy listy są małe, wiele z nich mieści się w wierszu pamięci podręcznej. Ponieważ przechodzimy przez listę w kolejności alokacji, a moduł odśmiecania pamięci zachowuje kolejność alokacji, nawet gdy kompaktuje martwe obiekty poza stertą, prawdopodobnie po pobraniu jednego węzła w wierszu pamięci podręcznej, że następne kilka węzłów jest teraz również w pamięci podręcznej. Jeśli węzły były większe lub węzły listy były w losowej kolejności adresów, każde odwiedzone węzły może być pełną chybiąc pamięć podręczną. Dodanie 16 bajtów do każdego węzła listy podwaja czas przechodzenia na element do 43 ns; +32 bajty, 67 ns/element; i dodanie 64 bajtów podwoi je ponownie, do 146 ns/item, prawdopodobnie średnie opóźnienie DRAM na maszynie testowej.

Więc jaka jest lekcja na wynos tutaj? Unikaj połączonych list 100 000 węzłów? brak. Lekcja polega na tym, że efekty pamięci podręcznej mogą zdominować wszelkie zagadnienia dotyczące niskiej wydajności kodu zarządzanego w porównaniu z kodem natywnym. Jeśli pisania kodu zarządzanego o krytycznym znaczeniu dla wydajności, szczególnie kodu zarządzającego dużymi strukturami danych, należy pamiętać o efektach pamięci podręcznej, przemyśleć wzorce dostępu do struktury danych i dążyć do mniejszych śladów danych i dobrej lokalizacji referencyjnej.

W ten sposób trend polega na tym, że ściana pamięci, stosunek czasu dostępu do pamięci DRAM podzielony przez czas operacji procesora CPU, będzie nadal rosnąć gorzej w miarę upływu czasu.

Oto kilka reguł "projektowania świadomego pamięci podręcznej" kciuka:

  • Poeksperymentuj z scenariuszami i zmierz je, ponieważ trudno jest przewidzieć efekty drugiej kolejności i dlatego, że reguły kciuka nie są warte papieru, na który są drukowane.
  • Niektóre struktury danych, na przykład tablice, używają niejawnych przylegania do reprezentowania relacji między danymi. Inne, na przykład listy połączone, używają jawnych wskaźników (odwołań) do reprezentowania relacji. Niejawne przyleganie jest ogólnie preferowane — "niejawność" oszczędza miejsce w porównaniu ze wskaźnikami; i przyleganie zapewnia stabilną lokalność odwołania i może umożliwić procesorowi rozpoczęcie większej pracy przed pościgem w dół następnego wskaźnika.
  • Niektóre wzorce użycia faworyzują struktury hybrydowe — listy małych tablic, tablic tablic lub drzew B.
  • Być może algorytmy planowania wrażliwego na dostęp do dysku, zaprojektowane z powrotem, gdy dostęp do dysku kosztuje tylko 50 000 instrukcji procesora CPU, powinny być odzyskiwanych teraz, gdy dostęp do pamięci DRAM może przyjmować tysiące operacji procesora CPU.
  • Ponieważ moduł odśmiecania pamięci CLR zachowuje względną kolejność obiektów, obiekty przydzielone razem w czasie (i w tym samym wątku) zwykle pozostają razem w przestrzeni. Możesz użyć tego zjawiska do przemyślanego sortowania cliquish danych na typowych wierszach pamięci podręcznej.
  • Możesz podzielić dane na gorące części, które są często traversed i muszą zmieścić się w pamięci podręcznej, oraz zimne części, które są rzadko używane i mogą być "buforowane".

Eksperymenty czasuIt-Yourself

W przypadku pomiarów czasu w tym dokumencie użyto licznika wydajności Win32 o wysokiej rozdzielczości QueryPerformanceCounter (i QueryPerformanceFrequency).

Są one łatwo wywoływane za pośrednictwem funkcji P/Invoke:

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceCounter(
        ref long lpPerformanceCount);

    [System.Runtime.InteropServices.DllImport("KERNEL32")]
    private static extern bool QueryPerformanceFrequency(
        ref long lpFrequency);

Wywołanie QueryPerformanceCounter tuż przed i tuż po pętli chronometrażu, odejmowanie liczby, pomnożenie przez 1,0e9, dzielenie według częstotliwości, dzielenie według liczby iteracji i przybliżony czas na iterację w ns.

Ze względu na ograniczenia dotyczące przestrzeni i czasu nie obejmowały blokady, obsługi wyjątków ani systemu zabezpieczeń dostępu do kodu. Rozważ to ćwiczenie dla czytelnika.

W ten sposób wyprodukowałem dezasemblowania w tym artykule przy użyciu okna dezasemblacji w VS.NET 2003 roku. Jest jednak dla niego sztuczka. Jeśli uruchomisz aplikację w debugerze VS.NET, nawet jako zoptymalizowany plik wykonywalny utworzony w trybie wydania, zostanie on uruchomiony w trybie debugowania, w którym optymalizacje, takie jak podkreślenie, są wyłączone. Jedynym sposobem, w jaki znalazłem wgląd w zoptymalizowany kod natywny emitowany przez kompilator JIT, było uruchomienie mojej aplikacji testowej poza debugera, a następnie dołączenie go przy użyciu debugera.Processes.Attach.

Model kosztów kosmicznych?

Jak na ironię, zagadnienia dotyczące przestrzeni wykluczają dokładną dyskusję na temat przestrzeni kosmicznej. Kilka krótkich akapitów, a następnie.

Zagadnienia dotyczące niskiego poziomu (kilka elementów to C# (domyślne atrybuty TypeAttributes.SequentialLayout) i x86 specyficzne):

  • Rozmiar typu wartości to zazwyczaj całkowity rozmiar pól z polami 4-bajtowymi lub mniejszymi polami wyrównanymi do ich naturalnych granic.
  • Do implementowania związków można użyć atrybutów [StructLayout(LayoutKind.Explicit)] i [FieldOffset(n)].
  • Rozmiar typu odwołania wynosi 8 bajtów oraz całkowity rozmiar pól, zaokrąglony do następnej granicy 4-bajtowej i z 4-bajtowymi lub mniejszymi polami wyrównanymi do ich naturalnych granic.
  • W języku C# deklaracje wyliczenia mogą określać dowolny typ podstawowy całkowity (z wyjątkiem znaków) — dlatego można zdefiniować wyliczenia 8-bitowe, 16-bitowe, 32-bitowe i 64-bitowe.
  • Podobnie jak w języku C/C++, często można golić kilkadziesiąt procent miejsca poza większym obiektem przez odpowiednie ustalanie rozmiaru pól całkowitych.
  • Rozmiar przydzielonego typu odwołania można sprawdzić za pomocą profilera CLR.
  • Duże obiekty (wieledziesiąt KB lub więcej) są zarządzane w osobnym dużym stercie obiektów, aby uniemożliwić kosztowne kopiowanie.
  • Finalizowalne obiekty przyjmują dodatkową generację GC w celu odzyskania — używaj ich oszczędnie i rozważ użycie wzorca usuwania.

Zagadnienia związane z dużym obrazem:

  • Każda domena aplikacji obecnie wiąże się ze znacznym obciążeniem. Wiele struktur środowiska uruchomieniowego i struktury nie jest współużytkowanych w domenach aplikacji.
  • W ramach procesu kod jitted zwykle nie jest współużytkowany w domenach AppDomains. Jeśli środowisko uruchomieniowe jest w szczególności hostowane, można zastąpić to zachowanie. Zapoznaj się z dokumentacją dotyczącą CorBindToRuntimeEx i flagi STARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN .
  • W każdym razie kod jitted nie jest współużytkowany między procesami. Jeśli masz składnik, który zostanie załadowany do wielu procesów, rozważ wstępne skompilowanie z NGEN, aby udostępnić kod macierzysty.

Odbicie

Powiedziano, że "jeśli musisz zapytać, jakie koszty refleksji, nie możesz sobie na to pozwolić". Jeśli czytasz tę dotąd, wiesz, jak ważne jest, aby zapytać, jakie rzeczy kosztują i zmierzyć te koszty.

Odbicie jest przydatne i wydajne, ale w porównaniu z jitted kodu natywnego, nie jest ani szybkie, ani małe. Zostałeś ostrzeżony. Mierz to dla siebie.

Konkluzja

Teraz wiesz już (mniej lub więcej), co kosztuje kod zarządzany na najniższym poziomie. Masz teraz podstawową wiedzę niezbędną do zapewnienia inteligentniejszego kompromisu implementacji i szybszego pisania kodu zarządzanego.

Widzieliśmy, że jitted zarządzany kod może być jako "pedał do metalu" jako kod natywny. Twoim wyzwaniem jest mądry kodowanie i wybór mądry wśród wielu bogatych i łatwych w użyciu obiektów w strukturze

Istnieją ustawienia, w których wydajność nie ma znaczenia, i ustawienia, w których jest to najważniejsza funkcja produktu. Przedwczesne optymalizacji jest korzeniem wszystkiego złego. Ale tak jest nieostrogi wobec skuteczności. Jesteś profesjonalistą, artystą, rzemiosłem. Więc upewnij się, że znasz koszty rzeczy. Jeśli nie wiesz, a nawet jeśli uważasz, że to robisz — regularnie ją mierzysz.

Jeśli chodzi o zespół CLR, nadal pracujemy nad zapewnieniem platformy, która jest znacznie wydajniejsza niż kod natywny, a jednak jest szybsza niż kod natywny. Spodziewaj się, że rzeczy będą lepsze i lepsze. Stay tuned.

Pamiętaj swoją obietnicę.

Zasoby