Optimiza, pero cuidado con los microbenchmarks

gene amdhal

Siempre me ha parecido una parte muy entretenida de desarrollar software. Optimizar una aplicación, ya sea en cuanto a velocidad de ejecución o consumo de memoria, suele ser divertido y gratificante. Es como un juego en el que siempre puedes intentar exprimir un poco más el sistema para intentar mejorar, y eso lo hace divertido. Además, todo el mundo sabe que performance is a feature, así que invertir algo de tiempo en esto puede resultar rentable. Sin embargo, muchas veces a la hora de optimizar ponemos el foco en cosas que no tienen mucha importancia y dejamos pasar las oportunidades realmente interesantes de optimización.

Olvídate de microbenchmarks

No sé por qué, pero a la gente le encanta medir el rendimiento de cosas muy pequeñas, que la mayoría de las veces no influyen casi nada en el rendimiento real de una aplicación.

Cosas como si es más rápido usar !!string_value o Boolean(string_value) en Javascript, lo que tarda un contenedor IoC en instanciar un componente, o la diferencia entre hacer una llamada directa o a través de un interface, quedan muy bien para hacer un microbenchmark, pero en general hablamos de cosas tan rápidas que da más o menos igual lo que tarden.

Por ponerlo en contexto, en el PC en el que estoy escribiendo esto, puedo convertir en un segundo 1.200.000.000 de strings en booleanos usando !! y sólo 43.000.000 usando Boolean(). Eso es menos de 25ns por operación en el caso peor. Hay muy pocas aplicaciones para las que eso sea crítico.

El hecho es que es frecuente encontrase en redes sociales, hackernews, reddits y similares, información con «consejos» sobre la gran mejora que podemos conseguir aplicando estas microoptimizaciones, y es fácil caer en la idea de que para hacer bien las cosas hay que aplicarlas.

Que Nick Craver intente ahorrarse una reserva de memoria en StackOverflow, y Ayende se cree su propio gestor de memoria para RavenDB, puede ser muy entretenido como curiosidad, pero en la mayoría de escenarios no merece la pena dedicar mucho tiempo a ello, y mucho menos comprometer la legibilidad del código para obtener esas mejoras.

Por supuesto, si te dedicas a desarrollar máquinas virtuales, bases de datos, o tienes escenarios con un volumen de tráfico brutal, este tipo de optimizaciones pueden ser la diferencia entre que el sistema sea viable o no, pero pero reconozcamos que no son los escenarios más habituales.

Un problema adicional de este tipo de microbenchmarks es que suele tratarse de benchmarks sintéticos, por lo que no tienen en cuenta el efecto de otras partes del sistema sobre el código ejecutado, por ejemplo, recolectores de basura, memoria disponible, número de hebras activas, etc. Una implementación que sobre el papel puede parecer muy atractiva, llevada al mundo real puede tener un rendimiento deficiente por la carga de memoria que introduce al sistema o el número de recursos de otro tipo que consume.

La Ley de Amdahl

Gene Amdahl fue un señor que se dedicó, entre otras cosas, a diseñar ordenadores allá por los años 50. Como en aquella época no existía Javascript y no necesitaba aprenderse la librería disruptiva de cada semana, se ve que le dio por pensar en cosas académicas de esas inútiles y enunció una ley para medir la mejora teórica que se podía obtener en un sistema cuando se mejoraba alguna de sus partes. Se conoce, claro, como Ley de Amdhal y se resumen en esta fórmula:

amdhal

Donde:

Slatency(s) es la ganancia teórica de rendimiento del sistema global.
s es la ganancia de rendimiento en la parte mejorada.
p es el proporción de tiempo invertida en la parte mejorada.

Por verlo con un ejemplo, eso quiere decir que si conseguimos que una parte del sistema vaya 4 veces más rápido (s = 4), y esa parte consume el 10% del tiempo (p = 0.1), la ganancia teórica del sistema completo es de 1.08, es decir, pese a haber mejorado el rendimiento de una parte un 400%, la ganancia real es de «sólo» es 8%.

No hace falta ser un experto en matemáticas para darse cuenta de que, por mucho que mejoremos el rendimiento de una parte del sistema, si el tiempo total invertido en ejecutar esa parte es pequeño, el rendimiento global apenas mejorará.

Cómo mejorar el rendimiento de una aplicación

Armados con este conocimiento, es fácil trazar estrategias a seguir a la hora de optimizar una aplicación. La idea es sencilla: identifica qué partes consumen más tiempo, y haz que tarden menos.

Como siempre, el diablo está en los detalles, y es que los humanos somos especialmente malos en adivinar cuáles son las partes que consumen más tiempo, y para no ir a ciegas y esta estrategia sea realmente efectiva, es imprescindible medir. Puedes usar un profiler o algún mecanismo más rupestre (un triste log) para intentar identificar los cuellos de botella de la aplicación, pero hasta que no los tengas identificados, ponerse a optimizar «por si acaso», no tiene mucho sentido.

En la mayoría de aplicaciones los cuellos de botella principales van a estar siempre ligados a procesos de entrada/salida: consultar una base de datos, lanzar una petición a un servidor externo, acceder a disco, pintar en pantalla…

En general, esos son los puntos en los que mayor impacto pueden tener las mejoras de rendimiento que hagamos, y como siempre, es bueno recordar que el código más rápido es aquel que no se ejecuta, por lo que todo lo que sea minimizar el número de operaciones de entrada/salida suele ser bastante beneficioso para una aplicación.

Por supuesto, si puedes introducir una cache que evite la operación de entrada salida, ganarás en rendimiento (a costa de memoria, claro).

Si en lugar de lanzar 30 consultas a la base de datos, te puedes traer la información en una, probablemente sea más rápido (no te olvides de medir, que te puedes llevar alguna sorpresa).

Hacer que tu API entre cliente y servidor use pocos mensajes «grandes» en lugar de muchos «pequeños», dará lugar a un interface menos chatty (¿hablador?) y mejorará el rendimiento.

Comprimir la información enviada a través de la red reducirá el tiempo de descarga de datos (aunque incrementará el coste de CPU, recuerda, siempre debes medir el impacto de los cambios).

Si puedes consolidar los repintados en una página web y evitar hacer un relayout completo, estarás más cerca de los famosos 60fps.

Este tipo de optimizaciones son las que más pueden ayudarte a mejorar el rendimiento general de la aplicación, más que centrarte en cuál es la forma más rápida de comparar strings en C#.

Conclusión

La conclusión a este post en realidad la escribió Donald Knuth, otro señor que tuvo que dedicarse a pensar en lugar de producir mejor software cambiando el sistema de compilación de su aplicación .NET Core cada dos meses, y famoso, entre otras citas, por ésta que seguro que os suena:

La optimización prematura es la raíz de todo mal.

Es una lástima que se haya sacado de contexto, por que la cita real es más bien la siguiente:

Los programadores malgastan enormes cantidades de tiempo preocupándose por la velocidad de partes no críticas de sus programas, y estos intentos por mejorar la eficiencia tienen un fuerte impacto negativo a la hora de depurarlos y mantenerlos. Debemos olvidarnos de pequeñas mejoras, digamos, el 97% del tiempo: la optimización prematura es la raíz de todo mal. Pero no debemos dejar pasar la oportunidad de mejorar ese 3% crítico.

Si conseguimos identificar cuál es esa parte crítica de nuestra código que realmente tiene el peso del tiempo de ejecución, podremos centrarnos en mejorarla y obtener un impacto real en el rendimiento del sistema, pero evitemos complicar innecesariamente el código en aras de ganar unos nanosegundos que no van a ninguna parte.

Imagen de cabecera: Gene Amdahl en una fotografía de Computer History

7 comentarios en “Optimiza, pero cuidado con los microbenchmarks

  1. En mi experiencia en proyectos mundanos, ese 3% se lo lleva en un 97% de casos la interacción con BBDD (con casos de una petición de 1 minuto, el 99% del tiempo estaba en BD segun el profiler). El otro porcentaje, manipulación de DOM por JS y CSS. Todo lo demás es vacilada profesional.

  2. Hay algo que le leí a Eric Lippert una vez (que habla mucho sobre esto y tiene un criterio cojonudo), que venía a decir algo como (no literal, aunque estoy seguro que recuerdo bien la cita):

    Hay diferencia entre lento y no lo suficientemente rápido: lento es irrelevante para tus clientes, para tus jefes, y para los accionistas… no lo suficientemente rápido es extremadamente relevante. No hay que medir lo rápido que va algo, y nunca se deben basar decisiones de negocio en ello… hay que medir cuanto algo es razonablemente rápido para ser aceptable para el cliente: si es aceptable, deja de invertir tiempo y dinero en hacerlo más rápido… está bien como está.

  3. GreenEyed dijo:

    A mi me ha tocado lidiar con aplicaciones ambos extremos, desde procesos batch en la universidad donde el tiempo de respuesta no es crítico, a motores de disponibilidad donde los milisegundos cuentan por que no acabar antes de X quiere decir que un competidor se lleva la venta.
    La Ley de Amdahl no la conocía pero vamos, es la que siguen los expertos en optimización que saben de lo que hablan y la herramienta fundamental para eso es medir. Si no lo mides, olvídate.

    Lo de los micro-benchmarks es para fardar de e-miembro en Internet, pero aun así hay mucha gente que se lee esos blogs y luego cree que la solución a todo es aplicar esas micro-optimizaciones a procesos mucho más complejos.

    Yo siempre respondo lo mismo, lo mides, me lo demuestras, y luego lo discutimos.

  4. Xavi Paper dijo:

    Hola Juanma,

    Este razonamiento es el punto de inflexión de que una aplicación sea rápida… aunque muchos esten en contra, yo uso CQS donde separo consultas de comandos. En las consulta, aunque parezca una barbaridad siempre empiezo utilizando consultas LINQ sobre EF… la gente me dice: pero si eso es superlento, si eso no rinde… a mi me da igual… la productividad de LINQ junto con los tipos anónimos y los retornos de dinámicos me dan una productividad envidiable… Estos métodos yo los subo a producción y mientras no superen 1s para mi son suficientemente rápidos y por tanto están perfectos… En el caso de que sea superior a 1s entonces ya me los pongo en la lista de procesos lentos y ya los optimizo hasta que baja de 1s.

    ¿para qué optimizar una consulta que tarda menos de 1s si luego el tiempo se pierde realmente en la red? es despreciable…

    Como siempre lo importante es tener medido el coste…

    Esta es mi opinión aunque haya gente que se monte pirámides egipcias y faros de constantinopla con dapper para ganarse unas pocas décimas…

    Evidentemente el tipo de sistemas empresariales que yo hago no son juegos ni sistemas en tiempo real donde el rendimiento sea supercrítico ni haya vidas en juego.

  5. Gracias por el artículo. Primer comentario en tu blog. Aunque lo conozco desde hace poco, todos los artículos que pones me parecen muy buenos.

    Para el asunto de encontrar los puntos del programa/sistema que consumen más tiempo/recursos, yo desde que descubrí los FlameGraphs no paro de recomendarlos. Me parecen una herramienta buenísima.

  6. Pues parece que las optimizaciones deben hacerse desde el principio, es decir si en lugar de usar Strings, en Java, concatenados, por regla siempre les dices a la gente de tu equipo que usen StringBuilder, si van a concatenar, obviamente eso ayudara, es decir no se esta cambiando el código, hay un dicho por allí que dice que hay que hacer las cosas bien desde el principio, pues ese prototipo que te dijeron que lo hiceras rapido y sin cuidado, resulta que se convirtio en la aplicación mas importante de X empresa y lleva en producción 10 años.

    Muy bueno tu blog

  7. GreenEyed dijo:

    Jesus, el truco está en que hay una diferencia entre hacer las cosas bien, siguiendo buenas prácticas y la optimización prematura y muchas veces innecesaria.
    Cogiendo tu mismo ejemplo, en el último proyecto en el que estuve implicado alguien leyó eso mismo que dices tú y cambió todos los sitios donde se usa el + para concatenar por StringBuilders, como quedan cosas larguísimas se hizo unas funciones etc.

    Cuando lo ví se lo eché todo para atrás por que el 90% de lo que había cambiado ya lo hace el compilador, el compilador de Java es suficientemente listo para cambiar los + por un StringBuilder etc., y del otro 10% casi todo no se repetía o era en trozos donde el 99.99% de veces el código no entra y la ganancia es totalmente inapreciable.

    Y ese es el problema. complicar el código, volverlo más ilegible e ineficiente (las llamadas extra a funciones no son grátis) por algo que en realidad no va a beneficiar el rendimiento.
    Unas buenas prácticas de código con sentido común son necesarias desde el principio, pero la optimización de verdad hay que pensarla bien y evaluar el coste/beneficio correctamente.

Comentarios cerrados.