Artigo sen éxito sobre a aceleración da reflexión

Inmediatamente explicarei o título do artigo. O plan orixinal era dar consellos bos e fiables para acelerar o uso da reflexión utilizando un exemplo sinxelo pero realista, pero durante o benchmarking resultou que a reflexión non é tan lenta como pensaba, LINQ é máis lento que nos meus pesadelos. Pero ao final resultou que tamén cometín un erro nas medidas... Os detalles desta historia de vida están baixo o corte e nos comentarios. Dado que o exemplo é bastante común e implementado en principio como se adoita facer nunha empresa, resultou ser unha demostración de vida bastante interesante, como me parece: o impacto na velocidade do tema principal do artigo foi non se nota debido á lóxica externa: Moq, Autofac, EF Core e outros "bandings".

Comecei a traballar baixo a impresión deste artigo: Por que a reflexión é lenta

Como podes ver, o autor suxire usar delegados compilados en lugar de chamar directamente métodos de tipo reflexión como unha boa forma de acelerar moito a aplicación. Hai, por suposto, a emisión de IL, pero gustaríame evitala, xa que esta é a forma máis laboriosa de realizar a tarefa, que está chea de erros.

Tendo en conta que sempre tiven unha opinión similar sobre a rapidez da reflexión, non pretendía especialmente cuestionar as conclusións do autor.

Moitas veces atopo un uso inxenuo da reflexión na empresa. Tómase o tipo. Tómase información sobre a propiedade. Chámase ao método SetValue e todos se alegran. O valor chegou ao campo obxectivo, todos están contentos. Persoas moi intelixentes - maiores e xefes de equipo - escriben as súas extensións para obxectar, baseándose nunha implementación tan inxenua, mapeadores "universais" dun tipo a outro. A esencia adoita ser esta: tomamos todos os campos, tomamos todas as propiedades, iteramos sobre eles: se os nomes dos membros do tipo coinciden, executamos SetValue. De cando en vez collemos excepcións por erros nos que non atopamos algunha propiedade nalgún dos tipos, pero aínda aquí hai unha saída que mellora o rendemento. Proba / atrapa.

Vin a xente reinventar analizadores e mapeadores sen estar completamente armado con información sobre como funcionan as máquinas que os precederon. Vin a xente ocultar as súas inxenuas implementacións detrás de estratexias, de interfaces, de inxeccións, como se iso desculpase a posterior bacanal. Voltei o nariz ante tales constatacións. De feito, non medii a fuga de rendemento real e, se é posible, simplemente cambiei a implementación a outra máis "óptima" se puidese poñerlle man. Polo tanto, as primeiras medicións que se comentan a continuación confundíronme seriamente.

Creo que moitos de vós, lendo a Richter ou outros ideólogos, atopádesvos cunha afirmación completamente xusta de que a reflexión no código é un fenómeno que ten un impacto extremadamente negativo no rendemento da aplicación.

Chamar a reflexión obriga ao CLR a percorrer as asembleas para atopar o que necesita, extraer os seus metadatos, analizalos, etc. Ademais, a reflexión ao percorrer secuencias leva á asignación dunha gran cantidade de memoria. Estamos esgotando memoria, CLR descobre o GC e comezan os frisos. Debería ser notablemente lento, créame. As enormes cantidades de memoria dos servidores de produción modernos ou das máquinas na nube non impiden grandes atrasos de procesamento. De feito, canto máis memoria, máis probabilidades terás de NOTAR como funciona o GC. A reflexión é, en teoría, un trapo vermello extra para el.

Non obstante, todos usamos contedores IoC e mapeadores de datas, cuxo principio de funcionamento tamén se basea na reflexión, pero normalmente non hai dúbidas sobre o seu rendemento. Non, non porque a introdución de dependencias e a abstracción de modelos externos de contexto limitado sexan tan necesarias que teñamos que sacrificar o rendemento en calquera caso. Todo é máis sinxelo: realmente non afecta moito o rendemento.

O caso é que os frameworks máis habituais que se basean na tecnoloxía de reflexión empregan todo tipo de trucos para traballar con ela de forma máis óptima. Normalmente isto é un caché. Normalmente son expresións e delegados compilados a partir da árbore de expresións. O mesmo automapper mantén un dicionario competitivo que combina tipos con funcións que poden converterse un noutro sen chamar á reflexión.

Como se consegue isto? Esencialmente, isto non é diferente da lóxica que usa a propia plataforma para xerar código JIT. Cando se chama un método por primeira vez, compílase (e, si, este proceso non é rápido); nas chamadas posteriores, o control transfírese ao método xa compilado e non haberá baixas significativas de rendemento.

No noso caso, tamén pode usar a compilación JIT e despois usar o comportamento compilado co mesmo rendemento que os seus homólogos AOT. As expresións virán na nosa axuda neste caso.

O principio en cuestión pódese formular brevemente do seguinte xeito:
Debería almacenar na caché o resultado final da reflexión como delegado que contén a función compilada. Tamén ten sentido almacenar na caché todos os obxectos necesarios con información de tipo nos campos do teu tipo, o traballador, que se almacenan fóra dos obxectos.

Hai lóxica nisto. O sentido común dinos que se algo se pode compilar e almacenar en caché, entón debería facelo.

De cara ao futuro, cómpre dicir que a caché ao traballar coa reflexión ten as súas vantaxes, aínda que non utilice o método proposto de compilación de expresións. En realidade, aquí simplemente repito as teses do autor do artigo ao que me remito anteriormente.

Agora sobre o código. Vexamos un exemplo que se basea na miña recente dor que tiven que enfrontarme nunha seria produción dunha entidade de crédito seria. Todas as entidades son ficticias para que ninguén o adiviñe.

Hai algunha esencia. Que haxa Contacto. Hai letras cun corpo normalizado, a partir do cal o analizador e o hidratador crean estes mesmos contactos. Chegou unha carta, lemos, analizámola en pares clave-valor, creamos un contacto e gardámola na base de datos.

É elemental. Digamos que un contacto ten as propiedades Nome completo, Idade e Teléfono de contacto. Estes datos transmítense na carta. A empresa tamén quere soporte para poder engadir rapidamente novas claves para mapear propiedades da entidade en pares no corpo da carta. No caso de que alguén cometese un erro de tipografía no modelo ou se antes do lanzamento é necesario lanzar de xeito urxente o mapeo desde un novo socio, adaptándose ao novo formato. Despois podemos engadir unha nova correlación de mapeo como unha corrección de datos barata. É dicir, un exemplo de vida.

Implementamos, creamos probas. Obras.

Non vou proporcionar o código: hai moitas fontes e están dispoñibles en GitHub a través da ligazón ao final do artigo. Podes cargalos, torturalos máis alá do recoñecemento e medilos, como afectaría no teu caso. Só darei o código de dous métodos de modelo que distinguen o hidratador, que se supón que era rápido, do hidratador, que se supón que era lento.

A lóxica é a seguinte: o método do modelo recibe pares xerados pola lóxica do analizador básico. A capa LINQ é o analizador e a lóxica básica do hidratador, que realiza unha solicitude ao contexto da base de datos e compara as claves con pares do analizador (para estas funcións hai código sen LINQ para comparación). A continuación, os pares pásanse ao método de hidratación principal e os valores dos pares establécense coas propiedades correspondentes da entidade.

"Rápido" (Prefixo Rápido nos puntos de referencia):

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

Como podemos ver, utilízase unha colección estática con propiedades setter: lambdas compilados que chaman á entidade setter. Creado polo seguinte código:

        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 xeral está claro. Percorrimos as propiedades, creamos delegados para eles que chaman setters e gardámolos. Despois chamamos cando sexa necesario.

"Slow" (prefixo Slow nos puntos de referencia):

        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í ignoramos inmediatamente as propiedades e chamamos directamente a SetValue.

Para claridade e como referencia, implementei un método inxenuo que escribe os valores dos seus pares de correlación directamente nos campos da entidade. Prefixo – Manual.

Agora tomemos BenchmarkDotNet e examinemos o rendemento. E de súpeto... (spoiler: este non é o resultado correcto, os detalles están a continuación)

Artigo sen éxito sobre a aceleración da reflexión

Que vemos aquí? Os métodos que levan triunfalmente o prefixo Rápido resultan máis lentos en case todos os pases que os métodos co prefixo Slow. Isto é certo tanto para a distribución como para a velocidade de traballo. Por outra banda, unha fermosa e elegante implementación de mapeo utilizando métodos LINQ destinados a iso sempre que sexa posible, pola contra, reduce moito a produtividade. A diferenza é de orde. A tendencia non cambia con diferentes números de pases. A única diferenza está na escala. Con LINQ é de 4 a 200 veces máis lento, hai máis lixo aproximadamente á mesma escala.

Actualizado

Non cría nos meus ollos, pero o máis importante é que o noso colega non cría nin nos meus ollos nin no meu código. Dmitry Tikhonov 0x1000000. Despois de revisar a miña solución, descubriu e sinalou brillantemente un erro que perdín debido a unha serie de cambios na implementación, de inicial a final. Despois de corrixir o erro atopado na configuración de Moq, todos os resultados quedaron no seu lugar. Segundo os resultados da nova proba, a tendencia principal non cambia - LINQ aínda afecta o rendemento máis que a reflexión. Non obstante, é bo que o traballo coa compilación de Expression non se faga en balde e o resultado sexa visible tanto na asignación como no tempo de execución. O primeiro lanzamento, cando se inician os campos estáticos, é naturalmente máis lento para o método "rápido", pero despois a situación cambia.

Este é o resultado do retest:

Artigo sen éxito sobre a aceleración da reflexión

Conclusión: cando se usa a reflexión nunha empresa, non hai necesidade de recorrer a trucos - LINQ consumirá máis produtividade. Non obstante, nos métodos de alta carga que requiren optimización, pode gardar a reflexión en forma de inicializadores e compiladores delegados, que logo proporcionarán lóxica "rápida". Deste xeito pode manter tanto a flexibilidade de reflexión como a velocidade da aplicación.

O código de referencia está dispoñible aquí. Calquera pode comprobar as miñas palabras:
HabraReflectionTests

PD: o código nas probas usa IoC, e nos benchmarks usa unha construción explícita. O caso é que na implementación final cortei todos os factores que poidan afectar ao rendemento e facer que o resultado sexa ruidoso.

PPS: Grazas ao usuario Dmitry Tikhonov @0x1000000 por descubrir o meu erro ao configurar Moq, que afectou ás primeiras medicións. Se algún dos lectores ten karma suficiente, gústalle. O home parou, o home leu, o home comprobou dúas veces e sinalou o erro. Creo que isto é digno de respecto e simpatía.

PPPS: grazas ao minucioso lector que chegou ao fondo do estilo e do deseño. Estou pola uniformidade e a comodidade. A diplomacia da presentación deixa moito que desexar, pero tiven en conta as críticas. Pido o proxectil.

Fonte: www.habr.com

Engadir un comentario