Emule la función escalar definida por el usuario de una manera que no evite el paralelismo

12

Estoy tratando de ver si hay una manera de engañar a SQL Server para que use un cierto plan para la consulta.

1. Medio ambiente

Imagine que tiene algunos datos que se comparten entre diferentes procesos. Entonces, supongamos que tenemos algunos resultados de experimentos que ocupan mucho espacio. Luego, para cada proceso, sabemos qué año / mes de resultado del experimento queremos usar.

if object_id('dbo.SharedData') is not null
    drop table SharedData

create table dbo.SharedData (
    experiment_year int,
    experiment_month int,
    rn int,
    calculated_number int,
    primary key (experiment_year, experiment_month, rn)
)
go

Ahora, para cada proceso tenemos parámetros guardados en la tabla

if object_id('dbo.Params') is not null
    drop table dbo.Params

create table dbo.Params (
    session_id int,
    experiment_year int,
    experiment_month int,
    primary key (session_id)
)
go

2. Datos de prueba

Agreguemos algunos datos de prueba:

insert into dbo.Params (session_id, experiment_year, experiment_month)
select 1, 2014, 3 union all
select 2, 2014, 4 
go

insert into dbo.SharedData (experiment_year, experiment_month, rn, calculated_number)
select
    2014, 3, row_number() over(order by v1.name), abs(Checksum(newid())) % 10
from master.dbo.spt_values as v1
    cross join master.dbo.spt_values as v2
go

insert into dbo.SharedData (experiment_year, experiment_month, rn, calculated_number)
select
    2014, 4, row_number() over(order by v1.name), abs(Checksum(newid())) % 10
from master.dbo.spt_values as v1
    cross join master.dbo.spt_values as v2
go

3. Obteniendo resultados

Ahora, es muy fácil obtener resultados de experimentos al @experiment_year/@experiment_month:

create or alter function dbo.f_GetSharedData(@experiment_year int, @experiment_month int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.SharedData as d
    where
        d.experiment_year = @experiment_year and
        d.experiment_month = @experiment_month
)
go

El plan es agradable y paralelo:

select
    calculated_number,
    count(*)
from dbo.f_GetSharedData(2014, 4)
group by
    calculated_number

consulta 0 plan

ingrese la descripción de la imagen aquí

4. problema

Pero, para hacer uso de los datos un poco más genérico, quiero tener otra función - dbo.f_GetSharedDataBySession(@session_id int). Entonces, la forma más sencilla sería crear funciones escalares, traduciendo @session_id-> @experiment_year/@experiment_month:

create or alter function dbo.fn_GetExperimentYear(@session_id int)
returns int
as
begin
    return (
        select
            p.experiment_year
        from dbo.Params as p
        where
            p.session_id = @session_id
    )
end
go

create or alter function dbo.fn_GetExperimentMonth(@session_id int)
returns int
as
begin
    return (
        select
            p.experiment_month
        from dbo.Params as p
        where
            p.session_id = @session_id
    )
end
go

Y ahora podemos crear nuestra función:

create or alter function dbo.f_GetSharedDataBySession1(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.f_GetSharedData(
        dbo.fn_GetExperimentYear(@session_id),
        dbo.fn_GetExperimentMonth(@session_id)
    ) as d
)
go

consulta 1 plan

ingrese la descripción de la imagen aquí

El plan es el mismo, excepto que, por supuesto, no es paralelo, porque las funciones escalares que realizan el acceso a datos hacen que todo el plan sea serial .

Así que probé varios enfoques diferentes, como usar subconsultas en lugar de funciones escalares:

create or alter function dbo.f_GetSharedDataBySession2(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.f_GetSharedData(
       (select p.experiment_year from dbo.Params as p where p.session_id = @session_id),
       (select p.experiment_month from dbo.Params as p where p.session_id = @session_id)
    ) as d
)
go

consulta 2 plan

ingrese la descripción de la imagen aquí

O usando cross apply

create or alter function dbo.f_GetSharedDataBySession3(@session_id int)
returns table
as
return (
    select
        d.rn,
        d.calculated_number
    from dbo.Params as p
        cross apply dbo.f_GetSharedData(
            p.experiment_year,
            p.experiment_month
        ) as d
    where
        p.session_id = @session_id
)
go

consulta 3 plan

ingrese la descripción de la imagen aquí

Pero no puedo encontrar una manera de escribir esta consulta para que sea tan buena como la que usa funciones escalares.

Par de pensamientos:

  1. Básicamente, lo que quiero es poder decirle de alguna manera a SQL Server que precalcule ciertos valores y luego los pase más como constantes.
  2. Lo que podría ser útil es si tuviéramos alguna pista de materialización intermedia . He comprobado un par de variantes (TVF multi-declaración o cte con top), pero hasta ahora ningún plan es tan bueno como el que tiene funciones escalares
  3. Sé acerca de la próxima mejora de SQL Server 2017 - Froid: optimización de programas imperativos en una base de datos relacional. Sin embargo, no estoy seguro de que ayude. Sin embargo, hubiera sido bueno que se demuestre lo contrario aquí.

Información Adicional

Estoy usando una función (en lugar de seleccionar datos directamente de las tablas) porque es mucho más fácil de usar en muchas consultas diferentes, que generalmente tienen @session_idcomo parámetro.

Me pidieron que comparara los tiempos de ejecución reales. En este caso particular

  • la consulta 0 se ejecuta durante ~ 500 ms
  • la consulta 1 se ejecuta durante ~ 1500 ms
  • la consulta 2 se ejecuta durante ~ 1500 ms
  • la consulta 3 se ejecuta durante ~ 2000 ms.

El plan n. ° 2 tiene una exploración de índice en lugar de una búsqueda, que luego se filtra por predicados en bucles anidados. El plan n. ° 3 no es tan malo, pero aún así trabaja más y funciona más lentamente que el plan n. ° 0.

Supongamos que dbo.Paramsse cambia raramente, y generalmente tiene alrededor de 1-200 filas, no más de, digamos, que se espera 2000. Ahora son alrededor de 10 columnas y no espero agregar columnas con demasiada frecuencia.

El número de filas en los parámetros no es fijo, por lo que por cada @session_idhabrá una fila. El número de columnas no está fijo, es una de las razones por las que no quiero llamar dbo.f_GetSharedData(@experiment_year int, @experiment_month int)desde todas partes, por lo que puedo agregar una nueva columna a esta consulta internamente. Me alegraría escuchar cualquier opinión / sugerencia sobre esto, incluso si tiene algunas restricciones.

Roman Pekar
fuente
El plan de consulta con Froid sería similar al de query2 anterior, así que sí, no lo llevará a la solución que desea lograr en este caso.
Karthik

Respuestas:

13

Realmente no puede lograr con seguridad exactamente lo que quiere en SQL Server hoy, es decir, en una sola declaración y con ejecución paralela, dentro de las restricciones establecidas en la pregunta (como las percibo).

Entonces mi respuesta simple es no . El resto de esta respuesta es principalmente una discusión de por qué es así, en caso de que sea de interés.

Es posible obtener un plan paralelo, como se señaló en la pregunta, pero hay dos variedades principales, ninguna de las cuales es adecuada para sus necesidades:

  1. Se unen los bucles anidados correlacionados, con un flujo de distribución de turnos en el nivel superior. Dado que se garantiza que una única fila proviene de Paramsun session_idvalor específico , el lado interno se ejecutará en un solo hilo, aunque esté marcado con el icono de paralelismo. Es por eso que el plan aparentemente paralelo 3 no funciona tan bien; De hecho es serial.

  2. La otra alternativa es el paralelismo independiente en el lado interno de la unión de bucles anidados. Independiente aquí significa que los subprocesos se inician en el lado interno, y no simplemente los mismos subprocesos que se ejecutan en el lado externo de la unión de bucles anidados. SQL Server solo admite el paralelismo de bucles anidados del lado interno independiente cuando se garantiza que haya una fila del lado externo y no hay parámetros de unión correlacionados ( plan 2 ).

Entonces, tenemos la opción de un plan paralelo que es serial (debido a un hilo) con los valores correlacionados deseados; o un plan paralelo del lado interno que tiene que escanear porque no tiene parámetros para buscar. (Aparte: realmente debería permitirse conducir el paralelismo del lado interno utilizando exactamente un conjunto de parámetros correlacionados, pero nunca se ha implementado, probablemente por una buena razón).

Una pregunta natural es: ¿por qué necesitamos parámetros correlacionados? ¿Por qué SQL Server no puede buscar directamente los valores escalares proporcionados por, por ejemplo, una subconsulta?

Bueno, SQL Server solo puede 'indexar la búsqueda' usando referencias escalares simples, por ejemplo, una referencia constante, variable, de columna o de expresión (por lo que un resultado de función escalar también puede calificar). Una subconsulta (u otra construcción similar) es simplemente demasiado compleja (y potencialmente insegura) para introducirla en el motor de almacenamiento completo. Por lo tanto, se requieren operadores de planes de consulta separados. Este turno requiere correlación, lo que significa que no hay paralelismo del tipo que desea.

En general, actualmente no hay una solución mejor que métodos como asignar los valores de búsqueda a las variables y luego usarlos en los parámetros de la función en una declaración separada.

Ahora puede tener consideraciones locales específicas que significan que SESSION_CONTEXTvale la pena almacenar en caché los valores actuales del año y mes, es decir:

SELECT FGSD.calculated_number, COUNT_BIG(*)
FROM dbo.f_GetSharedData
(
    CONVERT(integer, SESSION_CONTEXT(N'experiment_year')), 
    CONVERT(integer, SESSION_CONTEXT(N'experiment_month'))
) AS FGSD
GROUP BY FGSD.calculated_number;

Pero esto cae en la categoría de solución alternativa.

Por otro lado, si el rendimiento de la agregación es de importancia primordial, podría considerar quedarse con las funciones en línea y crear un índice de almacén de columnas (primario o secundario) en la tabla. Es posible que los beneficios del almacenamiento en almacén de columnas, el procesamiento en modo por lotes y el pushdown agregado brinden mayores beneficios que una búsqueda paralela en modo fila de todos modos.

Pero tenga cuidado con las funciones escalares de T-SQL, especialmente con el almacenamiento del almacén de columnas, ya que es fácil terminar con la función que se evalúa por fila en un filtro de modo de fila separado. Por lo general, es bastante complicado garantizar la cantidad de veces que SQL Server elegirá evaluar escalares, y mejor no intentarlo.

Paul White 9
fuente
Gracias, Paul, gran respuesta! Pensé en usarlo, session_contextpero decido que es una idea demasiado loca para mí y no estoy seguro de cómo se adaptará a mi arquitectura actual. Sin embargo, lo que sería útil es, puede ser, alguna pista que podría usar para informarle al optimizador que debería tratar el resultado de la subconsulta como una simple referencia escalar.
Roman Pekar
8

Hasta donde sé, la forma del plan que desea no es posible con solo T-SQL. Parece que desea que la forma del plan original (consulta 0 plan) con las subconsultas de sus funciones se apliquen como filtros directamente contra el escaneo de índice agrupado. Nunca obtendrá un plan de consulta como ese si no utiliza variables locales para contener los valores de retorno de las funciones escalares. En su lugar, el filtrado se implementará como una unión de bucle anidado. Hay tres formas diferentes (desde un punto de vista de paralelismo) de implementar la unión de bucle:

  1. Todo el plan es serial. Esto no es aceptable para ti. Este es el plan que obtienes para la consulta 1.
  2. La unión de bucle se ejecuta en serie. Creo que en este caso el lado interno puede ejecutarse en paralelo, pero no es posible pasarle ningún predicado. Por lo tanto, la mayor parte del trabajo se realizará en paralelo, pero está escaneando toda la tabla y el agregado parcial es mucho más costoso que antes. Este es el plan que obtienes para la consulta 2.
  3. La unión de bucle se ejecuta en paralelo. Con el bucle anidado paralelo se une el lado interno del bucle que se ejecuta en serie, pero puede tener subprocesos DOP ejecutándose en el lado interno a la vez. Su conjunto de resultados externo solo tendrá una sola fila, por lo que su plan paralelo será efectivamente serial. Este es el plan que obtienes para la consulta 3.

Esas son las únicas formas de plan posibles que conozco. Puede obtener otros si usa una tabla temporal, pero ninguno de ellos resuelve su problema fundamental si desea que el rendimiento de la consulta sea tan bueno como lo fue para la consulta 0.

Puede lograr un rendimiento de consulta equivalente utilizando las UDF escalares para asignar valores de retorno a variables locales y utilizando esas variables locales en su consulta. Puede ajustar ese código en un procedimiento almacenado o en un UDF de varias instrucciones para evitar problemas de mantenimiento. Por ejemplo:

DECLARE @experiment_year int = dbo.fn_GetExperimentYear(@session_id);
DECLARE @experiment_month int = dbo.fn_GetExperimentMonth(@session_id);

select
    calculated_number,
    count(*)
from dbo.f_GetSharedData(@experiment_year, @experiment_month)
group by
    calculated_number;

Los UDF escalares se han movido fuera de la consulta en la que desea ser elegible para el paralelismo. El plan de consulta que obtengo parece ser el que desea:

plan de consultas paralelas

Ambos enfoques tienen desventajas si necesita utilizar este conjunto de resultados en otras consultas. No puede unirse directamente a un procedimiento almacenado. Tendría que guardar los resultados en una tabla temporal que tiene su propio conjunto de problemas. Puede unirse a un MS-TVF, pero en SQL Server 2016 puede ver problemas de estimación de cardinalidad. SQL Server 2017 ofrece ejecución intercalada para MS-TVF que podría resolver el problema por completo.

Solo para aclarar algunas cosas: las UDF escalares de T-SQL siempre prohíben el paralelismo y Microsoft no ha dicho que FROID estará disponible en SQL Server 2017.

Joe Obbish
fuente
sobre Froid en SQL 2017, no estoy seguro de por qué pensé que estaba allí. Se confirma que estará en vNext - brentozar.com/archive/2018/01/…
Roman Pekar
4

Lo más probable es que esto se pueda hacer usando SQLCLR. Uno de los beneficios de SQLCLR escalares UDF es que no impiden el paralelismo si ellos no hacen ningún acceso a los datos (y, a veces necesitan también ser marcado como "determinista"). Entonces, ¿cómo hace uso de algo que no requiere acceso a datos cuando la operación en sí misma requiere acceso a datos?

Bueno, porque dbo.Paramsse espera que la tabla:

  1. generalmente nunca tiene más de 2000 filas,
  2. rara vez cambia la estructura,
  3. solo (actualmente) necesita tener dos INTcolumnas

es factible almacenar en caché las tres columnas - session_id, experiment_year int, experiment_monthen una colección estática (por ejemplo, un diccionario, tal vez) que se completa fuera de proceso y es leída por los UDF escalares que obtienen los valores experiment_year inty experiment_month. Lo que quiero decir con "fuera de proceso" es: puede tener un UDF escalar SQLCLR completamente separado o procedimiento almacenado que puede hacer acceso a datos y lecturas de la dbo.Paramstabla para completar la colección estática. Ese UDF o procedimiento almacenado se ejecutará antes de usar los UDF que obtienen los valores de "año" y "mes", de esa manera los UDF que obtienen los valores de "año" y "mes" no están haciendo ningún acceso a los datos de la base de datos.

El UDF o procedimiento almacenado que lee los datos puede verificar primero para ver si la colección tiene 0 entradas y, en caso afirmativo, completar, de lo contrario omitir. Incluso puede realizar un seguimiento de la hora en que se rellenó y si han pasado más de X minutos (o algo así), luego borre y vuelva a llenar incluso si hay entradas en la colección. Pero omitir a la población ayudará, ya que deberá ejecutarse con frecuencia para garantizar que siempre se complete para que los dos UDF principales obtengan los valores.

La principal preocupación es cuando SQL Server decide descargar el dominio de la aplicación por cualquier motivo (o se desencadena por algo que lo usa DBCC FREESYSTEMCACHE('ALL');). No desea arriesgarse a que se borre la colección entre la ejecución del UDF "poblado" o Procedimiento almacenado y los UDF para obtener los valores "año" y "mes". En ese caso, puede hacer una comprobación al comienzo de esos dos UDF para lanzar una excepción si la colección está vacía, ya que es mejor cometer un error que proporcionar resultados falsos con éxito.

Por supuesto, la preocupación mencionada anteriormente supone que el deseo es que la Asamblea se marque como SAFE. Si el ensamblaje se puede marcar como EXTERNAL_ACCESS, entonces es posible que un constructor estático ejecute el método que lee los datos y llena la colección, de modo que solo necesite ejecutarlos manualmente para actualizar las filas, pero siempre se completarán (porque el constructor de la clase estática siempre se ejecuta cuando se carga la clase, lo que sucede cada vez que se ejecuta un método en esta clase después de un reinicio o se descarga el dominio de la aplicación). Esto requiere el uso de una conexión regular y no la conexión de contexto en proceso (que no está disponible para los constructores estáticos, de ahí la necesidad de hacerlo EXTERNAL_ACCESS).

Tenga en cuenta: para no tener que marcar el ensamblado como UNSAFE, debe marcar cualquier variable de clase estática como readonly. Esto significa, al menos, la colección. Esto no es un problema ya que las colecciones de solo lectura pueden tener elementos agregados o eliminados, simplemente no se pueden inicializar fuera del constructor o la carga inicial. El seguimiento del tiempo en que se cargó la colección con el fin de expirarla después de X minutos es más complicado ya que una static readonly DateTimevariable de clase no se puede cambiar fuera del constructor o la carga inicial. Para evitar esta restricción, debe usar una colección estática de solo lectura que contenga un único elemento que sea el DateTimevalor para que pueda eliminarse y volver a agregarse después de una actualización.

Solomon Rutzky
fuente
No sé por qué alguien rechazó esto. Si bien no es muy genérico, creo que podría ser aplicable en mi caso actual. Prefiero tener una solución SQL pura, pero definitivamente voy a echar un vistazo más de cerca y trataré de ver si funciona
Roman Pekar
@RomanPekar No estoy seguro, pero hay muchas personas que son anti-SQLCLR. Y tal vez algunos que son anti-yo ;-). De cualquier manera, no puedo pensar por qué esta solución no funcionaría. Entiendo la preferencia por T-SQL puro, pero no sé cómo hacer que eso suceda, y si no hay una respuesta competitiva, tal vez nadie más lo haga tampoco. No sé si las tablas con memoria optimizada y las UDF compiladas de forma nativa serían mejores aquí. Además, acabo de agregar un párrafo con algunas notas de implementación para tener en cuenta.
Solomon Rutzky
1
Nunca he estado completamente convencido de que usar readonly staticsSQLCLR sea seguro o inteligente. Mucho menos, estoy convencido de que luego pasaré a engañar al sistema convirtiéndolo en readonlyun tipo de referencia, que luego irás y cambiarás . Me da la voluntad absoluta tbh.
Paul White 9
@PaulWhite Entendido, y recuerdo que esto surgió en una conversación privada hace años. Dada la naturaleza compartida de los dominios de aplicación (y, por lo tanto, los staticobjetos) en SQL Server, sí, existe el riesgo de condiciones de carrera. Es por eso que primero determiné a través del OP que estos datos son mínimos y estables, y por qué califiqué este enfoque como "rara vez cambia", y proporcioné un medio de actualización cuando era necesario. En este caso de uso, no veo mucho o ningún riesgo. Encontré una publicación hace años sobre la capacidad de actualizar colecciones de solo lectura por diseño (en C #, sin discusión sobre: ​​SQLCLR). Intentaremos encontrarlo.
Solomon Rutzky
2
No es necesario, no hay forma de que me sienta cómodo con esto, aparte de la documentación oficial de SQL Server que dice que está bien, que estoy bastante seguro de que no existe.
Paul White 9