Как я могу заставить скалярный UDF быть оценен только один раз в запросе?

12

У меня есть запрос, который необходимо отфильтровать по результату скалярного UDF. Запрос должен быть отправлен как один оператор (поэтому я не могу присвоить результат UDF локальной переменной), и я не могу использовать TVF. Я знаю о проблемах производительности, вызванных скалярными UDF, которые включают в себя принудительное выполнение всего плана поочередно, чрезмерное предоставление памяти, проблемы с оценкой мощности и отсутствие встраивания. Для этого вопроса, пожалуйста, предположите, что мне нужно использовать скалярную UDF.

Сам UDF вызывать довольно дорого, но теоретически запросы могут быть логически реализованы оптимизатором таким образом, что функцию нужно вычислять только один раз. Я создал очень упрощенный пример для этого вопроса. Следующий запрос занимает 6152 мсек на моем компьютере:

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

Оператор фильтра в плане запроса предполагает, что функция была оценена один раз для каждой строки:

План запроса 1

DDL и подготовка данных:

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;

Вот ссылка на db fiddle для приведенного выше примера, хотя выполнение кода там занимает около 18 секунд.

В некоторых случаях я не могу редактировать код функции, потому что он предоставляется поставщиком. В других случаях я могу вносить изменения. Как я могу заставить скалярный UDF быть оценен только один раз в запросе?

Джо Оббиш
источник

Ответы:

17

В конечном счете, невозможно заставить SQL Server вычислять скалярную UDF только один раз в запросе. Тем не менее, есть некоторые шаги, которые могут быть предприняты для его поощрения. Я считаю, что с тестированием вы можете получить что-то, что работает с текущей версией SQL Server, но возможно, что в будущих изменениях вам потребуется пересмотреть свой код.

Если есть возможность отредактировать код, первое, что нужно попробовать, - это сделать функцию детерминированной, если это возможно. Пол Уайт указывает здесь , что функция должна быть создана с помощью SCHEMABINDINGопции и сам код функции должен быть детерминированным.

После внесения следующих изменений:

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;

Запрос из вопроса выполняется за 64 мс:

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

План запроса больше не имеет оператора фильтра:

План запроса 1

Чтобы убедиться, что он выполняется только один раз, мы можем использовать новый DMV sys.dm_exec_function_stats, выпущенный в SQL Server 2016:

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

Вызов ALTERфункции против сбрасывает execution_countдля этого объекта. Приведенный выше запрос возвращает 1, что означает, что функция была выполнена только один раз.

Обратите внимание, что только то, что функция является детерминированной, не означает, что она будет оценена только один раз для любого запроса. Фактически, для некоторых запросов добавление SCHEMABINDINGможет ухудшить производительность. Рассмотрим следующий запрос:

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;

Лишнее DISTINCTбыло добавлено, чтобы избавиться от оператора Filter. План выглядит многообещающим:

план запроса 2

Исходя из этого, можно ожидать, что UDF будет оценен один раз и будет использоваться в качестве внешней таблицы в соединении с вложенным циклом. Однако для выполнения запроса на моей машине требуется 6446 мс. Согласно sys.dm_exec_function_statsфункции была выполнена 100 раз. Как это возможно? В статье «Производительность вычисления скаляров, выражений и плана выполнения » Пол Уайт указывает, что оператор вычисления скаляров может быть отложен:

Чаще всего Compute Scalar просто определяет выражение; фактическое вычисление откладывается до тех пор, пока что-то позже в плане выполнения не потребует результата.

Для этого запроса похоже, что вызов UDF был отложен до тех пор, пока он не был необходим, после чего он был оценен 100 раз.

Интересно, что пример CTE выполняется на моей машине за 71 мс, когда UDF не определен SCHEMABINDING, как в исходном вопросе. Функция выполняется только один раз при выполнении запроса. Вот план запроса для этого:

План запроса 3

Непонятно, почему Compute Scalar не откладывается. Это может быть связано с тем, что недетерминизм функции ограничивает перестановку операторов, которую может выполнять оптимизатор запросов.

Альтернативный подход - добавить небольшую таблицу в CTE и запросить единственную строку в этой таблице. Подойдет любая маленькая таблица, но давайте использовать следующее:

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

INSERT INTO dbo.X_ONE_ROW_TABLE VALUES (1);

Запрос тогда становится:

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;

Добавление dbo.X_ONE_ROW_TABLEдобавляет неопределенности для оптимизатора. Если в таблице ноль строк, CTE вернет 0 строк. В любом случае оптимизатор не может гарантировать, что CTE вернет одну строку, если UDF не является детерминированным, поэтому кажется вероятным, что UDF будет оцениваться до объединения. Я ожидал бы, что оптимизатор будет сканировать dbo.X_ONE_ROW_TABLE, использовать агрегат потока, чтобы получить максимальное значение одной возвращенной строки (что требует оценки функции), и использовать это в качестве внешней таблицы для присоединения к вложенному циклу dbo.X_100_INTEGERSв основном запросе. , Похоже, это то, что происходит :

план запроса 4

Запрос выполняется примерно за 110 мс на моем компьютере, и UDF оценивается только один раз согласно sys.dm_exec_function_stats. Было бы неправильно говорить, что оптимизатор запросов вынужден оценивать UDF только один раз. Однако трудно представить переписывание оптимизатора, которое привело бы к снижению затрат на запросы, даже с учетом ограничений, связанных с UDF и вычислением скалярных затрат.

Таким образом, для детерминированных функций (которые должны включать эту SCHEMABINDINGопцию) попробуйте написать запрос как можно более простым способом. Если на SQL Server 2016 или более поздней версии, подтвердите, что функция была выполнена только один раз, используя sys.dm_exec_function_stats. Планы выполнения могут вводить в заблуждение в этом отношении.

Для функций, которые SQL Server не считает детерминированными, включая те, которые не имеют SCHEMABINDINGопций, один из подходов заключается в том, чтобы поместить UDF в тщательно сконструированный CTE или производную таблицу. Это требует небольшой осторожности, но один и тот же CTE может работать как для детерминированных, так и для недетерминированных функций.

Джо Оббиш
источник