viernes, 21 de agosto de 2015

Calculando un grado de paralelismo “razonable”

Un parámetro de configuración que genera habitualmente discusiones de lo más controvertidas es el grado máximo de paralelismo (max degree of parallelism). En este artículo vamos a ver las recomendaciones generales y también una aproximación a cómo podemos calcular este valor de una forma más empírica para nuestras consultas.

Para sistemas con la carga más típica que nos solemos encontrar (mayor parte de OLTP y algunas consultas analíticas) las recomendaciones generales son un buen punto de partida:
  • En sistemas NUMA, no configurar un grado de paralelismo mayor que el número de cores físicos por nodo NUMA.
  • No configurar el grado de paralelismo máximo por encima de 8.
También es importante que en el caso que nuestra instancia vaya a ser utilizada por software de terceros revisemos las recomendaciones del fabricante al respecto. Por poner un ejemplo habitual, Dynamics AX recomienda un grado de paralelismo 1. Al tratarse de un parámetro a nivel de instancia y no de base de datos deberemos buscar un valor de compromiso pensando en todas las aplicaciones que vayamos a ejecutar. En casos extremos tendremos que aislar en instancias independientes las distintas aplicaciones o, si disponemos de Enterprise Edition, podremos utilizar Resource Governor para controlar este parámetro. También es importante revisar el algoritmo utilizado por SQL Server para obtener el valor de paralelismo a utilizar que podemos encontrar en el siempre interesante blog de CSS SQL Escalation Services:

http://blogs.msdn.com/b/psssql/archive/2013/09/27/how-it-works-maximizing-max-degree-of-parallelism-maxdop.aspx



En otros casos nos puede interesar ajustar el grado de paralelismo más óptimo para un conjunto reducido de consultas. Para ello tendremos que tener en cuenta tanto la capacidad de aprovechamiento del paralelismo como también el grado de concurrencia que van a tener dichas consultas. Dicho de otra forma, una consulta que se ejecute en una ventana de mantenimiento y sin concurrencia podrá ser más “avariciosa” en el consumo de recursos que una que se ejecute durante la jornada laboral con  una concurrencia media de 5 consultas concurrentes. Una posible técnica para evaluar cuál sería ese grado de paralelismo apropiado sería lanzar la consulta con distintos grados de paralelismo y medir los consumos de recursos, esperas, etc. que se generan. Por ejemplo podemos utilizar una sesión de xevents para capturar las esperas por sesión y una traza de Profiler para capturar las ejecuciones con distinto grado de paralelismo.

Imaginemos que queremos analizar el comportamiento de la siguiente consulta lanzada sobre “tablas engordadas” de AdventureWorks (Script de Adam Machanic disponible en http://sqlblog.com/blogs/adam_machanic/attachment/39106.ashx):
 

Un script que nos puede servir para capturar las esperas para distintos grados de paralelismo (de 8 a 1 en este ejemplo) sería el siguiente:


En el script básicamente componemos la sesión de eventos extendidos de forma dinámica (para poder filtrar por la sesión actual) y luego iteramos ejecutando la consulta con distintos niveles de paralelismo máximo. Como salida de este script obtendremos una salida similar a esta:

maxdopduracion_mswait_typetotaltotal_wait_time_mstotal_resource_wait_time_mstotal_signal_wait_time_ms
82130CXPACKET3774084405826
82130SOS_SCHEDULER_YIELD123000
62373CXPACKET401459345912
62373SOS_SCHEDULER_YIELD98000
42730CXPACKET2724998498414
42730SOS_SCHEDULER_YIELD226000
23686CXPACKET33247624706
23686SOS_SCHEDULER_YIELD390000
15506SOS_SCHEDULER_YIELD495000

Como vemos en el caso de planes paralelos tenemos entre 2 y 4 segundos de tiempos “perdidos” en sincronización entre threads y el propio tiempo del thread 0, “padre” o controlador del resto. Sería ideal que pudiéramos obtener desglosada esta espera de CXPACKET de forma que nos quedáramos solo con el tiempo de sincronización entre threads excluyendo el tiempo del thread padre.

Por otra parte con Profiler podemos obtener las distintas ejecuciones, sus tiempos y consumos agregados de CPU:

ejecuciones_MAXDOP

Si comparamos las duraciones reales con las ideales podemos ver como la “brecha” es menor cuando el paralelismo es 2 que cuando éste es 4,6 u 8.

duracion_ideal_real

Si comparamos el consumo de CPU por thread vemos un comportamiento similar entre el valor ideal (reparto perfecto y sin costes de sincronización) y la realidad:

cpu_thread_ideal_real

También podemos observar el porcentaje de mejora (reducción en % de la duración) vs el porcentaje de aumento de consumo de recursos (aumento en % de milisegundos de CPU) :

porcentaje_vs_serie

Llegados a este punto deberíamos plantearnos cuantos cores tenemos disponibles, cuanta concurrencia de este tipo de consultas vamos a tener y cuantos cores queremos dejar libres para el resto de carga del servidor. En este caso no tenemos “regresiones” ya que aunque aumentemos el paralelismo hasta 8 siempre obtenemos alguna mejora. En algunos casos, normalmente cuando excedemos el tamaño del nodo NUMA en lo que al número de cores respecta, podemos encontrarnos con “saltos” donde pasar de por ejemplo 8 cores a 9 suponga un aumento en el tiempo de respuesta. En general la curva de la tendencia es similar a la que tenemos aunque con un grado de “aplanamiento” distinto en la reducción del tiempo total de ejecución. Existen consultas que, en un hardware concreto, escalan muy bien hasta el 100% de los cores disponibles y otras que a partir de unos pocos cores ya no resultan más eficientes o incluso se vuelven menos eficientes cuantos más threads se involucran.

En resumen, si esta consulta concreta se fuese a ejecutar sin concurrencia y con todos los recursos disponibles para su ejecución optaríamos por el grado de paralelismo 8, ya que es el que obtiene un mejor tiempo de respuesta. Sin embargo si el grado de concurrencia fuese a ser notable, por ejemplo 10 queries concurrentes en un sistema con 32 cores, optaríamos por un grado de paralelismo de 2, con el que se podría llegar a consumir hasta 20 cores en total. Si necesitáramos consumir menos de esos 20 cores concurrentemente o el número de queries a soportar fuese mayor optaríamos por el plan serie por su mayor eficiencia desde el punto de consumo de CPU.

Por último no podemos olvidar que existen factores adicionales que tenemos que tener en cuenta ya que pueden darnos resultados muy distintos durante las pruebas y cuando estemos en un escenario de producción. Un caso habitual que nos encontramos en nuestras consultorías es el impacto de mezclar cargas analíticas con OLTP. Los continuos “picotazos” de CPU que las consultas OLTP demandan de los distintos cores hacen que el tiempo total y las diferencias de tiempos entre threads se acrecienten, haciendo los planes paralelos mucho menos eficientes que en un servidor sin carga. En esos casos una buena herramienta es forzar el particionado de la carga mediante resource governor, mapeando nodos numa a puerto TCP, etc.  de forma que dediquemos un conjunto de cores del servidor para la carga OLTP exclusivamente y el resto para las consultas pesadas de tipo analítico.