Como forçar uma UDF escalar a ser avaliada apenas uma vez em uma consulta?

12

Eu tenho uma consulta que precisa filtrar contra o resultado de uma UDF escalar. A consulta deve ser enviada como uma única instrução (portanto, não posso atribuir o resultado UDF a uma variável local) e não posso usar um TVF. Estou ciente dos problemas de desempenho causados ​​por UDFs escalares, que incluem forçar a execução de todo o plano em série, concessões excessivas de memória, problemas de estimativa de cardinalidade e falta de alinhamento. Para esta pergunta, suponha que eu precise usar uma UDF escalar.

A UDF em si é bastante cara de chamar, mas, em teoria, as consultas podem ser implementadas logicamente pelo otimizador, de modo que a função precise ser calculada apenas uma vez. Zombei de um exemplo bastante simplificado para esta pergunta. A seguinte consulta leva 6152 ms para executar na minha máquina:

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

O operador de filtro no plano de consulta sugere que a função foi avaliada uma vez para cada linha:

plano de consulta 1

DDL e preparação de dados:

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;

Aqui está um link db fiddle para o exemplo acima, embora o código demore cerca de 18 segundos para ser executado lá.

Em alguns casos, talvez eu não consiga editar o código da função porque ela é fornecida por um fornecedor. Em outros casos, sou capaz de fazer alterações. Como forçar uma UDF escalar a ser avaliada apenas uma vez em uma consulta?

Joe Obbish
fonte

Respostas:

17

Por fim, não é possível forçar o SQL Server a avaliar uma UDF escalar apenas uma vez em uma consulta. No entanto, existem algumas medidas que podem ser tomadas para incentivá-lo. Com os testes, acredito que você pode obter algo que funcione com a versão atual do SQL Server, mas é possível que alterações futuras exijam a revisão do seu código.

Se é possível editar o código, uma boa primeira coisa a tentar é tornar a função determinística, se possível. Paul White aponta aqui que a função deve ser criada com a SCHEMABINDINGopção e o próprio código da função deve ser determinístico.

Depois de fazer a seguinte alteração:

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;

A consulta da pergunta é executada em 64 ms:

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

O plano de consulta não possui mais o operador de filtro:

plano de consulta 1

Para garantir que ele seja executado apenas uma vez, podemos usar a nova DMV sys.dm_exec_function_stats lançada no SQL Server 2016:

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

Emitir um ALTERcontra a função redefinirá o execution_countpara esse objeto. A consulta acima retorna 1, o que significa que a função foi executada apenas uma vez.

Observe que o fato de a função ser determinística não significa que ela será avaliada apenas uma vez para qualquer consulta. De fato, para algumas consultas, a adição SCHEMABINDINGpode prejudicar o desempenho. Considere a seguinte 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;

O supérfluo DISTINCTfoi adicionado para se livrar de um operador de filtro. O plano parece promissor:

plano de consulta 2

Com base nisso, seria de esperar que o UDF fosse avaliado uma vez e fosse usado como tabela externa na junção de loop aninhado. No entanto, a consulta leva 6446 ms para executar na minha máquina. De acordo com sys.dm_exec_function_statsa função foi executada 100 vezes. Como isso é possível? Em " Escalares de computação, expressões e desempenho do plano de execução ", Paul White destaca que o operador Escalar de computação pode ser adiado:

Na maioria das vezes, um Escalar de computação simplesmente define uma expressão; o cálculo real é adiado até que algo mais tarde no plano de execução precise do resultado.

Para esta consulta, parece que a chamada UDF foi adiada até ser necessária, quando foi avaliada 100 vezes.

Curiosamente, o exemplo CTE é executado em 71 ms na minha máquina quando o UDF não está definido com SCHEMABINDING, como na pergunta original. A função é executada apenas uma vez quando a consulta é executada. Aqui está o plano de consulta para isso:

plano de consulta 3

Não está claro por que o Escalar de computação não é adiado. Pode ser porque o não determinismo da função limita a reorganização dos operadores que o otimizador de consulta pode fazer.

Uma abordagem alternativa é adicionar uma tabela pequena ao CTE e consultar a única linha nessa tabela. Qualquer tabela pequena serve, mas vamos usar o seguinte:

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

INSERT INTO dbo.X_ONE_ROW_TABLE VALUES (1);

A consulta se torna:

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;

A adição do dbo.X_ONE_ROW_TABLEadiciona incerteza para o otimizador. Se a tabela tiver zero linhas, o CTE retornará 0 linhas. Em qualquer caso, o otimizador não pode garantir que o CTE retornará uma linha se o UDF não for determinístico; portanto, parece provável que o UDF seja avaliado antes da junção. Eu esperaria que o otimizador varrasse dbo.X_ONE_ROW_TABLE, use um agregado de fluxo para obter o valor máximo da linha retornada (que requer que a função seja avaliada) e use isso como a tabela externa para um loop aninhado dbo.X_100_INTEGERSna consulta principal . Parece ser o que acontece :

plano de consulta 4

A consulta é executada em cerca de 110 ms na minha máquina e o UDF é avaliado apenas uma vez de acordo com sys.dm_exec_function_stats. Seria incorreto dizer que o otimizador de consulta é forçado a avaliar o UDF apenas uma vez. No entanto, é difícil imaginar uma reescrita do otimizador que levaria a uma consulta de custo mais baixo, mesmo com as limitações relacionadas ao UDF e ao cálculo do custo escalar.

Em resumo, para funções determinísticas (que devem incluir a SCHEMABINDINGopção), tente escrever a consulta da maneira mais simples possível. Se no SQL Server 2016 ou em uma versão posterior, confirme se a função foi executada apenas uma vez usando sys.dm_exec_function_stats. Os planos de execução podem ser enganosos nesse sentido.

Para que as funções não consideradas determinantes pelo SQL Server sejam determinísticas, incluindo qualquer coisa que não possua a SCHEMABINDINGopção, uma abordagem é colocar o UDF em uma tabela CTE ou derivada cuidadosamente criada. Isso requer um pouco de cuidado, mas o mesmo CTE pode funcionar para funções determinísticas e não determinísticas.

Joe Obbish
fonte