Behnam Analytics

Writing Power BI, DAX & TMDL

Power BI performance starts with VertiPaq

How the VertiPaq engine stores an import model, why cardinality drives size and speed, how to split date-time columns and cut unused ones, and how to use Performance Analyzer, DAX Studio and VertiPaq Analyzer to find the real bottleneck.

Behnam Ebrahimi 8 min read

When a Power BI report is slow, the first instinct is to rewrite the DAX. More often the model is carrying columns nobody uses, timestamps stored to the second, and IDs that are unique on every row. Those are storage problems, and fixing them helps every query that touches the table, not just the one you were looking at.

This article explains how the VertiPaq engine stores an import model and what that means for design. The examples use a synthetic admissions table from the same invented hospital as the rest of this series: 176,986 spells from April 2023 to August 2026, generated by the code in projects/article-examples/power-bi/. The counts below are properties of that data (distinct values, runs of repeated values) computed by the script. They are not measurements of Power BI, and I haven’t invented any sizes or timings. VertiPaq Analyzer, covered below, is how you get real sizes for your own model.

How VertiPaq stores a table

One column at a time. An import model stores each column separately. A query that sums length of stay by specialty reads two columns, however many others the table has. That’s why wide tables are cheaper to query than you’d expect, and why each extra column still costs memory and refresh time even if no visual uses it.

Encoding. Each column gets one of two encodings:

  • Value encoding stores the numbers themselves, shifted so they need fewer bits. Microsoft’s guidance is that numeric columns get this, and it’s the most efficient.
  • Hash encoding, also called dictionary encoding, builds a dictionary of the column’s distinct values and stores, for each row, the integer position of its value in that dictionary. Text always uses it. The engine can also choose it for numbers.

Either way, what’s stored per row is an integer, packed into as few bits as its range needs.

Compression. On top of that, run-length encoding (RLE) stores a run of identical consecutive values once, with a count. RLE depends on row order, and VertiPaq chooses the order itself while processing. You don’t control it directly, but the data shows why low-cardinality columns compress so well:

Column Runs in arrival order Runs sorted by specialty
Specialty key 134,023 7
Admission date 1,238 8,665

In arrival order, specialty changes almost every row: 134,023 runs. Sorted by specialty, it’s seven runs. Admission date goes the other way, from one run per day (the rows arrive in date order) to 8,665. Every sort order helps some columns and hurts others. Columns with few distinct values are the ones that can collapse into a handful of runs.

What cardinality does

Cardinality is the number of distinct values in a column, and it drives almost everything. More distinct values means a bigger dictionary, more bits per row, and fewer repeated runs. Here are several columns of the same 176,986-row table:

Column Distinct values Bits to index the dictionary
Spell ID 176,986 18
Admission date-time (to the second) 176,739 18
Admission time (to the second) 60,831 16
Admission time (to the minute) 1,440 11
Admission date 1,238 11
Admission hour 24 5
Length of stay 49 6
Specialty key 7 3

The bits column is simple arithmetic, the base-2 logarithm of the distinct count rounded up. It shows the idea, not the engine’s exact layout. Two columns stand out: the spell ID and the admission timestamp are both unique, or nearly unique, on every row. They will almost certainly be among the largest columns in the table, because run-length encoding has nothing to work with when values never repeat.

Split date-time columns

Source systems often store the admission time to the second. Most reports need the date, and some need the hour. Storing the timestamp as one column gives you a dictionary that grows with every row you load. Splitting it into a date column and a time column rounded to the minute gives two small dictionaries: the date column gains one entry per day, and the time column can never hold more than 1,440 values.

Distinct values in the admission timestamp as the table growsOne date-time column vs a date column plus a time-to-the-minute column
Data table
Data loaded up toDate-time to the secondDate + time to the minute
2023-04-013,9201,132
2023-05-017,9881,326
2023-06-0111,8731,439
2023-07-0115,8901,505
2023-08-0119,8591,553
2023-09-0123,9851,600
2023-10-0128,2631,641
2023-11-0132,5751,679
2023-12-0137,0091,712
2024-01-0141,6491,745
2024-02-0146,0771,774
2024-03-0150,5521,805
2024-04-0154,8401,835
2024-05-0158,9701,867
2024-06-0162,9371,897
2024-07-0166,9931,928
2024-08-0171,0071,959
2024-09-0175,1501,989
2024-10-0179,6752,020
2024-11-0184,0432,050
2024-12-0188,7632,081
2025-01-0193,7142,112
2025-02-0198,0912,140
2025-03-01102,6362,171
2025-04-01107,0222,201
2025-05-01111,2662,232
2025-06-01115,2682,262
2025-07-01119,5092,293
2025-08-01123,6852,324
2025-09-01128,0462,354
2025-10-01132,7092,385
2025-11-01137,2872,415
2025-12-01142,3192,446
2026-01-01147,3722,477
2026-02-01151,8322,505
2026-03-01156,5442,536
2026-04-01160,9952,566
2026-05-01165,4442,597
2026-06-01169,7002,627
2026-07-01174,0092,658
2026-08-01176,7392,678

Synthetic data. Source: projects/article-examples/power-bi.

By the August 2026 load, the single column holds 176,739 distinct values and the split pair 2,678 (1,238 dates and 1,440 minutes). The gap keeps widening as data arrives.

There’s a modelling reason too. A relationship matches values exactly, so a date-time of 14:37:12 on 3 March never matches the midnight value of 3 March in the Date table. You need a pure date column to relate to Date anyway. Do the split in Power Query or the source view, round the time to the precision anyone actually reports (minute, quarter hour, or just the hour), and drop the original.

Remove what nobody uses

Every column costs memory and refresh time whether or not a visual uses it. The candidates I look at first:

  • Row-level IDs. The spell ID is unique on every row, which makes it one of the most expensive columns you can load. Keep it only if a report needs to show it (drill-through to a spell list) or count it. If you drop it, remember that the fact table may then contain identical rows, which matters for iterators; the CALCULATE article shows how.
  • Text IDs with a prefix. Microsoft’s data reduction guidance suggests converting codes like SP123456 to integers where the prefix carries no information, so they can be value encoded.
  • Descriptive columns on the fact table. Specialty names and site names belong in their dimensions, with a small integer key on the fact. The star schema article covers the design.
  • History nobody reports on. Filtering the fact table to the years the reports use cuts every column at once.
  • DAX calculated columns. They’re stored like other columns but typically compress less well, and they’re built after the tables load. Create them in Power Query or the source instead, unless they need DAX.
  • Auto date/time. It adds a hidden date table for every date column. Turn it off once you have your own date table.

Formula engine and storage engine

A DAX query runs in two engines:

  • The storage engine is VertiPaq itself. It scans compressed columns, applies simple filters, groups and aggregates, and it uses multiple threads. It caches the results of its queries.
  • The formula engine does everything else: complex expressions, iterating over intermediate results, and putting the answer together. It runs on a single thread.

A fast query gets the storage engine to do most of the work in a few scans. A slow one usually makes the formula engine iterate over a large intermediate result, or makes the storage engine call back into the formula engine row by row. In DAX Studio’s storage engine query text, that callback shows up as CallbackDataID.

Some DAX habits push work the wrong way:

-- Slower: FILTER iterates the whole Admissions table to build a filter
Emergency Admissions :=
CALCULATE ( [Admissions], FILTER ( Admissions, Admissions[Admission Method] = "Emergency" ) )

-- Better: a column predicate the storage engine can apply directly
Emergency Admissions :=
CALCULATE ( [Admissions], KEEPFILTERS ( Admissions[Admission Method] = "Emergency" ) )

In this measure both return the same result (KEEPFILTERS keeps the intersection with existing filters that FILTER over the visible rows gives you), and Microsoft’s DAX guidance recommends the second form. The same applies to measure references inside iterators over fact tables, and to DISTINCTCOUNT on a near-unique column where a table at the right grain would let you use COUNTROWS.

Finding the problem

I work from the report inwards: find the slow visual, get its query, find where the time goes, then check the model.

1. Performance Analyzer. In Power BI Desktop, open the Optimize ribbon, select Performance analyzer, then Start recording and Refresh visuals. Each visual gets a breakdown:

  • DAX query: time for the model to return results. If this dominates, the problem is the model or the DAX.
  • Visual display: time to draw. If this dominates, the visual is usually plotting too many points, or it’s a heavy custom visual.
  • Other: preparing queries and waiting for other visuals. A page with dozens of visuals queues most of them here.

Refresh twice and compare. The first run can include cold-cache effects, so don’t draw conclusions from one run. Copy query gives you the DAX the visual sent.

2. DAX Studio. Paste the query, turn on Server Timings, clear the cache, and run it. You get total duration split into formula engine and storage engine time, the number of storage engine queries, and how many were answered from cache. Clear the cache before every timed run; otherwise the second run is fast because of the cache, not your change. Many storage engine queries, or a large share of formula engine time, point at the DAX. A few long storage engine scans point at the model.

3. VertiPaq Analyzer. In DAX Studio, Advanced > View Metrics analyses the connected model and lists every table and column with its cardinality, total size, dictionary size, data size, hierarchy size and encoding. Sort columns by total size and work down from the top. You can export the result as a .vpax file (or an obfuscated .ovpax) to keep a before-and-after record or share it without the data.

In a model like this one, I’d expect the timestamp and the spell ID at the top. The only way to know is to look.

Checklist

Model:

  • Every column has a reporting or modelling purpose; the rest are removed at source or in Power Query.
  • Date-time columns are split into a date and a rounded time, or just a date.
  • Row-level IDs are kept only when a report needs them.
  • Text codes that are really numbers are converted to integers.
  • Fact tables hold integer keys; names and labels live in dimensions.
  • Calculated columns are replaced by Power Query or source columns where possible.
  • Auto date/time is off.

DAX:

  • Filters are column predicates, not FILTER over a whole table.
  • Iterators over fact tables read columns, not measures.
  • Distinct counts over near-unique columns are replaced by COUNTROWS at the right grain.

Process:

  • Performance Analyzer identifies the slow visual before anyone rewrites anything.
  • DAX Studio timings are taken with a cleared cache, before and after each change.
  • A .vpax from VertiPaq Analyzer is saved before and after model changes.

For a worked semantic model, see the RTT waiting list semantic model project. The time intelligence article covers the date table, and DAX for waiting-list KPIs covers RTT measures.