Article sense èxit sobre accelerar la reflexió

De seguida explicaré el títol de l'article. El pla original era donar consells bons i fiables sobre com accelerar l'ús de la reflexió utilitzant un exemple senzill però realista, però durant l'avaluació comparativa va resultar que la reflexió no és tan lenta com pensava, LINQ és més lent que en els meus malsons. Però al final va resultar que també em vaig equivocar en les mides... Els detalls d'aquesta història de vida estan sota el tall i als comentaris. Com que l'exemple és bastant habitual i s'implementa en principi com es fa habitualment en una empresa, va resultar ser una demostració de vida força interessant, segons em sembla: l'impacte en la velocitat del tema principal de l'article va ser no es nota a causa de la lògica externa: Moq, Autofac, EF Core i altres "bandings".

Vaig començar a treballar sota la impressió d'aquest article: Per què la reflexió és lenta

Com podeu veure, l'autor suggereix utilitzar delegats compilats en comptes de cridar directament mètodes de tipus reflex com una bona manera d'accelerar molt l'aplicació. Hi ha, per descomptat, emissió d'IL, però m'agradaria evitar-la, ja que aquesta és la manera més intensiva de mà d'obra per dur a terme la tasca, que està plena d'errors.

Tenint en compte que sempre he tingut una opinió semblant sobre la velocitat de reflexió, no tenia la intenció particular de qüestionar les conclusions de l'autor.

Sovint em trobo amb un ús ingenu de la reflexió a l'empresa. Es pren el tipus. Es pren informació sobre la propietat. Es crida al mètode SetValue i tothom s'alegra. El valor ha arribat al camp objectiu, tothom està content. Persones molt intel·ligents (persones sèniors i líders d'equip) escriuen les seves extensions per objectar, basant-se en una implementació tan ingenua, mapeadors "universals" d'un tipus a un altre. L'essència sol ser aquesta: prenem tots els camps, agafem totes les propietats, i iterem sobre ells: si els noms dels membres del tipus coincideixen, executem SetValue. De tant en tant detectem excepcions per errors on no hem trobat alguna propietat en algun dels tipus, però fins i tot aquí hi ha una sortida que millora el rendiment. Prova/atrapa.

He vist gent reinventar analitzadors i mapeadors sense estar completament armat amb informació sobre com funcionen les màquines que els van precedir. He vist gent amagar les seves implementacions ingènues darrere d'estratègies, darrere d'interfícies, darrere d'injeccions, com si això excusés la posterior bacanal. Vaig aixecar el nas davant aquestes constatacions. De fet, no vaig mesurar la fuita de rendiment real i, si era possible, simplement vaig canviar la implementació per una de més "òptima" si podia posar-hi les mans. Per tant, les primeres mesures que es comenten a continuació em van confondre seriosament.

Crec que molts de vosaltres, llegint Richter o altres ideòlegs, us heu trobat amb una afirmació completament justa que la reflexió en el codi és un fenomen que té un impacte extremadament negatiu en el rendiment de l'aplicació.

Cridar a la reflexió obliga el CLR a passar per assemblatges per trobar el que necessiten, treure les seves metadades, analitzar-les, etc. A més, la reflexió mentre es recorren seqüències condueix a l'assignació d'una gran quantitat de memòria. Estem esgotant la memòria, CLR descobreix el GC i comencen els frisos. Hauria de ser notablement lent, creieu-me. Les grans quantitats de memòria als servidors de producció moderns o a les màquines al núvol no impedeixen retards elevats en el processament. De fet, com més memòria, més probabilitats hi haurà de notar com funciona el GC. La reflexió és, en teoria, un drap vermell addicional per a ell.

Tot i això, tots fem servir contenidors IoC i mapeadors de dates, el principi de funcionament dels quals també es basa en la reflexió, però normalment no hi ha dubtes sobre el seu rendiment. No, no perquè la introducció de dependències i l'abstracció dels models externs de context limitat siguin tan necessàries que hem de sacrificar el rendiment en qualsevol cas. Tot és més senzill: realment no afecta gaire el rendiment.

El cas és que els frameworks més habituals que es basen en la tecnologia de reflexió utilitzen tota mena de trucs per treballar-hi de manera més òptima. Normalment això és una memòria cau. Normalment es tracta d'expressions i delegats compilats a partir de l'arbre d'expressions. El mateix mapeador automàtic manté un diccionari competitiu que combina tipus amb funcions que es poden convertir una en una altra sense cridar a la reflexió.

Com s'aconsegueix això? Essencialment, això no és diferent de la lògica que utilitza la mateixa plataforma per generar codi JIT. Quan es crida un mètode per primera vegada, es compila (i, sí, aquest procés no és ràpid); a les trucades posteriors, el control es transfereix al mètode ja compilat i no hi haurà inconvenients significatius de rendiment.

En el nostre cas, també podeu utilitzar la compilació JIT i després utilitzar el comportament compilat amb el mateix rendiment que els seus homòlegs AOT. En aquest cas, ens ajudaran expressions.

El principi en qüestió es pot formular breument de la següent manera:
Hauríeu d'emmagatzemar a la memòria cau el resultat final de la reflexió com a delegat que conté la funció compilada. També té sentit guardar a la memòria cau tots els objectes necessaris amb informació de tipus als camps del vostre tipus, el treballador, que s'emmagatzemen fora dels objectes.

Hi ha lògica en això. El sentit comú ens diu que si alguna cosa es pot compilar i emmagatzemar a la memòria cau, s'hauria de fer.

De cara al futur, cal dir que la memòria cau en treballar amb reflexió té els seus avantatges, encara que no utilitzeu el mètode proposat per compilar expressions. De fet, aquí em limito a repetir les tesis de l'autor de l'article al qual em refereixo més amunt.

Ara sobre el codi. Vegem un exemple que es basa en el meu dolor recent que vaig haver d'enfrontar en una producció seriosa d'una entitat de crèdit seriosa. Totes les entitats són fictícies perquè ningú ho endevini.

Hi ha alguna essència. Que hi hagi Contacte. Hi ha lletres amb un cos estandarditzat, a partir de les quals l'analitzador i l'hidratador creen aquests mateixos contactes. Va arribar una carta, la vam llegir, la vam analitzar en parelles clau-valor, vam crear un contacte i la vam desar a la base de dades.

És elemental. Suposem que un contacte té les propietats Nom complet, Edat i Telèfon de contacte. Aquestes dades es transmeten a la carta. L'empresa també vol assistència per poder afegir ràpidament noves claus per mapejar les propietats de l'entitat en parells al cos de la carta. En cas que algú hagi fet una errada ortogràfica a la plantilla o si abans del llançament cal llançar urgentment el mapping des d'un nou soci, adaptant-se al nou format. A continuació, podem afegir una nova correlació de mapeig com a correcció de dades barata. És a dir, un exemple de vida.

Implementem, creem proves. Obres.

No proporcionaré el codi: hi ha moltes fonts i estan disponibles a GitHub mitjançant l'enllaç al final de l'article. Podeu carregar-los, torturar-los més enllà del reconeixement i mesurar-los, com afectaria en el vostre cas. Només donaré el codi de dos mètodes de plantilla que distingeixen l'hidratador, que se suposava que havia de ser ràpid, de l'hidratador, que se suposava que era lent.

La lògica és la següent: el mètode de plantilla rep parells generats per la lògica de l'analitzador bàsic. La capa LINQ és l'analitzador i la lògica bàsica de l'hidratador, que fa una sol·licitud al context de la base de dades i compara claus amb parells de l'analitzador (per a aquestes funcions hi ha codi sense LINQ per a la comparació). A continuació, els parells es passen al mètode d'hidratació principal i els valors dels parells s'estableixen a les propietats corresponents de l'entitat.

"Ràpid" (prefix Fast en els punts de referència):

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

Com podem veure, s'utilitza una col·lecció estàtica amb propietats setter: lambdas compilades que criden a l'entitat setter. Creat pel codi següent:

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

En general està clar. Travessem les propietats, creem delegats per a ells que criden a configuradors i els desem. Aleshores truquem quan cal.

"Slow" (prefix Slow en els punts de referència):

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

Aquí obviem immediatament les propietats i cridem directament a SetValue.

Per a més claredat i com a referència, vaig implementar un mètode ingenu que escriu els valors dels seus parells de correlació directament als camps de l'entitat. Prefix – Manual.

Ara prenem BenchmarkDotNet i examinem el rendiment. I de sobte... (spoiler: aquest no és el resultat correcte, els detalls es troben a continuació)

Article sense èxit sobre accelerar la reflexió

Què veiem aquí? Els mètodes que porten triomfant el prefix Ràpid resulten ser més lents en gairebé totes les passades que els mètodes amb el prefix Slow. Això és cert tant per a l'assignació com per a la velocitat de treball. D'altra banda, una bella i elegant implementació de mapeig utilitzant mètodes LINQ destinats a això sempre que sigui possible, per contra, redueix molt la productivitat. La diferència és d'ordre. La tendència no canvia amb diferents nombres de passades. L'única diferència és d'escala. Amb LINQ és de 4 a 200 vegades més lent, hi ha més escombraries aproximadament a la mateixa escala.

ACTUALITZAT

No em vaig creure els meus ulls, però el que és més important, el nostre company no es va creure ni els meus ulls ni el meu codi - Dmitry Tikhonov 0x1000000. Després d'haver revisat la meva solució, va descobrir i va assenyalar de manera brillant un error que vaig perdre a causa d'una sèrie de canvis en la implementació, de l'inici al final. Després de corregir l'error trobat a la configuració de Moq, tots els resultats van quedar al seu lloc. Segons els resultats de la nova prova, la tendència principal no canvia: LINQ encara afecta el rendiment més que la reflexió. Tanmateix, és bo que el treball amb la compilació d'Expressions no es faci en va, i el resultat és visible tant en l'assignació com en el temps d'execució. El primer llançament, quan s'inicien els camps estàtics, és naturalment més lent per al mètode "ràpid", però aleshores la situació canvia.

Aquest és el resultat de la nova prova:

Article sense èxit sobre accelerar la reflexió

Conclusió: quan s'utilitza la reflexió en una empresa, no hi ha necessitat particular de recórrer a trucs: LINQ consumirà més productivitat. Tanmateix, en mètodes d'alta càrrega que requereixen optimització, podeu desar la reflexió en forma d'inicialitzadors i compiladors delegats, que proporcionaran una lògica "ràpida". D'aquesta manera es pot mantenir tant la flexibilitat de reflexió com la velocitat de l'aplicació.

El codi de referència està disponible aquí. Qualsevol pot comprovar les meves paraules:
HabraReflectionTests

PD: el codi de les proves utilitza IoC, i en els benchmarks utilitza una construcció explícita. El cas és que en la implementació final vaig tallar tots els factors que podrien afectar el rendiment i fer que el resultat sigui sorollós.

PPS: Gràcies a l'usuari Dmitry Tikhonov @0x1000000 per descobrir el meu error en configurar Moq, que va afectar les primeres mesures. Si algun dels lectors té prou karma, si us plau, m'agrada. L'home es va aturar, l'home va llegir, l'home va comprovar i va assenyalar l'error. Crec que això és digne de respecte i simpatia.

PPPS: gràcies al lector meticulós que va arribar al fons de l'estil i el disseny. Estic per la uniformitat i la comoditat. La diplomàcia de la presentació deixa molt a desitjar, però he tingut en compte les crítiques. Demano el projectil.

Font: www.habr.com

Afegeix comentari