Lakshya

Analytics & BI Tools · Chapter 9 of 18

How a BI engine actually works

Columnar compression, the three storage modes, and the one number that decides whether your model is fast or hopeless.

9 min read2 diagramsAll 18 chapters

Almost every BI performance problem, cost surprise and refresh failure traces back to two things: how the engine stores data, and where the query runs. Neither is usually taught, which is why so much BI work is superstition — people learn that a particular chart type is slow without ever learning why.

Columnar storage, and why analytics is a different problem

A transactional database stores rows together, because a transaction touches whole rows: fetch this customer, update this order. An analytical engine stores columns together, because an analytical query touches few columns and many rows: sum revenue by region for the year.

Two consequences follow, and they explain the whole design:

  • You only read the columns you use. Summing one column of a fifty-column table reads one fiftieth of the data. This is the single largest reason columnar engines are fast on analytical work, and it is also why the same engine is dreadful at fetching one whole row.
  • A column compresses far better than a row does, because values in one column are the same type and often repeat. A row mixes a name, a date, a float and a flag, which share no structure.
THE SAME 10 MILLION ROWS, TWO COLUMNS, WILDLY DIFFERENT COST country — 190 distinct values dictionary: 190 strings stored ONCE column becomes 10M integers, 8 bits each then run-length encoded after sorting ≈ a few MB compression ratio can exceed 100× transaction_id — 10M distinct dictionary: 10M entries — as big as the data no runs to encode, every value differs the dictionary itself becomes the problem ≈ hundreds of MB and it is usually a column nobody aggregates CARDINALITY — not row count — DOMINATES MODEL SIZE. This one fact explains most BI performance work. Which is why the standard fixes are all cardinality reductions: drop the surrogate key nobody filters on, split a datetime into date plus time-of-day (86,400 values instead of millions), round a float to the precision you actually report, and remove the free-text notes column. AND WHY ANALYTICAL QUERIES SUIT THIS AND TRANSACTIONAL ONES DO NOT SUM over one columnreads one column, skips the rest entirelyGROUP BY two columnsreads two columns of a fifty-column tablefetch one whole rowmust touch every column — the worst case
Two columns, ten million rows each, and a hundredfold difference in cost. The teaching point in the middle is the one that matters: cardinality, not row count, dominates model size.

The compression stack, in the order it is applied

  • Dictionary encoding. Store each distinct value once in a dictionary and replace the column with integer references. A country column with 190 distinct values becomes a column of 8-bit integers plus a small dictionary.
  • Value encoding. For numeric columns, store an offset from a base rather than the value — if every value lies between 1,000,000 and 1,000,255, one byte per row will do.
  • Run-length encoding. Replace a run of identical values with the value and a count. This is why sort order matters enormously: the same column sorted well compresses several times better than the same column unsorted, and the engine chooses a sort order per segment to maximise this.
  • Bit-packing. If the dictionary has 190 entries, eight bits per row is enough. Nobody stores a 64-bit integer for a value that needs eight.

Cardinality is the number to look at, and the fixes are all the same shape

A column's cost is driven by its number of distinct values, not by how many rows the table has. Adding ten million rows to a table whose columns are all low-cardinality costs almost nothing. Adding one high-cardinality column costs a great deal.

So the standard remedies are all cardinality reductions, and they are worth knowing as a checklist because they work on every engine of this kind:

Drop the surrogate key nobody filters on. A transaction ID with ten million distinct values is frequently the largest single column in a model and is never used in a visual. This one change routinely halves a model.

Split a datetime into a date and a time-of-day. A datetime to the second across three years has tens of millions of distinct values; a date has about a thousand and a time-of-day has 86,400. Two low-cardinality columns beat one enormous one, and you almost never need them combined.

Round floats to the precision you actually report. A measure displayed to two decimal places does not need fifteen significant figures, and every extra digit is cardinality.

Remove free-text columns — notes, descriptions, raw JSON. They are maximally high-cardinality and are almost never aggregated.

The three storage modes

WHERE DOES THE QUERY ACTUALLY RUN? EVERY BI PERFORMANCE PROBLEM STARTS HERE. IMPORT / EXTRACT data lives in the BI engine query runs in the BI engine freshness as of last refresh speed fastest by far breaks when: the model exceeds memory refresh window runs out the default, and usually right DIRECT QUERY / LIVE data lives in the warehouse query runs in the warehouse freshness always current speed every interaction is a query breaks when: concurrency melts the warehouse every slicer click costs money use only when freshness demands it HYBRID / COMPOSITE data lives both query runs split by partition freshness hot data live, cold imported speed fast where it matters breaks when: the split is mis-specified fallback is silent the honest answer for most estates THE FAILURE PATTERN THAT REPEATS EVERYWHERE A team picks live query for “real-time”, ships to 400 users, and every filter click becomes a warehouse query. The warehouse billtriples, the dashboard is slow, and the data was only needed daily. Ask the question in this order: how fresh does this genuinely need to be? → how many concurrent users? → how big is the model? — andonly then choose a mode. “Real-time” asked for in a requirements document almost always means “not yesterday's”. An hourly refresh satisfies it at a fraction ofthe cost.
Where the query runs decides your cost, your concurrency and your failure mode. The pattern at the bottom repeats in almost every organisation that adopts live querying for ‘real-time’.

Import or extract loads a compressed copy into the BI engine. Queries never touch the source, so they are fast and free. The data is as of the last refresh, the model must fit in memory, and the refresh must finish inside its window.

Direct query or live connection leaves the data where it is and translates every visual into a query against the warehouse. The data is always current. But every user interaction becomes a query — and a dashboard with eight visuals and four slicers can generate dozens of queries per page view, per user.

Hybrid or composite splits the model: recent partitions query live, historical partitions are imported. This is usually the honest answer for a large estate, and its failure mode is that the split is mis-specified and users silently get the slow path.

The conversation to have before choosing a mode, in this order.

How fresh does this genuinely need to be? Ask what decision changes if the number is an hour old. ‘Real-time’ in a requirements document almost always means ‘not yesterday's’, and an hourly refresh satisfies that at a fraction of the cost of live querying. The number of dashboards that genuinely need sub-minute data is small, and they are usually operational monitoring rather than analytics.

How many concurrent users? Live query cost scales with interactions, not with data volume. Four hundred users clicking slicers is a load test you did not plan.

How large is the model? If a well-modelled import comfortably fits, import is almost always right.

The failure pattern to name in a design review: a team chooses live querying for freshness nobody asked for, ships to a large audience, and the warehouse bill triples while the dashboard gets slower. Both problems have the same cause and both are fixed by the same change.

Aggregations, and the trick that makes large models feasible

The most effective large-model technique is to keep a small pre-aggregated table in memory alongside a large detail table left in the warehouse. The engine answers a summary query from the aggregate — instantly — and only falls through to the detail when the user drills into something the aggregate cannot answer.

This works because of a fact about how dashboards are actually used: the overwhelming majority of queries are summaries, and only a small minority need row-level detail. You are optimising for the common case and accepting a slower path for the rare one, which is the same reasoning as any cache.

The thing to verify rather than assume: is the aggregate actually being hit? Every engine of this kind offers a way to see whether a query used the aggregation or fell through, and an aggregate that never gets hit is pure cost. A single mismatched column data type or an unexpected grain is enough to miss it silently.

Why the model shape matters more than the hardware

The single most consequential modelling decision is star schema versus one wide table, and the answer is nearly always the star: a fact table of measures and foreign keys, surrounded by small dimension tables.

  • Compression. Repeating a long product name on every one of a hundred million fact rows is expensive even after dictionary encoding, because the dictionary reference itself is stored per row. Keeping the name in a small dimension keeps the fact table narrow and low-cardinality.
  • Filter propagation. These engines are built to filter a small dimension and propagate the result to a large fact table. That path is heavily optimised; filtering a large table directly on a text column is not.
  • Ambiguity. A model with many-to-many relationships or bidirectional filters everywhere becomes genuinely ambiguous — the engine cannot tell which path a filter should take, and results become hard to explain. Most ‘the numbers are wrong’ reports in a mature BI estate are relationship ambiguity rather than arithmetic.
  • The exception worth naming: some newer cloud-native tools are built around a flattened single-table model and query the warehouse directly, on the argument that the warehouse should do the joins. That is a coherent position, and it moves the modelling work rather than removing it.

Check yourself

What dominates the size of an in-memory analytical model?Cardinality — the number of distinct values per column — not the row count. Adding rows to low-cardinality columns is nearly free; adding one high-cardinality column is expensive.
Why does splitting a datetime into date and time reduce model size so much?A datetime to the second has tens of millions of distinct values; a date has about a thousand and a time-of-day has 86,400. Two low-cardinality columns compress far better than one enormous one.
When is live query the wrong choice?When the freshness requirement is softer than it sounds and the audience is large. Cost scales with user interactions, so a popular dashboard on live query is both slow and expensive.
Why a star schema rather than one wide table?Compression — repeated text on every fact row is costly — and filter propagation, which these engines optimise specifically for small-dimension-to-large-fact.
← Making analytics actually get usedPower BI internals: the model, and what DAX is really doing →