¿Siente que hay una compensación entre escribir código orientado a objetos "agradable" y escribir código de baja latencia muy rápido? Por ejemplo, ¿evitar funciones virtuales en C ++ / la sobrecarga del polimorfismo, etc., reescribir código que parece desagradable, pero es muy rápido, etc.?
Trabajo en un campo que está un poco más centrado en el rendimiento que en la latencia, pero es muy crítico para el rendimiento y diría "algo así" .
Sin embargo, un problema es que muchas personas tienen sus nociones de rendimiento completamente equivocadas. Los principiantes a menudo se equivocan casi por completo, y todo su modelo conceptual de "costo computacional" necesita una revisión, y solo la complejidad algorítmica es lo único que pueden hacer bien. Los intermedios hacen muchas cosas mal. Los expertos se equivocan en algunas cosas.
Medir con herramientas precisas que pueden proporcionar métricas como errores de caché y predicciones erróneas de sucursales es lo que mantiene a todas las personas de cualquier nivel de experiencia en el campo bajo control.
La medición también es lo que indica qué no optimizar . Los expertos a menudo dedican menos tiempo a la optimización que los novatos, ya que están optimizando puntos de acceso medidos verdaderos y no están tratando de optimizar las puñaladas salvajes en la oscuridad basadas en corazonadas sobre lo que podría ser lento (lo que, en forma extrema, podría tentar a uno a micro-optimizar solo sobre cualquier otra línea en la base de código).
Diseñando para el rendimiento
Con eso aparte, la clave para diseñar para el rendimiento proviene de la parte de diseño , como en el diseño de interfaz. Uno de los problemas con la inexperiencia es que tiende a haber un cambio temprano en las métricas de implementación absoluta, como el costo de una llamada de función indirecta en algún contexto generalizado, como si el costo (que se entiende mejor en un sentido inmediato desde el punto de vista del optimizador es una razón para evitarlo en toda la base de código.
Los costos son relativos . Si bien hay un costo para una llamada de función indirecta, por ejemplo, todos los costos son relativos. Si está pagando ese costo una vez para llamar a una función que recorre millones de elementos, preocuparse por este costo es como pasar horas regateando centavos por la compra de un producto de mil millones de dólares, solo para concluir que no comprar ese producto porque Era un centavo demasiado caro.
Diseño de interfaz más grueso
El aspecto del diseño de la interfaz del rendimiento a menudo busca antes llevar estos costos a un nivel más grueso. En lugar de pagar los costos de abstracción de tiempo de ejecución para una sola partícula, por ejemplo, podríamos llevar ese costo al nivel del sistema / emisor de partículas, convirtiendo efectivamente una partícula en un detalle de implementación y / o simplemente datos sin procesar de esta colección de partículas.
Por lo tanto, el diseño orientado a objetos no tiene que ser incompatible con el diseño para el rendimiento (ya sea latencia o rendimiento), pero puede haber tentaciones en un lenguaje que se enfoca en él para modelar objetos granulares cada vez más pequeños, y allí el último optimizador no puede ayuda. No puede hacer cosas como fusionar una clase que representa un solo punto de una manera que produce una representación eficiente de SoA para los patrones de acceso a la memoria del software. Una colección de puntos con el diseño de interfaz modelado a nivel de grosería ofrece esa oportunidad y permite iterar hacia soluciones cada vez más óptimas según sea necesario. Tal diseño está diseñado para memoria masiva *.
* Tenga en cuenta el enfoque en la memoria aquí y no en los datos , ya que trabajar en áreas críticas para el rendimiento durante mucho tiempo tenderá a cambiar su visión de los tipos de datos y las estructuras de datos y ver cómo se conectan a la memoria. Un árbol de búsqueda binario ya no se trata únicamente de la complejidad logarítmica en casos como fragmentos de memoria posiblemente dispares y poco amigables con la caché para los nodos del árbol a menos que sea ayudado por un asignador fijo. La vista no descarta la complejidad algorítmica, pero ya no la ve independientemente de los diseños de memoria. Uno también comienza a ver iteraciones de trabajo como más sobre iteraciones de acceso a memoria. *
Una gran cantidad de diseños críticos para el rendimiento en realidad pueden ser muy compatibles con la noción de diseños de interfaz de alto nivel que son fáciles de entender y usar para los humanos. La diferencia es que "alto nivel" en este contexto se trataría de la agregación masiva de memoria, una interfaz modelada para colecciones de datos potencialmente grandes y con una implementación oculta que puede ser de nivel bastante bajo. Una analogía visual podría ser un automóvil que es realmente cómodo y fácil de manejar y manejar, y muy seguro a la velocidad del sonido, pero si abres el capó, hay pequeños demonios que escupen fuego dentro.
Con un diseño más grueso también tiende a ser una forma más fácil de proporcionar patrones de bloqueo más eficientes y explotar el paralelismo en el código (el subprocesamiento múltiple es un tema exhaustivo que voy a omitir aquí).
Pool de memoria
Un aspecto crítico de la programación de baja latencia probablemente será un control muy explícito sobre la memoria para mejorar la localidad de referencia, así como solo la velocidad general de asignación y desasignación de memoria. Una memoria de agrupación de asignadores personalizados en realidad se hace eco del mismo tipo de mentalidad de diseño que describimos. Está diseñado para granel ; Está diseñado en un nivel grueso. Preasigna memoria en bloques grandes y agrupa la memoria ya asignada en pequeños fragmentos.
La idea es exactamente la misma de impulsar cosas costosas (asignar una porción de memoria contra un asignador de propósito general, por ejemplo) a un nivel más y más grueso. Un grupo de memoria está diseñado para manejar la memoria en masa .
Sistemas de tipo segregar memoria
Una de las dificultades con el diseño orientado a objetos granular en cualquier lenguaje es que a menudo quiere introducir una gran cantidad de tipos y estructuras de datos pequeños definidos por el usuario. Esos tipos pueden querer asignarse en pequeños fragmentos si se asignan dinámicamente.
Un ejemplo común en C ++ sería para los casos en que se requiere polimorfismo, donde la tentación natural es asignar cada instancia de una subclase contra un asignador de memoria de propósito general.
Esto termina separando los diseños de memoria posiblemente contiguos en pequeños bits y pedazos dispersos en el rango de direccionamiento que se traduce en más fallas de página y errores de caché.
Los campos que exigen la respuesta determinista de menor latencia, sin tartamudeos, son probablemente el único lugar donde los puntos críticos no siempre se reducen a un solo cuello de botella, donde las pequeñas ineficiencias pueden realmente acumularse (algo que mucha gente imagina sucede incorrectamente con un generador de perfiles para mantenerlos bajo control, pero en los campos controlados por la latencia, en realidad puede haber algunos casos raros donde se acumulan pequeñas ineficiencias). Y muchas de las razones más comunes para tal acumulación pueden ser esta: la asignación excesiva de pequeños fragmentos de memoria en todo el lugar.
En lenguajes como Java, puede ser útil usar más matrices de tipos de datos antiguos simples cuando sea posible para áreas con embotellamiento (áreas procesadas en bucles estrechos) como una matriz de int
(pero aún detrás de una interfaz voluminosa de alto nivel) en lugar de, digamos , una ArrayList
de Integer
objetos definidos por el usuario . Esto evita la segregación de memoria que típicamente acompañaría a este último. En C ++, no tenemos que degradar tanto la estructura si nuestros patrones de asignación de memoria son eficientes, ya que los tipos definidos por el usuario pueden asignarse de manera contigua allí e incluso en el contexto de un contenedor genérico.
Fusionar memoria de nuevo juntos
Una solución aquí es alcanzar un asignador personalizado para tipos de datos homogéneos, y posiblemente incluso a través de tipos de datos homogéneos. Cuando pequeños tipos de datos y estructuras de datos se aplanan en bits y bytes en la memoria, adquieren una naturaleza homogénea (aunque con algunos requisitos de alineación variables). Cuando no los miramos desde una mentalidad centrada en la memoria, el sistema tipo de lenguajes de programación "quiere" dividir / segregar regiones de memoria potencialmente contiguas en pequeños fragmentos dispersos.
La pila utiliza este enfoque centrado en la memoria para evitar esto y potencialmente almacenar cualquier posible combinación mixta de instancias de tipo definidas por el usuario dentro de ella. Utilizar la pila más es una gran idea cuando sea posible, ya que la parte superior de la misma casi siempre está en una línea de caché, pero también podemos diseñar asignadores de memoria que imiten algunas de estas características sin un patrón LIFO, fusionando la memoria a través de tipos de datos dispares en forma contigua. fragmentos incluso para patrones de asignación de memoria y desasignación más complejos.
El hardware moderno está diseñado para estar en su apogeo al procesar bloques contiguos de memoria (accediendo repetidamente a la misma línea de caché, la misma página, por ejemplo). La palabra clave allí es contigüidad, ya que esto solo es beneficioso si hay datos de interés en torno. Por lo tanto, gran parte de la clave (pero también la dificultad) del rendimiento es fusionar fragmentos de memoria segregados nuevamente en bloques contiguos a los que se accede en su totalidad (todos los datos circundantes son relevantes) antes del desalojo. El rico sistema de tipos de tipos especialmente definidos por el usuario en lenguajes de programación puede ser el mayor obstáculo aquí, pero siempre podemos alcanzar y resolver el problema a través de un asignador personalizado y / o diseños más voluminosos cuando sea apropiado.
Feo
"Feo" es difícil de decir. Es una métrica subjetiva, y alguien que trabaja en un campo muy crítico para el rendimiento comenzará a cambiar su idea de "belleza" a una que esté mucho más orientada a los datos y se centre en interfaces que procesen cosas en masa.
Peligroso
"Peligroso" podría ser más fácil. En general, el rendimiento tiende a querer alcanzar el código de nivel inferior. Implementar un asignador de memoria, por ejemplo, es imposible sin llegar por debajo de los tipos de datos y trabajar en el nivel peligroso de bits y bytes sin procesar. Como resultado, puede ayudar a aumentar el enfoque en un procedimiento de prueba cuidadoso en estos subsistemas críticos para el rendimiento, escalando la minuciosidad de las pruebas con el nivel de optimizaciones aplicadas.
Belleza
Sin embargo, todo esto estaría en el nivel de detalle de implementación. Tanto en una mentalidad veterana como a gran escala y crítica del rendimiento, la "belleza" tiende a cambiar hacia diseños de interfaz en lugar de detalles de implementación. Se convierte en una prioridad exponencialmente mayor buscar interfaces "hermosas", utilizables, seguras y eficientes en lugar de implementaciones debido a roturas de acoplamiento y cascada que pueden ocurrir ante un cambio en el diseño de la interfaz. Las implementaciones se pueden cambiar en cualquier momento. Por lo general, iteramos hacia el rendimiento según sea necesario y como lo indican las mediciones. La clave con el diseño de la interfaz es modelar a un nivel lo suficientemente grueso como para dejar espacio para tales iteraciones sin romper todo el sistema.
De hecho, sugeriría que el enfoque de un veterano en el desarrollo crítico para el rendimiento a menudo tenderá a centrarse principalmente en la seguridad, las pruebas, la mantenibilidad, solo el discípulo de SE en general, ya que una base de código a gran escala que tiene una serie de resultados Los subsistemas críticos (sistemas de partículas, algoritmos de procesamiento de imágenes, procesamiento de video, retroalimentación de audio, trazadores de rayos, motores de malla, etc.) deberán prestar mucha atención a la ingeniería del software para evitar ahogarse en una pesadilla de mantenimiento. No es una mera coincidencia que a menudo los productos más asombrosamente eficientes que existen también pueden tener la menor cantidad de errores.
TL; DR
De todos modos, esa es mi opinión sobre el tema, que abarca desde las prioridades en campos genuinamente críticos para el rendimiento, lo que puede reducir la latencia y causar que se acumulen pequeñas ineficiencias, y lo que en realidad constituye "belleza" (al mirar las cosas de manera más productiva).