Misslyckad artikel om accelererande reflektion

Jag ska omedelbart förklara rubriken på artikeln. Den ursprungliga planen var att ge bra, pålitliga råd om hur man kan påskynda användningen av reflektion med hjälp av ett enkelt men realistiskt exempel, men under benchmarking visade det sig att reflektion inte är så långsam som jag trodde, LINQ är långsammare än i mina mardrömmar. Men till slut visade det sig att jag också gjorde ett misstag i måtten... Detaljer om denna livshistoria finns under klippet och i kommentarerna. Eftersom exemplet är ganska vanligt och implementerat i princip som vanligtvis görs i ett företag, visade det sig vara en ganska intressant, som det förefaller mig, demonstration av livet: inverkan på hastigheten för artikelns huvudämne var inte märkbar på grund av extern logik: Moq, Autofac, EF Core och andra "bandingar".

Jag började arbeta under intrycket av denna artikel: Varför är Reflektion långsam

Som du kan se föreslår författaren att du använder kompilerade delegater istället för att direkt anropa metoder för reflektionstyp som ett utmärkt sätt att avsevärt snabba upp applikationen. Det finns givetvis IL-utsläpp, men det skulle jag vilja undvika eftersom det är det mest arbetskrävande sättet att utföra uppgiften som är kantad med fel.

Med tanke på att jag alltid har haft en liknande uppfattning om reflektionshastigheten, hade jag inte för avsikt att ifrågasätta författarens slutsatser.

Jag möter ofta naiv användning av reflektion i företaget. Typen är tagen. Information om fastigheten tas. SetValue-metoden kallas och alla gläds. Värdet har anlänt i målfältet, alla är nöjda. Mycket smarta människor - seniorer och teamledare - skriver sina tillägg till objekt, baserat på en sådan naiv implementering "universella" kartläggare av en typ till en annan. Kärnan är vanligtvis denna: vi tar alla fält, tar alla egenskaper, itererar över dem: om namnen på typmedlemmarna matchar, kör vi SetValue. Då och då fångar vi undantag på grund av misstag där vi inte hittat någon egenskap hos någon av typerna, men även här finns det en utväg som förbättrar prestandan. Försök fånga.

Jag har sett människor återuppfinna analyserare och kartläggare utan att vara helt beväpnade med information om hur maskinerna som kom före dem fungerar. Jag har sett människor gömma sina naiva implementeringar bakom strategier, bakom gränssnitt, bakom injektioner, som om detta skulle ursäkta den efterföljande bacchanalia. Jag vände upp näsan åt sådana insikter. Faktum är att jag inte mätte den verkliga prestandaläckan, och om möjligt ändrade jag helt enkelt implementeringen till en mer "optimal" om jag kunde lägga vantarna på den. Därför förvirrade de första mätningarna som diskuteras nedan mig allvarligt.

Jag tror att många av er, som läser Richter eller andra ideologer, har stött på ett helt rättvist uttalande om att reflektion i kod är ett fenomen som har en extremt negativ inverkan på applikationens prestanda.

Att anropa reflektion tvingar CLR att gå igenom sammansättningar för att hitta den de behöver, dra upp deras metadata, analysera dem, etc. Dessutom leder reflektion när man korsar sekvenser till allokering av en stor mängd minne. Vi använder upp minnet, CLR avslöjar GC och friser börjar. Det borde vara märkbart långsamt, tro mig. De enorma mängderna minne på moderna produktionsservrar eller molnmaskiner förhindrar inte höga bearbetningsförseningar. Faktum är att ju mer minne, desto mer sannolikt är det att du MÄRKAR hur GC fungerar. Reflektion är i teorin en extra röd trasa för honom.

Men vi använder alla IoC-behållare och datumkartare, vars funktionsprincip också bygger på reflektion, men det finns vanligtvis inga frågor om deras prestanda. Nej, inte för att införandet av beroenden och abstraktion från externa begränsade kontextmodeller är så nödvändiga att vi i alla fall måste offra prestanda. Allt är enklare - det har verkligen ingen stor inverkan på prestandan.

Faktum är att de vanligaste ramverken som är baserade på reflektionsteknik använder alla möjliga knep för att arbeta med det mer optimalt. Vanligtvis är detta en cache. Vanligtvis är dessa uttryck och delegater kompilerade från uttrycksträdet. Samma automapper har en konkurrenskraftig ordbok som matchar typer med funktioner som kan omvandla en till en annan utan att anropa reflektion.

Hur uppnås detta? I huvudsak skiljer sig detta inte från logiken som plattformen själv använder för att generera JIT-kod. När en metod anropas för första gången kompileras den (och ja, den här processen är inte snabb); vid efterföljande anrop överförs kontrollen till den redan kompilerade metoden, och det kommer inte att bli några betydande prestandaavdrag.

I vårt fall kan du också använda JIT-kompilering och sedan använda det kompilerade beteendet med samma prestanda som dess AOT-motsvarigheter. Uttryck kommer att hjälpa oss i detta fall.

Principen i fråga kan kortfattat formuleras på följande sätt:
Du bör cache det slutliga resultatet av reflektion som en delegat som innehåller den kompilerade funktionen. Det är också vettigt att cachelagra alla nödvändiga objekt med typinformation i fälten för din typ, arbetaren, som lagras utanför objekten.

Det finns logik i detta. Sunt förnuft säger oss att om något kan kompileras och cachelagras, då bör det göras.

Framöver ska det sägas att cachen i arbetet med reflektion har sina fördelar, även om man inte använder den föreslagna metoden för att kompilera uttryck. Egentligen upprepar jag här helt enkelt teserna från författaren till artikeln som jag hänvisar till ovan.

Nu om koden. Låt oss titta på ett exempel som är baserat på min senaste smärta som jag var tvungen att möta i en seriös produktion av ett seriöst kreditinstitut. Alla enheter är fiktiva så att ingen skulle gissa.

Det finns någon essens. Låt det finnas kontakt. Det finns bokstäver med en standardiserad kropp, från vilken parsern och hydratorn skapar samma kontakter. Ett brev kom, vi läste det, analyserade det i nyckel-värdepar, skapade en kontakt och sparade det i databasen.

Det är elementärt. Låt oss säga att en kontakt har egenskaperna Fullständigt namn, Ålder och Kontakttelefon. Dessa uppgifter överförs i brevet. Verksamheten vill också ha stöd för att snabbt kunna lägga till nya nycklar för att kartlägga entitetsegenskaper i par i brevets brödtext. Om någon gjorde ett stavfel i mallen eller om det är nödvändigt att snarast starta kartläggningen från en ny partner före releasen, anpassa sig till det nya formatet. Sedan kan vi lägga till en ny kartläggningskorrelation som en billig datafix. Det vill säga ett livsexempel.

Vi implementerar, skapar tester. Arbetar.

Jag kommer inte att tillhandahålla koden: det finns många källor, och de är tillgängliga på GitHub via länken i slutet av artikeln. Du kan ladda dem, tortera dem till oigenkännlighet och mäta dem, som det skulle påverka i ditt fall. Jag kommer bara att ge koden för två mallmetoder som skiljer hydratorn, som var tänkt att vara snabb, från hydratorn, som var tänkt att vara långsam.

Logiken är som följer: mallmetoden tar emot par som genereras av den grundläggande parserlogiken. LINQ-lagret är parsern och den grundläggande logiken för hydratorn, som gör en begäran till databaskontexten och jämför nycklar med par från parsern (för dessa funktioner finns kod utan LINQ för jämförelse). Därefter skickas paren till huvudhydreringsmetoden och värdena för paren ställs in på motsvarande egenskaper hos enheten.

"Snabb" (prefix Snabb i 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;
        }

Som vi kan se används en statisk samling med setteregenskaper - kompilerade lambdas som anropar setter-entiteten. Skapat av följande kod:

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

I allmänhet är det tydligt. Vi korsar fastigheterna, skapar delegater för dem som ringer sättare och sparar dem. Sedan ringer vi vid behov.

"Långsam" (prefix Långsam i 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;
        }

Här går vi direkt förbi fastigheterna och ringer SetValue direkt.

För tydlighetens skull och som referens implementerade jag en naiv metod som skriver värdena för deras korrelationspar direkt i entitetsfälten. Prefix – Manuell.

Låt oss nu ta BenchmarkDotNet och undersöka prestandan. Och plötsligt... (spoiler - detta är inte rätt resultat, detaljer finns nedan)

Misslyckad artikel om accelererande reflektion

Vad ser vi här? Metoder som triumferande bär snabbprefixet visar sig vara långsammare i nästan alla pass än metoder med långsam prefix. Detta gäller både för tilldelning och arbetshastighet. Å andra sidan, en vacker och elegant implementering av kartläggning med LINQ-metoder avsedda för detta där det är möjligt, tvärtom, minskar produktiviteten avsevärt. Skillnaden är av ordning. Trenden förändras inte med olika antal pass. Den enda skillnaden är skalan. Med LINQ är det 4 - 200 gånger långsammare, det blir mer sopor i ungefär samma skala.

UPPDATERAD

Jag trodde inte mina ögon, men ännu viktigare, vår kollega trodde varken mina ögon eller min kod - Dmitry Tikhonov 0x1000000. Efter att ha dubbelkollat ​​min lösning upptäckte han briljant och påpekade ett fel som jag missade på grund av ett antal ändringar i implementeringen, initialt till slutligt. Efter att ha fixat den hittade buggen i Moq-installationen föll alla resultat på plats. Enligt omtestresultaten förändras inte huvudtrenden - LINQ påverkar fortfarande prestandan mer än reflektion. Det är dock trevligt att arbetet med att sammanställa Expressions inte görs förgäves, och resultatet syns både i allokering och exekveringstid. Den första lanseringen, när statiska fält initieras, är naturligtvis långsammare för den "snabba" metoden, men sedan förändras situationen.

Här är resultatet av omtestet:

Misslyckad artikel om accelererande reflektion

Slutsats: när man använder reflektion i ett företag finns det inget särskilt behov av att ta till knep - LINQ kommer att äta upp produktiviteten mer. Men i högbelastningsmetoder som kräver optimering kan du spara reflektion i form av initierare och delegerade kompilatorer, som sedan ger "snabb" logik. På så sätt kan du behålla både flexibiliteten i reflektionen och applikationens hastighet.

Benchmarkkoden finns här. Vem som helst kan dubbelkolla mina ord:
HabraReflectionTests

PS: koden i testerna använder IoC, och i riktmärkena använder den en explicit konstruktion. Faktum är att jag i den slutliga implementeringen klippte bort alla faktorer som kan påverka prestandan och göra resultatet brusigt.

PPS: Tack till användaren Dmitry Tikhonov @0x1000000 för att jag upptäckte mitt fel när jag satte upp Moq, vilket påverkade de första mätningarna. Om någon av läsarna har tillräckligt med karma, gilla det. Mannen stannade, mannen läste, mannen dubbelkollade och påpekade misstaget. Jag tycker att detta är värt respekt och sympati.

PPPS: tack till den noggranna läsaren som gick till botten med stilen och designen. Jag är för enhetlighet och bekvämlighet. Presentationens diplomati lämnar mycket övrigt att önska, men jag tog hänsyn till kritiken. Jag ber om projektilen.

Källa: will.com

Lägg en kommentar