Mislukt artikel over het versnellen van reflectie

Ik zal meteen de titel van het artikel toelichten. Het oorspronkelijke plan was om aan de hand van een simpel maar realistisch voorbeeld goed en betrouwbaar advies te geven over hoe je het gebruik van reflectie kunt versnellen, maar tijdens het benchmarken bleek dat reflectie niet zo langzaam is als ik dacht, LINQ is langzamer dan in mijn nachtmerries. Maar uiteindelijk bleek dat ik ook een fout had gemaakt bij de metingen... Details van dit levensverhaal staan ​​onder de snede en in de commentaren. Omdat het voorbeeld vrij alledaags is en in principe wordt geïmplementeerd zoals gewoonlijk in een onderneming, bleek het een behoorlijk interessante, naar mijn mening, demonstratie van het leven te zijn: de impact op de snelheid van het hoofdonderwerp van het artikel was niet merkbaar vanwege externe logica: Moq, Autofac, EF Core en andere "bandings".

Ik begon te werken onder de indruk van dit artikel: Waarom is reflectie traag?

Zoals u kunt zien, stelt de auteur voor om gecompileerde afgevaardigden te gebruiken in plaats van rechtstreeks reflectiemethoden aan te roepen, als een geweldige manier om de toepassing aanzienlijk te versnellen. Er is natuurlijk IL-emissie, maar ik zou deze graag willen vermijden, aangezien dit de meest arbeidsintensieve manier is om de taak uit te voeren, die vol zit met fouten.

Aangezien ik altijd een soortgelijke mening heb gehad over de snelheid van reflectie, was het niet bepaald mijn bedoeling om de conclusies van de auteur in twijfel te trekken.

Ik kom vaak naïef gebruik van reflectie tegen in de onderneming. Het type is overgenomen. Er wordt informatie over de woning verzameld. De SetValue-methode wordt aangeroepen en iedereen is blij. De waarde is aangekomen in het doelveld, iedereen is blij. Zeer slimme mensen - senioren en teamleiders - schrijven hun extensies om bezwaar te maken, op basis van zo'n naïeve implementatie van 'universele' mappers van het ene type naar het andere. De essentie is meestal dit: we nemen alle velden, nemen alle eigenschappen, herhalen ze: als de namen van de typeleden overeenkomen, voeren we SetValue uit. Van tijd tot tijd komen we uitzonderingen tegen als gevolg van fouten waarbij we geen eigenschap in een van de typen hebben gevonden, maar zelfs hier is er een uitweg die de prestaties verbetert. Proberen te vangen.

Ik heb gezien dat mensen parsers en mappers opnieuw uitvonden zonder volledig gewapend te zijn met informatie over hoe de machines die hen voorgingen, werkten. Ik heb mensen hun naïeve implementaties zien verbergen achter strategieën, achter interfaces, achter injecties, alsof dit de daaropvolgende bacchanalen zou excuseren. Ik haalde mijn neus op voor zulke realisaties. In feite heb ik het echte prestatielek niet gemeten, en indien mogelijk heb ik de implementatie eenvoudigweg gewijzigd in een meer “optimale” versie als ik die in handen kon krijgen. Daarom brachten de eerste metingen die hieronder worden besproken mij ernstig in verwarring.

Ik denk dat velen van jullie, die Richter of andere ideologen lezen, een volkomen eerlijke uitspraak zijn tegengekomen dat reflectie in code een fenomeen is dat een extreem negatieve invloed heeft op de prestaties van de applicatie.

Het oproepen van reflectie dwingt de CLR om door vergaderingen te gaan om degene te vinden die ze nodig hebben, hun metadata op te halen, te parseren, enz. Bovendien leidt reflectie tijdens het doorlopen van reeksen tot de toewijzing van een grote hoeveelheid geheugen. We gebruiken ons geheugen, CLR ontdekt de WG en de friezen beginnen. Het zou merkbaar langzaam moeten zijn, geloof me. De enorme hoeveelheden geheugen op moderne productieservers of cloudmachines voorkomen geen grote verwerkingsvertragingen. Hoe meer geheugen, hoe groter de kans dat u merkt hoe de GC werkt. Reflectie is voor hem in theorie een extra rode lap.

We gebruiken echter allemaal IoC-containers en datemappers, waarvan het werkingsprincipe ook gebaseerd is op reflectie, maar er zijn meestal geen vragen over hun prestaties. Nee, niet omdat de introductie van afhankelijkheden en abstractie van externe beperkte contextmodellen zo noodzakelijk zijn dat we hoe dan ook prestaties moeten opofferen. Alles is eenvoudiger: het heeft niet veel invloed op de prestaties.

Feit is dat de meest voorkomende raamwerken die gebaseerd zijn op reflectietechnologie allerlei trucjes gebruiken om er optimaal mee te werken. Meestal is dit een cache. Meestal zijn dit expressies en afgevaardigden die zijn samengesteld uit de expressieboom. Dezelfde automapper houdt een competitief woordenboek bij dat typen koppelt aan functies die de ene in de andere kunnen omzetten zonder reflectie op te roepen.

Hoe wordt dit bereikt? In wezen verschilt dit niet van de logica die het platform zelf gebruikt om JIT-code te genereren. Wanneer een methode voor de eerste keer wordt aangeroepen, wordt deze gecompileerd (en ja, dit proces is niet snel); bij volgende oproepen wordt de controle overgedragen aan de reeds gecompileerde methode en zullen er geen significante prestatieverminderingen optreden.

In ons geval kunt u ook JIT-compilatie gebruiken en vervolgens het gecompileerde gedrag gebruiken met dezelfde prestaties als de AOT-tegenhangers. Uitdrukkingen zullen ons in dit geval te hulp komen.

Het betreffende beginsel kan in het kort als volgt worden geformuleerd:
U moet het eindresultaat van de reflectie in de cache opslaan als een gedelegeerde die de gecompileerde functie bevat. Het is ook zinvol om alle benodigde objecten met type-informatie in de cache op te slaan in de velden van uw type, de worker, die buiten de objecten zijn opgeslagen.

Hier zit logica in. Gezond verstand vertelt ons dat als iets kan worden gecompileerd en in de cache kan worden opgeslagen, het ook moet worden gedaan.

Vooruitkijkend moet gezegd worden dat de cache bij het werken met reflectie zijn voordelen heeft, zelfs als je de voorgestelde methode voor het compileren van expressies niet gebruikt. Eigenlijk herhaal ik hier eenvoudigweg de stellingen van de auteur van het artikel waarnaar ik hierboven verwijs.

Nu over de code. Laten we eens kijken naar een voorbeeld dat is gebaseerd op mijn recente pijn waarmee ik te maken kreeg bij een serieuze productie van een serieuze kredietinstelling. Alle entiteiten zijn fictief, zodat niemand het zou raden.

Er is een essentie. Laat er contact zijn. Er zijn brieven met een gestandaardiseerde hoofdtekst, waaruit de parser en hydrator dezelfde contacten creëren. Er kwam een ​​brief binnen, we lazen hem, ontleedden hem in sleutel-waardeparen, maakten een contactpersoon aan en sloegen hem op in de database.

Het is elementair. Laten we zeggen dat een contactpersoon de eigenschappen Volledige naam, Leeftijd en Contacttelefoon heeft. Deze gegevens worden in de brief verzonden. Het bedrijf wil ook ondersteuning om snel nieuwe sleutels toe te voegen voor het in paren indelen van entiteitseigenschappen in de hoofdtekst van de brief. In het geval dat iemand een typefout heeft gemaakt in de sjabloon of als het vóór de release dringend nodig is om mapping van een nieuwe partner te starten, aangepast aan het nieuwe formaat. Dan kunnen we een nieuwe mappingcorrelatie toevoegen als een goedkope datafix. Dat wil zeggen: een levensvoorbeeld.

Wij implementeren, maken tests. Werken.

Ik zal de code niet verstrekken: er zijn veel bronnen, en ze zijn beschikbaar op GitHub via de link aan het einde van het artikel. Je kunt ze laden, onherkenbaar martelen en meten, zoals dat in jouw geval van invloed zou zijn. Ik zal alleen de code geven van twee sjabloonmethoden die onderscheid maken tussen de hydrator, die snel moest zijn, en de hydrator, die langzaam moest zijn.

De logica is als volgt: de sjabloonmethode ontvangt paren die zijn gegenereerd door de basisparserlogica. De LINQ-laag is de parser en de basislogica van de hydrator, die een verzoek doet aan de databasecontext en sleutels vergelijkt met paren uit de parser (voor deze functies is er code zonder LINQ ter vergelijking). Vervolgens worden de paren doorgegeven aan de hoofdhydratatiemethode en worden de waarden van de paren ingesteld op de overeenkomstige eigenschappen van de entiteit.

“Snel” (voorvoegsel Snel in 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;
        }

Zoals we kunnen zien, wordt een statische verzameling met setter-eigenschappen gebruikt: gecompileerde lambda's die de setter-entiteit aanroepen. Gemaakt door de volgende code:

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

Over het algemeen is het duidelijk. We doorkruisen de eigendommen, creëren afgevaardigden voor hen die de instellers bellen en slaan ze op. Dan bellen wij als het nodig is.

“Langzaam” (voorvoegsel Langzaam in 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;
        }

Hier omzeilen we onmiddellijk de eigenschappen en roepen we SetValue rechtstreeks aan.

Voor de duidelijkheid en als referentie heb ik een naïeve methode geïmplementeerd die de waarden van hun correlatieparen rechtstreeks in de entiteitsvelden schrijft. Voorvoegsel – Handmatig.

Laten we nu BenchmarkDotNet nemen en de prestaties onderzoeken. En plotseling... (spoiler - dit is niet het juiste resultaat, details staan ​​hieronder)

Mislukt artikel over het versnellen van reflectie

Wat zien we hier? Methoden die triomfantelijk het voorvoegsel Snel dragen blijken in bijna alle passages langzamer te zijn dan methoden met het voorvoegsel Langzaam. Dit geldt zowel voor de toewijzing als voor de snelheid van werken. Aan de andere kant vermindert een mooie en elegante implementatie van mapping met behulp van LINQ-methoden die hiervoor waar mogelijk zijn bedoeld, integendeel de productiviteit aanzienlijk. Het verschil is van orde. De trend verandert niet met verschillende aantallen passen. Het enige verschil zit in de schaal. Met LINQ is het 4 - 200 keer langzamer, er is meer afval op ongeveer dezelfde schaal.

UPDATE

Ik geloofde mijn ogen niet, maar wat nog belangrijker is, onze collega geloofde mijn ogen en mijn code niet - Dmitri Tichonov 0x1000000. Nadat hij mijn oplossing dubbel had gecontroleerd, ontdekte en wees hij op briljante wijze op een fout die ik had gemist vanwege een aantal wijzigingen in de implementatie, van begin tot eind. Nadat de gevonden bug in de Moq-installatie was opgelost, vielen alle resultaten op hun plaats. Volgens de hertestresultaten verandert de belangrijkste trend niet: LINQ heeft nog steeds meer invloed op de prestaties dan op reflectie. Het is echter fijn dat het werk met Expression-compilatie niet voor niets is gedaan en dat het resultaat zowel qua toewijzing als qua uitvoeringstijd zichtbaar is. De eerste lancering, wanneer statische velden worden geïnitialiseerd, verloopt uiteraard langzamer bij de ‘snelle’ methode, maar daarna verandert de situatie.

Hier is het resultaat van de hertest:

Mislukt artikel over het versnellen van reflectie

Conclusie: bij het gebruik van reflectie in een onderneming is het niet echt nodig om toevlucht te nemen tot trucs - LINQ zal de productiviteit nog meer opslokken. Bij methoden met hoge belasting die optimalisatie vereisen, kunt u reflectie echter opslaan in de vorm van initializers en gedelegeerde compilers, die dan voor “snelle” logica zorgen. Zo behoudt u zowel de flexibiliteit van de reflectie als de snelheid van de toepassing.

De benchmarkcode is hier beschikbaar. Iedereen kan mijn woorden controleren:
HabraReflectieTests

PS: de code in de tests maakt gebruik van IoC, en in de benchmarks wordt gebruik gemaakt van een expliciete constructie. Feit is dat ik bij de uiteindelijke implementatie alle factoren heb uitgeschakeld die de prestaties zouden kunnen beïnvloeden en het resultaat luidruchtig zouden kunnen maken.

PPS: Dank aan de gebruiker Dmitri Tichonov @0x1000000 voor het ontdekken van mijn fout bij het instellen van Moq, die de eerste metingen beïnvloedde. Als een van de lezers voldoende karma heeft, like dit dan alstublieft. De man stopte, de man las, de man controleerde het nogmaals en wees op de fout. Ik denk dat dit respect en sympathie verdient.

PPPS: dankzij de nauwgezette lezer die de stijl en het ontwerp tot op de bodem heeft uitgezocht. Ik ben voor uniformiteit en gemak. De diplomatie van de presentatie laat veel te wensen over, maar ik heb rekening gehouden met de kritiek. Ik vraag om het projectiel.

Bron: www.habr.com

Voeg een reactie