Neuspješan članak o ubrzavanju refleksije

Odmah ću objasniti naslov članka. Prvobitni plan je bio dati dobar, pouzdan savjet o ubrzavanju upotrebe refleksije koristeći jednostavan, ali realan primjer, ali se tokom benchmarkinga pokazalo da refleksija nije spora kao što sam mislio, LINQ je sporiji nego u mojim noćnim morama. Ali na kraju se ispostavilo da sam i ja pogrešio u merenjima... Detalji ove životne priče su ispod i u komentarima. Budući da je primjer sasvim uobičajen i u principu implementiran kao što se obično radi u poduzeću, ispao je prilično zanimljiv, kako mi se čini, demonstracija života: utjecaj na brzinu glavne teme članka bio je nije primjetno zbog vanjske logike: Moq, Autofac, EF Core i drugi "bandings".

Počeo sam raditi pod utiskom ovog članka: Zašto je Refleksija spora

Kao što možete vidjeti, autor predlaže korištenje kompajliranih delegata umjesto direktnog pozivanja metoda refleksivnog tipa kao sjajnog načina za značajno ubrzanje aplikacije. Postoji, naravno, emisija IL, ali bih je želio izbjeći, jer je ovo najintenzivniji način za obavljanje zadatka, koji je prepun grešaka.

S obzirom da sam oduvijek imao slično mišljenje o brzini refleksije, nisam posebno imao namjeru dovoditi u pitanje autorove zaključke.

Često se susrećem sa naivnom upotrebom refleksije u preduzeću. Tip je uzet. Podaci o nekretnini se preuzimaju. Poziva se metoda SetValue i svi se raduju. Vrijednost je stigla u ciljno polje, svi su zadovoljni. Vrlo pametni ljudi - seniori i voditelji timova - pišu svoje ekstenzije za objekt, bazirajući se na tako naivnoj implementaciji "univerzalnih" mapera jednog tipa u drugi. Suština je obično sledeća: uzimamo sva polja, uzimamo sva svojstva, iterujemo preko njih: ako se imena članova tipa poklapaju, izvršavamo SetValue. S vremena na vrijeme uhvatimo iznimke zbog grešaka gdje nismo pronašli neko svojstvo u nekom od tipova, ali čak i ovdje postoji izlaz koji poboljšava performanse. Probaj/uhvati.

Vidio sam ljude kako iznova izmišljaju parsere i mapere, a da nisu bili potpuno naoružani informacijama o tome kako rade mašine koje su bile prije njih. Vidio sam ljude kako skrivaju svoje naivne implementacije iza strategija, iza interfejsa, iza injekcija, kao da bi to opravdalo kasniju bakhanaliju. Digao sam nos na takve spoznaje. Zapravo, nisam mjerio stvarno curenje performansi, i, ako je moguće, jednostavno sam promijenio implementaciju u „optimalniju“ ako sam je mogao dohvatiti. Stoga su me prva mjerenja o kojima se govori u nastavku ozbiljno zbunila.

Mislim da su mnogi od vas, čitajući Richtera ili druge ideologe, naišli na potpuno poštenu izjavu da je refleksija u kodu fenomen koji ima izuzetno negativan utjecaj na performanse aplikacije.

Pozivanje refleksije prisiljava CLR da prolazi kroz sklopove kako bi pronašao onu koja im je potrebna, izvukao njihove metapodatke, raščlanio ih itd. Osim toga, refleksija pri prelasku niza dovodi do alokacije velike količine memorije. Trošimo memoriju, CLR otkriva GC i frizovi počinju. Trebalo bi biti primjetno sporo, vjerujte mi. Ogromne količine memorije na modernim proizvodnim serverima ili cloud mašinama ne sprečavaju velika kašnjenja u procesuiranju. U stvari, što je više memorije, veća je vjerovatnoća da ćete PRIMJETITI kako GC radi. Refleksija je, u teoriji, za njega ekstra crvena krpa.

Međutim, svi mi koristimo IoC kontejnere i mapere datuma, čiji je princip rada također zasnovan na refleksiji, ali obično nema pitanja o njihovoj izvedbi. Ne, ne zato što su uvođenje zavisnosti i apstrakcija od eksternih modela ograničenog konteksta toliko neophodni da moramo žrtvovati performanse u svakom slučaju. Sve je jednostavnije - to zaista ne utiče mnogo na performanse.

Činjenica je da najčešći okviri koji se temelje na tehnologiji refleksije koriste razne trikove kako bi s njom radili što optimalnije. Obično je ovo keš memorija. Obično su to izrazi i delegati kompajlirani iz stabla izraza. Isti automapper održava konkurentan rječnik koji uparuje tipove sa funkcijama koje mogu pretvoriti jednu u drugu bez pozivanja refleksije.

Kako se to postiže? U suštini, ovo se ne razlikuje od logike koju sama platforma koristi za generiranje JIT koda. Kada se metoda pozove prvi put, ona se kompajlira (i, da, ovaj proces nije brz); pri narednim pozivima, kontrola se prenosi na već prevedenu metodu i neće biti značajnih smanjenja performansi.

U našem slučaju, možete koristiti i JIT kompilaciju, a zatim koristiti prevedeno ponašanje sa istim performansama kao i njegove AOT kolege. Izrazi će nam u ovom slučaju priskočiti u pomoć.

Princip o kojem je riječ može se ukratko formulirati na sljedeći način:
Trebali biste keširati konačni rezultat refleksije kao delegat koji sadrži kompajliranu funkciju. Također ima smisla keširati sve potrebne objekte sa informacijama o tipu u poljima vašeg tipa, radnika, koja su pohranjena izvan objekata.

Ima logike u ovome. Zdrav razum nam govori da ako se nešto može kompajlirati i keširati, onda to treba i učiniti.

Gledajući unaprijed, treba reći da keš u radu s refleksijom ima svoje prednosti, čak i ako ne koristite predloženu metodu sastavljanja izraza. Zapravo, ovdje jednostavno ponavljam teze autora članka na koji se gore pozivam.

Sada o kodu. Pogledajmo primjer koji se temelji na mojoj nedavnoj muci s kojom sam se morao suočiti u ozbiljnoj produkciji ozbiljne kreditne institucije. Svi entiteti su fiktivni da niko ne bi pogodio.

Postoji neka suština. Neka bude Kontakt. Postoje slova sa standardiziranim tijelom, od kojih parser i hidrator stvaraju te iste kontakte. Stiglo je pismo, pročitali smo ga, raščlanili na parove ključ/vrijednost, kreirali kontakt i sačuvali ga u bazi podataka.

To je elementarno. Recimo da kontakt ima svojstva Puno ime, dob i kontakt telefon. Ovi podaci se prenose u pismu. Kompanija takođe želi podršku da može brzo da doda nove ključeve za mapiranje svojstava entiteta u parove u telu pisma. U slučaju da je neko napravio grešku u šablonu ili ako je prije objavljivanja potrebno hitno pokrenuti mapiranje od novog partnera, prilagođavajući se novom formatu. Tada možemo dodati novu korelaciju mapiranja kao jeftinu popravku podataka. Odnosno životni primjer.

Mi implementiramo, kreiramo testove. Radi.

Neću davati kod: postoji mnogo izvora, a dostupni su na GitHubu preko veze na kraju članka. Možete ih natovariti, mučiti do neprepoznatljivosti i mjeriti, kako bi to u vašem slučaju uticalo. Dat ću samo kod za dvije metode šablona koje razlikuju hidrator, koji je trebao biti brz, od hidratatora koji je trebao biti spor.

Logika je sljedeća: metoda šablona prima parove generirane osnovnom logikom parsera. LINQ sloj je parser i osnovna logika hidratatora, koji postavlja zahtjev kontekstu baze podataka i uspoređuje ključeve sa parovima iz parsera (za ove funkcije postoji kod bez LINQ za poređenje). Zatim se parovi prosljeđuju glavnoj metodi hidratacije i vrijednosti parova se postavljaju na odgovarajuća svojstva entiteta.

“Brzo” (prefiks Fast u mjerilima):

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

Kao što vidimo, koristi se statička kolekcija sa svojstvima setera - kompajlirane lambda koje pozivaju entitet za postavljanje. Kreiran sljedećim kodom:

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

Općenito je jasno. Prolazimo kroz svojstva, kreiramo delegate za njih koji pozivaju settere i spremamo ih. Onda zovemo kada je potrebno.

“Sporo” (prefiks Sporo u testovima):

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

Ovdje odmah zaobilazimo svojstva i direktno pozivamo SetValue.

Radi jasnoće i kao referenca, implementirao sam naivnu metodu koja upisuje vrijednosti njihovih korelacijskih parova direktno u polja entiteta. Prefiks – Manual.

Sada uzmimo BenchmarkDotNet i ispitajmo performanse. I odjednom... (spoiler - ovo nije tačan rezultat, detalji su ispod)

Neuspješan članak o ubrzavanju refleksije

Šta vidimo ovdje? Metode koje trijumfalno nose prefiks Fast ispadaju sporije u gotovo svim prolazima od metoda sa prefiksom Slow. To vrijedi i za raspodjelu i za brzinu rada. S druge strane, lijepa i elegantna implementacija mapiranja korištenjem LINQ metoda namijenjenih tome gdje god je to moguće, naprotiv, uvelike smanjuje produktivnost. Razlika je u redu. Trend se ne mijenja s različitim brojem prolaza. Jedina razlika je u skali. Sa LINQ-om je 4 - 200 puta sporiji, ima više smeća na približno istoj skali.

Promjena

Nisam vjerovao svojim očima, ali što je još važnije, naš kolega nije vjerovao ni mojim očima ni mojoj šifri - Dmitry Tikhonov 0x1000000. Nakon što je još jednom provjerio moje rješenje, sjajno je otkrio i ukazao na grešku koju sam propustio zbog niza izmjena u implementaciji, od početne do konačne. Nakon ispravljanja pronađene greške u Moq postavci, svi rezultati su došli na svoje mjesto. Prema rezultatima ponovnog testiranja, glavni trend se ne mijenja - LINQ i dalje više utiče na performanse nego na refleksiju. Međutim, lijepo je što posao sa kompajliranjem izraza nije uzaludan, a rezultat je vidljiv i u dodjeli i vremenu izvršenja. Prvo pokretanje, kada se inicijaliziraju statička polja, prirodno je sporije za “brzi” metod, ali onda se situacija mijenja.

Evo rezultata ponovnog testiranja:

Neuspješan članak o ubrzavanju refleksije

Zaključak: kada koristite refleksiju u preduzeću, nema posebne potrebe za pribjegavanjem trikovima - LINQ će više pojesti produktivnost. Međutim, u metodama sa visokim opterećenjem koje zahtijevaju optimizaciju, možete sačuvati refleksiju u obliku inicijalizatora i delegatskih kompajlera, koji će onda pružiti „brzu“ logiku. Na ovaj način možete održati i fleksibilnost refleksije i brzinu aplikacije.

Benchmark kod je dostupan ovdje. Svako može još jednom provjeriti moje riječi:
HabraReflectionTests

PS: kod u testovima koristi IoC, au benchmark-u koristi eksplicitnu konstrukciju. Činjenica je da sam u konačnoj implementaciji odsjekao sve faktore koji bi mogli utjecati na performanse i učiniti rezultat bučnim.

PPS: Hvala korisniku Dmitrij Tihonov @0x1000000 za otkrivanje moje greške u postavljanju Moq-a, što je uticalo na prva mjerenja. Ako neko od čitalaca ima dovoljno karme, lajkujte ga. Čovjek je stao, čovjek je pročitao, čovjek je još jednom provjerio i ukazao na grešku. Mislim da je ovo vredno poštovanja i saosećanja.

PPPS: hvala pažljivom čitatelju koji je došao do dna stila i dizajna. Ja sam za uniformnost i udobnost. Diplomatizam prezentacije ostavlja mnogo da se poželi, ali sam kritike uzeo u obzir. Tražim projektil.

izvor: www.habr.com

Dodajte komentar