Comment puis-je forcer une UDF scalaire à être évaluée une seule fois dans une requête?

12

J'ai une requête qui doit filtrer le résultat d'un UDF scalaire. La requête doit être envoyée comme une seule instruction (donc je ne peux pas affecter le résultat UDF à une variable locale) et je ne peux pas utiliser de TVF. Je suis conscient des problèmes de performances causés par les FDU scalaires, qui comprennent le fait de forcer le plan entier à s'exécuter en série, les allocations de mémoire excessives, les problèmes d'estimation de la cardinalité et le manque d'inline. Pour cette question, supposez que je dois utiliser un UDF scalaire.

L'UDF lui-même est assez cher à appeler, mais en théorie, les requêtes peuvent être implémentées logiquement par l'optimiseur de telle manière que la fonction ne doit être calculée qu'une seule fois. J'ai simulé un exemple très simplifié pour cette question. La requête suivante prend 6152 ms pour s'exécuter sur ma machine:

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

L'opérateur de filtre dans le plan de requête suggère que la fonction a été évaluée une fois pour chaque ligne:

plan de requête 1

DDL et préparation des données:

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;

Voici un lien de violon db pour l'exemple ci-dessus, bien que le code prenne environ 18 secondes pour s'y exécuter.

Dans certains cas, il se peut que je ne puisse pas modifier le code de la fonction car il est fourni par un fournisseur. Dans d'autres cas, je peux apporter des modifications. Comment puis-je forcer une UDF scalaire à être évaluée une seule fois dans une requête?

Joe Obbish
la source

Réponses:

17

En fin de compte, il n'est pas possible de forcer SQL Server à évaluer une UDF scalaire une seule fois dans une requête. Cependant, certaines mesures peuvent être prises pour l'encourager. Avec les tests, je pense que vous pouvez obtenir quelque chose qui fonctionne avec la version actuelle de SQL Server, mais il est possible que de futures modifications vous obligent à revoir votre code.

S'il est possible d'éditer le code, une bonne première chose à essayer est de rendre la fonction déterministe si possible. Paul White souligne ici que la fonction doit être créée avec l' SCHEMABINDINGoption et que le code de fonction lui-même doit être déterministe.

Après avoir effectué la modification suivante:

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 requête de la question est exécutée en 64 ms:

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

Le plan de requête n'a plus l'opérateur de filtre:

plan de requête 1

Pour être sûr qu'il ne s'est exécuté qu'une fois, nous pouvons utiliser le nouveau DMV sys.dm_exec_function_stats publié dans SQL Server 2016:

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

L'émission d'un ALTERcontre la fonction réinitialisera le execution_countpour cet objet. La requête ci-dessus renvoie 1, ce qui signifie que la fonction n'a été exécutée qu'une seule fois.

Notez que ce n'est pas parce que la fonction est déterministe qu'elle ne sera évaluée qu'une seule fois pour n'importe quelle requête. En fait, pour certaines requêtes, l'ajout SCHEMABINDINGpeut dégrader les performances. Considérez la requête suivante:

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;

Le superflu a DISTINCTété ajouté pour se débarrasser d'un opérateur Filter. Le plan semble prometteur:

plan de requête 2

Sur la base de cela, on s'attendrait à ce que l'UDF soit évalué une fois et utilisé comme table externe dans la jointure de boucle imbriquée. Cependant, la requête prend 6446 ms pour s'exécuter sur ma machine. Selon sys.dm_exec_function_statsla fonction a été exécutée 100 fois. Comment est-ce possible? Dans " Compute Scalars, Expressions and Execution Plan Performance ", Paul White souligne que l'opérateur Compute Scalar peut être différé:

Le plus souvent, un calcul scalaire définit simplement une expression; le calcul réel est différé jusqu'à ce que quelque chose plus tard dans le plan d'exécution ait besoin du résultat.

Pour cette requête, il semble que l'appel UDF a été différé jusqu'à ce qu'il soit nécessaire, moment auquel il a été évalué 100 fois.

Fait intéressant, l'exemple CTE s'exécute en 71 ms sur ma machine lorsque l'UDF n'est pas défini avec SCHEMABINDING, comme dans la question d'origine. La fonction n'est exécutée qu'une seule fois lorsque la requête est exécutée. Voici le plan de requête pour cela:

plan de requête 3

On ne sait pas pourquoi le calcul scalaire n'est pas différé. Cela peut être dû au fait que le non-déterminisme de la fonction limite le réarrangement des opérateurs que l'optimiseur de requête peut effectuer.

Une autre approche consiste à ajouter une petite table au CTE et à interroger la seule ligne de cette table. N'importe quelle petite table fera l'affaire, mais utilisons ce qui suit:

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

INSERT INTO dbo.X_ONE_ROW_TABLE VALUES (1);

La requête devient alors:

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;

L'ajout de l' dbo.X_ONE_ROW_TABLEincertitude ajoute à l'optimiseur. Si la table n'a aucune ligne, le CTE renverra 0 ligne. Dans tous les cas, l'optimiseur ne peut garantir que le CTE retournera une ligne si l'UDF n'est pas déterministe, il semble donc probable que l'UDF sera évalué avant la jointure. Je m'attendrais à ce que l'optimiseur analyse dbo.X_ONE_ROW_TABLE, utilise un agrégat de flux pour obtenir la valeur maximale d'une ligne retournée (ce qui nécessite l'évaluation de la fonction), et l'utiliser comme table externe pour une jointure de boucle imbriquée dbo.X_100_INTEGERSdans la requête principale . Cela semble être ce qui se passe :

plan de requête 4

La requête s'exécute en 110 ms environ sur ma machine et l'UDF n'est évalué qu'une seule fois selon sys.dm_exec_function_stats. Il serait incorrect de dire que l'optimiseur de requêtes est obligé d'évaluer une seule fois l'UDF. Cependant, il est difficile d'imaginer une réécriture de l'optimiseur qui conduirait à une requête à moindre coût, même avec les limitations concernant UDF et le calcul des coûts scalaires.

En résumé, pour les fonctions déterministes (qui doivent inclure l' SCHEMABINDINGoption), essayez d'écrire la requête de la manière la plus simple possible. Si sur SQL Server 2016 ou une version ultérieure, confirmez que la fonction n'a été exécutée qu'une seule fois à l'aide de sys.dm_exec_function_stats. Les plans d'exécution peuvent être trompeurs à cet égard.

Pour les fonctions qui ne sont pas considérées par SQL Server comme déterministes, y compris tout ce qui manque SCHEMABINDING, une approche consiste à placer l'UDF dans un CTE ou une table dérivée soigneusement conçue. Cela nécessite un peu de soin mais le même CTE peut fonctionner à la fois pour les fonctions déterministes et non déterministes.

Joe Obbish
la source