Articol nereușit despre accelerarea reflecției

Voi explica imediat titlul articolului. Planul inițial a fost să ofere sfaturi bune și de încredere despre cum să grăbești utilizarea reflecției folosind un exemplu simplu, dar realist, dar în timpul benchmarking-ului s-a dovedit că reflecția nu este atât de lentă pe cât credeam, LINQ este mai lent decât în ​​coșmarurile mele. Dar până la urmă s-a dovedit că am greșit și eu la măsurători... Detalii despre această poveste de viață sunt sub tăietură și în comentarii. Deoarece exemplul este destul de obișnuit și implementat în principiu așa cum se face de obicei într-o întreprindere, sa dovedit a fi o demonstrație destul de interesantă, după cum mi se pare, de viață: impactul asupra vitezei subiectului principal al articolului a fost nu se observă din cauza logicii externe: Moq, Autofac, EF Core și alte „curele”.

Am început să lucrez sub impresia acestui articol: De ce Reflecția este lentă

După cum puteți vedea, autorul sugerează să folosiți delegați compilați în loc să apelați direct metode de tip reflectare ca o modalitate excelentă de a accelera foarte mult aplicația. Există, desigur, emisie IL, dar aș dori să o evit, deoarece aceasta este cea mai laborioasă modalitate de a efectua sarcina, care este plină de erori.

Având în vedere că am avut întotdeauna o părere similară cu privire la viteza de reflecție, nu am intenționat în mod deosebit să pun la îndoială concluziile autorului.

Întâlnesc adesea utilizarea naivă a reflecției în întreprindere. Tipul este luat. Sunt preluate informații despre proprietate. Metoda SetValue este apelată și toată lumea se bucură. Valoarea a ajuns în câmpul țintă, toată lumea este fericită. Oameni foarte inteligenți - seniori și lideri de echipă - își scriu extensiile pentru a obiecta, bazându-se pe o implementare atât de naivă, mapper „universale” de la un tip la altul. Esența este de obicei aceasta: luăm toate câmpurile, luăm toate proprietățile, iterăm peste ele: dacă numele membrilor tipului se potrivesc, executăm SetValue. Din când în când prindem excepții din cauza unor greșeli în care nu am găsit vreo proprietate într-unul dintre tipuri, dar și aici există o ieșire care îmbunătățește performanța. Încearcă să prinzi.

Am văzut oameni reinventând parsere și mapper fără a fi pe deplin înarmați cu informații despre modul în care funcționează mașinile care au apărut înaintea lor. Am văzut oameni ascunzându-și implementările naive în spatele strategiilor, în spatele interfețelor, în spatele injecțiilor, de parcă asta ar scuza bacanalele ulterioare. Mi-am întors nasul la astfel de realizări. De fapt, nu am măsurat scurgerea reală de performanță și, dacă este posibil, pur și simplu am schimbat implementarea cu una mai „optimă” dacă aș putea pune mâna pe ea. Prin urmare, primele măsurători discutate mai jos m-au derutat serios.

Cred că mulți dintre voi, citind pe Richter sau alți ideologi, ați dat peste o afirmație complet corectă că reflectarea în cod este un fenomen care are un impact extrem de negativ asupra performanței aplicației.

Apelarea reflecției obligă CLR să parcurgă ansambluri pentru a-l găsi pe cel de care au nevoie, să-și ridice metadatele, să le analizeze etc. În plus, reflecția în timpul parcurgerii secvențelor duce la alocarea unei cantități mari de memorie. Epuizăm memoria, CLR descoperă GC și încep frizele. Ar trebui să fie vizibil lent, crede-mă. Cantitățile uriașe de memorie de pe serverele moderne de producție sau mașinile cloud nu împiedică întârzierile mari de procesare. De fapt, cu cât mai multă memorie, cu atât este mai probabil să observați cum funcționează GC. Reflecția este, în teorie, o cârpă roșie în plus pentru el.

Cu toate acestea, cu toții folosim containere IoC și cartografieri de date, al căror principiu de funcționare se bazează și pe reflecție, dar de obicei nu există întrebări cu privire la performanța lor. Nu, nu pentru că introducerea dependențelor și abstracția din modelele externe de context limitat sunt atât de necesare încât trebuie să sacrificăm performanța în orice caz. Totul este mai simplu - într-adevăr nu afectează prea mult performanța.

Faptul este că cele mai comune cadre care se bazează pe tehnologia reflexiei folosesc tot felul de trucuri pentru a lucra cu ea mai optim. De obicei, acesta este un cache. De obicei, acestea sunt expresii și delegați compilați din arborele de expresii. Același automapper menține un dicționar competitiv care potrivește tipuri cu funcții care se pot converti unul în altul fără a apela la reflectare.

Cum se realizează acest lucru? În esență, aceasta nu este diferită de logica pe care platforma în sine o folosește pentru a genera codul JIT. Când o metodă este apelată pentru prima dată, aceasta este compilată (și, da, acest proces nu este rapid); la apelurile ulterioare, controlul este transferat metodei deja compilate și nu vor exista scăderi semnificative de performanță.

În cazul nostru, puteți utiliza și compilarea JIT și apoi utilizați comportamentul compilat cu aceeași performanță ca și omologii săi AOT. Expresiile ne vor veni în ajutor în acest caz.

Principiul în cauză poate fi formulat pe scurt după cum urmează:
Ar trebui să memorați în cache rezultatul final al reflecției ca delegat care conține funcția compilată. De asemenea, are sens să memorezi în cache toate obiectele necesare cu informații de tip în câmpurile tipului tău, lucrătorul, care sunt stocate în afara obiectelor.

Există o logică în asta. Bunul simț ne spune că dacă ceva poate fi compilat și stocat în cache, atunci ar trebui făcut.

Privind în viitor, trebuie spus că cache-ul în lucrul cu reflectarea are avantajele sale, chiar dacă nu folosiți metoda propusă de compilare a expresiilor. De fapt, aici repet pur și simplu tezele autorului articolului la care mă refer mai sus.

Acum despre cod. Să ne uităm la un exemplu care se bazează pe durerea mea recentă cu care a trebuit să mă confrunt într-o producție serioasă a unei instituții de credit serioase. Toate entitățile sunt fictive, astfel încât nimeni să nu ghicească.

Există o anumită esență. Să fie Contact. Există litere cu un corp standardizat, din care analizatorul și hidratatorul creează aceleași contacte. A sosit o scrisoare, am citit-o, am analizat-o în perechi cheie-valoare, am creat o persoană de contact și am salvat-o în baza de date.

Este elementar. Să presupunem că un contact are proprietățile Nume complet, Vârstă și Telefon de contact. Aceste date sunt transmise prin scrisoare. Compania dorește, de asemenea, suport pentru a putea adăuga rapid chei noi pentru maparea proprietăților entităților în perechi în corpul scrisorii. În cazul în care cineva a făcut o greșeală de tipar în șablon sau dacă înainte de lansare este necesară lansarea urgentă de cartografiere de la un nou partener, adaptându-se la noul format. Apoi putem adăuga o nouă corelație de mapare ca o remediere de date ieftină. Adică un exemplu de viață.

Implementăm, creăm teste. Lucrări.

Nu voi furniza codul: există o mulțime de surse și sunt disponibile pe GitHub prin linkul de la sfârșitul articolului. Puteți să le încărcați, să le torturați dincolo de recunoaștere și să le măsurați, așa cum ar afecta cazul dvs. Voi da doar codul a două metode șablon care deosebesc hidratatorul, care trebuia să fie rapid, de hidratatorul, care trebuia să fie lent.

Logica este următoarea: metoda șablonului primește perechi generate de logica parserului de bază. Stratul LINQ este parserul și logica de bază a hidratatorului, care face o solicitare în contextul bazei de date și compară cheile cu perechile din parser (pentru aceste funcții există cod fără LINQ pentru comparație). Apoi, perechile sunt trecute la metoda principală de hidratare, iar valorile perechilor sunt setate la proprietățile corespunzătoare ale entității.

„Fast” (Prefixul Rapid în benchmarks):

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

După cum putem vedea, este folosită o colecție statică cu proprietăți setter - lambda compilate care apelează entitatea setter. Creat de următorul cod:

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

În general este clar. Parcurgem proprietățile, le creăm delegați care apelează seteri și îi salvăm. Apoi sunăm când este necesar.

„Slow” (Prefix Slow în benchmarks):

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

Aici ocolim imediat proprietățile și apelăm direct SetValue.

Pentru claritate și ca referință, am implementat o metodă naivă care scrie valorile perechilor lor de corelație direct în câmpurile entității. Prefix – Manual.

Acum să luăm BenchmarkDotNet și să examinăm performanța. Și dintr-o dată... (spoiler - acesta nu este rezultatul corect, detaliile sunt mai jos)

Articol nereușit despre accelerarea reflecției

Ce vedem aici? Metodele care poartă triumfător prefixul Fast se dovedesc a fi mai lente în aproape toate trecerile decât metodele cu prefixul Slow. Acest lucru este valabil atât pentru alocare, cât și pentru viteza de lucru. Pe de altă parte, o implementare frumoasă și elegantă a cartografierii folosind metode LINQ destinate acestui lucru oriunde este posibil, dimpotrivă, reduce foarte mult productivitatea. Diferența este de ordine. Tendința nu se schimbă cu un număr diferit de treceri. Singura diferență este de scară. Cu LINQ este de 4 - 200 de ori mai lent, există mai mult gunoi la aproximativ aceeași scară.

ACTUALIZAT

Nu mi-a crezut ochilor, dar, mai important, colegul nostru nu mi-a crezut nici în ochi și nici în codul meu - Dmitri Tikhonov 0x1000000. După ce mi-a verificat soluția, a descoperit și a subliniat cu brio o eroare pe care am omis-o din cauza mai multor modificări în implementare, de la început la final. După remedierea erorii găsite în configurarea Moq, toate rezultatele au căzut la locul lor. Conform rezultatelor retestării, tendința principală nu se schimbă - LINQ încă afectează performanța mai mult decât reflectarea. Cu toate acestea, este bine că munca cu compilarea Expression nu se face în zadar, iar rezultatul este vizibil atât în ​​timpul de alocare, cât și în timpul de execuție. Prima lansare, atunci când câmpurile statice sunt inițializate, este în mod natural mai lentă pentru metoda „rapidă”, dar apoi situația se schimbă.

Iată rezultatul retestării:

Articol nereușit despre accelerarea reflecției

Concluzie: atunci când utilizați reflectarea într-o întreprindere, nu este nevoie în mod special de a recurge la trucuri - LINQ va consuma mai mult productivitatea. Cu toate acestea, în metodele cu încărcare mare care necesită optimizare, puteți salva reflectarea sub formă de inițializatoare și compilatoare delegate, care vor oferi apoi o logică „rapidă”. Astfel puteți menține atât flexibilitatea reflexiei, cât și viteza de aplicare.

Codul de referință este disponibil aici. Oricine poate verifica cuvintele mele:
HabraReflectionTests

PS: codul din teste folosește IoC, iar în benchmark-uri folosește un construct explicit. Cert este că în implementarea finală am tăiat toți factorii care ar putea afecta performanța și ar putea face rezultatul zgomotos.

PPS: Mulțumim utilizatorului Dmitri Tikhonov @0x1000000 pentru că am descoperit eroarea mea în configurarea Moq, care a afectat primele măsurători. Dacă vreunul dintre cititori are suficientă karmă, vă rugăm să-i placă. Bărbatul s-a oprit, omul a citit, bărbatul a verificat de două ori și a subliniat greșeala. Cred că acest lucru este demn de respect și simpatie.

PPPS: datorită cititorului meticulos care a ajuns la fundul stilului și designului. Sunt pentru uniformitate și comoditate. Diplomația prezentării lasă de dorit, dar am ținut cont de critică. Cer proiectilul.

Sursa: www.habr.com

Adauga un comentariu