El desempeño de gevent (Parte 3 de 4)

Este artículo fue publicado originalmente el 16 de octubre de 2020 en eng.lyft.com por Roy Williams y fue traducido por Fernando A. López P.

Perspectiva general

Con lo que Gevent es más eficiente es con trabajos que dependen de la red. Las corrutinas nos permiten intercalar eficientemente otros trabajos del CPU mientras esperamos que nos lleguen resultados de la red.

Como discutimos en la parte 1: gevent usa corrutinas para hacer multitareas y las corrutinas no son hilos: corren completamente en espacio de usuario (user space); desde la perspectiva del núcleo del sistema operativo, sigue habiendo solo una tarea. Las corrutinas hacen uso de bibliotecas a nivel de usuario para realizar planificación de tiempos — gevent usa libev por defecto. Las corrutinas son significativamente más ligeras que los hilos reales, de modo que un proceso puede tener más de ellas; cambiar entre corrutinas también es mucho más rápido dado que no se necesita operar en espacio de núcleo (kernel space).

A cambio de ser más rápidas y ligeras, las corrutinas requieren más planeación y trabajo en espacio de usuario. Dado que las corrutinas no son hilos, no hay interrupciones como con hilos de verdad. Si un hilo del sistema operativo acapara demasiados recursos, el sistema operativo lo interrumpirá para que corra otra tarea, de la manera más justa posible.

A fin de cuentas, la máquina solo tiene una cantidad limitada de CPUs y tenemos que lidiar con esas limitaciones físicas — las corrutinas no son útiles para paralelizar trabajo del procesador, puede que terminen haciendo las cosas peores, pero son increíblemente útiles para paralelizar trabajo relacionado con la red.

El uso efectivo de gevent

Imaginemos que tenemos un punto final que necesita obtener tres recursos en la red, hace algún cálculo y regresa un resultado. La manera más sencilla de escribir esto es obtener los tres recursos serialmente, computar el resultado y regresar:

6

¡Excelente! Esto funciona como esperábamos. Veamos cuándo tiempo toma:

%timeit computar_cosa()
30.7 ms ± 39 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Hmm — no está mal, pero tal vez puede ser más rápido. La mayor parte del tiempo de trabajo parece estar en la espera de la red, de modo que ¡quizás sea buen candidato para ser acelerado con corrutinas!

%timeit computar_cosa_en_paralelo()
10.3 ms ± 24.2 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Mezclando CPU y E/S

Pocas cargas de trabajo son tan limpias como esta — generalmente nuestros servidores hacen algo de computación y algo de entradas/salidas, aunque la computación solo sea la serialización y deserialización de solicitudes. Creemos una referencia donde variemos la cantidad de trabajo del procesador, la cantidad de trabajo de la red y el número de corrutinas corriendo en paralelo para simular solicitudes concurrentes. Esta referencia hará la mitad de su trabajo de CPU, luego hará una llamada a la red, luego hará el resto del trabajo de CPU. Encontrarás el código para esta referencia en la última celda del Jupyter Notebook incluido en este post.

Primero, corramos un trabajo que haga 5 ms de trabajo de CPU y 55 ms de trabajo de red, con un greenlet para comenzar:

5ms CPU/55ms Network per request (500 requests with 1 green threads)
Throughput: 16.49 rps (1.00X Speedup)
CPU Utilization: 8.24%
p50: 60.53ms
p95: 60.60ms
p99: 60.62ms
Histograma de tiempo de respuesta: un hilo verde (green thread)

Todo bien hasta ahora — nuestro RPS es más o menos lo que esperaríamos de una carga de trabajo de 60 ms y los tiempos de respuesta están bien compactos. Lo único malo es esa utilización — ¡solo estamos usando el 8.24% de nuestro CPU! Usemos más hilos verdes en esta tarea dado que nuestros tiempos están mayormente dominados por las E/S:

5ms CPU/55ms Network per request (500 requests with 5 green threads)
Throughput: 81.61 rps (4.94X Speedup)
CPU Utilization: 40.80%
p50: 60.88ms
p95: 61.38ms
p99: 64.57ms
Histograma de tiempos de respuesta: 5 hilos verdes

¡Sorprendente! ¡El aumento de rapidez es casi lineal con respecto al número de hilos verdes, y ahora estamos usando el 41% de nuestro CPU en vez del 8%! Bueno, si 5 hilos verdes sirvieron, 50 serán aún mejores, ¿verdad?

5ms CPU/55ms Network per request(500 requests with 50 green threads)
Throughput: 192.46 rps (11.60X Speedup)
CPU Utilization: 96.23%
p50: 182.12ms
p95: 182.52ms
p99: 182.77ms
Histograma de tiempos de respuesta: 50 hilos verdes

Oh, no… aunque nuestro rendimiento (throughput) es 11.6 veces mayor que la referencia (un número atractivamente cercano a 12, o 1/(1-(55/60)) pero ahondaremos en eso después), ¡nuestros tiempos de respuesta se han disparado! ¿Cómo puede ser posible que el tiempo p50 para nuestra carga de trabajo que toma 60 ms sean 188 ms? Probablemente el GIL o el recolector de basura, ¿verdad? Tratemos de debuguear: despleguemos en pantalla quién hacía qué cosa durante nuestra solicitud más tardada. Usaremos una mezcla diferente para mostrar el problema de manera más simple (+ indica el inicio del trabajo en una solicitud, indica que el trabajo terminó).

50ms CPU/150ms Network per request (500 requests with 5 green threads)
Throughput: 19.49 rps (3.91X Speedup)
CPU Utilization: 97.46%
p50: 230.27ms
p95: 255.59ms
p99: 255.89ms
=========================
Longest Request:
[0.763] Request 3: + 25.00ms CPU time
[0.788] Request 3: + 150.00ms Network time
[0.788] Request 4: + 25.00ms CPU time
[0.813] Request 4: + 150.00ms Network time
[0.863] Request 0: - 150.00ms Network time took 150.72ms
[0.863] Request 0: + 25.00ms CPU time
[0.889] Request 5: + 25.00ms CPU time
[0.914] Request 5: + 150.00ms Network time
[0.915] Request 1: - 150.00ms Network time took 177.05ms
[0.915] Request 1: + 25.00ms CPU time
[0.940] Request 2: - 150.00ms Network time took 177.02ms
[0.940] Request 2: + 25.00ms CPU time
[0.965] Request 6: + 25.00ms CPU time
[0.990] Request 6: + 150.00ms Network time
[0.990] Request 7: + 25.00ms CPU time
[1.015] Request 7: + 150.00ms Network time
[1.016] Request 3: - 150.00ms Network time took 228.47ms
[1.016] Request 3: + 25.00ms CPU time

Interesante — cuando corremos con 5 hilos verdes vemos que el problema en la solicitud más tardada (la solicitud 3) fue que 150 ms de tiempo de red “tardaron” 228.47ms. Viendo los detalles notamos que hubieron 25 ms de tiempo de CPU agendados para otras ocho solicitudes — lo cual ayudó a que nuestro tiempo de 150 ms se inflara. Las primeras 6 veces que se había agendado algo de tiempo de CPU, no hubo problema, porque nuestra solicitud no había terminado… pero cada vez adicional que se agendaba tiempo de CPU, nos quedábamos sin recursos. El problema es que conforme aumenta el número de peticiones concurrentes, aumenta la probabilidad de que se agende más trabajo del que puede ser paralelizado eficientemente.

Veamos cómo se muestra esto en el límite graficando la aceleración de la eficiencia contra el número de hilos verdes y los tiempos de respuesta contra el número de hilos verdes:

Vemos que la aceleración se acerca asintóticamente a 12, pero después de aproximadamente 11 solicitudes concurrente, los tiempos de respuesta p50 comienzan a incrementarse. Revisitemos el número 1/(1-(55/60)); éste sale de la ley de Amdahl que dice que la aceleración máxima de una tarea mediante paralelización es 1/(1-p) donde p = {Proporción de Trabajo Paralelizable}. En nuestro ejemplo, 55ms de cada 60ms son paralelizables, así que p = 0.92.

Nuestro caso de uso es un poco diferente de la ley de Amdahl; en el caso de la ley de Amdahl, metemos más máquinas al problema para reducir incrementalmente el costo de la porción paralelizable. Con gevent y multitareas cooperativo, tras pasar la cantidad de 1/1-p trabajadores, el tiempo que toma la porción paralelizable tiende a cero, dado que podemos hacer todo nuestro trabajo de CPU mientras las tareas de red están corriendo.

Poniéndolo en práctica

Aunque nos hemos enfocado en Python y en Gevent, estos problemas no se limitan a gevent; igual son aplicables a NodeJS, Asyncio, o cualquier otro sistema de multitareas cooperativo — si un sistema cooperativo trata de atender a más tareas de las que puede, los tiempos de respuesta se degradarán. Para poner esto en práctica, sin embargo, nos enfocaremos en gevent y en gunicorn.

En la práctica, esto significa que nos gustaría minimizar el número de solicitudes concurrentes que maneja un proceso — cualquier proceso que maneje demasiadas solicitudes concurrentes hará que los tiempos de respuesta se degraden. Con esto en mente, hay algunas cosas a las que hay que estar atentos:

  1. epoll rutea solicitudes injustamente, favoreciendo a los PIDs más altos. Esto rápidamente puede llevar a que un proceso esté atendiendo demasiados procesos, sobrecargándose, mientras los demás procesos casi no tienen carga. Esto es exacerbado por un bug en gunicorn que hace que un trabajador acepte tantas conexiones como pueda cuando se despierta. Aplicar arreglos para estos errores lleva a un ruteo más justo.
  2. Establecer SO_REUSEPORT en el socket de escucha también mejora el balance. Esta bandera cambió de estar siempre prendida a ser opcional en gunicorn 19.8. Los servicios deben considerar especificar manualmente --reuse-port en su configuración de gunicorn.
  3. Gunicorn les permite a los servicios especificar el número máximo de solicitudes concurrentes que un trabajador servirá con la bandera — worker-connections. Esta tendrá un valor de 1000 por defecto, lo cual es casi seguramente demasiado alto para la mayoría de los servicios (por ejemplo, un servicio tendría que hacer 1 ms de trabajo de CPU y 1 segundo de trabajo de red para que esto fuera correcto). Considera disminuir este valor a un número más apropiado, usando herramientas como el trazado (tracing) para determinar cuánto tiempo el servicio dura bloqueado por la red vs. consumiendo CPU. Un número más adecuado es {tiempo_de_red}/{tiempo_de_cpu} — por ejemplo, si para una solicitud normal un servicio toma 5 ms de trabajo de CPU y espera la red por 55 ms, seguramente debería ajustar el valor a -- worker-connections=12 — cualquier número mayor degradará los tiempos de respuesta.
  4. Optimizar los tiempos de CPU: Reducir el tiempo de CPU incrementa la proporción {tiempo_de_red}/{tiempo_de_cpu}, permitiendo que el servicio naturalmente maneje más solicitudes concurrentes.
  5. Reducir la frecuencia en la que el código crítico cede a otros. Ceder más constantemente al ciclo de eventos reduce la previsibilidad de las respuestas, dado que habrá más oportunidades para que otras solicitudes corran y hagan más lenta la solicitud original. En estas gráficas corrimos la misma referencia, pero en vez de tener una llamada a la red de 55 ms, hay 5 llamadas de 11 ms cada una. Vemos que los tiempos de respuesta se degradan conforme el número de hilos verdes aumenta.

Nota que todas las optimizaciones mencionadas solo sirven realmente a servicios que ya están bastante ocupados. Si cada servidor está sirviendo una sola solicitud a la vez, esto no ayudará; la concurrencia no es un problema en ese caso. Dicho esto, los problemas mencionados pueden bloquear la eficiencia de manera importante: si los tiempos de respuesta de un servicio se degradaron al aumentar el tiempo de CPU, trata de hacer las optimizaciones citadas y darle más carga al servicio de nuevo.

¡De nuevo, puedes bajar esta publicación (en inglés) como un Jupyter Notebook! Te invito a que juegues con las referencias (benchmarks) para ayudarte a entender cómo se degrada el desempeño conforme tratas de hacer más trabajo en greenlets.

--

--