¿Cómo puedo forzar que un UDF escalar se evalúe solo una vez en una consulta?

12

Tengo una consulta que necesita filtrar contra el resultado de un UDF escalar. La consulta debe enviarse como una sola declaración (por lo que no puedo asignar el resultado UDF a una variable local) y no puedo usar un TVF. Soy consciente de los problemas de rendimiento causados ​​por las UDF escalares, que incluyen obligar a que todo el plan se ejecute en serie, concesiones de memoria excesivas, problemas de estimación de cardinalidad y falta de alineación. Para esta pregunta, suponga que necesito usar un UDF escalar.

El UDF en sí es bastante costoso de llamar, pero en teoría las consultas pueden ser implementadas lógicamente por el optimizador de tal manera que la función solo necesita ser calculada una vez. Me he burlado de un ejemplo muy simplificado para esta pregunta. La siguiente consulta tarda 6152 ms en ejecutarse en mi máquina:

SELECT x1.ID
FROM dbo.X_100_INTEGERS x1
WHERE x1.ID >= dbo.EXPENSIVE_UDF();

El operador de filtro en el plan de consulta sugiere que la función se evaluó una vez para cada fila:

plan de consulta 1

DDL y preparación de datos:

CREATE OR ALTER FUNCTION dbo.EXPENSIVE_UDF () RETURNS INT
AS
BEGIN
    DECLARE @tbl TABLE (VAL VARCHAR(5));

    -- make the function expensive to call
    INSERT INTO @tbl
    SELECT [VALUE]
    FROM STRING_SPLIT(REPLICATE(CAST('Z ' AS VARCHAR(MAX)), 20000), ' ');

    RETURN 1;
END;

GO

DROP TABLE IF EXISTS dbo.X_100_INTEGERS;

CREATE TABLE dbo.X_100_INTEGERS (ID INT NOT NULL);

-- insert 100 integers from 1 - 100
WITH
    L0   AS(SELECT 1 AS c UNION ALL SELECT 1),
    L1   AS(SELECT 1 AS c FROM L0 AS A CROSS JOIN L0 AS B),
    L2   AS(SELECT 1 AS c FROM L1 AS A CROSS JOIN L1 AS B),
    L3   AS(SELECT 1 AS c FROM L2 AS A CROSS JOIN L2 AS B),
    L4   AS(SELECT 1 AS c FROM L3 AS A CROSS JOIN L3 AS B),
    L5   AS(SELECT 1 AS c FROM L4 AS A CROSS JOIN L4 AS B),
    Nums AS(SELECT ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) AS n FROM L5)
INSERT INTO dbo.X_100_INTEGERS WITH (TABLOCK)
SELECT n FROM Nums WHERE n <= 100;

Aquí hay un enlace de violín db para el ejemplo anterior, aunque el código tarda unos 18 segundos en ejecutarse allí.

En algunos casos, es posible que no pueda editar el código de la función porque es suministrado por un proveedor. En otros casos puedo hacer cambios. ¿Cómo puedo forzar que un UDF escalar se evalúe solo una vez en una consulta?

Joe Obbish
fuente

Respuestas:

17

En última instancia, no es posible forzar a SQL Server a evaluar un UDF escalar solo una vez en una consulta. Sin embargo, hay algunos pasos que se pueden tomar para alentarlo. Con las pruebas, creo que puede obtener algo que funcione con la versión actual de SQL Server, pero es posible que los cambios futuros requieran que vuelva a visitar su código.

Si es posible editar el código, lo primero que debe intentar es hacer que la función sea determinista si es posible. Paul White señala aquí que la función debe crearse con la SCHEMABINDINGopción y el código de la función en sí debe ser determinista.

Después de hacer el siguiente cambio:

CREATE OR ALTER FUNCTION dbo.EXPENSIVE_UDF () RETURNS INT
WITH SCHEMABINDING
AS
BEGIN
    DECLARE @tbl TABLE (VAL VARCHAR(5));

    -- make the function expensive to call
    INSERT INTO @tbl
    SELECT [VALUE]
    FROM STRING_SPLIT(REPLICATE(CAST('Z ' AS VARCHAR(MAX)), 20000), ' ');

    RETURN 1;
END;

La consulta de la pregunta se ejecuta en 64 ms:

SELECT x1.ID
FROM dbo.X_100_INTEGERS x1
WHERE x1.ID >= dbo.EXPENSIVE_UDF();

El plan de consulta ya no tiene el operador de filtro:

plan de consulta 1

Para asegurarnos de que se ejecutó solo una vez, podemos usar el nuevo DMV sys.dm_exec_function_stats lanzado en SQL Server 2016:

SELECT execution_count
FROM sys.dm_exec_function_stats
WHERE object_id = OBJECT_ID('EXPENSIVE_UDF', 'FN');

Emitir un en ALTERcontra de la función restablecerá execution_countpara ese objeto. La consulta anterior devuelve 1, lo que significa que la función solo se ejecutó una vez.

Tenga en cuenta que el hecho de que la función sea determinista no significa que se evaluará solo una vez para cualquier consulta. De hecho, para algunas consultas, agregar SCHEMABINDINGpuede degradar el rendimiento. Considere la siguiente consulta:

WITH cte (UDF_VALUE) AS
(
    SELECT DISTINCT dbo.EXPENSIVE_UDF() UDF_VALUE
)
SELECT ID
FROM dbo.X_100_INTEGERS
INNER JOIN cte ON ID >= cte.UDF_VALUE;

Lo superfluo DISTINCTse agregó para deshacerse de un operador de filtro. El plan parece prometedor:

plan de consulta 2

Basado en eso, uno esperaría que la UDF se evalúe una vez y se use como la tabla externa en la unión de bucle anidado. Sin embargo, la consulta tarda 6446 ms en ejecutarse en mi máquina. Según sys.dm_exec_function_statsla función se ejecutó 100 veces. ¿Cómo es eso posible? En " Compute Scalars, Expressions and Execution Plan Performance ", Paul White señala que el operador Compute Scalar puede diferirse:

La mayoría de las veces, un Escalar Compute simplemente define una expresión; el cálculo real se difiere hasta que algo posterior en el plan de ejecución necesite el resultado.

Para esta consulta, parece que la llamada UDF se aplazó hasta que se necesitó, momento en el que se evaluó 100 veces.

Curiosamente, el ejemplo de CTE se ejecuta en 71 ms en mi máquina cuando el UDF no está definido con SCHEMABINDING, como en la pregunta original. La función solo se ejecuta una vez cuando se ejecuta la consulta. Aquí está el plan de consulta para eso:

consulta plan 3

No está claro por qué no se aplaza el cálculo escalar. Podría deberse a que el no determinismo de la función limita la reorganización de los operadores que puede hacer el optimizador de consultas.

Un enfoque alternativo es agregar una tabla pequeña al CTE y consultar la única fila en esa tabla. Cualquier tabla pequeña servirá, pero usemos lo siguiente:

CREATE TABLE dbo.X_ONE_ROW_TABLE (ID INT NOT NULL);

INSERT INTO dbo.X_ONE_ROW_TABLE VALUES (1);

La consulta se convierte en:

WITH cte (UDF_VALUE) AS
(       
    SELECT DISTINCT dbo.EXPENSIVE_UDF() UDF_VALUE
    FROM dbo.X_ONE_ROW_TABLE
)
SELECT ID
FROM dbo.X_100_INTEGERS
INNER JOIN cte ON ID >= cte.UDF_VALUE;

La adición de dbo.X_ONE_ROW_TABLEagrega incertidumbre para el optimizador. Si la tabla tiene cero filas, el CTE devolverá 0 filas. En cualquier caso, el optimizador no puede garantizar que el CTE devolverá una fila si el UDF no es determinista, por lo que parece probable que el UDF sea evaluado antes de la unión. Esperaría que el optimizador escanee dbo.X_ONE_ROW_TABLE, use un agregado de flujo para obtener el valor máximo de la fila devuelta (lo que requiere que se evalúe la función), y usar eso como la tabla externa para una unión de bucle anidado dbo.X_100_INTEGERSen la consulta principal . Esto parece ser lo que sucede :

plan de consulta 4

La consulta se ejecuta en aproximadamente 110 ms en mi máquina y el UDF se evalúa solo una vez de acuerdo con sys.dm_exec_function_stats. Sería incorrecto decir que el optimizador de consultas se ve obligado a evaluar el UDF solo una vez. Sin embargo, es difícil imaginar una reescritura del optimizador que conduzca a una consulta de menor costo, incluso con las limitaciones en torno a UDF y calcular el costo escalar.

En resumen, para funciones deterministas (que deben incluir la SCHEMABINDINGopción) intente escribir la consulta de la manera más simple posible. Si está en SQL Server 2016 o una versión posterior, confirme que la función solo se ejecutó una vez usando sys.dm_exec_function_stats. Los planes de ejecución pueden ser engañosos en ese sentido.

Para las funciones que SQL Server no considera deterministas, incluso cualquier cosa que no tenga la SCHEMABINDINGopción, un enfoque es colocar el UDF en un CTE cuidadosamente elaborado o en una tabla derivada. Esto requiere un poco de cuidado, pero el mismo CTE puede funcionar tanto para funciones deterministas como no deterministas.

Joe Obbish
fuente