The bottom line on scalar UDFs is that they’re poison pills for performance.
They’re bad enough in the select list, but they get even worse if they appear in join or where clause portions of the query.
The example query we’re going to use doesn’t even go out and touch other tables, which can certainly make things worse. It does all its processing “in memory”.
CREATE FUNCTION dbo.nonsargable(@d datetime) RETURNS bit WITH SCHEMABINDING, RETURNS NULL ON NULL INPUT AS BEGIN RETURN ( SELECT CASE WHEN DATEDIFF ( DAY, @d, GETDATE() ) > 30 THEN 1 ELSE 0 END ); END; GO
When we run this query, the plan is a messmare.
SELECT c = COUNT_BIG(*) FROM dbo.Users AS u WHERE u.Reputation > 100 AND dbo.nonsargable(u.LastAccessDate) = 1;
The Filter operator is a familiar face at this point.
The function has to run once per row that leaves the Users table, and the predicate has to be calculated and evaluated later at the filter operation.
If we look in dm_exec_function_stats, we can see just how many times, and how much CPU the function used when we ran just this one query:
If you pay extra special attention, you’ll noticed that the execution_count here exactly matches the number of rows that pass through the Filter operator above.
The way to fix this is to rewrite the function as an inline table valued function.
CREATE FUNCTION dbo.nonsargable_inline(@d datetime) RETURNS TABLE WITH SCHEMABINDING AS RETURN SELECT b = CASE WHEN DATEDIFF ( DAY, @d, GETDATE() ) > 30 THEN 1 ELSE 0 END; GO
Now we don’t have all those scalar problems.
Save The Wheels
We have to call our function a little bit differently, but that’s far less of a big deal.
SELECT c = COUNT_BIG(*) FROM dbo.Users AS u WHERE u.Reputation > 100 AND ( SELECT * FROM dbo.nonsargable_inline(u.LastAccessDate) ) = 1;
And our query can go parallel, and take way less than 10 seconds.
While the predicate isn’t exactly ideal, in this case I’ll deal with it, and maybe even add some better indexes if I need to. At 183 milliseconds, I don’t need to do that right now.
Save The Feels
Scalar UDFs are still the downfall of many queries, and because this one has getdate in it, it can’t be inlined automatically in SQL Server 2019.
With simple enough functions, rewrites to inline table valued functions are easy enough. The more nonsense you put into the body of a function, the harder quick fixes get. I’ve spent days rewriting some that ran into the thousands of lines, and it’s not a pretty process.
Tomorrow we’re going to look at how indexed views can help you solve SARGability issues.
Thanks for reading!