Los enfoques contra la base del código se vuelven uniformemente lentos

11

Estamos trabajando en una base de código C ++ de tamaño moderado (10Mloc) que a través de nuestros esfuerzos de optimización se está volviendo uniformemente lenta .

Esta base de código es un conjunto de bibliotecas que combinamos para ponerlas a trabajar. Cuando se desarrolló el marco general de cómo se comunican estas bibliotecas, hubo un cierto énfasis en el rendimiento y más tarde, cuando se agregaron más partes, el marco general no cambió mucho. La optimización se realizó cuando fue necesario y a medida que nuestro hardware evolucionó. Esto hizo que la decisión temprana y costosa fuera evidente solo mucho más tarde. Ahora estamos en un punto donde las optimizaciones adicionales son mucho más caras ya que requerirían reescribir grandes partes de la base del código. Nos encontramos acercándonos a un mínimo local indeseable ya que sabemos que, en principio, el código debería poder ejecutarse mucho más rápido.

¿Existen metodologías exitosas que ayuden a decidir qué turnos para hacerse cargo de la evolución de una base de código hacia una solución global de rendimiento óptimo que no se confunde fácilmente con las oportunidades de optimización fácil?

EDITAR

Para responder a la pregunta de cómo nos perfilamos actualmente:

Realmente solo tenemos 2 escenarios diferentes de cómo se puede usar este código, ambos vergonzosamente paralelos. La creación de perfiles se realiza tanto con el tiempo promedio del reloj de pared sobre una gran muestra de entradas como con ejecuciones más detalladas (costos de instrucción, predicciones erróneas de rama y problemas de almacenamiento en caché). Esto funciona bien ya que corremos exclusivamente en nuestras máquinas extremadamente homogéneas (un grupo de miles de máquinas idénticas). Dado que generalmente mantenemos ocupadas todas nuestras máquinas la mayor parte del tiempo funcionando más rápido, podemos ver cosas nuevas adicionales. El problema es, por supuesto, que cuando aparezcan nuevas variaciones de entrada, podrían recibir una penalización tardía, ya que eliminamos las microeficiencias más obvias para los otros casos de uso, lo que posiblemente reduzca el número de escenarios de "ejecución óptima".

Benjamin Bannier
fuente
10
10Mloc es en realidad un gran proyecto
B 23овић
1
Son 10 millones de loc (prefijo SI) contados por sloc. Lo llamé "de tamaño moderado" porque no tengo idea de lo que se considera "grande" aquí.
Benjamin Bannier el
55
es bastante seguro que 10 millones son al menos grandes en todas partes y probablemente enormes en la mayoría de los lugares.
Ryathal
1
Impresionante, gracias @honk Por 10M LOC parece que estás optimizando a un nivel muy bajo, casi a nivel de hardware. La OOP tradicional ("matriz de estructuras" AOS) es terriblemente ineficiente en cachés, ¿ha intentado reorganizar sus clases para que sean SOA (estructura de matrices) para que los puntos de datos en los que está trabajando su código sean coherentes en la memoria? Con esa cantidad de máquinas, ¿te estás quedando con bloqueos de comunicación o sincronización que consume tiempo? Pregunta final, ¿se trata de grandes volúmenes de transmisión de datos o es principalmente un problema de operaciones complejas en sus conjuntos de datos?
Patrick Hughes
1
Cuando tienes tanto código, las posibilidades van desde excelente hasta apostar a tu vida de que hay grandes aceleraciones potenciales del tipo no local que mencioné. No hay diferencia si hay miles de hilos / procesos. Algunas pausas aleatorias los señalarán por ti o probarán que estoy equivocado.
Mike Dunlavey

Respuestas:

9

No conozco un enfoque de propósito general para este problema, pero dos enfoques algo relacionados me funcionaron bien en el pasado: por falta de mejores términos, los llamé agrupamiento y optimización horizontal .

El enfoque de agrupamiento es un intento de reemplazar un gran número de operaciones cortas y rápidas con una operación única, altamente lenta y altamente especializada que finalmente produce el mismo resultado.

Ejemplo: Después de perfilar una operación particularmente lenta de nuestro editor de reglas visuales, no descubrimos ninguna "fruta baja": no hubo una sola operación que tomara más del 2% del tiempo de ejecución, pero la operación en su conjunto se sintió lenta. Sin embargo, descubrimos que el editor estaba enviando una gran cantidad de pequeñas solicitudes al servidor. Aunque el editor estaba procesando rápidamente las respuestas individuales, el número de interacciones de solicitud / respuesta tuvo un efecto multiplicador, por lo que el tiempo total que tomó la operación fue de varios segundos. Después de catalogar cuidadosamente las interacciones del editor durante esa operación de larga duración, agregamos un nuevo comando a la interfaz del servidor. El comando adicional era más especializado, ya que aceptaba los datos necesarios para realizar un subconjunto de operaciones cortas, exploró las dependencias de datos para determinar el conjunto final de datos a devolver, y proporcionó una respuesta que contenía la información necesaria para completar todas las pequeñas operaciones individuales en un solo viaje al servidor. Esto no redujo el tiempo de procesamiento en nuestro código, pero redujo una cantidad muy significativa de latencia debido a la eliminación de múltiples costosos viajes de ida y vuelta cliente-servidor.

La optimización horizontal es una técnica relacionada cuando elimina la "lentitud" que se distribuye de manera delgada entre múltiples componentes de su sistema utilizando una característica particular de su entorno de ejecución.

Ejemplo: Después de perfilar una operación de larga duración, descubrimos que realizamos muchas llamadas a través del límite del dominio de la aplicación (esto es muy específico de .NET). No pudimos eliminar ninguna de las llamadas, y no pudimos agruparlas: venían en diferentes momentos de secciones muy diferentes de nuestro sistema, y ​​las cosas que solicitaron dependían de los resultados devueltos de solicitudes anteriores. Cada llamada requería la serialización y deserialización de una cantidad relativamente pequeña de datos. Nuevamente, las llamadas individuales fueron de corta duración, pero muy grandes en número. Terminamos diseñando un esquema que evitaba la serialización casi por completo, reemplazándolo con pasar un puntero a través del límite del dominio de la aplicación. Esta fue una gran victoria, porque muchas solicitudes de clases completamente no relacionadas se volvieron instantáneamente mucho más rápidas como resultado de aplicar una solasolución horizontal

dasblinkenlight
fuente
Gracias por compartir su experiencia, estas son optimizaciones útiles para tener en cuenta. Además, dado que llevan las partes problemáticas a un lugar distinto, será mucho mejor controlarlas en el futuro. En cierto sentido, pusieron en su lugar lo que debería haber sucedido en primer lugar, ahora solo por datos duros.
Benjamin Bannier el
3

Esto hizo que la decisión temprana y costosa fuera evidente solo mucho más tarde. Ahora estamos en un punto donde las optimizaciones adicionales son mucho más caras, ya que requerirían reescribir grandes partes de la base del código.

Cuando comienzas esta reescritura tienes que hacer varias cosas de manera diferente.

Primero. Y lo más importante. Deja de "optimizar". La "optimización" no importa mucho. Como has visto, solo importa la reescritura al por mayor.

Por lo tanto.

Segundo. Comprenda las implicaciones de cada estructura de datos y elección de algoritmo.

Tercero. Haga que la elección real de la estructura de datos y el algoritmo sea una cuestión de "vinculación tardía". Diseñe interfaces que puedan tener cualquiera de varias implementaciones utilizadas detrás de la interfaz.

Lo que está haciendo ahora (reescribir) debería ser mucho, mucho menos doloroso si tiene un conjunto de interfaces definidas que le permite realizar un cambio general en la estructura de datos o el algoritmo.

S.Lott
fuente
1
Gracias por tu respuesta. Si bien todavía tenemos que optimizar (que se ubica en 1. y 2. para mí), realmente me gusta el pensamiento organizado detrás de 3. Al hacer que la estructura de datos, el algoritmo y el acceso se definan tarde y explícitamente, uno debería ser capaz de manejar muchos problemas a los que nos enfrentamos. Gracias por ponerlo en un lenguaje coherente.
Benjamin Bannier el
En realidad no necesitas optimizar. Una vez que tenga la estructura de datos correcta, se demostrará que la optimización es una pérdida de esfuerzo. La creación de perfiles mostrará dónde tiene una estructura de datos incorrecta y un algoritmo incorrecto. Perder el tiempo con la diferencia de rendimiento entre ++y +=1será irrelevante y casi inconmensurable. Es lo que debes durar .
S.Lott
1
No todos los algoritmos malos se pueden encontrar por puro razonamiento. De vez en cuando uno necesita sentarse y hacer un perfil. Esta es la única forma de averiguar si la suposición inicial fue correcta. Esa es la única forma de estimar el costo real (BigO + const).
Benjamin Bannier el
La creación de perfiles revelará malos algoritmos. Totalmente correcto Eso todavía no es "optimización". Eso sigue siendo la corrección de un defecto de diseño fundamental al hacer un cambio de diseño. La optimización (ajustes, ajustes, etc.) rara vez será visible para la creación de perfiles.
S.Lott
3

Un buen truco práctico es utilizar su conjunto de pruebas unitarias como conjunto de pruebas de rendimiento .

El siguiente enfoque funcionó bien en mis bases de código:

  1. Asegúrese de que su cobertura de prueba de unidad sea buena (ya lo hizo, ¿verdad?)
  2. Asegúrese de que su marco de ejecución de prueba informe el tiempo de ejecución en cada prueba individual . Esto es importante porque desea encontrar dónde está ocurriendo un rendimiento lento.
  3. Si una prueba se ejecuta lentamente, úsela como una forma de sumergirse y optimizar el objetivo en esta área . La optimización antes de identificar un problema de rendimiento puede considerarse prematura, por lo que lo mejor de este enfoque es que primero obtiene evidencia concreta de un rendimiento deficiente. Si es necesario, divida la prueba en pruebas más pequeñas que comparen diferentes aspectos para que pueda identificar dónde está el problema raíz.
  4. Si una prueba se ejecuta extremadamente rápido, eso suele ser bueno, aunque puede considerar ejecutar la prueba en un bucle con diferentes parámetros. Esto lo convierte en una mejor prueba de rendimiento y también aumenta su cobertura de prueba del espacio de parámetros.
  5. Escriba algunas pruebas adicionales que se dirijan específicamente al rendimiento, por ejemplo, tiempos de transacción de extremo a extremo o tiempo para completar 1,000 aplicaciones de reglas. Si tiene requisitos de rendimiento no funcionales específicos (p. Ej., <300 ms de tiempo de respuesta), haga que la prueba falle si toma demasiado tiempo.

Si sigue haciendo todo esto, con el tiempo el rendimiento promedio de su base de código debería mejorar orgánicamente.

También sería posible rastrear tiempos de prueba históricos y dibujar gráficos de rendimiento y regresiones puntuales a lo largo del tiempo en el rendimiento promedio. Nunca me molesté en esto principalmente porque es un poco complicado asegurarse de que comparas cosas similares a medida que cambias y agregas nuevas pruebas, pero podría ser un ejercicio interesante si el rendimiento es lo suficientemente importante para ti.

mikera
fuente
me gusta la técnica - inteligente - sin embargo, esta es una micro optimización y mejorará a un mínimo local - no resolverá problemas arquitectónicos que le permitan alcanzar los mínimos globales
jasonk
@jasonk: tienes toda la razón. Aunque me gustaría añadir que a veces le puede dar la evidencia que necesita para demostrar por qué un cambio de arquitectura particular, se justifica .....
mikera
1

La respuesta de @dasblinkenlight señala un problema muy común, especialmente con bases de código grandes (en mi experiencia). Puede haber serios problemas de rendimiento, pero no están localizados . Si ejecuta un generador de perfiles, no hay rutinas que tomen suficiente tiempo, por sí mismas, para llamar la atención. (Suponiendo que observe el porcentaje de tiempo inclusivo que incluye callees. Ni siquiera se moleste con el "tiempo propio").

De hecho, en ese caso, el problema real no se encontró mediante la elaboración de perfiles, sino por una visión afortunada.

Tengo un estudio de caso, para el cual hay una breve presentación de diapositivas en PDF , que ilustra este problema en detalle y cómo manejarlo. El punto básico es que, dado que el código es mucho más lento de lo que podría ser, eso significa (por definición) que se dedica el tiempo sobrante a hacer algo que podría eliminarse.

Si mirara algunas muestras de tiempo aleatorio del estado del programa, simplemente lo vería haciendo la actividad extraíble, debido al porcentaje de tiempo que lleva. Es muy posible que la actividad extraíble no se limite a una función, o incluso a muchas funciones. No se localiza de esa manera.

No es un "punto caliente".

Es una descripción que haces de lo que ves, que resulta ser cierto un gran porcentaje de tiempo. Eso lo hace fácil de descubrir, pero si es fácil de arreglar depende de la cantidad de reescritura que requiera.

(A menudo se hace una crítica de este enfoque, que el número de muestras es demasiado pequeño para la validez estadística. Eso se responde en la diapositiva 13 del PDF. Brevemente: sí, existe una gran incertidumbre en la "medición" de los ahorros potenciales, pero 1) el valor esperado de ese ahorro potencial no se ve afectado esencialmente, y 2) cuando el ahorro potencial $ x $ se traduce en una tasa de aceleración de $ 1 / (1-x) $, se sesga fuertemente hacia factores altos (beneficiosos).

Mike Dunlavey
fuente
Gracias por tu respuesta. No creemos en el muestreo estadístico y utilizamos instrumentación con valgrind. Esto nos da buenas estimaciones del costo "propio" e "inclusivo" de la mayoría de las cosas que hacemos.
Benjamin Bannier
@honk: Correcto. Pero, lamentablemente, la instrumentación aún conlleva la idea de que los problemas de rendimiento están localizados y, por lo tanto, se pueden encontrar midiendo la fracción del tiempo dedicado a las rutinas, etc. Sin duda, puede ejecutar valgrind, etc. .
Mike Dunlavey