A little DISTINCT challenge with DAX optimisation | by Salvatore Cagliari | Towards Data Science

One of my clients asked me about the best way to perform a count of all his orders. This looks like an easy task in DAX, but it holds a few minor hurdles to answer the question in the best way.
I used the Contoso dataset from Microsoft for this article. You can find it here: Microsoft Contoso BI Demo Dataset for Retail Industry. Take care that you need a SQL Server instance for this dataset, as the downloaded file is a SQL Server database backup file.
Because the original file contains “only” 12 million rows in the Online Sales table, and I wanted to have as much test data as possible, I generated a further 51 million rows in this table, for a total of 63 million rows. The Retail sales table contains 15.5 million rows now, instead of 3.4 million rows.
I loaded this data in a Power BI file, which has now 1.45 GB instead of 210 MB from the original dataset.
I used DAX Studio to measure the performance of the different variants.
When we look at the Online Sales table, we will notice that
The table contains a column SaleOrderNumber
Each SaleOrderNumber exists multiple times
Each row has a SalesOrderLineNumber, which start from 1 for each Order Number.
My client’s question was, “How can I count my orders most efficiently?”.
The most straightforward approach to fulfil the requirement is a DISTINCTCOUNT(‘Online Sales’[Sales Order Number]).
But I already experienced some performance issues for DISTINCTCOUNT() and wanted to try something different and compare the performance. You can read about possible problems with DISTINCTCOUNT() in the SQLBI Article Analyzing the performance of DISTINCTCOUNT in DAX.
So I tried the following approach:
I added a calculated column to the Online Sales table with the following expression:
[First Order Line Counter] = IF(‘Online Sales’[Sales Order Line Number] = 1, 1, 0)
To test the result, I executed the following query:
DEFINE   MEASURE ‘All Measures’[Distinct Order Count] =         DISTINCTCOUNT(‘Online Sales’[Sales Order Number])    MEASURE ‘All Measures’[Sum First Line Counter] =         SUM(‘Online Sales’[First Order Line Counter]) EVALUATE   SUMMARIZECOLUMNS(       ‘Date’[Year]       ,”Distinct Order Count”, ‘All Measures’[Distinct Order Count]       ,”First Order Line Count”, ‘All Measures’[Sum First Line Counter]         )
The result is identical for both Measures.
But as soon as you start slicing the result by other attributes, for example, by Brand Name, the situation changes:
DEFINE   MEASURE ‘All Measures’[Distinct Order Count] =      DISTINCTCOUNT(‘Online Sales’[Sales Order Number])    MEASURE ‘All Measures’[Sum First Line Counter] =      SUM(‘Online Sales’[First Order Line Counter]) EVALUATE   SUMMARIZECOLUMNS(      ‘Date’[Year]      ,’Product’[BrandName]      ,”Distinct Order Count”, ‘All Measures’[Distinct Order Count]      ,”First Order Line Count”, ‘All Measures’[Sum First Line Counter]       )
The result is no longer the same:
Most of the rows have a [Sales Order Line Number], which is different from 1. As a consequence, all these rows have 0 as [First Order Line Counter]).
The approach with the calculated column is not usable in this scenario. Even that I usually add a counter column with 1 in each row to simplify calculations (SUM() instead of COUNTROWS() ).
The second approach is to use the following Measure:
‘All Measures’[Order Count] =   COUNTROWS(      SUMMARIZE(          ‘Online Sales’          ,’Online Sales’[Sales Order Number]          )      )
You can use VALUES(‘Online Sales’[SalesOrderNumber]) or DISTINCT(‘Online Sales’[SalesOrderNumber]) instead of SUMMARIZE(). But there is almost no difference in performance between these three solutions. Still, it would help if you made attention to the different behaviour between VALUES() and DISTINCT() related to possible blank values (See here for details: Blank row in DAX (SQLBI) ).
I tested both variants against my test model, and I get almost the same performance:
Both are quick, and the query is fulfilled almost entirely from the Storage Engine. And all three variants return the same result.
I will explain why it is important that the Storage Engine fulfils the query instead of the Formula Engine later.
At this point, I would have been able to go to my client and tell him what I found out: “It doesn’t matter. You can use whatever method you want”.
But I knew that he has some complex calculation that relies on the DISTINCTCOUNT() pattern. So I needed to test these pattern as well.
One of them is to count stock items using the LASTNONBLANK() function with a DISTINCTCOUNT() over specific columns.
To use the same amount of data, I created a measure to count the last day’s orders in a specific period.
I started with the DISTINCTCOUNT() function to get a baseline:
‘All Measures’[Count Last Day Sales] =   VAR LastSaleDate = LASTNONBLANK(‘Date’[Date]         ,DISTINCTCOUNT(‘Online Sales’[SalesOrderNumber])         )RETURN   CALCULATE(         DISTINCTCOUNT(‘Online Sales’[SalesOrderNumber])         ,LastSaleDate         )
I queried the data with this measure and got this result:
After this test, I applied the second approach described above to this measure:
‘All Measures’[Count Last Day Sales 2] =   VAR LastSaleDate = LASTNONBLANK(‘Date’[Date]         ,COUNT(‘Online Sales’[SalesOrderNumber])         )RETURN   CALCULATE(      COUNTROWS(         SUMMARIZE(            ‘Online Sales’            ,’Online Sales’[SalesOrderNumber]         )      )      ,LastSaleDate   )
The performance was noticeably different:
As you can see, the Total time is almost one second less.
More importantly, the Storage Engine is used ~5% more as with DISTINCTCOUNT().
Why this is so important:
Two factors make the usage of Storage Engine preferably instead of the Formula Engine:
The Storage Engine is multi-threaded, while the Formula Engine is single-threaded only.The Storage Engine has a cache, while the Formula Engine doesn’t have any cache.
The Storage Engine is multi-threaded, while the Formula Engine is single-threaded only.
The Storage Engine has a cache, while the Formula Engine doesn’t have any cache.
This first factor makes the Storage Engine fast and more efficient. The second factor makes measures, which uses more of the Storage Engine, more scalable. This means that when several users are using a report with this measure, the Storage Engine will use his cache, and the performance will be better.
This topic is explained here in detail: Formula engine and storage engine in DAX (SQLBI)
As the guys at SQLBI always point out, it is essential to understand the theory, but it is more important to test this knowledge against the specific situation.
So I executed a benchmark test in DAX Studio. The results are surprising:
Now compare the benchmark result with the measure which uses SUMMARIZE():
This result is surprising when you look at the results with a warm cache.
With a cold cache, the Measure with SUMMARIZE() is consistently quicker. But as soon as we use the cache, the measure with DISTINCT() becomes quicker.
The measure which uses less from the Storage Engine performs better when the query is executed several times. This result is unexpected and needs more analysis.
You can find the reason for this result when you look at the execution plans side by side:
You see the significant difference when you compare the first two lines.
The Measure with DISTINCTCOUNT() generates 4'290 and 6'575 intermediary rows, while the Measure with SUMMARIZE() generates 8'536 and 6'575 intermediary rows.
The following steps are using fewer rows as well with the DISTINCTCOUNT() measure. This is the reason why the measure with DISTINCTCOUNT() is faster when using the cache. There are fewer data to store in the cache, and therefore, it has fewer data to retrieve.
My main message to you is: Don’t take any advice for granted. Your situation is always unique, and it can change the outcome of any optimisation applied to your code.
You need a good understanding of how the two Engines works and how to use the available tools to gain as much knowledge as possible.
When you test your code, execute the query with and without cache. You can use the “Clear Cache then run” feature in DAX Studio for this:
Apply your know-how to your specific situation and don’t take anything for granted because you have read something written on the Internet.
Think about the existing use-cases and how your users will query the data. Use this knowledge to perform meaningful benchmarks and take valuable measurements.
Document all steps, changes to your code and measurements after each change.
For my tests, I used the following DAX Query with the definition of all different Measures to perform the tests:
DEFINE   MEASURE ‘All Measures’[Distinct Count] =      DISTINCTCOUNT(‘Online Sales’[SalesOrderNumber])    MEASURE ‘All Measures’[Order Count] =      COUNTROWS(         SUMMARIZE(            ‘Online Sales’            ,’Online Sales’[SalesOrderNumber]         )      )    MEASURE ‘All Measures’[Order Count D] =      COUNTROWS(         DISTINCT( ‘Online Sales’[SalesOrderNumber] )         )    MEASURE ‘All Measures’[Order Count V] =      COUNTROWS(         VALUES( ‘Online Sales’[SalesOrderNumber] )         )     MEASURE ‘All Measures’[Sum Main Row] =      SUM(‘Online Sales’[First Order Line Count])    MEASURE ‘All Measures’[Sum Sales Line] =      SUM(‘Online Sales’[Sale Line Counter])    MEASURE ‘All Measures’[Sales Last Date] =      VAR LastSaleDate = LASTNONBLANK(‘Date’[Date]         ,COUNT(‘Online Sales’[SalesOrderNumber])         )   RETURN       LastSaleDate    MEASURE ‘All Measures’[Count Last Day Sales] =      VAR LastSaleDate = LASTNONBLANK(‘Date’[Date]         ,DISTINCTCOUNT(‘Online Sales’[SalesOrderNumber])         )   RETURN      CALCULATE(         DISTINCTCOUNT(‘Online Sales’[SalesOrderNumber])         ,LastSaleDate         )    MEASURE ‘All Measures’[Count Last Day Sales 2] =      VAR LastSaleDate = LASTNONBLANK(‘Date’[Date]           ,COUNT(‘Online Sales’[SalesOrderNumber])           )   RETURN      CALCULATE(         COUNTROWS(            SUMMARIZE(               ‘Online Sales’               ,’Online Sales’[SalesOrderNumber]               )          )         // Alternative to SUMMARIZE is:         // VALUES(‘Online Sales’[SalesOrderNumber]         // )         // )         ,LastSaleDate      )   //EVALUATE //   SUMMARIZECOLUMNS( //      ‘Date’[Year] //      ,’Customer’[CompanyName] //      ,”Distinct Count”, ‘All Measures’[Distinct Count] //      ,”Sum First order Line”, ‘All Measures’[Sum Main Row] //      ,”Sum Line Counter”, ‘All Measures’[Sum Sales Line] //      ,”Order Count Adv.”, ‘All Measures’[Order Count] //      ,”Distinct Count”, ‘All Measures’[Distinct Count] //      ,”Distinct Count D”, ‘All Measures’[Order Count D] //      ,”Distinct Count V”, ‘All Measures’[Order Count V] //      )  EVALUATE    SUMMARIZECOLUMNS(       ‘Date’[Year]       ,’Customer’[CompanyName]       // ,”Distinct Count”, ‘All Measures’[Distinct Count]       // ,”Sum First order Line”, ‘All Measures’[Sum Main Row]       // ,”Sum Line Counter”, ‘All Measures’[Sum Sales Line]       // ,”Order Count Adv.”, ‘All Measures’[Order Count]       // ,”Sales Last Date”, ‘All Measures’[Sales Last Date]       ,”Count Last Sales”, ‘All Measures’[Count Last Day Sales]      // ,”Count Last Sales 2", ‘All Measures’[Count Last Day Sales 2]      )
I uncommented the parts I wanted to test or compare and changed the Measures directly in the Script.
In the end: Use your common sense to choose the right approach when you try to find the best solution.
BTW: You can apply everything in this article to Power BI and Analysis Services.