Nepovedený článek o zrychlení odrazu

Hned vysvětlím název článku. Původní plán byl dát dobrou, spolehlivou radu, jak urychlit použití odrazu pomocí jednoduchého, ale realistického příkladu, ale během benchmarkingu se ukázalo, že odraz není tak pomalý, jak jsem si myslel, LINQ je pomalejší než v mých nočních můrách. Nakonec se ale ukázalo, že jsem se spletl i v měřeních... Detaily tohoto životního příběhu jsou pod střihem a v komentářích. Vzhledem k tomu, že příklad je zcela běžný a v zásadě implementován jako obvykle v podniku, ukázalo se, že je to docela zajímavá, jak se mi zdá, ukázka života: dopad na rychlost hlavního předmětu článku byl není znatelný kvůli externí logice: Moq, Autofac, EF Core a další "popruhy".

Začal jsem pracovat pod dojmem tohoto článku: Proč je Reflection pomalý

Jak můžete vidět, autor navrhuje použití kompilovaných delegátů namísto přímého volání metod typu reflexe jako skvělý způsob, jak výrazně urychlit aplikaci. Existuje samozřejmě emise IL, ale rád bych se tomu vyhnul, protože je to nejnáročnější způsob provedení úkolu, který je plný chyb.

Vzhledem k tomu, že jsem vždy zastával podobný názor na rychlost reflexe, neměl jsem v úmyslu nijak zvlášť zpochybňovat autorovy závěry.

Často se v podniku setkávám s naivním využíváním reflexe. Typ je převzat. Jsou převzaty informace o nemovitosti. Zavolá se metoda SetValue a všichni se radují. Hodnota dorazila do cílového pole, všichni jsou šťastní. Velmi chytří lidé - senioři a vedoucí týmů - zapisují svá rozšíření do objektu, na základě takové naivní implementace „univerzálních“ mapovačů jednoho typu na druhý. Podstata je obvykle tato: vezmeme všechna pole, vezmeme všechny vlastnosti, iterujeme je: pokud se názvy členů typu shodují, provedeme SetValue. Čas od času zachytíme výjimky kvůli chybám, kdy jsme u některého z typů nenašli nějakou vlastnost, ale i zde existuje východisko, které zlepšuje výkon. Zkus chytit.

Viděl jsem lidi znovu vynalézat analyzátory a mapovače, aniž by byli plně vyzbrojeni informacemi o tom, jak fungují stroje, které byly před nimi. Viděl jsem lidi schovávat své naivní implementace za strategie, za rozhraní, za injekce, jako by to omlouvalo následné bakchanálie. Nad takovými zjištěními jsem ohrnoval nos. Ve skutečnosti jsem neměřil skutečný únik výkonu, a pokud to bylo možné, jednoduše jsem změnil implementaci na „optimálnější“, pokud se mi to dostalo do rukou. Proto mě první níže probíraná měření vážně zmátla.

Myslím, že mnozí z vás, kteří čtete Richtera nebo jiné ideology, narazili na zcela férové ​​tvrzení, že odraz v kódu je fenomén, který má extrémně negativní dopad na výkon aplikace.

Volání reflexe nutí CLR procházet sestavení, aby nalezlo to, které potřebují, vytáhlo jejich metadata, analyzovalo je atd. Navíc odraz při procházení sekvencí vede k přidělení velkého množství paměti. Využíváme paměť, CLR odkrývá GC a začínají vlysy. Mělo by to být znatelně pomalé, věřte mi. Obrovské množství paměti na moderních produkčních serverech nebo cloudových strojích nezabrání vysokým zpožděním zpracování. Ve skutečnosti, čím více paměti, tím je pravděpodobnější, že si všimnete, jak GC funguje. Odraz je pro něj teoreticky červený hadr navíc.

Všichni však používáme IoC kontejnery a data mappery, jejichž princip fungování je také založen na reflexi, ale o jejich výkonu většinou nejsou žádné otázky. Ne, ne proto, že by zavedení závislostí a abstrakce od externích modelů s omezeným kontextem bylo tak nutné, abychom museli v každém případě obětovat výkon. Všechno je jednodušší - opravdu to moc neovlivňuje výkon.

Faktem je, že nejběžnější frameworky, které jsou založeny na reflexní technologii, využívají nejrůznější triky, jak s ní pracovat optimálněji. Obvykle se jedná o cache. Obvykle se jedná o výrazy a delegáty sestavené ze stromu výrazů. Stejný automapper udržuje konkurenční slovník, který spojuje typy s funkcemi, které mohou jeden převést na jiný bez volání reflexe.

Jak je toho dosaženo? V podstatě se to neliší od logiky, kterou samotná platforma používá ke generování kódu JIT. Když je metoda zavolána poprvé, je zkompilována (a, ano, tento proces není rychlý), při dalších voláních se řízení přenese na již zkompilovanou metodu a nedojde k žádnému výraznému snížení výkonu.

V našem případě můžete také použít kompilaci JIT a poté použít kompilované chování se stejným výkonem jako jeho protějšky AOT. Výrazy nám v tomto případě přijdou na pomoc.

Dotyčný princip lze stručně formulovat takto:
Konečný výsledek reflexe byste měli uložit do mezipaměti jako delegát obsahující zkompilovanou funkci. Má také smysl ukládat do mezipaměti všechny potřebné objekty s informacemi o typu v polích vašeho typu, pracovník, která jsou uložena mimo objekty.

V tom je logika. Zdravý rozum nám říká, že pokud lze něco zkompilovat a uložit do mezipaměti, pak by se to mělo udělat.

Výhledově je třeba říci, že cache v práci s odrazem má své výhody, i když nepoužijete navrhovaný způsob kompilace výrazů. Vlastně zde jen opakuji teze autora článku, na který odkazuji výše.

Nyní o kódu. Podívejme se na příklad, který je založen na mé nedávné bolesti, které jsem musel čelit ve vážné produkci vážné úvěrové instituce. Všechny entity jsou fiktivní, aby je nikdo neuhádl.

Existuje nějaká podstata. Nechť je zde Kontakt. Existují písmena se standardizovaným tělem, ze kterých analyzátor a hydrátor vytvářejí stejné kontakty. Přišel dopis, přečetli jsme ho, analyzovali ho na páry klíč-hodnota, vytvořili kontakt a uložili jej do databáze.

Je to elementární. Řekněme, že kontakt má vlastnosti Celé jméno, Věk a Kontaktní telefon. Tyto údaje se předávají v dopise. Podnik také požaduje podporu, aby bylo možné rychle přidávat nové klíče pro mapování vlastností entity do párů v těle dopisu. V případě, že někdo udělal v šabloně překlep nebo pokud je před vydáním nutné urychleně spustit mapování od nového partnera, přizpůsobující se novému formátu. Pak můžeme přidat novou mapovací korelaci jako levný datafix. Tedy životní příklad.

Implementujeme, vytváříme testy. funguje.

Kód neposkytnu: existuje spousta zdrojů a jsou k dispozici na GitHubu prostřednictvím odkazu na konci článku. Můžete je nakládat, mučit k nepoznání a měřit, jak by to ovlivnilo ve vašem případě. Uvedu pouze kód dvou šablonových metod, které odlišují hydrátor, který měl být rychlý, od hydrátoru, který měl být pomalý.

Logika je následující: šablonová metoda přijímá páry generované základní logikou analyzátoru. Vrstva LINQ je parser a základní logika hydrátoru, který dělá požadavek na databázový kontext a porovnává klíče s páry z parseru (pro tyto funkce existuje pro srovnání kód bez LINQ). Dále jsou páry předány hlavní hydratační metodě a hodnoty párů jsou nastaveny na odpovídající vlastnosti entity.

„Fast“ (předpona Fast v benchmarcích):

 protected override Contact GetContact(PropertyToValueCorrelation[] correlations)
        {
            var contact = new Contact();
            foreach (var setterMapItem in _proprtySettersMap)
            {
                var correlation = correlations.FirstOrDefault(x => x.PropertyName == setterMapItem.Key);
                setterMapItem.Value(contact, correlation?.Value);
            }
            return contact;
        }

Jak vidíme, používá se statická kolekce s vlastnostmi setter – kompilované lambdy, které volají entitu setter. Vytvořeno následujícím kódem:

        static FastContactHydrator()
        {
            var type = typeof(Contact);
            foreach (var property in type.GetProperties())
            {
                _proprtySettersMap[property.Name] = GetSetterAction(property);
            }
        }

        private static Action<Contact, string> GetSetterAction(PropertyInfo property)
        {
            var setterInfo = property.GetSetMethod();
            var paramValueOriginal = Expression.Parameter(property.PropertyType, "value");
            var paramEntity = Expression.Parameter(typeof(Contact), "entity");
            var setterExp = Expression.Call(paramEntity, setterInfo, paramValueOriginal).Reduce();
            
            var lambda = (Expression<Action<Contact, string>>)Expression.Lambda(setterExp, paramEntity, paramValueOriginal);

            return lambda.Compile();
        }

Obecně je to jasné. Procházíme vlastnosti, vytváříme pro ně delegáty, kteří volají settery, a ukládáme je. V případě potřeby pak voláme.

„Pomalý“ (předpona Slow v benchmarcích):

        protected override Contact GetContact(PropertyToValueCorrelation[] correlations)
        {
            var contact = new Contact();
            foreach (var property in _properties)
            {
                var correlation = correlations.FirstOrDefault(x => x.PropertyName == property.Name);
                if (correlation?.Value == null)
                    continue;

                property.SetValue(contact, correlation.Value);
            }
            return contact;
        }

Zde okamžitě obejdeme vlastnosti a zavoláme přímo SetValue.

Pro přehlednost a jako referenci jsem implementoval naivní metodu, která zapisuje hodnoty jejich korelačních párů přímo do polí entity. Předpona – Manuál.

Nyní si vezmeme BenchmarkDotNet a prozkoumáme výkon. A najednou... (spoiler - toto není správný výsledek, podrobnosti jsou níže)

Nepovedený článek o zrychlení odrazu

co tady vidíme? Metody, které vítězně nesou předponu Fast, se ukážou jako pomalejší téměř ve všech průchodech než metody s předponou Slow. To platí jak pro alokaci, tak pro rychlost práce. Na druhou stranu krásná a elegantní implementace mapování pomocí metod LINQ k tomu určených všude, kde je to možné, naopak velmi snižuje produktivitu. Rozdíl je v pořadí. Trend se nemění s různým počtem průchodů. Jediný rozdíl je v měřítku. S LINQ je to 4 - 200 krát pomalejší, je tam více odpadků přibližně ve stejném měřítku.

AKTUALIZOVÁNO

Nevěřil jsem svým očím, ale co je důležitější, náš kolega nevěřil ani mým očím, ani mému kódu - Dmitrij Tichonov 0x1000000. Po dvojité kontrole mého řešení brilantně objevil a poukázal na chybu, kterou jsem přehlédl kvůli řadě změn v implementaci, od začátku až po konečnou. Po opravě nalezené chyby v nastavení Moq všechny výsledky zapadly. Podle výsledků retestu se hlavní trend nemění - LINQ stále ovlivňuje výkon více než odraz. Je však hezké, že práce s kompilací Expression není marná a výsledek je viditelný jak na alokaci, tak na době provádění. První spuštění, kdy jsou inicializována statická pole, je u „rychlé“ metody přirozeně pomalejší, ale pak se situace změní.

Zde je výsledek opakovaného testu:

Nepovedený článek o zrychlení odrazu

Závěr: při použití reflexe v podniku není třeba se uchylovat k trikům - LINQ více pohltí produktivitu. U metod s vysokým zatížením, které vyžadují optimalizaci, však můžete ušetřit odraz ve formě inicializátorů a delegovaných kompilátorů, které pak poskytnou „rychlou“ logiku. Můžete tak zachovat jak flexibilitu odrazu, tak rychlost aplikace.

Referenční kód je k dispozici zde. Kdokoli si může zkontrolovat moje slova:
HabraReflectionTests

PS: kód v testech používá IoC a v benchmarcích používá explicitní konstrukci. Faktem je, že ve finální implementaci jsem odřízl všechny faktory, které by mohly ovlivnit výkon a zašumit výsledek.

PPS: Díky uživateli Dmitrij Tichonov @0x1000000 za zjištění mé chyby v nastavení Moq, která ovlivnila první měření. Pokud má někdo ze čtenářů dostatečnou karmu, dejte like. Muž se zastavil, muž četl, muž znovu zkontroloval a upozornil na chybu. Myslím, že si to zaslouží respekt a soucit.

PPPS: díky pečlivému čtenáři, který se dostal až na dno stylu a designu. Jsem pro uniformitu a pohodlí. Diplomacie prezentace ponechává mnoho přání, ale kritiku jsem vzal v úvahu. Ptám se na projektil.

Zdroj: www.habr.com

Přidat komentář