Scalar functions, inlining, and performance: An entertaining title for a boring post
Scalar. Function.
Wow.
Could any other combination of words evoke the same feeling of encapsulation, information hiding, and simplification of client code? After years spent developing software in the procedural and OO worlds, it can be difficult--perhaps, even impossible--to migrate over to working with SQL Server and not consider how to architect your data access logic using some of the same techniques you'd use in the application tier.
In short: Why would you
ever write the same piece of logic more than once? Answer:
You wouldn't (damn it!). And so Microsoft bestowed upon the SQL Server community, in SQL Server 2000, the ability to write scalar user-defined functions. And they could have been such beautiful things...
But alas, reality can be painful, and as developers tried these new tools they were struck with a strange feeling of sadness as their applications buckled under the weight of what otherwise would have been a wonderful idea. As it turned out, putting all but the simplest of logic into these scalar functions was a recipe for disaster. Why? Because they're essentially cursors waiting to happen (but they don't
look like cursors, so you may not know... until it's too late.)
The central problem is that when you wrap logic in a multistatement UDF, the query optimizer just can't unwrap it too easily. And so there's really only one way to evaluate a scalar UDF: call it once per row. And that is really nothing more than a cursor.
Seeing this behavior in action is easy enough; consider the following scalar function that some poor sap DBA working for AdventureWorks might be compelled to create:
CREATE FUNCTION GetMaxProductQty_Scalar
(
@ProductId INT
)
RETURNS INT
AS
BEGIN
DECLARE @maxQty INT
SELECT @maxQty = MAX(sod.OrderQty)
FROM Sales.SalesOrderDetail sod
WHERE sod.ProductId = @ProductId
RETURN (@maxQty)
END
Simple enough, right? Let's pretend that AdventureWorks has a bunch of reports, each of which requires maximum quantity sold per product. So the DBA, thinking he can save himself some time and keep everything centralized (and that is a good idea), puts all of the logic into a scalar UDF. Now, when he needs this logic, he can just call the UDF. And if the logic has a bug, or needs to be changed, he can change it in exactly
one place. And so life is great... Or is it?
Let's take a look at a sample query:
SELECT
ProductId,
dbo.GetMaxProductQty_Scalar(ProductId)
FROM Production.Product
ORDER BY ProductId
This query does nothing more than get the max quantity sold for each product in the Productin.Product table. And a look at the execution plan or the STATISTICS IO output might indicate that there's nothing too interesting going on here: The execution plan shows an index scan (to be expected, with no WHERE clause), followed by a compute scalar operation (the call to the UDF). And STATISTICS IO shows a mere 16 reads.
So why is this query so problematic? Because the real issue is hiding just beneath the surface.
The execution plan and STATISTICS IO didn't consider any of the code evaluated within the UDF! To see what's
really going on, fire up SQL Server Profiler, turn on the SQL:BatchCompleted event, and make sure you're showing the Reads column. Now run the query again and you'll see that this seemingly-innocent block of T-SQL is, in fact, using 365,247 logical reads. Quite a difference!
Each of those "compute scalar" operations is really a call to the UDF, and each of the calls to the UDF is really a new query. And all of those queries (all 504 of them -- the number of products in the Product table) add up to massive I/O. Clearly not a good idea in a production environment.
But luckily, we're not done here yet (or this would be a very boring post). Because while the performance penalty is a major turnoff, I really do love the encapsulation afforded by scalar UDFs. I want them (or a similar tool) in my toolbox... And so I got to thinking.
The answer to my dilemma, as it turns out, is to not use scalar UDFs at all, but rather to use
inline table-valued UDFs and treat them like scalars. This means that queries get slightly more complex than with scalar UDFs, but because the funtions are inlined (treated like macros) they're optimized along with the rest of the query. Which means, no more under-the-cover cursors.
Following is a modified version of the scalar UDF posted above:
CREATE FUNCTION GetMaxProductQty_Inline
(
@ProductId INT
)
RETURNS TABLE
AS
RETURN
(
SELECT MAX(sod.OrderQty) AS maxqty
FROM Sales.SalesOrderDetail sod
WHERE sod.ProductId = @ProductId
)
This function is no longer actually scalar--in fact, it now returns a table. It just so happens that the table has exactly one column and exactly one row, and uses the same logic as the scalar UDF shown above. So it's still scalar enough for my purposes.
The query shown above, used to retrieve the maximum quantity sold for each product, will not quite work with this UDF as-is. Trying to substitute in the new UDF will result in nothing more than a variant on an "object not found" error. Instead, you need actually treat this function like it returns a table (due to the fact that it does). And that means, in this case, a
subquery:
SELECT
ProductId,
(
SELECT MaxQty
FROM dbo.GetMaxProductQty_Inline(ProductId)
) MaxQty
FROM Production.Product
ORDER BY ProductId
So there it is. We're now treating the table-valued UDF more or less just like a scalar UDF. And the difference in I/O results is really quite astounding: 1267 logical reads in this case. Meaning that the scalar UDF solution is around 288 times more I/O intensive!
The question being, of course, was it worth it? The whole thing could have been written as one query, without the need for any UDFs at all. And the final query in this case is quite a bit more complex than the previous version, in addition to the fact that the encapsulation breaks down to some degree by forcing the caller to have some knowledge of how the UDF actually works. But I do feel that this sacrifice is warranted in some cases. Although the "greatest quantity sold" example shown here is simplistic, imagine other situations in which the same code fragments or logic are used over and over, due to lack of a good way of standardizing and centralizing them. I know I've seen that a lot in my work, and some examples I can think of have included complex logic that might very well have been easier to maintain in a UDF.
This technique may not be perfect for every case, and it certainly has its tradeoffs. But it may be a useful trick to keep in the back of your mind for a rainy day in the data center when someone's scalar UDF solution starts breaking down and you need a fix that doesn't require a massive code rewrite.
Cross-posted from SQLBlog! -
http://www.sqlblog.com