Nepodarený článok o zrýchľovaní odrazu

Hneď vysvetlím názov článku. Pôvodný plán bol dať dobrú, spoľahlivú radu, ako zrýchliť použitie odrazu pomocou jednoduchého, no realistického príkladu, no pri benchmarkingu sa ukázalo, že odraz nie je taký pomalý, ako som si myslel, LINQ je pomalší ako v mojich nočných morách. Nakoniec sa ale ukázalo, že som sa pomýlila aj v mierach... Detaily tohto životného príbehu sú pod strihom a v komentároch. Keďže príklad je celkom bežný a v zásade implementovaný tak, ako sa to bežne robí v podniku, ukázalo sa, že je to celkom zaujímavá, ako sa mi zdá, ukážka života: vplyv na rýchlosť hlavného predmetu článku bol nepozorovateľné kvôli externej logike: Moq, Autofac, EF Core a iné „bandáže“.

Začal som pracovať pod dojmom tohto článku: Prečo je Reflection pomalý

Ako vidíte, autor navrhuje použiť kompilovaných delegátov namiesto priameho volania metód typu reflexie ako skvelý spôsob, ako výrazne urýchliť aplikáciu. Existuje, samozrejme, emisia IL, ale chcel by som sa tomu vyhnúť, pretože ide o najnáročnejší spôsob vykonania úlohy, ktorá je plná chýb.

Vzhľadom na to, že som vždy zastával podobný názor na rýchlosť reflexie, nemal som v úmysle nijako zvlášť spochybňovať autorove závery.

Často sa v podniku stretávam s naivným využívaním reflexie. Typ je prevzatý. Preberajú sa informácie o nehnuteľnosti. Zavolá sa metóda SetValue a všetci sa radujú. Hodnota dorazila do cieľového poľa, všetci sú spokojní. Veľmi inteligentní ľudia - seniori a vedúci tímov - píšu svoje rozšírenia do objektu, na základe takejto naivnej implementácie „univerzálnych“ mapovačov jedného typu na druhý. Podstata je zvyčajne takáto: vezmeme všetky polia, vezmeme všetky vlastnosti, iterujeme ich: ak sa mená členov typu zhodujú, vykonáme SetValue. Z času na čas zachytíme výnimky kvôli chybám, kde sme v niektorom z typov nenašli nejakú vlastnosť, ale aj tu existuje východisko, ktoré zlepšuje výkon. Skús chytiť.

Videl som ľudí znovu vynájsť analyzátory a mapovače bez toho, aby boli plne vyzbrojení informáciami o tom, ako fungujú stroje, ktoré boli pred nimi. Videl som ľudí schovávať svoje naivné implementácie za stratégie, za rozhrania, za injekcie, akoby to malo ospravedlniť následné bakchanálie. Nad takýmito zisteniami som ohŕňal nos. V skutočnosti som nemeral skutočný únik výkonu a ak to bolo možné, jednoducho som zmenil implementáciu na „optimálnejšiu“, ak sa mi to podarilo. Preto ma prvé merania rozoberané nižšie vážne zmiatli.

Myslím, že mnohí z vás, ktorí čítate Richtera alebo iných ideológov, narazili na úplne férové ​​tvrdenie, že odraz v kóde je fenomén, ktorý má extrémne negatívny vplyv na výkon aplikácie.

Volanie reflexie núti CLR prejsť cez zostavy, aby našli tú, ktorú potrebujú, vytiahli svoje metadáta, analyzovali ich atď. Navyše, odraz pri prechádzaní sekvencií vedie k alokácii veľkého množstva pamäte. Využívame pamäť, CLR odkryje GC a začínajú vlysy. Malo by to byť výrazne pomalé, verte mi. Obrovské množstvo pamäte na moderných produkčných serveroch alebo cloudových strojoch nezabráni veľkému oneskoreniu spracovania. V skutočnosti, čím viac pamäte, tým je pravdepodobnejšie, že si všimnete, ako GC funguje. Odraz je pre neho teoreticky extra červená handra.

Všetci však používame IoC kontajnery a dátumové mapovače, ktorých princíp fungovania je tiež založený na reflexii, no o ich výkone väčšinou nie sú žiadne otázky. Nie, nie preto, že by zavedenie závislostí a abstrakcia od modelov s obmedzeným vonkajším kontextom boli také nevyhnutné, že musíme v každom prípade obetovať výkon. Všetko je jednoduchšie - skutočne to nemá veľký vplyv na výkon.

Faktom je, že najbežnejšie frameworky, ktoré sú založené na reflexnej technológii, využívajú najrôznejšie triky, ako s ňou pracovať optimálnejšie. Zvyčajne ide o vyrovnávaciu pamäť. Typicky sú to výrazy a delegáti zostavené zo stromu výrazov. Rovnaký automapovač udržiava konkurenčný slovník, ktorý spája typy s funkciami, ktoré môžu jeden konvertovať na iný bez volania reflexie.

Ako sa to dosiahne? V podstate sa to nelíši od logiky, ktorú samotná platforma používa na generovanie kódu JIT. Keď je metóda zavolaná prvýkrát, je skompilovaná (a áno, tento proces nie je rýchly); pri ďalších volaniach sa riadenie prenesie na už zostavenú metódu a nedôjde k žiadnym výrazným zníženiam výkonu.

V našom prípade môžete tiež použiť kompiláciu JIT a potom použiť kompilované správanie s rovnakým výkonom ako jeho náprotivky AOT. Výrazy nám v tomto prípade pomôžu.

Uvedený princíp možno stručne sformulovať takto:
Konečný výsledok reflexie by ste mali uložiť do vyrovnávacej pamäte ako delegát obsahujúci skompilovanú funkciu. Má tiež zmysel ukladať všetky potrebné objekty do vyrovnávacej pamäte s informáciami o type v poliach vášho typu, pracovník, ktoré sú uložené mimo objektov.

Je v tom logika. Zdravý rozum nám hovorí, že ak sa dá niečo skompilovať a uložiť do vyrovnávacej pamäte, treba to urobiť.

Pri pohľade do budúcnosti treba povedať, že cache pri práci s odrazom má svoje výhody, aj keď nepoužívate navrhovaný spôsob zostavovania výrazov. Vlastne tu len opakujem tézy autora článku, na ktorý sa odvolávam vyššie.

Teraz o kóde. Pozrime sa na príklad, ktorý je založený na mojej nedávnej bolesti, ktorej som musel čeliť pri serióznej produkcii serióznej úverovej inštitúcie. Všetky entity sú fiktívne, aby ich nikto neuhádol.

Existuje nejaká podstata. Nech je tu Kontakt. Existujú písmená so štandardizovaným telom, z ktorých syntaktický analyzátor a hydrátor vytvárajú rovnaké kontakty. Prišiel list, prečítali sme ho, analyzovali ho na páry kľúč-hodnota, vytvorili kontakt a uložili ho do databázy.

Je to elementárne. Povedzme, že kontakt má vlastnosti Celé meno, Vek a Kontaktný telefón. Tieto údaje sa prenášajú v liste. Podnik chce tiež podporu, aby bolo možné rýchlo pridať nové kľúče na mapovanie vlastností entity do párov v tele listu. V prípade, že niekto urobil v šablóne preklep alebo ak je pred vydaním potrebné urýchlene spustiť mapovanie od nového partnera, prispôsobenie sa novému formátu. Potom môžeme pridať novú mapovaciu koreláciu ako lacný datafix. Teda životný príklad.

Implementujeme, vytvárame testy. Tvorba.

Kód neposkytnem: existuje veľa zdrojov a sú k dispozícii na GitHub prostredníctvom odkazu na konci článku. Môžete ich nakladať, týrať na nepoznanie a merať, ako by to ovplyvnilo vo vašom prípade. Uvediem len kód dvoch šablónových metód, ktoré odlišujú hydrátor, ktorý mal byť rýchly, od hydrátora, ktorý mal byť pomalý.

Logika je nasledovná: metóda šablóny prijíma páry generované základnou logikou syntaktického analyzátora. Vrstva LINQ je analyzátor a základná logika hydrátora, ktorý robí požiadavku na databázový kontext a porovnáva kľúče s pármi zo syntaktického analyzátora (pre tieto funkcie existuje na porovnanie kód bez LINQ). Ďalej sa páry prenesú do hlavnej hydratačnej metódy a hodnoty párov sa nastavia na zodpovedajúce vlastnosti entity.

„Rýchly“ (Prefix Fast v benchmarkoch):

 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;
        }

Ako vidíme, používa sa statická kolekcia s vlastnosťami setter – zostavené lambdy, ktoré volajú entitu setter. Vytvorené nasledujúcim kódom:

        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();
        }

Vo všeobecnosti je to jasné. Prechádzame vlastnosti, vytvárame pre nich delegátov, ktorí volajú nastavovačov, a ukladáme ich. Potom si zavoláme, keď bude treba.

„Slow“ (predpona Slow v benchmarkoch):

        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;
        }

Tu okamžite obídeme vlastnosti a zavoláme priamo SetValue.

Pre prehľadnosť a ako referenciu som implementoval naivnú metódu, ktorá zapisuje hodnoty ich korelačných párov priamo do polí entít. Predpona – Manuál.

Teraz si vezmime BenchmarkDotNet a preskúmajme výkon. A zrazu... (spoiler - toto nie je správny výsledok, podrobnosti sú nižšie)

Nepodarený článok o zrýchľovaní odrazu

Čo tu vidíme? Metódy, ktoré víťazne nesú predponu Fast, sa ukážu byť pomalšie takmer vo všetkých prechodoch ako metódy s predponou Slow. Platí to pre alokáciu aj rýchlosť práce. Na druhej strane krásna a elegantná implementácia mapovania pomocou metód LINQ na to určených všade tam, kde je to možné, naopak veľmi znižuje produktivitu. Rozdiel je v poradí. Trend sa nemení s rôznym počtom prihrávok. Rozdiel je len v mierke. S LINQ je to 4 - 200 krát pomalšie, je tam viac odpadu približne v rovnakom rozsahu.

AKTUALIZOVANÉ

Neveril som vlastným očiam, ale čo je dôležitejšie, náš kolega neveril ani mojim očiam, ani môjmu kódu - Dmitrij Tichonov 0x1000000. Po dvojitej kontrole môjho riešenia brilantne objavil a poukázal na chybu, ktorú som prehliadol kvôli množstvu zmien v implementácii, od začiatku až po konečnú. Po odstránení nájdenej chyby v nastavení Moq zapadli všetky výsledky na svoje miesto. Podľa výsledkov retestu sa hlavný trend nemení – LINQ stále ovplyvňuje výkon viac ako odraz. Je však pekné, že práca s kompiláciou Expression nie je zbytočná a výsledok je viditeľný v čase alokácie aj vykonania. Prvé spustenie, keď sa inicializujú statické polia, je pri „rýchlej“ metóde prirodzene pomalšie, ale potom sa situácia zmení.

Tu je výsledok opakovaného testu:

Nepodarený článok o zrýchľovaní odrazu

Záver: pri používaní reflexie v podniku nie je potrebné uchýliť sa k trikom - LINQ viac pohltí produktivitu. Pri metódach s vysokým zaťažením, ktoré vyžadujú optimalizáciu, však môžete ušetriť odraz vo forme inicializátorov a delegovaných kompilátorov, ktoré potom poskytnú „rýchlu“ logiku. Takto môžete zachovať flexibilitu odrazu aj rýchlosť aplikácie.

Referenčný kód je k dispozícii tu. Ktokoľvek môže skontrolovať moje slová:
HabraReflectionTests

PS: kód v testoch používa IoC a v benchmarkoch používa explicitnú konštrukciu. Faktom je, že vo finálnej implementácii som odstrihol všetky faktory, ktoré by mohli ovplyvniť výkon a zašumiť výsledok.

PPS: Ďakujem používateľovi Dmitrij Tichonov @0x1000000 za zistenie mojej chyby pri nastavovaní Moq, ktorá ovplyvnila prvé merania. Ak má niekto z čitateľov dostatočnú karmu, nech sa páči. Muž sa zastavil, muž čítal, muž to skontroloval a upozornil na chybu. Myslím si, že si to zaslúži rešpekt a súcit.

PPPS: vďaka starostlivému čitateľovi, ktorý sa dostal až na dno štýlu a dizajnu. Som za uniformitu a pohodlnosť. Diplomacia prezentácie ponecháva veľa želaní, ale kritiku som bral do úvahy. Pýtam sa na projektil.

Zdroj: hab.com

Pridať komentár