Skip to content
About
Main content

Build a pruning cascade and count rows touched

Simulate a modelNeeds Python with NumPy

Problem

Analytical engines rarely find rows with one index. They cascade cheap filters, each removing most of what the previous one let through: a (min/max per file) skips files whose range cannot match, a per file skips files that cannot contain the key, and a inside the file narrows the read to a few granules. Each stage is only as good as the property it relies on: the zone map needs the data clustered by the filtered column.

Generate 10 million rows (tracking_id, ts), written in time order, into 100 files. For each file keep min/max of ts (zone map), a Bloom filter of tracking_id (1% false-positive rate), and a sparse index with one entry per 8,192 rows. Run 1,000 queries "all rows for tracking_id T in a given 1-day range" and count rows examined at each stage. Then shuffle the rows so that files are no longer clustered by time.

The question. How many rows does each stage of the cascade leave to examine, and what happens to the cascade when the rows are shuffled?


Predict

With the rows shuffled, how many of the 10 million rows does the zone map leave to examine?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    build-a-pruning-cascade-and-count.py72 linesDownload
    View the code
    build-a-pruning-cascade-and-count.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Build a pruning cascade and count the work.
    
    10 million rows (tracking_id, ts) written in time order into 100 files. Per
    file: a zone map on ts, a Bloom filter on tracking_id (1% false positives),
    and a sparse index with one entry per 8,192 rows (rows sorted by tracking_id
    within each file). 1,000 queries for one tracking_id in a one-day range:
    rows examined after each stage. Then the same with the rows shuffled.
    """
    import argparse, json
    import numpy as np
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--rows", type=int, default=10_000_000, help="Rows.")
    p.add_argument("--files", type=int, default=100, help="Files.")
    p.add_argument("--ids", type=int, default=2_000_000, help="Distinct tracking ids.")
    p.add_argument("--days", type=int, default=100, help="Days of data.")
    p.add_argument("--granule", type=int, default=8192, help="Rows per sparse-index entry.")
    p.add_argument("--queries", type=int, default=1000, help="Queries.")
    p.add_argument("--seed", type=int, default=0, help="Random seed.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    rng = np.random.default_rng(args.seed)
    ts = np.sort(rng.uniform(0, args.days, args.rows))
    tid = rng.integers(0, args.ids, args.rows)
    
    def build(order):
        t, k = ts[order], tid[order]
        files = []
        for f in np.array_split(np.arange(args.rows), args.files):
            ft, fk = t[f], k[f]
            srt = np.argsort(fk, kind="stable"); ft, fk = ft[srt], fk[srt]           # within a file, sort by tracking_id
            m = int(len(fk) * 9.6); kk = 7
            bits = np.zeros(m, dtype=bool)
            h = [((fk * (2 * i + 1) * 0x9E3779B1) ^ (fk >> 3)) % m for i in range(kk)]
            for x in h: bits[x] = True
            files.append({"tmin": ft.min(), "tmax": ft.max(), "bloom": (bits, m, kk), "keys": fk, "sparse": fk[:: args.granule]})
        return files
    
    def bloom_has(b, key):
        bits, m, kk = b
        return all(bits[((key * (2 * i + 1) * 0x9E3779B1) ^ (key >> 3)) % m] for i in range(kk))
    
    def run(files):
        qs = [(int(rng.integers(0, args.ids)), float(rng.uniform(0, args.days - 1))) for _ in range(args.queries)]
        work = {"no pruning": 0, "zone map": 0, "zone map + Bloom": 0, "zone map + Bloom + sparse index": 0}
        for key, day in qs:
            for f in files:
                n = len(f["keys"]); work["no pruning"] += n
                if f["tmax"] < day or f["tmin"] > day + 1: continue
                work["zone map"] += n
                if not bloom_has(f["bloom"], key): continue
                work["zone map + Bloom"] += n
                g = np.searchsorted(f["sparse"], key, side="right") - 1                # the granule that can hold the key
                work["zone map + Bloom + sparse index"] += min(args.granule, n - max(g, 0) * args.granule)
        return {k: v / args.queries for k, v in work.items()}
    
    rows = []
    for label, order in (("written in time order", np.arange(args.rows)), ("rows shuffled", rng.permutation(args.rows))):
        w = run(build(order))
        for stage, v in w.items(): rows.append({"layout": label, "stage": stage, "rows_examined": v})
        print(f"{label}: " + "   ".join(f"{s}: {v:,.0f}" for s, v in w.items()))
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"stages": rows}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run build-a-pruning-cascade-and-count.py
    uv run build-a-pruning-cascade-and-count.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--rows10,000,000 Rows.
--files100 Files.
--ids2,000,000 Distinct tracking ids.
--days100 Days of data.
--granule8192 Rows per sparse-index entry.
--queries1000 Queries.
--seed0 Random seed.

Our run

Rows examined per query after each stageApple M2 · 8 cores · 8 GB
written in time orderrows shuffled
Data table
Rows examined per query after each stage
Stages appliedseriesRows examined per query
—written in time order10M
—written in time order200K
—written in time order12.2K
—written in time order986
—rows shuffled10M
—rows shuffled10M
—rows shuffled571K
—rows shuffled46.4K
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20

Average rows examined per query

Average rows examined per query
LayoutStagesRows examined
written in time orderno pruning10M
written in time orderzone map200K
written in time orderzone map + Bloom12.2K
written in time orderzone map + Bloom + sparse index986
rows shuffledno pruning10M
rows shuffledzone map10M
rows shuffledzone map + Bloom571K
rows shuffledzone map + Bloom + sparse index46.4K
Output · uv run build-a-pruning-cascade-and-count.py
written in time order: no pruning: 10,000,000   zone map: 199,700   zone map + Bloom: 12,200   zone map + Bloom + sparse index: 986
rows shuffled: no pruning: 10,000,000   zone map: 10,000,000   zone map + Bloom: 571,300   zone map + Bloom + sparse index: 46,424

What we found

In time order, each stage cut the work by one to two orders of magnitude: from 10M rows to 200K with the zone map, 12.2K with the Bloom filter and 986 with the sparse index. Shuffled, the zone map skipped nothing (10M), and the cascade ended at 46.4K rows, many times more: the Bloom filter now passes every file that holds the key somewhere, and each file has a few rows of it.


Read it

The stages multiply, so one stage that stops working costs its whole factor. The zone map's factor came from the write order, not from the index, which is why a lake table's layout (sort or cluster on the columns queries filter by) is part of its indexing.

When a query gets slower without any code change, check whether the data stopped being clustered before suspecting the engine.