Come posso forzare un UDF scalare per essere valutato una sola volta in una query?


12

Ho una query che deve filtrare in base al risultato di un UDF scalare. La query deve essere inviata come una singola istruzione (quindi non posso assegnare il risultato UDF a una variabile locale) e non posso usare un TVF. Sono a conoscenza dei problemi di prestazioni causati da UDF scalari, tra cui forzare l'esecuzione dell'intero piano in serie, concessioni di memoria eccessive, problemi di stima della cardinalità e mancanza di allineamento. Per questa domanda, supponiamo che sia necessario utilizzare un UDF scalare.

L'UDF stesso è piuttosto costoso da chiamare, ma in teoria le query possono essere logicamente implementate dall'ottimizzatore in modo tale che la funzione debba essere calcolata una sola volta. Ho preso in giro un esempio molto semplificato per questa domanda. La seguente query richiede 6152 ms per essere eseguita sulla mia macchina:

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

L'operatore di filtro nel piano di query suggerisce che la funzione è stata valutata una volta per ogni riga:

piano di query 1

DDL e preparazione dati:

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;

Ecco un link db fiddle per l'esempio sopra, anche se il codice impiega circa 18 secondi per essere eseguito lì.

In alcuni casi, potrei non essere in grado di modificare il codice della funzione perché è fornito da un fornitore. In altri casi sono in grado di apportare modifiche. Come posso forzare un UDF scalare per essere valutato una sola volta in una query?

Risposte:


17

In definitiva, non è possibile forzare SQL Server a valutare un UDF scalare solo una volta in una query. Tuttavia, ci sono alcuni passaggi che possono essere presi per incoraggiarlo. Con i test credo che sia possibile ottenere qualcosa che funzioni con la versione corrente di SQL Server, ma è possibile che le modifiche future richiedano la revisione del codice.

Se è possibile modificare il codice, una buona cosa da provare è se possibile rendere deterministica la funzione. Paul White sottolinea qui che la funzione deve essere creata con l' SCHEMABINDINGopzione e che il codice funzione stesso deve essere deterministico.

Dopo aver apportato la seguente modifica:

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 query dalla domanda viene eseguita in 64 ms:

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

Il piano di query non ha più l'operatore filtro:

piano di query 1

Per essere sicuri che sia stato eseguito solo una volta, possiamo usare il nuovo DMV sys.dm_exec_function_stats rilasciato in SQL Server 2016:

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

Emettere un ALTERcontro la funzione reimposterà il execution_countper quell'oggetto. La query sopra restituisce 1, il che significa che la funzione è stata eseguita una sola volta.

Si noti che solo perché la funzione è deterministica non significa che verrà valutata una sola volta per qualsiasi query. In effetti, per alcune query l'aggiunta SCHEMABINDINGpuò peggiorare le prestazioni. Considera la seguente query:

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;

Il superfluo è DISTINCTstato aggiunto per sbarazzarsi di un operatore di filtro. Il piano sembra promettente:

piano di query 2

Sulla base di ciò, ci si aspetterebbe che l'UDF venga valutato una volta e utilizzato come tabella esterna nel join del ciclo nidificato. Tuttavia, l'esecuzione della query richiede 6446 ms sul mio computer. Secondo sys.dm_exec_function_statsla funzione è stata eseguita 100 volte. Com'è possibile? In " Calcoli scalari, espressioni ed esecuzione del piano di esecuzione ", Paul White sottolinea che l'operatore di calcolo scalare può essere rinviato:

Più spesso, uno scalare di calcolo definisce semplicemente un'espressione; il calcolo effettivo viene rinviato fino a quando qualcosa di più tardi nel piano di esecuzione necessita del risultato.

Per questa query sembra che la chiamata UDF sia stata rinviata fino a quando non fosse necessaria, a quel punto è stata valutata 100 volte.

È interessante notare che l'esempio CTE viene eseguito in 71 ms sulla mia macchina quando l'UDF non è definito con SCHEMABINDING, come nella domanda originale. La funzione viene eseguita una sola volta quando viene eseguita la query. Ecco il piano di query per questo:

piano di query 3

Non è chiaro perché lo scalare di calcolo non sia differito. Potrebbe essere perché il non determinismo della funzione limita la riorganizzazione degli operatori che Query Optimizer può fare.

Un approccio alternativo consiste nell'aggiungere una piccola tabella al CTE e interrogare l'unica riga in quella tabella. Qualsiasi tavolino farà, ma usiamo quanto segue:

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

INSERT INTO dbo.X_ONE_ROW_TABLE VALUES (1);

La query diventa quindi:

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'aggiunta dbo.X_ONE_ROW_TABLEdell'incertezza aggiunge per l'ottimizzatore. Se la tabella ha zero righe, il CTE restituirà 0 righe. In ogni caso, l'ottimizzatore non può garantire che il CTE restituisca una riga se l'UDF non è deterministico, quindi sembra probabile che l'UDF verrà valutato prima del join. Mi aspetto che l'ottimizzatore esegua la scansione dbo.X_ONE_ROW_TABLE, utilizzi un aggregato di flusso per ottenere il valore massimo di una riga restituita (che richiede la valutazione della funzione) e utilizzarlo come tabella esterna per un loop nidificato da unire dbo.X_100_INTEGERSnella query principale . Questo sembra essere ciò che accade :

piano di query 4

La query viene eseguita in circa 110 ms sulla mia macchina e l'UDF viene valutato una sola volta in base a sys.dm_exec_function_stats. Sarebbe errato dire che Query Optimizer è costretto a valutare l'UDF solo una volta. Tuttavia, è difficile immaginare una riscrittura dell'ottimizzatore che porterebbe a una query a costi inferiori, anche con le limitazioni relative a UDF e al calcolo dei costi scalari.

In breve, per le funzioni deterministiche (che devono includere l' SCHEMABINDINGopzione) prova a scrivere la query nel modo più semplice possibile. Se su SQL Server 2016 o versione successiva, confermare che la funzione è stata eseguita solo una volta utilizzando sys.dm_exec_function_stats. I piani di esecuzione possono essere fuorvianti al riguardo.

Affinché le funzioni non considerate da SQL Server siano deterministiche, incluso tutto ciò che manca SCHEMABINDINGdell'opzione, un approccio è quello di inserire l'UDF in un CTE o tabella derivata attentamente predisposti. Ciò richiede un po 'di attenzione, ma lo stesso CTE può funzionare sia per le funzioni deterministiche che non deterministiche.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.