Artículo fallido sobre acelerar la reflexión.

Explicaré inmediatamente el título del artículo. El plan original era dar consejos buenos y confiables sobre cómo acelerar el uso de la reflexión usando un ejemplo simple pero realista, pero durante la evaluación comparativa resultó que la reflexión no es tan lenta como pensaba, LINQ es más lento que en mis pesadillas. Pero al final resultó que también me equivoqué en las medidas... Los detalles de esta historia de vida están debajo del corte y en los comentarios. Dado que el ejemplo es bastante banal y se implementa en principio como se suele hacer en una empresa, resultó ser una demostración de vida bastante interesante, en mi opinión: el impacto en la velocidad del tema principal del artículo fue no se nota debido a la lógica externa: Moq, Autofac, EF Core y otras "bandas".

Empecé a trabajar bajo la impresión de este artículo: ¿Por qué la reflexión es lenta?

Como puede ver, el autor sugiere utilizar delegados compilados en lugar de llamar directamente a métodos de tipo reflexión como una excelente manera de acelerar enormemente la aplicación. Por supuesto, hay emisiones de IL, pero me gustaría evitarlas, ya que esta es la forma más laboriosa de realizar la tarea, que está plagada de errores.

Teniendo en cuenta que siempre he tenido una opinión similar sobre la velocidad de la reflexión, no tenía la intención de cuestionar las conclusiones del autor.

A menudo me encuentro con un uso ingenuo de la reflexión en la empresa. El tipo está tomado. Se toma información sobre la propiedad. Se llama al método SetValue y todos se alegran. El valor ha llegado al campo objetivo y todos están contentos. Personas muy inteligentes (personas mayores y líderes de equipos) escriben sus extensiones en el objeto, basándose en una implementación tan ingenua de mapeadores “universales” de un tipo a otro. La esencia suele ser la siguiente: tomamos todos los campos, tomamos todas las propiedades, las iteramos: si los nombres de los miembros del tipo coinciden, ejecutamos SetValue. De vez en cuando detectamos excepciones debido a errores en los que no encontramos alguna propiedad en uno de los tipos, pero incluso aquí hay una salida que mejora el rendimiento. Trata de atraparlo.

He visto a personas reinventar analizadores y mapeadores sin estar completamente armados con información sobre cómo funcionan las máquinas que los precedieron. He visto a personas esconder sus implementaciones ingenuas detrás de estrategias, detrás de interfaces, detrás de inyecciones, como si esto fuera a excusar la bacanal posterior. Levanté la nariz ante tales descubrimientos. De hecho, no medí la pérdida de rendimiento real y, si era posible, simplemente cambié la implementación a una más "óptima" si podía conseguirla. Por lo tanto, las primeras mediciones que se comentan a continuación me confundieron seriamente.

Creo que muchos de ustedes, al leer a Richter u otros ideólogos, se han topado con una afirmación bastante justa de que la reflexión en el código es un fenómeno que tiene un impacto extremadamente negativo en el rendimiento de la aplicación.

Llamar a la reflexión obliga al CLR a revisar ensamblajes para encontrar el que necesita, extraer sus metadatos, analizarlos, etc. Además, la reflexión al atravesar secuencias conduce a la asignación de una gran cantidad de memoria. Estamos consumiendo memoria, CLR descubre el GC y comienzan los frisos. Debería ser notablemente lento, créanme. Las enormes cantidades de memoria de los servidores de producción modernos o de las máquinas en la nube no evitan grandes retrasos en el procesamiento. De hecho, cuanta más memoria, más probabilidades tendrá de NOTAR cómo funciona el GC. La reflexión es, en teoría, un trapo rojo extra para él.

Sin embargo, todos utilizamos contenedores IoC y mapeadores de fechas, cuyo principio de funcionamiento también se basa en la reflexión, pero normalmente no surgen dudas sobre su rendimiento. No, no porque la introducción de dependencias y la abstracción de modelos de contexto externos limitados sean tan necesarias que tengamos que sacrificar el rendimiento en cualquier caso. Todo es más sencillo: realmente no afecta mucho al rendimiento.

El hecho es que los frameworks más comunes que se basan en la tecnología de reflexión utilizan todo tipo de trucos para trabajar con ella de manera más óptima. Normalmente se trata de un caché. Normalmente se trata de expresiones y delegados compilados a partir del árbol de expresiones. El mismo mapeador automático mantiene un diccionario competitivo que relaciona tipos con funciones que pueden convertir uno en otro sin llamar a la reflexión.

¿Cómo se logra esto? Básicamente, esto no es diferente de la lógica que utiliza la propia plataforma para generar código JIT. Cuando se llama a un método por primera vez, se compila (y sí, este proceso no es rápido); en llamadas posteriores, el control se transfiere al método ya compilado y no habrá reducciones significativas en el rendimiento.

En nuestro caso, también puedes usar la compilación JIT y luego usar el comportamiento compilado con el mismo rendimiento que sus contrapartes AOT. Las expresiones vendrán en nuestra ayuda en este caso.

El principio en cuestión puede formularse brevemente de la siguiente manera:
Debes almacenar en caché el resultado final de la reflexión como un delegado que contiene la función compilada. También tiene sentido almacenar en caché todos los objetos necesarios con información de tipo en los campos de su tipo, el trabajador, que se almacenan fuera de los objetos.

Hay lógica en esto. El sentido común nos dice que si algo se puede compilar y almacenar en caché, entonces se debe hacer.

De cara al futuro, cabe decir que el caché al trabajar con reflexión tiene sus ventajas, incluso si no se utiliza el método propuesto para compilar expresiones. En realidad, aquí no hago más que repetir las tesis del autor del artículo al que me refiero más arriba.

Ahora sobre el código. Veamos un ejemplo que se basa en el dolor reciente que tuve que afrontar en una producción seria de una entidad de crédito seria. Todas las entidades son ficticias para que nadie lo adivine.

Hay algo de esencia. Que haya contacto. Hay letras con un cuerpo estandarizado, a partir de las cuales el analizador y el hidratador crean estos mismos contactos. Llegó una carta, la leímos, la analizamos en pares clave-valor, creamos un contacto y lo guardamos en la base de datos.

Es elemental. Digamos que un contacto tiene las propiedades Nombre completo, Edad y Teléfono de contacto. Estos datos se transmiten en la carta. La empresa también quiere soporte para poder agregar rápidamente nuevas claves para mapear las propiedades de la entidad en pares en el cuerpo de la carta. En caso de que alguien haya cometido un error tipográfico en la plantilla o si antes del lanzamiento es necesario lanzar urgentemente el mapeo desde un nuevo socio, adaptándolo al nuevo formato. Luego podemos agregar una nueva correlación de mapeo como una solución de datos económica. Es decir, un ejemplo de vida.

Implementamos, creamos pruebas. Obras.

No proporcionaré el código: hay muchas fuentes y están disponibles en GitHub a través del enlace al final del artículo. Puedes cargarlos, torturarlos hasta dejarlos irreconocibles y medirlos, como afectaría en tu caso. Sólo daré el código de dos métodos de plantilla que distinguen el hidratador, que se suponía que era rápido, del hidratador, que se suponía que era lento.

La lógica es la siguiente: el método de plantilla recibe pares generados por la lógica básica del analizador. La capa LINQ es el analizador y la lógica básica del hidratador, que realiza una solicitud al contexto de la base de datos y compara claves con pares del analizador (para estas funciones hay código sin LINQ para comparar). A continuación, los pares se pasan al método de hidratación principal y los valores de los pares se establecen en las propiedades correspondientes de la entidad.

“Rápido” (Prefijo Rápido en los 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, se utiliza una colección estática con propiedades de establecimiento: lambdas compiladas que llaman a la entidad de establecimiento. Creado por el siguiente 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 general está claro. Recorremos las propiedades, creamos delegados para ellas que llaman a los configuradores y los guardamos. Luego llamamos cuando sea necesario.

“Lento” (Prefijo Lento en los 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í omitimos inmediatamente las propiedades y llamamos a SetValue directamente.

Para mayor claridad y como referencia, implementé un método ingenuo que escribe los valores de sus pares de correlación directamente en los campos de la entidad. Prefijo – Manual.

Ahora tomemos BenchmarkDotNet y examinemos el rendimiento. Y de repente... (spoiler: este no es el resultado correcto, los detalles se encuentran a continuación)

Artículo fallido sobre acelerar la reflexión.

¿Qué vemos aquí? Los métodos que llevan triunfalmente el prefijo Rápido resultan ser más lentos en casi todas las pasadas que los métodos con el prefijo Lento. Esto es válido tanto para la asignación como para la velocidad del trabajo. Por otro lado, una implementación hermosa y elegante del mapeo utilizando métodos LINQ destinados a esto siempre que sea posible, por el contrario, reduce en gran medida la productividad. La diferencia es de orden. La tendencia no cambia con diferentes números de pases. La única diferencia está en la escala. Con LINQ es 4 - 200 veces más lento, hay más basura aproximadamente en la misma escala.

ACTUALIZADO

No creí lo que veía, pero lo más importante es que nuestro colega no creyó ni lo que veía ni mi código. Dmitri Tijonov 0x1000000. Después de verificar dos veces mi solución, descubrió y señaló brillantemente un error que no había visto debido a una serie de cambios en la implementación, desde el inicial hasta el final. Después de corregir el error encontrado en la configuración de Moq, todos los resultados encajaron. Según los resultados de la nueva prueba, la tendencia principal no cambia: LINQ todavía afecta el rendimiento más que la reflexión. Sin embargo, es bueno que el trabajo con la compilación de expresiones no se haga en vano y que el resultado sea visible tanto en la asignación como en el tiempo de ejecución. El primer lanzamiento, cuando se inicializan los campos estáticos, es naturalmente más lento para el método "rápido", pero luego la situación cambia.

Aquí está el resultado de la nueva prueba:

Artículo fallido sobre acelerar la reflexión.

Conclusión: cuando se utiliza la reflexión en una empresa, no es necesario recurrir a trucos en particular: LINQ consumirá más productividad. Sin embargo, en métodos de alta carga que requieren optimización, puede guardar la reflexión en forma de inicializadores y compiladores delegados, que luego proporcionarán una lógica "rápida". De esta forma podrás mantener tanto la flexibilidad de reflexión como la velocidad de la aplicación.

El código de referencia está disponible aquí. Cualquiera puede comprobar mis palabras:
HabraReflexiónPruebas

PD: el código en las pruebas usa IoC y en los puntos de referencia usa una construcción explícita. El hecho es que en la implementación final eliminé todos los factores que podrían afectar el rendimiento y hacer que el resultado fuera ruidoso.

PPS: Gracias al usuario. Dmitri Tijonov @0x1000000 por descubrir mi error al configurar Moq, que afectó las primeras mediciones. Si alguno de los lectores tiene suficiente karma, dale me gusta. El hombre se detuvo, leyó, volvió a comprobar y señaló el error. Creo que esto es digno de respeto y simpatía.

PPPS: gracias al meticuloso lector que llegó al fondo del estilo y diseño. Estoy a favor de la uniformidad y la conveniencia. La diplomacia de la presentación deja mucho que desear, pero tuve en cuenta las críticas. Pido el proyectil.

Fuente: habr.com

Añadir un comentario