Bagaimana saya bisa memaksa UDF skalar untuk dievaluasi hanya sekali dalam permintaan?

12

Saya punya pertanyaan yang perlu disaring terhadap hasil skalar UDF. Permintaan harus dikirim sebagai pernyataan tunggal (jadi saya tidak bisa menetapkan hasil UDF ke variabel lokal) dan saya tidak bisa menggunakan TVF. Saya menyadari masalah kinerja yang disebabkan oleh skalar UDFs, yang meliputi pemaksaan seluruh rencana untuk dijalankan secara seri, hibah memori yang berlebihan, masalah estimasi kardinalitas, dan kurangnya inlining. Untuk pertanyaan ini, silakan asumsikan bahwa saya perlu menggunakan skalar UDF.

UDF itu sendiri cukup mahal untuk dipanggil tetapi secara teori kueri dapat secara logis dilaksanakan oleh pengoptimal sedemikian rupa sehingga fungsi hanya perlu dihitung sekali. Saya telah membuat contoh yang sangat sederhana untuk pertanyaan ini. Permintaan berikut membutuhkan 6152 ms untuk dijalankan di mesin saya:

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

Operator filter dalam rencana kueri menyarankan bahwa fungsi tersebut dievaluasi satu kali untuk setiap baris:

rencana permintaan 1

Persiapan data dan 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;

Berikut ini adalah tautan biola db untuk contoh di atas, walaupun kode membutuhkan waktu sekitar 18 detik untuk mengeksekusi di sana.

Dalam beberapa kasus saya mungkin tidak dapat mengedit kode fungsi karena disediakan oleh vendor. Dalam kasus lain saya bisa melakukan perubahan. Bagaimana saya bisa memaksa UDF skalar untuk dievaluasi hanya sekali dalam permintaan?

Joe Obbish
sumber

Jawaban:

17

Pada akhirnya, SQL Server tidak mungkin untuk mengevaluasi skalar UDF hanya sekali dalam kueri. Namun, ada beberapa langkah yang bisa diambil untuk mendorongnya. Dengan pengujian saya percaya bahwa Anda bisa mendapatkan sesuatu yang berfungsi dengan versi SQL Server saat ini, tetapi ada kemungkinan bahwa perubahan di masa depan akan mengharuskan Anda untuk meninjau kembali kode Anda.

Jika mungkin untuk mengedit kode, hal pertama yang baik untuk dicoba adalah membuat fungsi deterministik jika memungkinkan. Paul White menunjukkan di sini bahwa fungsi harus dibuat dengan SCHEMABINDINGopsi dan kode fungsi itu sendiri harus deterministik.

Setelah melakukan perubahan berikut:

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;

Permintaan dari pertanyaan dijalankan dalam 64 ms:

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

Paket kueri tidak lagi memiliki operator filter:

rencana permintaan 1

Untuk memastikan bahwa itu dijalankan hanya sekali kita dapat menggunakan DMV sys.dm_exec_function_stats baru yang dirilis di SQL Server 2016:

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

Mengeluarkan ALTERfungsi yang berlawanan akan mengatur ulang execution_countobjek tersebut. Kueri di atas mengembalikan 1 yang berarti fungsi hanya dijalankan sekali.

Perhatikan bahwa hanya karena fungsi bersifat deterministik, bukan berarti hanya akan dievaluasi sekali untuk setiap kueri. Bahkan, untuk beberapa permintaan menambahkan SCHEMABINDINGdapat menurunkan kinerja. Pertimbangkan pertanyaan berikut:

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;

Superfluous DISTINCTditambahkan untuk menyingkirkan operator Filter. Rencananya terlihat menjanjikan:

rencana permintaan 2

Berdasarkan itu, orang akan mengharapkan UDF untuk dievaluasi sekali dan akan digunakan sebagai tabel luar dalam loop bersarang bergabung. Namun, kueri membutuhkan 6446 ms untuk dijalankan di mesin saya. Menurut sys.dm_exec_function_statsfungsinya dijalankan 100 kali. Bagaimana itu mungkin? Dalam " Compute Scalars, Expressions, dan Performance Execution Plan ", Paul White menunjukkan bahwa operator Compal Scalar dapat ditunda:

Lebih sering daripada tidak, sebuah Compute Scalar hanya mendefinisikan ekspresi; perhitungan yang sebenarnya ditangguhkan sampai sesuatu nanti dalam rencana pelaksanaan membutuhkan hasil.

Untuk kueri ini, sepertinya panggilan UDF ditunda sampai diperlukan, pada saat itu dievaluasi 100 kali.

Menariknya, contoh CTE dieksekusi dalam 71 ms pada mesin saya ketika UDF tidak didefinisikan dengan SCHEMABINDING, seperti pada pertanyaan asli. Fungsi ini hanya dijalankan sekali ketika kueri dijalankan. Berikut adalah rencana permintaan untuk itu:

rencana permintaan 3

Tidak jelas mengapa Compute Scalar tidak ditangguhkan. Ini bisa jadi karena ketidaktentuan fungsi membatasi pengaturan ulang operator yang dapat dilakukan oleh optimizer kueri.

Pendekatan alternatif adalah menambahkan tabel kecil ke CTE dan untuk menanyakan satu-satunya baris dalam tabel itu. Meja kecil mana pun bisa digunakan, tetapi mari kita gunakan yang berikut ini:

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

INSERT INTO dbo.X_ONE_ROW_TABLE VALUES (1);

Permintaan kemudian menjadi:

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;

Penambahan dbo.X_ONE_ROW_TABLEmenambahkan ketidakpastian untuk pengoptimal. Jika tabel memiliki nol baris maka CTE akan mengembalikan 0 baris. Dalam hal apa pun, pengoptimal tidak dapat menjamin bahwa CTE akan mengembalikan satu baris jika UDF tidak deterministik, sehingga sepertinya UDF akan dievaluasi sebelum bergabung. Saya akan mengharapkan pengoptimal untuk memindai dbo.X_ONE_ROW_TABLE, menggunakan agregat aliran untuk mendapatkan nilai maksimum dari satu baris yang dikembalikan (yang membutuhkan fungsi untuk dievaluasi), dan menggunakannya sebagai tabel luar untuk loop bersarang bergabung ke dbo.X_100_INTEGERSdalam permintaan utama . Ini tampaknya yang terjadi :

rencana permintaan 4

Permintaan dijalankan dalam sekitar 110 ms pada mesin saya dan UDF dievaluasi hanya sekali menurut sys.dm_exec_function_stats. Tidak benar untuk mengatakan bahwa pengoptimal permintaan dipaksa untuk mengevaluasi UDF hanya sekali. Namun, sulit untuk membayangkan penulisan ulang pengoptimal yang akan mengarah pada permintaan biaya yang lebih rendah, bahkan dengan keterbatasan sekitar UDF dan menghitung biaya skalar.

Singkatnya, untuk fungsi deterministik (yang harus menyertakan SCHEMABINDINGopsi) cobalah menulis kueri dengan cara sesederhana mungkin. Jika pada SQL Server 2016 atau versi yang lebih baru, konfirmasikan bahwa fungsi hanya dijalankan sekali menggunakan sys.dm_exec_function_stats. Rencana pelaksanaan dapat menyesatkan dalam hal itu.

Untuk fungsi yang tidak dipertimbangkan oleh SQL Server menjadi deterministik, termasuk apa pun yang tidak memiliki SCHEMABINDINGopsi, salah satu pendekatan adalah menempatkan UDF dalam CTE yang dibuat dengan hati-hati atau tabel turunan. Ini membutuhkan sedikit perawatan tetapi CTE yang sama dapat berfungsi untuk fungsi deterministik dan nondeterministik.

Joe Obbish
sumber