Site icon Phil Seamark on DAX

Why the RLS bitmap default just went up 4x

If you have ever watched a model with row-level security run beautifully for months and then quietly fall off a cliff, this one is for you. Nothing in the model changed. You did not touch the security rules. One table just grew past a line you could not see, and every query under that role started doing a great deal more work than it used to.

That line just moved, and it moved in your favour. Here is what it is and why it sits where it does.

What row-level security actually does

Row-level security in a Tabular model is a DAX expression attached to a table inside a role. The engine runs that expression, the predicate, against every row of the table. Rows where it returns TRUE are visible to members of the role. Everything else disappears, not just from the report but from totals, from COUNTROWS, from everything downstream.

Predicates come in two flavours, and the engine cares about the difference a lot more than you do.

-- Cheap. A column compared to a constant or a user attribute.
Geography[Country] = "Canada"

-- Expensive. Needs a lookup, a relationship traversal, a measure.
Customer[CustomerID] IN
    CALCULATETABLE ( VALUES ( Access[CustomerID] ), Access[User] = USERPRINCIPALNAME() )

Both answer the same yes or no question for each row. The cheap one can be answered inside the columnar scan without leaving the storage engine. The expensive one cannot, and that is where things get interesting.

Three ways to apply a filter

When a query runs under an active role, the engine picks one of three strategies.

                    Is the predicate simple
                    enough to push into the scan?
                              |
                  yes ────────┴──────── no
                   |                     |
          1. Native filter      Is the table small enough
          (free, in the scan)   to build a bitmap for?
                                         |
                             yes ────────┴──────── no
                              |                     |
                     2. Cached bitmap      3. Per-row callback
                     (build once, reuse)   (call back for every row)

Path one is free. Path three is the slow one Marco Russo has been warning people about for years in his piece on the cost of security in Tabular. Path two, the bitmap, is the one that just got more room to breathe.

The bitmap, in one picture

When a predicate is too complex to push into the scan, the engine evaluates it once and writes down the answer as a bitmap. One bit per row. One means the row is visible to the role, zero means it is filtered out.

Predicate:  Geography[Country] = "Canada"

Row#:    0    1    2    3    4    5    6    7
Country: CA   US   CA   CA   JP   US   CA   CA
Bit:     1    0    1    1    0    0    1    1      1 = visible, 0 = filtered

Under the covers it is a single contiguous bit array, sliced to line up with the table’s storage segments and packed into 64-bit words so the engine can march through 64 rows at a time. At query time it does not re-run your DAX. It just ANDs that bitmap into the same scan it was already going to do. One pass, no detours back into the formula engine.

A few things worth knowing about it:

it is probed while the engine scans the fact table through the relationship.

pays for it once. Everyone after that gets it for free.

not depend on who is asking, produces a single bitmap that every user in the role shares. A dynamic predicate, one that calls USERNAME or USERPRINCIPALNAME, gives a different answer per user, so each user gets their own bitmap. The same user’s queries still reuse it; two different users in the same role do not.

model, a refresh, an ALTER, a schema change, clears it. So does the model being evicted from memory or loaded up on another node, whichever happens first. Short of that it sticks around, and adding or removing users in the role does not throw it away.

a role using dynamic security builds one per user on top of that.

The change: 128K becomes 512K

Here is the actual news. The engine only builds and caches a bitmap up to a certain table size. Above that size it gives up on the bitmap and drops to the per-row callback, which is the path you do not want.

ThresholdBitmap size
Old default128K rows16 KiB
New default512K rows64 KiB

That is a 4x increase, and it arrives with the engine. You do not change anything in your model. Any table sitting in the 128K to 512K band with a complex security predicate now stays on the fast bitmap path instead of falling back to callbacks.

Why 512K and not two million

This is the part I found genuinely good. The first instinct was to push the threshold much higher, out towards one or two million rows. The number landed at 512K because of how a CPU is built, not because of anything in the data.

A 512K-row bitmap is 64 KiB. That fits comfortably inside the cache that sits right next to a modern CPU core, which is where you want it if you are going to AND it into a scan over and over. Make the bitmap much bigger and it stops fitting in that cache, and you start paying to fetch it from further away every time you use it.

To be honest about it, bigger would still usually win. Even at one or two million rows, a cached bitmap beats calling back into the formula engine for every single row, because that callback is so expensive. So this is not a hard cliff where 512K is fast and 513K is a disaster. It is a pragmatic sweet spot: big enough to catch a lot more real-world dimension tables, small enough to stay where the CPU likes it.

What this means for your models

The guidance has not changed, you just got more headroom.

of this applies to it.

it**. Move the predicate off the big fact table and onto a smaller related dimension table that sits under the threshold. This is ordinary star-schema discipline, and row-level security is one more reason to follow it.

128K line, and the new 512K line gives the borderline ones some breathing room.

Credits

The fix was done by Ming Han Teh on the Analysis Services engine team. Sean Tang and Marius Dumitru walked me through the architecture and the reasoning behind the number, which is the only reason the cache explanation above is more than hand-waving. Thanks all three.

If you have a model where a security predicate sits on a table somewhere between 128K and 512K rows, this is the rare performance improvement you get for doing nothing at all.

5 1 vote
Article Rating
Exit mobile version