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):

SELECT
    BP.ProductID,
    cnt = COUNT_BIG(*)
	FROM dbo.bigProduct AS BP
	JOIN dbo.bigTransactionHistory AS BTH
		ON BTH.ProductID = BP.ProductID
	GROUP BY BP.ProductID
	ORDER BY BP.ProductID

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:

IF EXISTS (SELECT * FROM sys.server_event_sessions WHERE name = 'eventos_session')
    DROP EVENT SESSION eventos_session ON SERVER
GO
DECLARE @create nvarchar(max) = '
CREATE EVENT SESSION eventos_session ON SERVER
ADD EVENT sqlos.wait_info
    (WHERE sqlserver.session_id = ' + convert(nvarchar(max),@@spid) + ')
ADD TARGET package0.ring_buffer
WITH (MAX_DISPATCH_LATENCY = 1 SECONDS); '
EXEC (@create)
GO
declare @iterar int = 5
declare @query nvarchar(max) = '
IF OBJECT_ID(''tempdb..#temp'') IS NOT NULL DROP TABLE #temp
SELECT
    BP.ProductID,
    cnt = COUNT_BIG(*)
	into #temp
	FROM dbo.bigProduct AS BP
	JOIN dbo.bigTransactionHistory AS BTH
		ON BTH.ProductID = BP.ProductID
	GROUP BY BP.ProductID
	ORDER BY BP.ProductID
'
declare @sql nvarchar(max) 
declare @maxdop int = 8 
declare @fecha datetime
declare @fecha2 datetime

IF OBJECT_ID('tempdb..#esperas') IS NOT NULL DROP TABLE #esperas

create table #esperas (
maxdop int,
duracion_ms int,
wait_type varchar(max),
total bigint,
total_wait_time_ms bigint,
total_resource_wait_time_ms bigint,
total_signal_wait_time_ms bigint
)

while (@maxdop >= 1)
begin
	ALTER EVENT SESSION eventos_session ON SERVER STATE = START;

	set @fecha=getdate()
	set @sql= @query + ' OPTION (RECOMPILE, QUERYTRACEON 8649, MAXDOP ' + convert(varchar(max),@MAXDOP) + ')' 
	exec(@sql)
	set @fecha2=getdate()

	IF OBJECT_ID('tempdb..#t') IS NOT NULL DROP TABLE #t

	SELECT
		CAST (target_data AS XML) xml
		into #t
	FROM sys.dm_xe_sessions AS s 
	JOIN sys.dm_xe_session_targets AS t 
		ON t.event_session_address = s.address
	WHERE s.name = 'eventos_session'
	AND t.target_name = 'ring_buffer'

	INSERT INTO #esperas
	select 
		@maxdop,
		datediff(ms, @fecha, @fecha2) duracion_ms,
		table2.wait_type,
		count(*) total,
		SUM (convert(bigint,table2.duration)) AS total_wait_time_ms,
		SUM (convert(bigint,table2.duration))-SUM (convert(bigint,table2.signal_duration)) AS total_resource_wait_time_ms,
		SUM (convert(bigint,table2.signal_duration)) AS total_signal_wait_time_ms
		FROM
			-- Credit to Jonathan Kehayias for the xevent extraction query template
			(  
				SELECT  XEvent.query('.') XEvent
				from #t t
				CROSS APPLY t.xml.nodes ('RingBufferTarget/event') AS XEventData (XEvent)   
			) table1
			CROSS APPLY
			(  SELECT *
			   FROM
			   (   -- Get the Event Name
				   SELECT
					   'event_name' AS 'name',
					   RTRIM(LTRIM(XEvent.value('(event/@name)[1]', 'nvarchar(max)'))) AS value
				   UNION ALL
				   -- Get the Event Timestamp
				   SELECT
					   'timestamp' AS 'name',
					   RTRIM(LTRIM(XEvent.value('(event/@timestamp)[1]', 'nvarchar(40)'))) AS value
				   UNION ALL
				   -- Get the Event data columns
				   SELECT
					   RTRIM(LTRIM(XData.value('@name[1]','nvarchar(max)'))) AS 'name',
					   RTRIM(LTRIM(COALESCE(NULLIF(XData.value('(text)[1]','nvarchar(max)'), ''),
								 XData.value('(value)[1]','nvarchar(max)')))) AS 'value'
				   FROM XEvent.nodes('event/data') AS Data (XData)
				   UNION ALL
				   -- Get the Event action columns
				   SELECT
					   XData.value('@name[1]','nvarchar(max)') AS 'name'
					   ,COALESCE(NULLIF(XData.value('(text)[1]','nvarchar(max)'), ''),
								 XData.value('(value)[1]','nvarchar(max)')) AS 'value'
				   FROM XEvent.nodes('event/action') AS Data (XData)   ) AS tab
			   PIVOT
			   (   MAX(value)
				   -- Specify columns for PIVOT Operation
				   FOR name IN
					   (   [event_name], [timestamp], [wait_type], [opcode], [duration], [signal_duration]
					   )
			   ) AS pvt
			) AS table2
		WHERE table2.[opcode] = 'End'
		and table2.wait_type in ('SOS_SCHEDULER_YIELD','CXPACKET')
		GROUP BY table2.wait_type

	ALTER EVENT SESSION eventos_session ON SERVER STATE = STOP;
    
	-- Bajamos de 2 en dos y probamos por último en serie, maxdop=1
	SET @maxdop = @maxdop -2
	if @maxdop = 0
		set @maxdop=1
END
GO 

select * from #esperas

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:

maxdop duracion_ms wait_type total total_wait_time_ms total_resource_wait_time_ms total_signal_wait_time_ms
8 2130 CXPACKET 377 4084 4058 26
8 2130 SOS_SCHEDULER_YIELD 123 0 0 0
6 2373 CXPACKET 401 4593 4591 2
6 2373 SOS_SCHEDULER_YIELD 98 0 0 0
4 2730 CXPACKET 272 4998 4984 14
4 2730 SOS_SCHEDULER_YIELD 226 0 0 0
2 3686 CXPACKET 33 2476 2470 6
2 3686 SOS_SCHEDULER_YIELD 390 0 0 0
1 5506 SOS_SCHEDULER_YIELD 495 0 0 0

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.

 

0 Shares:
Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

You May Also Like