Psaní rychlejšího spravovaného kódu: Informace o nákladech
Jan Gray
Tým výkonu Microsoft CLR
Dne
Platí pro:
Microsoft® .NET Framework
Shrnutí: Tento článek představuje nízkoúrovňový model nákladů na dobu provádění spravovaného kódu, který je založený na naměřených časech operací, aby vývojáři mohli dělat informovanější rozhodnutí o kódování a psát kód rychleji. (30 vytištěných stránek)
Stáhněte si profiler CLR. (330 kB)
Obsah
Úvod (a příslib)
Směrem k modelu nákladů pro spravovaný kód
Náklady ve spravovaném kódu
Závěr
Zdroje informací
Úvod (a příslib)
Existuje nesčetné způsoby implementace výpočtu a některé jsou mnohem lepší než jiné: jednodušší, čistší a snadněji se udržují. Některé způsoby jsou bleskově rychlé a některé jsou překvapivě pomalé.
Nechovejte na světě pomalý a tlustý kód. Nepohrdnete takovým kódem? Kód, který se spouští ve vhodných a spuštěných počítačích? Kód, který uzamkne uživatelské rozhraní na několik sekund v čase? Kód, který uchytá procesor nebo zatěžuje disk?
Nedělejte to. Místo toho se postavte a slibte spolu se mnou:
"Slibuji, že nedoručím pomalý kód. Rychlost je funkce, na které mi záleží. Každý den budu věnovat pozornost výkonu kódu. Budu pravidelně a metodicky měřit jeho rychlost a velikost. Naučím se, sestavím nebo koupím nástroje, které k tomu potřebuji. Je to moje zodpovědnost."
(Opravdu.) Takže jsi to slíbil? Dobře pro tebe.
Takže jak napíšete nejrychlejší a nejtěsnější kód den co den? Je to věc vědomého výběru štědrého způsobu v preferencích extravagantního, nafouklého způsobu, znovu a znovu, a věc promýšlení důsledků. Každá daná stránka kódu zachycuje desítky takových malých rozhodnutí.
Pokud ale nevíte, co stojí, nemůžete dělat chytrá rozhodnutí mezi alternativami: nemůžete psát efektivní kód, pokud nevíte, co stojí.
Za starých dobrých časů to bylo jednodušší. Dobří programátoři jazyka C to věděli. Každý operátor a operace v jazyce C, ať už je to přiřazení, celé číslo nebo matematika s plovoucí desetinou čárkou, dereference nebo volání funkce, mapují více nebo méně 1:1 na jednu primitivní operaci počítače. Pravda, někdy bylo k vložení správných operandů do správných registrů vyžadováno několik strojových instrukcí a někdy jedna instrukce mohla zachytit několik operací jazyka C (pověstně *dest++ = *src++;
), ale obvykle jste mohli napsat (nebo přečíst) řádek kódu jazyka C a zjistit, kam čas uplynul. Pro kód i data byl kompilátor jazyka C WYWIWYG – "to, co napíšete, je to, co získáte". (Výjimkou byla a je volání funkcí. Pokud nevíte, jaké jsou náklady na tuto funkci, nevíte, že ano.)
V roce 1990 se počítačový softwarový průmysl rozhodl přejít z jazyka C na C++, aby se v roce 1990 těšil mnoha výhodám v oblasti softwarového inženýrství a produktivity, které přináší abstrakce dat, objektově orientované programování a opakované použití kódu.
C++ je nadmnožina jazyka C a je to "průběžné platby" – nové funkce nic nestojí, pokud je nepoužíváte , takže jsou přímo použitelné znalosti programování jazyka C, včetně interního nákladového modelu. Pokud vezmete nějaký funkční kód C a znovu ho zkompilujete pro C++, doba provádění a prostorová režie by se moc změnit neměla.
Na druhé straně C++ zavádí mnoho nových jazykových funkcí, včetně konstruktorů, destruktorů, nové, odstranění, jednoduché, více a virtuální dědičnosti, přetypování, členské funkce, virtuální funkce, přetížené operátory, ukazatele na členy, pole objektů, zpracování výjimek a složení stejných, které představují netriviální skryté náklady. Například virtuální funkce stojí další dvě nepřímé hodnoty za volání a do každé instance přidají skryté pole ukazatele tabulky. Nebo si představte, že tento neškodně vypadající kód:
{ complex a, b, c, d; … a = b + c * d; }
se zkompiluje do přibližně třinácti implicitních volání členské funkce (snad inlined).
Před devíti lety jsme toto téma prozkoumali v mém článku C++: Pod kapotou. Napsal(a):
"Je důležité pochopit, jak je váš programovací jazyk implementovaný. Takové znalosti rozptýlí strach a úžas "Co tady proboha kompilátor dělá?"; poskytuje důvěru při používání nových funkcí; a poskytuje přehled při ladění a učení dalších jazykových funkcí. Dává také pocit relativních nákladů na různé volby kódování, které jsou nezbytné k napsání nejúčinnějšího kódu den od dne."
Teď se podíváme na spravovaný kód podobně. Tento článek se zabývá nízkými časovými a prostorovými náklady na spravované provádění, abychom mohli v našem každodenním kódování udělat chytřejší kompromisy.
A dodržte naše sliby.
Proč spravovaný kód?
Pro velkou většinu vývojářů nativního kódu je spravovaný kód lepší a produktivnější platformou pro spouštění softwaru. Odstraňuje celé kategorie chyb, jako jsou poškození haldy a chyby typu matice-index-mimo-vázané, které tak často vedou k frustrujícím ladicím relacím pozdě v noci. Podporuje moderní požadavky, jako je bezpečný mobilní kód (prostřednictvím zabezpečení přístupu kódu) a webové služby XML, a ve srovnání se stárnoucími Win32/COM/ATL/MFC/VB je rozhraní .NET Framework obnovující návrh s čistou tabulí, kde můžete s menším úsilím dělat více.
Pro vaši komunitu uživatelů umožňuje spravovaný kód bohatší a robustnější aplikace – lepší život díky lepšímu softwaru.
Jaký je tajný kód pro psaní rychlejšího spravovaného kódu?
Jen proto, že toho zvládnete více s menším úsilím, není licence na to, abyste se vzdali své odpovědnosti za kódování moudře. Nejdřív si to musíte přiznat sami sobě: "Jsem nováček." Jsi nováček. Jsem taky nováček. Všichni jsme holky v zemi spravovaného kódu. Všichni se pořád učíme provazy – včetně toho, co stojí.
Pokud jde o bohatou a pohodlnou platformu .NET Framework, je to, jako bychom byli děti v obchodě s candy. "Wow, nemusím dělat všechny ty nudné strncpy
věci, můžu jen '+' řetězce dohromady! Wow, můžu načíst megabajt XML v několika řádcích kódu! Óó!"
Je to tak snadné. Tak snadné, opravdu. Takže snadno vypálit megabajty paměti RAM parsující xml infoset, jen aby se z nich vytáhlo několik prvků. V C nebo C++ to bylo tak bolestivé, že byste si to dvakrát rozmysleli, možná byste vytvořili stavový počítač na nějakém rozhraní API podobném SAX. S rozhraním .NET Framework stačí načíst celou informační sadu v jednom souboru. Možná to dokonce děláš znovu a znovu. Pak se vaše aplikace už nezdá být tak rychlá. Možná má pracovní sadu mnoha megabajtů. Možná jste si měli dvakrát rozmyslet, co tyto jednoduché metody stojí...
Podle mého názoru aktuální dokumentace k rozhraní .NET Framework bohužel dostatečně nevypisuje důsledky pro výkon typů a metod rozhraní – ani neurčí, které metody mohou vytvářet nové objekty. Modelování výkonu není snadným předmětem krytí nebo dokumentu; ale přesto, "nevědět" je pro nás mnohem obtížnější dělat informovaná rozhodnutí.
Vzhledem k tomu, že jsme tady všichni nováčci, a protože nevíme, co něco stojí, a protože náklady nejsou jasně zdokumentovány, co máme dělat?
Změřte ho. Tajemstvím je změřit abýt ostražitý. Všichni si budeme muset zvyknout měřit náklady na věci. Pokud se dostaneme k potížím s měřením nákladů, pak nebudeme těmi, kdo neúmyslně volají novou metodu, která stojí desetkrát víc, než jsme předpokládali .
(Mimochodem, pokud chcete získat hlubší přehled o výkonu, který je základem seznamu BCL (knihovny základních tříd) nebo samotné knihovny CLR, zvažte možnost podívat se na rozhraní příkazového řádku sdíleného zdroje, neboli Rotor. Kód rotoru sdílí pokrevní linii s rozhraním .NET Framework a CLR. Není to stejný kód, ale i tak vám slibuji, že promyšlená studie Rotoru vám poskytne nové poznatky o dějích pod pokličkou CLR. Nejprve si ale nezapomeňte projít licenci SSCLI.)
Znalosti
Pokud chcete být taxikár v Londýně, musíte nejprve získat Znalosti. Studenti studují mnoho měsíců, aby si zapamatovali tisíce malých ulic v Londýně a naučili se nejlepší trasy z místa na místo. A každý den vyjdou na skútrech, aby se skautovali a posílili svou výuku knih.
Podobně pokud chcete být vysoce výkonným vývojářem spravovaného kódu, musíte získat znalosti spravovaného kódu. Musíte se dozvědět, jaké jsou náklady na provoz na nízké úrovni. Musíte se dozvědět, jaké funkce, jako jsou delegáti a zabezpečení přístupu kódu. Musíte se seznámit s náklady na typy a metody, které používáte, a těch, které píšete. A není na škodu zjistit, které metody můžou být pro vaši aplikaci příliš nákladné , a proto se jim vyhněte.
Znalosti nejsou v žádné knize, bohužel. Musíte se dostat na svůj skútr a prozkoumat – to znamená csc, ildasm, ladicí program VS.NET, CLR Profiler, váš profiler, některé časovače výkonu atd. a zjistit, co váš kód stojí v čase a prostoru.
Směrem k modelu nákladů pro spravovaný kód
Kromě předběžných předpokladů se podívejme na model nákladů pro spravovaný kód. Díky tomu se budete moct podívat na listovou metodu a na první pohled poznat, které výrazy a příkazy jsou nákladnější; a při psaní nového kódu budete moct dělat inteligentnější volby.
(Tím se neřeší tranzitivní náklady na volání metod nebo metod rozhraní .NET Framework. To bude muset počkat na další článek v jiný den.)
Dříve jsem uvedl, že většina nákladového modelu jazyka C stále platí ve scénářích C++. Podobně většina nákladového modelu C/C++ stále platí pro spravovaný kód.
Jak je to možné? Znáte model spouštění CLR. Kód napíšete v jednom z několika jazyků. Kompilujete ho do formátu CIL (Common Intermediate Language) zabaleného do sestavení. Spustíte hlavní sestavení aplikace a spustí se CIL. Ale není to o řád pomalejší, jako bytecode interprety starých?
Kompilátor za běhu
Ne, není. ClR používá kompilátor JIT (za běhu) ke kompilaci každé metody v CIL do nativního kódu x86 a pak spustí nativní kód. I když je kompilace JIT každé metody při prvním zavolání malá prodleva, každá volaná metoda spouští čistě nativní kód bez interpretační režie.
Na rozdíl od tradičního off-line procesu kompilace C++ je čas strávený v kompilátoru JIT zpožděním "nástěnného času" v obličeji každého uživatele, takže kompilátor JIT nemá luxus vyčerpávajících průchodů optimalizace. I tak je seznam optimalizací, které kompilátor JIT provádí, působivý:
- Konstantní skládání
- Šíření konstant a kopírování
- Běžná eliminace dílčího výrazu
- Pohyb kódu invariantů smyčky
- Vyřazení mrtvého úložiště a nedosažujícího kódu
- Registrace přidělení
- Vkládání metod
- Odváření smyčky (malé smyčky s malými těly)
Výsledek je srovnatelný s tradičním nativním kódem – alespoň ve stejném ballparku.
Pokud jde o data, použijete kombinaci hodnotových typů nebo odkazových typů. Hodnotové typy, včetně integrálních typů, typů s plovoucí desetinou čárkou, výčtů a struktur, obvykle žijí v zásobníku. Jsou stejně malé a rychlé jako místní hodnoty a struktury jsou v C/C++. Stejně jako u C/C++ byste se pravděpodobně měli vyhnout předávání velkých struktur jako argumentů metody nebo návratových hodnot, protože režie kopírování může být neúnosně nákladná.
Odkazové a krabicové typy hodnot jsou v haldě. Jsou adresovány odkazy na objekty, které jsou jednoduše strojové ukazatele stejně jako ukazatele na objekty v C/C++.
Proto může být vychýcený spravovaný kód rychlý. S několika výjimkami, které probereme níže, pokud máte dobrý pocit z nákladů na nějaký výraz v nativním kódu jazyka C, nebudete daleko špatně modelovat jeho náklady jako ekvivalent ve spravovaném kódu.
Měl bych také zmínit nástroj NGEN, který "předem" kompiluje CIL do sestavení nativního kódu. Zatímco funkce NGEN sestavení v současné době nemá podstatný vliv (dobrý nebo špatný) na dobu provádění, může snížit celkovou pracovní sadu pro sdílená sestavení, která jsou načtena do mnoha aplikačních domén a procesů. (Operační systém může sdílet jednu kopii kódu NGEN mezi všemi klienty; zatímco zachytěný kód se obvykle v současné době nesdílí napříč doménami nebo procesy AppDomains. Ale viz také LoaderOptimizationAttribute.MultiDomain
.)
Automatická správa paměti
Nejvýznamnější odchylkou spravovaného kódu (od nativního) je automatická správa paměti. Přidělíte nové objekty, ale uvolňování paměti CLR (GC) je automaticky uvolní, když se stanou nedostupnými. GC běží teď a znovu, často nepostřehnutelně, a obecně zastavuje vaši aplikaci jen na milisekundu nebo dva – někdy i déle.
Několik dalších článků popisuje důsledky uvolňování paměti na výkon a nebudeme je tady rekapitulaci. Pokud se vaše aplikace řídí doporučeními v těchto dalších článcích, celkové náklady na uvolňování paměti mohou být nevýznamné, několik procent doby provádění, konkurenceschopné s tradičními objekty C++ nebo vyšší než tradiční objekt new
C++ a delete
. Amortizované náklady na vytvoření a pozdější automatické uvolnění objektu jsou dostatečně nízké, abyste mohli vytvořit mnoho desítek milionů malých objektů za sekundu.
Přidělení objektů ale stále není zadarmo. Objekty zabírají místo. Alokace nekontrolovatých objektů vede k častějším cyklům uvolňování paměti.
Ještě horší je, že zbytečné uchovávání odkazů na nepotřebné grafy objektů je udržuje při životě. Někdy vidíme skromné programy s více než 100 MB pracovních sad, jejichž autoři popírají jejich vinu a místo toho přisuzují jejich nízký výkon nějakému záhadným, neidentifikovaným (a proto neřešitelným) problému se spravovaným kódem samotným. Je to tragické. Pak ale hodinová studie s profilerem CLR a změny na několik řádků kódu sníží využití haldy o deset nebo více. Pokud máte velký problém s pracovní sadou, prvním krokem je podívat se do zrcadla.
Nevytvářejte proto zbytečně objekty. Právě proto, že automatická správa paměti rozptyluje mnoho složitostí, potíží a chyb při přidělování a uvolnění objektů, protože je tak rychlá a pohodlná, máme přirozeně tendenci vytvářet další a další objekty, jako by rostly na stromech. Pokud chcete psát opravdu rychle spravovaný kód, vytvářejte objekty promyšleně a správně.
To platí i pro návrh rozhraní API. Je možné navrhnout typ a jeho metody tak, aby klienti museli vytvářet nové objekty s wild abandon. Nedělejte to.
Náklady ve spravovaném kódu
Teď se podívejme na časové náklady různých operací spravovaného kódu na nízké úrovni.
Tabulka 1 uvádí přibližné náklady na celou řadu operací spravovaného kódu na nízké úrovni v nanosekundách na klidovém počítači Pentium-III s Windows XP a .NET Framework v1.1 ("Everett") shromážděných se sadou jednoduchých časování smyček.
Testovací ovladač volá každou testovací metodu a určuje počet iterací, které se mají provést, a automaticky škáluje tak, aby iterovaly mezi 218 a 230 iteracemi, podle potřeby k provedení každého testu po dobu nejméně 50 ms. Obecně řečeno, to je dostatečně dlouhé na to, aby bylo vidět několik cyklů uvolňování paměti generace 0 v testu, který provádí intenzivní alokaci objektů. V tabulce jsou uvedeny výsledky s průměrem více než 10 pokusů a také nejlepší zkušební verze (minimální doba) pro každý testovací předmět.
Každá testovací smyčka se podle potřeby rozbalí 4 až 64krát, aby se snížila režie testovací smyčky. Zkontroloval(a) jsem nativní kód vygenerovaný pro každý test, aby se zajistilo, že kompilátor JIT ne optimalizuje test . V několika případech jsem například upravil test tak, aby během testovací smyčky a po jeho skončení zůstaly průběžné výsledky živé. Podobně jsem udělal změny, aby se zabránilo běžné subexpression eliminace v několika testech.
Tabulka 1 Primitivní časy (průměr a minimum) (ns)
Průměr | Min | Primitivní | Průměr | Min | Primitivní | Průměr | Min | Primitivní |
---|---|---|---|---|---|---|---|---|
0,0 | 0,0 | Řízení | 2,6 | 2,6 | nový valtype L1 | 0.8 | 0.8 | isinst up 1 |
1.0 | 1.0 | Int add | 4,6 | 4,6 | nový valtype L2 | 0.8 | 0.8 | isinst down 0 |
1.0 | 1.0 | Int sub | 6.4 | 6.4 | nový valtype L3 | 6.3 | 6.3 | Isinst down 1 |
2.7 | 2.7 | Int mul | 8.0 | 8.0 | nový valtype L4 | 10,7 | 10.6 | isinst (nahoru 2) dolů 1 |
35.9 | 35.7 | Int div | 23,0 | 22.9 | nový valtype L5 | 6.4 | 6.4 | isinst down 2 |
2.1 | 2.1 | Int shift | 22,0 | 20.3 | nový reftype L1 | 6.1 | 6.1 | Isinst down 3 |
2.1 | 2.1 | long add | 26.1 | 23.9 | nový reftype L2 | 1.0 | 1.0 | get field |
2.1 | 2.1 | dlouhé předplatné | 30.2 | 27.5 | nový reftype L3 | 1.2 | 1.2 | získat rekvizitu |
34.2 | 34.1 | dlouhá mula | 34.1 | 30.8 | nový typ L4 | 1.2 | 1.2 | set field |
50.1 | 50,0 | dlouhé div | 39.1 | 34,4 | nový reftype L5 | 1.2 | 1.2 | nastavit prop |
5,1 | 5,1 | dlouhá směna | 22,3 | 20.3 | new reftype empty ctor L1 | 0,9 | 0,9 | získat toto pole |
1.3 | 1.3 | float add | 26.5 | 23.9 | new reftype empty ctor L2 | 0,9 | 0,9 | získat tuto prop |
1.4 | 1.4 | float sub | 38.1 | 34.7 | new reftype empty ctor L3 | 1.2 | 1.2 | nastavit toto pole |
2.0 | 2.0 | float mul | 34.7 | 30.7 | new reftype empty ctor L4 | 1.2 | 1.2 | nastavit tuto vlastnost |
27.7 | 27.6 | float div | 38.5 | 34.3 | new reftype empty ctor L5 | 6.4 | 6.3 | získat virtuální prop |
1.5 | 1.5 | double add | 22.9 | 20.7 | new reftype ctor L1 | 6.4 | 6.3 | nastavit virtuální prop |
1.5 | 1.5 | dvojitá sub | 27.8 | 25.4 | new reftype ctor L2 | 6.4 | 6.4 | bariéra zápisu |
2.1 | 2.0 | dvojitá mula | 32.7 | 29.9 | new reftype ctor L3 | 1.9 | 1.9 | load int array elem |
27.7 | 27.6 | double div | 37.7 | 34.1 | nový reftype ctor L4 | 1.9 | 1.9 | store int array elem |
0,2 | 0,2 | vložené statické volání | 43.2 | 39.1 | new reftype ctor L5 | 2.5 | 2.5 | load obj array elem |
6.1 | 6.1 | statické volání | 28.6 | 26.7 | new reftype ctor no-inl L1 | 16.0 | 16.0 | store obj array elem |
1.1 | 1.0 | Volání vložené instance | 38.9 | 36.5 | new reftype ctor no-inl L2 | 29.0 | 21.6 | box int |
6.8 | 6.8 | volání instance | 50.6 | 47.7 | new reftype ctor no-inl L3 | 3,0 | 3,0 | unbox int |
0,2 | 0,2 | inlined this inst call | 61.8 | 58.2 | new reftype ctor no-inl L4 | 41.1 | 40.9 | vyvolání delegáta |
6,2 | 6,2 | volání této instance | 72.6 | 68.5 | new reftype ctor no-inl L5 | 2.7 | 2.7 | sum array 1000 |
5.4 | 5.4 | virtuální hovor | 0.4 | 0.4 | přetypování 1 | 2,8 | 2,8 | sum array 10000 |
5.4 | 5.4 | toto virtuální volání | 0.3 | 0.3 | cast down 0 | 2.9 | 2,8 | sum array 100000 |
6.6 | 6.5 | volání rozhraní | 8.9 | 8.8 | odsadí 1 | 5.6 | 5.6 | sum array 1000000 |
1.1 | 1.0 | volání instance inst itf | 9.8 | 9.7 | přetypování (nahoru 2) dolů 1 | 3,5 | 3,5 | sum list 1000 |
0,2 | 0,2 | toto volání instance itf | 8.9 | 8.8 | přetypování 2 | 6.1 | 6.1 | sum list 10000 |
5.4 | 5.4 | inst itf virtual call | 8.7 | 8.6 | přetypování 3 | 22,0 | 22,0 | sum list 100000 |
5.4 | 5.4 | toto virtuální volání ITF | 21.5 | 21.4 | sum list 1000000 |
Právní omezení: Neberte tato data příliš doslova. Testování času je plné nebezpečí neočekávaných efektů druhého řádu. Náhodná náhoda může umístit zamíchaný kód nebo nějaká důležitá data tak, aby překlenoval řádky mezipaměti, zasahoval do něčeho jiného nebo co jste udělali. Je to trochu jako princip nejistoty: časy a časové rozdíly 1 nanosekundy jsou na hranicích pozorovatelného.
Další právní omezení: Tato data jsou relevantní pouze pro scénáře s malým kódem a daty, které se zcela zapadají do mezipaměti. Pokud se "horké" části vaší aplikace nevejdou do mezipaměti na čipu, můžete mít jinou sadu problémů s výkonem. O mezipamětí ke konci dokumentu máme ještě mnohem víc co říct.
A ještě další právní omezení: jednou ze vznešených výhod dodání komponent a aplikací jako sestavení CIL je, že váš program může být automaticky rychlejší každou sekundu a každý rok rychlejší – "rychlejší každou sekundu", protože modul runtime může (teoreticky) přeladit kód zkompilovaný jit při spuštění programu; a "rychlejší celý rok", protože s každou novou vydanou verzí modulu runtime mohou lepší, chytřejší a rychlejší algoritmy získat nový bod pro optimalizaci vašeho kódu. Pokud se tedy některé z těchto časování v .NET 1.1 zdají být méně než optimální, mějte na srdci, že by se mělo zlepšit v následných verzích produktu. Z toho vyplývá, že jakákoli daná sekvence nativního kódu hlášená v tomto článku se může v budoucích verzích rozhraní .NET Framework změnit.
Kromě toho se zřeknou, že data poskytují přiměřenou cit pro aktuální výkon různých primitiv. Čísla mají smysl a dokládají moje tvrzení, že většina zamotaných spravovaných kódů běží "blízko počítače" stejně jako kompilovaný nativní kód. Primitivní celočíselné a plovoucí operace jsou rychlé, volání metod různých druhů méně, ale (věřte mi) stále srovnatelné s nativní C/C++; a přesto vidíme, že některé operace, které jsou v nativním kódu obvykle levné (přetypování, úložiště polí a polí, ukazatele na funkce (delegáty)), jsou teď dražší. Proč? Uvidíme.
Aritmetické operace
Tabulka 2 – časy aritmetických operací (ns)
Průměr | Min | Primitivní | Průměr | Min | Primitivní |
---|---|---|---|---|---|
1.0 | 1.0 | int add | 1.3 | 1.3 | float add |
1.0 | 1.0 | int sub | 1.4 | 1.4 | float sub |
2.7 | 2.7 | int mul | 2.0 | 2.0 | plovoucí mula |
35.9 | 35.7 | int div | 27.7 | 27.6 | float div |
2.1 | 2.1 | int shift | |||
2.1 | 2.1 | long add | 1.5 | 1.5 | double add |
2.1 | 2.1 | dlouhé předplatné | 1.5 | 1.5 | dvojitá sub |
34.2 | 34.1 | dlouhá mula | 2.1 | 2.0 | dvojitá mula |
50.1 | 50,0 | dlouhé div | 27.7 | 27.6 | double div |
5,1 | 5,1 | dlouhá směna |
Ve starých dobách byla matematika s plovoucí desetinnou čárkou možná o řád pomalejší než celočíselná matematika. Jak ukazuje tabulka 2, u moderních jednotek s plovoucí desetinou čárkou v kanálu se zdá, že je malý nebo žádný rozdíl. Je úžasné si myslet, že průměrný počítač s poznámkovým blokem je nyní počítač třídy gigaflop (pro problémy, které se vejdou do mezipaměti).
Pojďme se podívat na řádek zamíchaného kódu z celočíselného kódu a testů s plovoucí desetinou čárkou:
Demontáž 1 Int přidat a plovoucí přidat
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]
Tady vidíme, že kód s vychytávkovaným kódem je blízko optimálnímu. V tomto int add
případě kompilátor dokonce zapregistroval pět místních proměnných. V případě plovoucího přidání jsem byl povinen vytvořit proměnné a
prostřednictvím h
statiky třídy, abych porazil běžné eliminace dílčích výrazů.
Volání metod
V této části prozkoumáme náklady a implementace volání metod. Předmět testu je třída T
implementuje rozhraní I
s různými druhy metod. Viz výpis 1.
Výpis 1 testovacích metod volání metody
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() { } …}
Podívejme se na tabulku 3. Zdá se, že při první aproximaci je metoda buď vložená (abstrakce nic nestojí) nebo ne (abstrakce stojí >5krát celočíselnou operaci). Nezdá se, že by byl výrazný rozdíl v nezpracovaných nákladech statického volání, volání instance, virtuálního volání nebo volání rozhraní.
Table 3 – časy volání metod (ns)
Průměr | Min | Primitivní | Volaný | Průměr | Min | Primitivní | Volaný |
---|---|---|---|---|---|---|---|
0,2 | 0,2 | vložené statické volání | inl_s1 |
5.4 | 5.4 | virtuální hovor | v1 |
6.1 | 6.1 | statické volání | s1 |
5.4 | 5.4 | toto virtuální volání | v1 |
1.1 | 1.0 | Volání vložené instance | inl_i1 |
6.6 | 6.5 | volání rozhraní | itf1 |
6.8 | 6.8 | volání instance | i1 |
1.1 | 1.0 | volání instance inst itf | itf1 |
0,2 | 0,2 | inlined this inst call | inl_i1 |
0,2 | 0,2 | toto volání instance itf | itf1 |
6,2 | 6,2 | volání této instance | i1 |
5.4 | 5.4 | inst itf virtual call | itf5 |
5.4 | 5.4 | toto virtuální volání ITF | itf5 |
Tyto výsledky jsou však nereprezentativním nejlepším případem, vliv běžících těsných časových smyček miliónkrát. V těchto testovacích případech jsou weby volání virtuálních metod a metod rozhraní monomorfní (např. cílová metoda na lokalitu volání se v průběhu času nemění), takže kombinace mechanismů volání virtuální metody a metody rozhraní (ukazatele a položky mapové tabulky metody a rozhraní) a úžasně provident branch predikce umožňuje procesoru provést nerealisticky efektivní úlohu volání prostřednictvím těchto jinak obtížně předvídatelných, větve závislé na datech. V praxi může a bude zpomalovat virtuální volání a volání rozhraní v desítkách cyklů kvůli chybějícím datům v mezipaměti dat nebo chybné předvolání větve (ať už jde o povinnou chybějící kapacitu nebo polymorfní lokalitu volání).
Pojďme se podrobněji podívat na každou z těchto časů volání metody.
V prvním případě, vložené statické volání, voláme řadu prázdných statických metod s1_inl()
atd. Vzhledem k tomu, že kompilátor zcela vyřadí všechna volání, načasujeme časování prázdné smyčky.
Abychom změřili přibližné náklady na volání statické metody, nastavíme statické metody s1()
atd. tak velké, že nejsou výhodné pro vložení do volajícího.
Všimněte si, že musíme dokonce použít explicitní proměnnou falsePred
predikátu false . Kdybychom napsali
static void s1() { if (false) dummy(1, 2, 3, …, 16); }
kompilátor JIT by vyřadil mrtvé volání dummy
a vložený celý (nyní prázdný) tělo metody jako předtím. Mimochodem, zde některé z 6.1 ns doby volání musí být přiřazeny (false) predikát testu a přeskočit v rámci volal statická metoda s1
. (Mimochodem, lepší způsob, jak zakázat vkládání, je atribut . CompilerServices.MethodImpl(MethodImplOptions.NoInlining)
)
Stejný přístup byl použit pro volání vložené instance a běžné časování volání instance. Vzhledem k tomu, že specifikace jazyka C# zajišťuje, že každé volání na objekt null odkaz vyvolá Výjimku NullReferenceException, každý web volání musí zajistit, aby instance nebyla null. To se provádí dereferencováním odkazu na instanci; Pokud je hodnota null, vygeneruje chybu, která se změní na tuto výjimku.
V části Disassembly 2 používáme jako instanci statickou proměnnou t
, protože když jsme použili místní proměnnou
T t = new T();
kompilátor vytáhl kontrolu instance null ze smyčky.
Disassembly 2 Instance method call site with null instance "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
Případy volání vložené této instance a volání této instance jsou stejné, s výjimkou instance je this
; zde byla vyvolána kontrola hodnoty null.
Zpětný překlad 3 Tato instance metody volání webu
this.i1();
00000012 8B CE mov ecx,esi
00000014 E8 AF FE FF FF call FFFFFEC8
Volání virtuálních metod fungují stejně jako v tradičních implementacích jazyka C++. Adresa každé nově zavedené virtuální metody je uložena v novém slotu v tabulce metod typu. Tabulka metod každého odvozeného typu odpovídá a rozšiřuje tabulku jeho základního typu a jakékoli přepsání virtuální metody nahradí adresu virtuální metody základního typu adresou virtuální metody odvozeného typu v odpovídajícím slotu v tabulce metod odvozeného typu.
V lokalitě volání vyvolá volání virtuální metody v porovnání s voláním instance dvě další načtení: jedno pro načtení adresy tabulky metody (vždy se nachází na adrese *(this+0)
) a druhé pro načtení příslušné adresy virtuální metody z tabulky metod a volání. Viz Demontáž 4.
Zpětný překlad 4 Místo volání virtuální metody
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
Nakonec se dostáváme k volání metod rozhraní (Disassembly 5). V jazyce C++ nemají žádný přesný ekvivalent. Každý daný typ může implementovat libovolný počet rozhraní a každé rozhraní logicky vyžaduje vlastní tabulku metod. K odeslání metody rozhraní vyhledáme tabulku metody, její mapu rozhraní, položku rozhraní v tomto mapování a pak zavoláme nepřímo prostřednictvím příslušné položky v oddílu rozhraní tabulky metody.
Disassembly 5 Interface call site
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
Zbytek primitivních časování, inst itf volání instance, toto volání instance itf, inst itf virtuální volání, toto itf virtuální volání zdůrazňují myšlenku, že pokaždé, když metoda odvozeného typu implementuje metodu rozhraní, zůstává volatelná prostřednictvím webu volání metody instance.
Například pro test tohoto itf volání instance, volání na implementaci metody rozhraní prostřednictvím instance (ne rozhraní) odkaz, metoda rozhraní je úspěšně vložena a náklady jdou na 0 ns. Dokonce i implementace metody rozhraní je potenciálně vložená, když ji zavoláte jako metodu instance.
Volání metod, které ještě mají být jitted
Pro statická volání a volání metod instance (ale ne volání virtuálních metod a metod rozhraní) kompilátor JIT v současné době generuje různé sekvence volání metod v závislosti na tom, zda cílová metoda již byla zamíchaná v okamžiku, kdy je zamíchaná lokalita volání.
Pokud volaný (cílová metoda) ještě nebyl jitted, kompilátor vygeneruje volání nepřímé prostřednictvím ukazatele, který je nejprve inicializován pomocí "prejit stub". První volání cílové metody přichází na zástupný kód, který aktivuje kompilaci JIT metody, vygeneruje nativní kód a aktualizuje ukazatel na adresu nového nativního kódu.
Pokud již byl volaný volaný, jeho adresa nativního kódu je známa, takže kompilátor vygeneruje přímé volání.
Vytvoření nového objektu
Vytvoření nového objektu se skládá ze dvou fází: přidělení objektu a inicializace objektu.
Pro odkazové typy jsou objekty přiděleny na haldě uvolňování paměti. U hodnotových typů, ať už se jedná o rezidentní zásobník nebo vložený do jiného typu odkazu nebo typu hodnoty, se objekt typu hodnoty nachází v určitém konstantním posunu od nadřazené struktury – není vyžadováno přidělení.
U typických malých objektů odkazového typu je přidělení haldy velmi rychlé. Po každém uvolňování paměti, s výjimkou přítomnosti připnutých objektů, jsou živé objekty z haldy generace 0 zkomprimovány a povýšeny na generaci 1, a tak má přidělování paměti pěknou velkou souvislou volnou paměť arénu pro práci s. U většiny objektů dochází pouze ke zvýšení počtu ukazatelů a kontrole hranic, což je levnější než obvyklý bezplatný alokátor seznamů C/C++ (malloc/operator new). Systém uvolňování paměti dokonce bere v úvahu velikost mezipaměti vašeho počítače a snaží se udržet objekty gen 0 na rychlém místě v hierarchii mezipaměti/paměti.
Vzhledem k tomu, že upřednostňovaným stylem spravovaného kódu je přidělení většiny objektů s krátkou životností a jejich rychlé uvolnění, zahrneme také (do časových nákladů) amortizované náklady na uvolňování paměti těchto nových objektů.
Všimněte si, že systém uvolňování paměti nestrání žádný čas truchlením nad mrtvými předměty. Pokud je objekt mrtvý, GC ho nevidí, nechodí po něm, nedává na něj nanosekundu. GC se zabývá pouze blaho života.
(Výjimka: Finalizovatelné mrtvé objekty jsou zvláštní případ. GC je sleduje a speciálně podporuje nedokončící objekty na další generaci čekající na dokončení. To je nákladné a v nejhorším případě může přechodně zvýšit úroveň grafů velkých neaktivních objektů. Proto nedělejte objekty finalizovatelné, pokud to není nezbytně nutné; a pokud je to nutné, zvažte použití vzoru Dispose a volání GC.SuppressFinalizer
, pokud je to možné.) Pokud to vaše Finalize
metoda nevyžaduje, neuchovávejte odkazy z finalizovatelného objektu na jiné objekty.
Amortizované náklady na uvolňování paměti velkého krátkodobého objektu jsou samozřejmě vyšší než náklady na malý krátkodobý objekt. Každé přidělení objektu nás o hodně blíže k dalšímu cyklu uvolňování paměti; Větší objekty tak udělají mnohem dříve než ty malé. Dříve (nebo později) přijde okamžik zúčtování. Cykly uvolňování paměti, zejména kolekce generace 0, jsou velmi rychlé, ale nejsou volné, i když je velká většina nových objektů neaktivní: pokud chcete najít (označit) živé objekty, je nejprve nutné pozastavit vlákna a pak procházet zásobníky a další datové struktury, aby se shromáždily odkazy na kořenové objekty do haldy.
(Možná významnější je, že menší počet větších objektů se vejde do stejné velikosti mezipaměti jako menší objekty. Efekty zmeškané v mezipaměti můžou snadno převládat efekty délky cesty kódu.)
Jakmile je pro objekt přidělen prostor, zbývá ho inicializovat (sestavit). CLR zaručuje, že všechny odkazy na objekt jsou předicializovány na hodnotu null a všechny primitivní skalární typy jsou inicializovány na hodnotu 0, 0.0, false atd. (Proto není nutné provádět redundantní v uživatelem definovaných konstruktorech. Nebojte se, samozřejmě. Mějte ale na paměti, že kompilátor JIT v současné době nemusí nutně optimalizovat vaše redundantní úložiště.)
Kromě vynulování polí instancí clr inicializuje (pouze typy odkazů) interní implementační pole objektu: ukazatel tabulky metody a slovo záhlaví objektu, které předchází ukazatel tabulky metody. Pole také získají pole Délka a pole objektů získají pole délka a typ prvku.
ClR pak zavolá konstruktor objektu, pokud existuje. Konstruktor každého typu, ať už definovaný uživatelem nebo vygenerovaný kompilátorem, nejprve zavolá konstruktor základního typu a pak spustí uživatelem definovanou inicializaci, pokud existuje.
Teoreticky by to mohlo být nákladné pro scénáře hluboké dědičnosti. Pokud E rozšiřuje D rozšiřuje C rozšiřuje B rozšiřuje A (rozšiřuje System.Object), pak inicializace E vždy způsobí pět volání metody. V praxi to není tak špatné, protože kompilátor inlines pryč (do prázdna) volá prázdné konstruktory základního typu.
S odkazem na první sloupec tabulky 4 si všimněte, že můžeme vytvořit a inicializovat strukturu D
se čtyřmi poli int za přibližně 8 int-add-times. Disassembly 6 je vygenerovaný kód ze tří různých časových smyček, které vytvářejí A, C a E. (V každé smyčce upravíme každou novou instanci, což brání kompilátoru JIT v optimalizaci všeho.)
Tabulka 4 Časy vytvoření objektu typu hodnoty a odkazu (ns)
Průměr | Min | Primitivní | Průměr | Min | Primitivní | Průměr | Min | Primitivní |
---|---|---|---|---|---|---|---|---|
2,6 | 2,6 | nový valtype L1 | 22,0 | 20.3 | nový reftype L1 | 22.9 | 20.7 | new rt ctor L1 |
4,6 | 4,6 | nový valtype L2 | 26.1 | 23.9 | nový reftype L2 | 27.8 | 25.4 | new rt ctor L2 |
6.4 | 6.4 | nový valtype L3 | 30.2 | 27.5 | nový reftype L3 | 32.7 | 29.9 | new rt ctor L3 |
8.0 | 8.0 | nový valtype L4 | 34.1 | 30.8 | nový reftype L4 | 37.7 | 34.1 | new rt ctor L4 |
23,0 | 22.9 | nový valtype L5 | 39.1 | 34,4 | nový reftype L5 | 43.2 | 39.1 | new rt ctor L5 |
22,3 | 20.3 | new rt empty ctor L1 | 28.6 | 26.7 | new rt no-inl L1 | |||
26.5 | 23.9 | new rt empty ctor L2 | 38.9 | 36.5 | new rt no-inl L2 | |||
38.1 | 34.7 | new rt empty ctor L3 | 50.6 | 47.7 | new rt no-inl L3 | |||
34.7 | 30.7 | new rt empty ctor L4 | 61.8 | 58.2 | new rt no-inl L4 | |||
38.5 | 34.3 | new rt empty ctor L5 | 72.6 | 68.5 | new rt no-inl L5 |
Demontáž 6 Konstrukce objektu typu hodnota
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]
Dalších pět časování (nový reftype L1, ... nové reftype L5) jsou pro pět úrovní dědičnosti odkazových typů A
, ..., E
, bez uživatelem definovaných konstruktorů:
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; }
Při porovnání časů referenčního typu s časy typu hodnoty vidíme, že amortizované přidělení a náklady na uvolnění každé instance jsou na testovacím počítači přibližně 20 ns (20krát více času přidání). To je rychlé – přidělování, inicializace a uvolnění asi 50 milionů krátkodobých objektů za sekundu, trvalé. Pro objekty tak malé jako pět polí, alokace a kolekce zaúčtují pouze polovinu času vytváření objektu. Viz Demontáž 7.
Demontáž 7 Konstrukce objektu referenčního typu
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
Poslední tři sady pěti časování představují varianty tohoto scénáře konstrukce zděděné třídy.
New rt empty ctor L1, ..., new rt empty ctor L5: Každý typ
A
, ...,E
má prázdný uživatelem definovaný konstruktor. Všechny jsou vložené a vygenerovaný kód je stejný jako výše uvedený kód.New rt ctor L1, ..., new rt ctor L5: Každý typ
A
, ...,E
má uživatelem definovaný konstruktor, který nastaví proměnnou instance na hodnotu 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; } }
Kompilátor vnoří každou sadu vnořených volání konstruktoru základní třídy do new
webu. (Demontáž 8).
Demontáž 8 Hluboce vložené zděděné konstruktory
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
New rt no-inl L1, ..., new rt no-inl L5: Každý typ
A
, ..., má uživatelem definovaný konstruktor, který byl záměrně napsán tak,E
aby byl příliš nákladný na vložený. Tento scénář simuluje náklady na vytváření složitých objektů pomocí hierarchií hluboké dědičnosti a opožděných konstruktorů.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(…); } }
Posledních pět časování v tabulce 4 ukazuje další režii volání vnořených základních konstruktorů.
Mezihra: Ukázka profileru CLR
Nyní pro rychlou ukázku modulu CLR Profiler. ClR Profiler, dříve označovaný jako alokační profiler, používá rozhraní API pro profilaci CLR ke shromažďování dat událostí, zejména událostí volání, vrácení a přidělení objektů a uvolňování paměti při spuštění vaší aplikace. (ClR Profiler je "invazní" profiler, což znamená, že profilovanou aplikaci bohužel výrazně zpomaluje.) Po shromáždění událostí můžete pomocí modulu CLR Profiler prozkoumat přidělování paměti a chování uvolňování paměti vaší aplikace, včetně interakce mezi hierarchickým grafem volání a vzory přidělování paměti.
ClR Profiler stojí za to se naučit, protože pro mnoho aplikací spravovaného kódu s problémy s výkonem poskytuje porozumění profilu přidělení dat důležité přehledy potřebné ke snížení pracovní sady, a tak poskytovat rychlé a náročné komponenty a aplikace.
Modul CLR Profiler také může odhalit, které metody přidělují více úložiště, než jste očekávali, a může odhalit případy, kdy neúmyslně uchováváte odkazy na nepotřebné grafy objektů, které by jinak mohly být uvolňováním paměti uvolněny. (Běžným vzorem návrhu problémů je softwarová mezipaměť nebo vyhledávací tabulka položek, které už nejsou potřeba nebo je lze bezpečně rekonstituovat později. Je to tragické, když mezipaměť udržuje grafy objektů naživu po jejich životnosti. Místo toho nezapomeňte vynulovat odkazy na objekty, které už nepotřebujete.)
Obrázek 1 je zobrazení časové osy haldy během provádění ovladače testu časování. Model sawtooth označuje přidělení mnoha tisíc instancí objektů C
(purpurová), D
(fialová) a E
(modrá). Každých pár milisekund v nové haldě objektu (generace 0) ožvýkáme dalších přibližně 150 kB paměti RAM a systém uvolňování paměti spustí krátce, aby ji recykloval a povýšil všechny živé objekty na gen 1. generace. Je pozoruhodné, že i v tomto invazním (pomalém) profilačním prostředí v intervalu 100 ms (2,8 s až 2,9s) procházíme cykly GC přibližně 8 generace 0. Pak ve 2,977 s vytvoří místo pro další E
příklad, systém uvolňování paměti provede uvolňování paměti generace 1, který shromažďuje a komprimuje haldu 1. generace – a tak pila pokračuje z nižší počáteční adresy.
Obrázek 1 Zobrazení časové osy profileru CLR
Všimněte si, že čím větší je objekt (E větší než D větší než C), tím rychleji se halda generace 0 zaplní a tím častější je cyklus uvolňování paměti.
Přetypování a kontroly typů instancí
Základem bezpečného, zabezpečeného a ověřitelného spravovaného kódu je bezpečnost typu. Pokud by bylo možné přetypovat objekt na typ, který není, bylo by jednoduché ohrozit integritu CLR a nechat ho tak napospas nedůvěryhodnému kódu.
Tabulka 5 Přetypování a časy (ns)
Průměr | Min | Primitivní | Průměr | Min | Primitivní |
---|---|---|---|---|---|
0.4 | 0.4 | přetypování 1 | 0.8 | 0.8 | isinst up 1 |
0.3 | 0.3 | přetypování 0 | 0.8 | 0.8 | isinst down 0 |
8.9 | 8.8 | přehozen 1 | 6.3 | 6.3 | isinst down 1 |
9.8 | 9.7 | přetypování (nahoru 2) dolů 1 | 10,7 | 10.6 | isinst (nahoru 2) dolů 1 |
8.9 | 8.8 | přetypování dolů 2 | 6.4 | 6.4 | isinst down 2 |
8.7 | 8.6 | přetypování 3 | 6.1 | 6.1 | isinst down 3 |
Tabulka 5 ukazuje režijní náklady na tyto povinné kontroly typů. Přetypování z odvozeného typu na základní typ je vždy bezpečné a volné; zatímco přetypování ze základního typu na odvozený typ musí být zkontrolováno.
Přetypování (zaškrtnuté) převede odkaz na objekt na cílový typ nebo vyvolá InvalidCastException
.
Naproti tomu isinst
instrukce CIL se používá k implementaci klíčového slova jazyka C# as
:
bac = ac as B;
Pokud ac
není B
nebo je odvozen z B
, výsledek je null
, není výjimkou.
Výpis 2 ukazuje jednu ze smyček časování přetypování a Disassembly 9 zobrazuje vygenerovaný kód pro jedno přetypování na odvozený typ. K provedení přetypování kompilátor vygeneruje přímé volání pomocné rutiny.
Výpis smyčky 2 pro testování časování přetypování
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;
}
}
Demontáž 9 odlitek dolů
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
Vlastnosti
Ve spravovaném kódu je vlastností dvojice metod, getter vlastností a setter vlastností, které se chovají jako pole objektu. Metoda get_ načte vlastnost; metoda set_ aktualizuje vlastnost na novou hodnotu.
Kromě toho se vlastnosti chovají a stojí stejně jako běžné metody instance a virtuální metody. Pokud používáte vlastnost k jednoduchému načtení nebo uložení pole instance, je obvykle vložená, stejně jako u jakékoli malé metody.
Tabulka 6 ukazuje čas potřebný k načtení (a přidání) a k uložení sady celočíselná instance polí a vlastností. Náklady na získání nebo nastavení vlastnosti jsou skutečně stejné jako přímý přístup k podkladovému poli, pokud není vlastnost deklarována jako virtuální. V takovém případě jsou náklady přibližně stejné jako náklady na volání virtuální metody. Žádné překvapení.
Časy polí a vlastností tabulky 6 (ns)
Průměr | Min | Primitivní |
---|---|---|
1.0 | 1.0 | získat pole |
1.2 | 1.2 | získat rekvizitu |
1.2 | 1.2 | set field |
1.2 | 1.2 | nastavit prop |
6.4 | 6.3 | získat virtuální prop |
6.4 | 6.3 | nastavit virtuální prop |
Překážky zápisu
Systém uvolňování paměti CLR využívá výhod "generační hypotézy" – většina nových objektů zemře mladá – k minimalizaci režie shromažďování.
Halda je logicky rozdělená do generací. Nejnovější objekty jsou v generaci 0 (gen 0). Tyto objekty ještě nepřežily kolekci. Během kolekce gen 0 GC určuje, které objekty generace 0 jsou dostupné z kořenové sady GC, která zahrnuje odkazy na objekty v registrech počítačů, na zásobníku, odkazy na objekty statického pole třídy atd. Přechodně dostupné objekty jsou "živé" a povýšené (zkopírované) na generaci 1.
Vzhledem k tomu, že celková velikost haldy může být stovky MB, zatímco velikost haldy gen 0 může být pouze 256 kB, je omezení rozsahu trasování grafu objektů GC na haldu gen 0 optimalizace nezbytná pro dosažení velmi krátkých časů pozastavení kolekce CLR.
Je však možné uložit odkaz na objekt gen 0 v referenčním poli objektu gen 1 nebo gen2. Vzhledem k tomu, že během kolekce gen 0 neskenujeme objekty gen 1 nebo gen2, pokud je to jediný odkaz na daný objekt gen 0, může být objekt GC chybně uvolněn. Nemůžeme dopustit, aby se to stalo!
Místo toho všechna úložiště do všech polí odkazu na objekt v haldě za následek překážku zápisu. Toto je účetní kód, který efektivně zaznamenává ukládání odkazů na objekty nové generace do polí objektů starší generace. Tato stará referenční pole na objekt jsou přidána do kořenové sady GC následných GC.
Režie za bariéru zápisu úložiště pro jednotlivé objekty odkazy na pole je srovnatelná s náklady na volání jednoduché metody (tabulka 7). Jedná se o nový náklad, který není k dispozici v nativním kódu C/C++, ale obvykle je to malá cena za superrychlé přidělení objektů a uvolňování paměti a mnoho výhod produktivity automatické správy paměti.
Tabulka 7 Doba bariéry zápisu (ns)
Průměr | Min | Primitivní |
---|---|---|
6.4 | 6.4 | bariéra zápisu |
Překážky zápisu mohou být v těsných vnitřních smyčkách nákladné. V nadcházejících letech se ale můžeme těšit na pokročilé techniky kompilace, které snižují počet překážek pro zápis a celkové amortizované náklady.
Možná si myslíte, že překážky zápisu jsou nezbytné pouze v úložištích pro referenční pole objektů odkazových typů. V rámci metody typu hodnoty jsou však úložiště do polí odkazu na objekt (pokud existují) chráněna také překážkami zápisu. To je nezbytné, protože samotný typ hodnoty může být někdy vložen do typu odkazu umístěného v haldě.
Přístup k elementům pole
Aby bylo možné diagnostikovat a zabránit chybám polí mimo rozsah a poškození haldy a k ochraně integrity samotného modulu CLR, jsou zatížení prvků pole a úložiště kontrolovány hranice, čímž se zajistí, že index je v intervalu [0,pole. Length-1] včetně nebo házení IndexOutOfRangeException
.
Naše testy měří čas načtení nebo uložení prvků int[]
pole a A[]
pole. (tabulka 8).
Tabulka 8 Časy přístupu k poli (ns)
Průměr | Min | Primitivní |
---|---|---|
1.9 | 1.9 | load int array elem |
1.9 | 1.9 | store int array elem |
2.5 | 2.5 | load obj array elem |
16.0 | 16.0 | store obj array elem |
Kontrola hranic vyžaduje porovnání indexu pole s implicitním polem. Pole Délka Jak ukazuje Disassembly 10, v pouhých dvou pokynech zkontrolujeme, že index není menší než 0 ani větší než nebo roven poli. Délka – pokud je, vytvoříme větev na sekvenci mimo řádek, která vyvolá výjimku. Totéž platí pro zatížení elementů pole objektů a pro úložiště do polí int a jiných jednoduchých typů hodnot. (Load obj pole elem čas je (nevýznamně) pomalejší kvůli mírnému rozdílu v jeho vnitřní smyčce.)
Disassembly 10 Load int array element
; 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
Díky optimalizaci kvality kódu kompilátor JIT často eliminuje kontroly redundantních hranic.
Když si vzpomeneme na předchozí části, můžeme očekávat, že úložiště prvků pole objektů budou výrazně dražší. Pokud chcete uložit odkaz na objekt do pole odkazů na objekty, modul runtime musí:
- kontrola indexu pole je v mezích;
- check objekt je instance typu prvku pole;
- provádět bariéru zápisu (zapisování mezigeneračních odkazů na objekt z pole na objekt).
Tato sekvence kódu je poměrně dlouhá. Místo toho, aby ji vygeneroval v každém úložišti pole objektů, kompilátor vysílá volání sdílené pomocné funkce, jak je znázorněno v disassembly 11. Toto volání plus tyto tři akce zaúčtují další čas potřebný v tomto případě.
Disassembly 11 Store object array element
; 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
Zabalení a rozbalení
Partnerství mezi kompilátory .NET a CLR umožňuje, aby se hodnotové typy, včetně primitivních typů, jako je int (System.Int32), účastnily, jako by šlo o referenční typy – a byly adresovány jako odkazy na objekty. Tato cenová dostupnost – tento syntaktický cukr – umožňuje, aby se hodnotové typy předávaly metodám jako objekty, ukládaly se v kolekcích jako objekty atd.
"box" typ hodnoty je vytvoření objektu typu odkazu, který obsahuje kopii jeho typu hodnoty. To je koncepčně stejné jako vytvoření třídy s nepojmenovaným polem instance stejného typu jako typ hodnoty.
"Unbox" typ hodnoty v rámečku je zkopírování hodnoty z objektu do nové instance typu hodnoty.
Jak ukazuje tabulka 9 (v porovnání s tabulkou 4), amortizovaný čas potřebný k balení int a později k jeho uvolnění do koše je srovnatelný s časem potřebným k vytvoření instance malé třídy s jedním int polem.
Tabulka 9 Box a Unbox int Times (ns)
Průměr | Min | Primitivní |
---|---|---|
29.0 | 21.6 | box int |
3,0 | 3,0 | unbox int |
K rozbalení objektu int v rámečku je potřeba explicitní přetypování na int. To se zkompiluje do porovnání typu objektu (reprezentovaného adresou tabulky metody) a adresy tabulky metody int v rámečku. Pokud jsou stejné, hodnota se zkopíruje z objektu . V opačném případě dojde k výjimce. Viz Demontáž 12.
Demontáž 12 Box a unbox int
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
Delegáti
V jazyce C je ukazatel na funkci primitivní datový typ, který doslova ukládá adresu funkce.
Jazyk C++ přidává ukazatele na členské funkce. Ukazatel na členovou funkci (PMF) představuje volání odložené členské funkce. Adresa ne virtuální členské funkce může být jednoduchá adresa kódu, ale adresa virtuální členské funkce musí ztělesňovat konkrétní volání virtuální členské funkce – dereference takového SOUBORU PMF je volání virtuální funkce.
Pokud chcete přes odkazovat na C++ PMF, musíte zadat instanci:
A* pa = new A;
void (A::*pmf)() = &A::af;
(pa->*pmf)();
Před lety jsme se ve vývojovém týmu kompilátoru Visual C++ ptali sami sebe, co je to za pa->*pmf
bestii (operátor volání funkce sans)? Nazvali jsme ho vázaným ukazatelem na členovou funkci , ale stejně tak je i volání latentní členské funkce .
Při návratu do spravovaného kódu je objekt delegáta právě to – latentní volání metody. Objekt delegáta představuje jak metodu volání, tak instanci, na které se má volat , nebo pro delegáta statické metody, pouze statickou metodu, která se má volat.
(Jak uvádí naše dokumentace: Deklarace delegáta definuje typ odkazu, který lze použít k zapouzdření metody s konkrétním podpisem. Instance delegáta zapouzdřuje statickou metodu nebo metodu instance. Delegáty jsou zhruba podobné ukazatelům na funkce v jazyce C++; delegáti jsou však typově bezpečné a zabezpečené.)
Typy delegátů v jazyce C# jsou odvozené typy funkce MulticastDelegate. Tento typ poskytuje bohatou sémantiku, včetně možnosti vytvořit seznam vyvolání párů (objekt,metoda), které se mají vyvolat při vyvolání delegáta.
Delegáti také poskytují zařízení pro asynchronní volání metody. Po definování typu delegáta a vytvoření instance typu inicializovaného s voláním latentní metody ho můžete vyvolat synchronně (syntaxe volání metody) nebo asynchronně přes BeginInvoke
. Pokud BeginInvoke
je volána, modul runtime zařadí volání do fronty a okamžitě se vrátí volajícímu. Cílová metoda je volána později ve vlákně fondu vláken.
Všechny tyto bohaté sémantiky nejsou levné. Při porovnání tabulky 10 a tabulky 3 si všimněte, že volání delegáta je ** přibližně osmkrát pomalejší než volání metody. Očekávejte, že se to časem zlepší.
Tabulka 10 Čas vyvolání delegáta (ns)
Průměr | Min | Primitivní |
---|---|---|
41.1 | 40.9 | vyvolání delegáta |
Neúspěšných přístupů do mezipaměti, chyb stránky a architektury počítače
V "starých dobrých časech", kolem roku 1983, byly procesory pomalé (přibližně 0,5 milionu instrukcí/s) a relativně řečeno, paměť RAM byla dostatečně rychlá, ale malá (přístupové doby přibližně 300 ns na 256 kB paměti DRAM) a disky byly pomalé a velké (přístupové doby přibližně 25 ms na 10 MB discích). Pc mikroprocesory byly skalární CIC, většina plovoucí desetiny byla v softwaru a nebyly k dispozici žádné mezipaměti.
Po dalších dvaceti letech Moorova zákona, kolem roku 2003, jsou procesory rychlé (až tři operace za cyklus při 3 GHz), paměť RAM je relativně velmi pomalá (přístupová doba přibližně 100 ns při 512 MB PAMĚTI DRAM) a disky jsou velmi pomalé a obrovské (přístupové časy přibližně 10 ms na 100GB discích). Mikroprocesory počítačů jsou nyní mimo provoz superskalární hyperthreadingové mezipaměti trasování mezipaměti (spouští dekódované instrukce CISC) a existuje několik vrstev mezipamětí – například určitý serverově orientovaný mikroprocesor má mezipaměť dat 32 kB úrovně 1 (možná 2 cykly latence), 512 kB mezipaměti dat L2 a 2 MB datové mezipaměti L3 (možná tucet cyklů latence), vše na čipu.
Za starých dobrých časů jste mohli (a někdy i) spočítat bajty kódu, který jste napsali, a počet cyklů, které kód potřeboval ke spuštění. Zatížení nebo úložiště trvalo přibližně stejný počet cyklů jako při přidání. Moderní procesor používá predikci větví, spekulaci a provádění mimo pořadí (tok dat) napříč několika jednotkami funkcí, aby zjistil paralelismus na úrovni instrukcí, a tak postupujte na několika frontách najednou.
Naše nejrychlejší počítače teď můžou provádět až 9 000 operací za mikrosekundu, ale ve stejné mikrosekundě se načítají nebo ukládají do paměti DRAM ~10 řádků mezipaměti. V počítačích architektury se to označuje jako zásah do zdi paměti. Mezipaměti skrývají latenci paměti, ale pouze k určitému bodu. Pokud se kód nebo data nevejdou do mezipaměti a/nebo vykazují špatnou referenční lokalitu, náš nadzvukový proud 9000 operací za mikrosekundu degeneruje na 10 zátěžových za mikrosekundový tříkolek.
A (nedovolte, aby se to stalo vám) pokud pracovní sada programu překročí dostupnou fyzickou paměť RAM a program začne přijímat chyby pevné stránky, pak v každé 10 000-mikrosekundové službě selhání stránky (přístup k disku) promeškáme příležitost přiblížit uživateli až 90 milionů operací blíže k jeho odpovědi. To je tak hrozné, že věřím, že se od tohoto dne postaráte o měření pracovní sady (vadump) a použití nástrojů jako CLR Profiler k odstranění zbytečných přidělení a neúmyslného uchovávání objektů grafů.
Ale co to všechno má společného se znalostmi nákladů na primitiva spravovaného kódu?Všechno*.*
Když si vzpomeneme na tabulku 1, omnibusový seznam primitivních časů spravovaného kódu měřený na 1,1 GHz P-III, všimněte si, že pokaždé, i amortizované náklady na přidělení, inicializaci a uvolnění pěti objektů pole s pěti úrovněmi explicitních volání konstruktoru, jsou rychlejší než jeden přístup DRAM. Jen jedno zatížení, které vynechá všechny úrovně mezipaměti na čipu, může trvat déle než téměř jakákoli operace s jedním spravovaným kódem.
Pokud vás tedy zajímá rychlost kódu, je nutné při návrhu a implementaci algoritmů a datových struktur zvážit a měřit hierarchii mezipaměti a paměti.
Čas na jednoduchou ukázku: Je rychlejší sečíst pole int nebo sečíst ekvivalentní propojený seznam int? Kolik a proč?
Chvilku si to rozmyslete. U malých položek, jako jsou ints, je využití paměti na prvek pole jedna čtvrtina oproti propojenému seznamu. (Každý uzel propojeného seznamu má dvě slova o režii objektu a dvě slova polí (další odkaz a položka int).) To bude bolet využití mezipaměti. Vyhodnotte jeden pro přístup k poli.
Při procházení polí ale může dojít ke kontrole mezí pole pro jednotlivé položky. Právě jste viděli, že kontrola hranic chvíli trvá. Možná to vychytává měřítka ve prospěch propojeného seznamu?
Demontáž 13 Součet int matice versus součet int propojený seznam
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
Odkaz na Disassembly 13, jsem naskládal balíček ve prospěch propojeného seznamu procházení, rozbalit ho čtyřikrát, dokonce i odstranit obvyklý nulový ukazatel konec-of-seznam kontrola. Každá položka v maticové smyčce vyžaduje šest instrukcí, zatímco každá položka ve smyčce propojeného seznamu potřebuje pouze 11/4 = 2,75 instrukce. Co je teď podle vás rychlejší?
Testovací podmínky: Nejprve vytvořte pole jednoho milionu int a jednoduchý tradiční propojený seznam jednoho milionu int (1 M uzlů seznamu). Pak dejte určitou dobu, jak dlouho trvá sečíst prvních 1 000, 10 000, 100 000 a 1 000 000 položek. Opakujte každou smyčku mnohokrát, abyste pro každý případ změřili chování mezipaměti, které nejvíce lichotivá.
Co je rychlejší? Až uhodnete, podívejte se na odpovědi: posledních osm položek v tabulce 1.
Zajímavé! Časy se výrazně zkrátí s tím, jak se odkazovaná data zvětší, než jsou po sobě jdoucí velikosti mezipaměti. Verze pole je vždy rychlejší než verze propojeného seznamu, i když provádí dvakrát tolik instrukcí. u 100 000 položek je maticová verze sedmkrát rychlejší!
Proč tomu tak je? Za prvé, méně propojených položek seznamu se vejde do libovolné úrovně mezipaměti. Všechna tato záhlaví a odkazy objektů plýtvou místem. Zadruhé, náš moderní procesor toku dat mimo pořadí může potenciálně přiblížit dopředu a provádět pokroky u několika položek v poli současně. Naproti tomu s propojeným seznamem, dokud není aktuální uzel seznamu v mezipaměti, nemůže procesor začít načítat další odkaz na uzel.
V případě 100 000 položek procesor utrácí (v průměru) přibližně (22-3,5)/22 = 84 % času, kdy se pohybuje palcem a čeká na načtení řádku mezipaměti některého uzlu seznamu z DRAM. To zní špatně, ale věci by mohly být mnohem horší. Vzhledem k tomu, že propojené položky seznamu jsou malé, mnoho z nich se vejde na řádek mezipaměti. Vzhledem k tomu, že procházíme seznamem v pořadí přidělení a protože uvolňování paměti zachovává pořadí přidělení, i když komprimuje mrtvé objekty z haldy, je pravděpodobné, že po načtení jednoho uzlu na řádku mezipaměti je nyní v mezipaměti také několik dalších uzlů. Pokud byly uzly větší nebo pokud byly uzly seznamu v náhodném pořadí adres, pak každý navštívený uzel může být zcela chybějící v mezipaměti. Přidání 16 bajtů do každého uzlu seznamu zdvojnásobí dobu procházení na položku na 43 ns; +32 bajtů, 67 ns/položka; a přidáním 64 bajtů se znovu zdvojnásobí na 146 ns/položka, pravděpodobně průměrná latence DRAM na testovacím počítači.
Tak co je tady ta lekce nabídku? Chcete se vyhnout propojeným seznamům 100 000 uzlů? Ne. Z toho plyne, že efekty mezipaměti můžou dominovat všem aspektům nízké úrovně efektivity spravovaného kódu oproti nativnímu kódu. Pokud píšete spravovaný kód s kritickým výkonem, zejména kód spravující velké datové struktury, mějte na paměti efekty mezipaměti, promyslete si vzory přístupu k datovým strukturám a snažte se o menší stopy dat a dobrou lokalitu referencí.
Mimochodem, trendem je, že paměťová stěna, poměr doby přístupu k DRAM dělený časem operace procesoru, se bude v průběhu času dál zhoršovat.
Tady jsou některá pravidla návrhu s vědomím mezipaměti:
- Experimentujte se svými scénáři a změřte je, protože je těžké předpovědět účinky druhého řádu a protože za papír, na který jsou vytištěná, nestojí žádná pravidla.
- Některé datové struktury, například pole, využívají implicitní sousedství k reprezentaci vztahu mezi daty. Jiné, například propojené seznamy, používají explicitní ukazatele (odkazy) k reprezentaci relace. Implicitní sousedství je obecně vhodnější – "implicitnost" šetří místo ve srovnání s ukazateli; a sousedství poskytuje stabilní umístění odkazu a může procesoru umožnit zahájit další práci před spuštěním dalšího ukazatele.
- Některé vzorce použití upřednostňují hybridní struktury – seznamy malých polí, pole polí nebo B-stromy.
- Možná by se teď měly recyklovat algoritmy plánování citlivé na přístup k disku, které byly navrženy v případě, že přístupy na disk stojí pouze 50 000 instrukcí procesoru, protože přístupy DRAM můžou trvat tisíce operací procesoru.
- Vzhledem k tomu, že uvolňování paměti clR mark-and-compact zachovává relativní pořadí objektů, objekty přidělené společně v čase (a ve stejném vlákně) mají tendenci zůstat pohromadě v prostoru. Tento jev můžete použít k promyšlené kolokaci cliquish dat na běžných linkách mezipaměti.
- Možná budete chtít rozdělit data na horké části, které se často procházejí a musí se vejít do mezipaměti, a studené části, které se často používají a dají se "ukládat do mezipaměti".
Experimenty s časem pro sebe
Pro měření časování v tomto dokumentu jsem použil čítač QueryPerformanceCounter
výkonu Win32 s vysokým rozlišením (a QueryPerformanceFrequency
).
Snadno se volají prostřednictvím 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);
Zavoláte QueryPerformanceCounter
těsně před a hned za smyčkou časování, odečtete počty, vynásobíte číslem 1,0e9, vydělíte frekvencí, vydělíte počtem iterací a to je váš přibližný čas na iteraci v ns.
Kvůli prostorovým a časovým omezením jsme se nezabírali zamykáním, zpracováním výjimek ani systémem zabezpečení přístupu kódu. Představte si to jako cvičení pro čtenáře.
Mimochodem, v tomto článku jsem vygeneroval demontáže pomocí okna pro demontáž v VS.NET 2003. Má to ale trik. Pokud aplikaci spustíte v ladicího programu VS.NET, a to i jako optimalizovaný spustitelný soubor integrovaný v režimu vydání, spustí se v režimu ladění, ve kterém jsou zakázány optimalizace, jako je vkládání. Jediný způsob, jak získat náhled optimalizovaného nativního kódu generovaného kompilátorem JIT, bylo spustit testovací aplikaci mimo ladicí program a pak se k ní připojit pomocí Debug.Processes.Attach.
Model prostorových nákladů?
Je ironií, že úvahy o prostoru brání důkladné diskuzi o prostoru. Takže pár krátkých odstavců.
Aspekty nízké úrovně (některé jsou C# (výchozí TypeAttributes.SequentialLayout) a x86 specifické):
- Velikost typu hodnoty je obecně celková velikost jeho polí, přičemž 4 bajtová nebo menší pole jsou zarovnaná s jejich přirozenými hranicemi.
- K implementaci sjednocení je možné použít
[StructLayout(LayoutKind.Explicit)]
atributy a[FieldOffset(n)]
. - Velikost referenčního typu je 8 bajtů plus celková velikost jeho polí zaokrouhlených nahoru na další 4 bajtovou hranici a se 4 bajtovými nebo menšími poli zarovnanými k jejich přirozeným hranicím.
- V jazyce C# můžou deklarace výčtu určovat libovolný celočíselný základní typ (s výjimkou znaku char), takže je možné definovat 8bitové, 16bitové, 32bitové a 64bitové výčty.
- Stejně jako v C/C++ můžete často oholit několik desítek procent místa od většího objektu správnou velikostí integrálních polí.
- Velikost přiděleného typu odkazu můžete zkontrolovat pomocí modulu CLR Profiler.
- Velké objekty (mnoho desítek a více kB) se spravují v samostatné haldě velkých objektů, aby se zabránilo nákladnému kopírování.
- Finalizovatelné objekty využívají k uvolnění další generace uvolňování paměti – používejte je střídmě a zvažte použití vzoru Dispose.
Důležité informace:
- Každá doména AppDomain v současné době nese značné režijní náklady na místo. Mnoho struktur modulu runtime a framework se nesdílí mezi doménami AppDomains.
- V rámci procesu se vysoučený kód obvykle nesdílí mezi doménami AppDomains. Pokud je modul runtime speciálně hostovaný, je možné toto chování přepsat. Informace o příznaku a najdete v
STARTUP_LOADER_OPTIMIZATION_MULTI_DOMAIN
dokumentaciCorBindToRuntimeEx
. - V každém případě se vysoučený kód nesdílí mezi procesy. Pokud máte komponentu, která bude načtena do mnoha procesů, zvažte předkompilování pomocí nástroje NGEN ke sdílení nativního kódu.
Reflexe
Bylo řečeno, že "pokud se musíte zeptat, jaké náklady reflexe, nemůžete si to dovolit". Pokud jste si to přečetli, víte, jak důležité je ptát se, co stojí, a měřit tyto náklady.
Reflexe je užitečná a výkonná, ale ve srovnání s jitkovaným nativním kódem není ani rychlá, ani malá. Byli jste varováni. Změřte si to sami.
Závěr
Teď už více či méně víte, jaké náklady na spravovaný kód mají nejnižší úroveň. Teď máte základní znalosti potřebné k tomu, abyste dosáhli chytřejších kompromisů implementace a rychlejšího psaní spravovaného kódu.
Viděli jsme, že vychýcený spravovaný kód může být "pedálem na kov" jako nativní kód. Vaším úkolem je moudře programovat a moudře si vybrat z mnoha bohatých a snadno použitelných zařízení v rámci architektury.
Existují nastavení, u kterých nezáleží na výkonu, a nastavení, kde je nejdůležitější funkcí produktu. Předčasné optimalizace je kořenem veškerého zla. Ale stejně tak je neopatrná nepozornost k efektivitě. Jste profesionál, umělec, řemeslník. Takže se ujistěte, že znáte náklady na věci. Pokud nevíte, nebo si myslíte, že to děláte, měříte to pravidelně.
Pokud jde o tým CLR, i nadále pracujeme na poskytování platformy, která je podstatně produktivnější než nativní kód , a přesto je rychlejší než nativní kód. Očekávejte, že se věci budou zlepšovat a zlepšovat. Sledujte nejnovější informace.
Zapamatujte si svůj slib.
Zdroje informací
- David Stutz et al, Shared Source CLI Essentials. O'Reilly a Assoc., 2003. ISBN 059600351X.
- Jan Gray, C++: Pod pokličkou.
- Gregor Noriskin, Psaní High-Performance spravovaných aplikací: A Primer, MSDN.
- Rico Mariani, Základy a tipy k výkonu systému Garbage Collector, MSDN.
- Emanuel Schanzer, Tipy a triky pro výkon v aplikacích .NET, MSDN.
- Emanuel Schanzer, Aspekty výkonu pro technologie Run-Time v rozhraní .NET Framework, MSDN.
- vadump (Platform SDK Tools), MSDN.
- .NET Show, [Managed] Code Optimization, 10. září 2002, MSDN.