If your code works don’t touch it… Always?
Mmm… puede ser, si el código funciona, no lo toquemos… pero ¿qué pasa si lo hacemos?
En 2020 encaramos el proyecto Marie Kondo, una aplicación multiproducto que reordena la primera página de los resultados con distintos tipos de estrategias utilizando algoritmos de machine learning.
Nuestro objetivo era construir una aplicación que al estar en el flujo online del negocio, tenía que cumplir con un tiempo de respuesta corto y esperado (100 ms).
Al terminar y comenzar las pruebas, la app cumplía con los requisitos funcionales y no funcionales, el P90 de los tiempos era aceptable, podríamos haber dejado todo ahí, If you’re code works don’t touch it, pero fuimos un poquito mas allá y encontramos, observando nuestras métricas, que había un grupo de tiempos que estaba superando ese umbral y había que analizarlo mejor. Desde el inicio del proyecto tenemos configurado Zipkin y kamon zipkin reporter, que durante la la fase de desarrollo nos ayudó a hacer mejoras de performance. Si bien en producción estuvo siempre apagado, porque como todo aquello que medís tiene un costo de performance, lo activamos en una instancia para poder hacer este análisis.
Bueno, dale, tanto blah blah pero ¿qué fue lo que hicimos?
Usando la UI de Zipkin pudimos ver el tiempo total del servicio y su desglose. Veamos un ejemplo:
Se ve que el tiempo total es 94.774 ms, de los cuales 52.815 se lo lleva el “transformer-createfeature” (más del 50% del tiempo!). Este comportamiento lo vimos en todas las trazas que analizamos.
Para poner un poco de contexto, el tracing consiste en poder describir las operaciones ejecutadas por sus servicios y la relación causal entre ellos (para ello podemos marcar una parte del código con “trace”). Un Span representa una sola operación y contiene suficiente información para determinar el trace al que pertenece, cuánto tiempo tardó en completarse y qué Span es su padre. Además, los spans pueden tener etiquetas y marcas que describan mejor la semántica y los efectos de las operaciones en el sistema.
Una vez reunidos todos los spans relacionados, es posible reconstruir el seguimiento completo de un request.
Eso nos llevó a ir directamente a analizar esa porción de código:
En el bloque anterior vemos que estamos llamando a createFeatures, método de una librería desarrollada internamente por otro equipo. Entonces nos clonamos ese proyecto, revisamos el código y vimos que podíamos hacer cambios para mejorar la performance de la librería y, en consecuencia, los tiempos de nuestra app.
Veamos un poco del código de la librería.
Dentro del método createFeatures, nos llamaron la atención dos bloques:
En la segunda imagen de arriba vemos que encodeamos cada “amenity” del hotel pasándole una lista de “amenities”. ¿Qué hay en el OneHotEncoder?
Cada vez que llamamos al encode estamos recorriendo una lista (siempre la misma categoriesList) que pasamos por parámetro y luego generamos otra prendiendo con un 1 la posición que corresponde. El objetivo del encode es poder armar un vector de ceros y unos, para luego utilizarlo en los algoritmos de Machine Learning. Por ejemplo, el resultado sería List(0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0).
Además, se hace una concatenación de Listas enlazadas, que ya sabemos que es muy poco performante.
¿Entonces?
A partir de este análisis decidimos hacer los siguientes cambios:
- El método encode no debe recibir la lista de categories ya que es siempre la misma.
- No generar por cada stringValue una Lista, sino tener precalculado el array con la configuración que corresponde a cada categoría.
- No usar List sino Array. El tipo List en Scala es una implementación de Linked Lists que, al concatenarlas, se estaba recorriendo la lista todo el tiempo. Al usar Array podemos acceder al elemento directamente y actualizarlo, como se puede ver en la siguiente imagen, donde creamos un array con 0 (ceros) y luego hacemos update a la posición que nos interesa. Creemos que este cambio, junto al punto anterior, fueron los que generaron los resultados que vamos a ver más adelante.
- Hacer un factory para tener 1 Encoder por categoría (en este caso Amenities y Countries)
Ahora el OneHotEncoder luce de la siguiente manera:
Generamos una nueva versión de la librería haciendo un Pull Request al equipo que la desarrolló, les contamos lo que encontramos y como fixearlo, validaron los cambios, sacaron un nuevo release de la librería, actualizamos nuestra app y deployamos un Release Candidate.
Por último, hicimos el mismo análisis obteniendo los siguientes resultados:
- El método en cuestión pasó de 52ms a 8 ms. Redujimos un 85% el tiempo del Span, lo que derivó en una mejora del 50% en el tiempo total del servicio.
2. En nuestra tool interna de performance vemos, después del pico en el deploy, un cambio considerable en los percentiles:
Si vemos el día anterior desde las 15 a las 18 hs:
Al dia siguiente, luego del deploy en el mismo período:
3. También , aprovechando que usamos Kamon, podemos tener un reporter más preciso utilizando prometheus y grafana, y observamos el mismo comportamiento en los percentiles 95, 99 y 99.9
Conclusión:
Gracias al uso de herramientas como Zipkin y Kamon, pudimos hacer un research de nuestra performance y encontrar dónde se estaba consumiendo más tiempo de procesamiento, encontrar el código, donde, cambiando algunas líneas y haciendo uso, en este caso, de las colecciones correctas para el problema que estamos atacando, llegar a dar una mejora de performance considerable.
Muchas veces, en los desarrollos que hacemos, llegamos al punto en que corren los tests, cumplimos con los requerimientos funcionales y no funcionales y lo damos por terminado, no queremos volver a tocar para no romper o vaya uno a saber por qué, pero, como Software Engineer, es tarea nuestra volver sobre el código, analizar las métricas, ver qué pasa en ese porcentaje, aunque sea pequeño, que no está cumpliendo o no nos cierra del todo. Si nos animamos a tocar, aunque funcione, los beneficios serán apps de mayor calidad, más robustas, con mejores tiempos.