Skip to content
About
Main content

Sparse index granule size

Measure your machineNeeds Python

Problem

A keeps one entry per granule of G sorted records instead of one per record: a lookup binary-searches the index for the granule, reads it, and scans inside it. G sets an exchange rate. A small granule means a big index (more memory, more cache misses while searching it) but little scanning; a large granule means a tiny index but a large read and scan per lookup. The best G is where the granule is about the size the medium reads efficiently in one request.

Create a sorted file of 100 million fixed-size records on an . Build a sparse index with one entry per G records for G = 64, 512, 8,192 and 65,536. For each G, measure the index size, and the p50 and time of random point lookups.

The script does 20,000 lookups per granule size. It does not drop the page cache between runs (that needs root), so with a file larger than memory most granule reads still go to the SSD, but some hit the cache.

The question. How do index size and lookup time change with the granule size, and where is the best G?


Predict

Which granule size gives the lowest p99 lookup?

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.

    sparse-index-granule-size.py66 linesDownload
    View the code
    sparse-index-granule-size.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Sparse index: the granule size.
    
    A sorted file of fixed-size records and a sparse index with one entry per G
    records, for G from 64 to 65,536: index size, and p50/p99 of random point
    lookups that binary-search the index in memory and then read one granule
    from disk with the page cache bypassed.
    """
    import argparse, json, os, platform, random, tempfile, time
    import numpy as np
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--records", type=int, default=100_000_000, help="Records in the file (16 bytes each).")
    p.add_argument("--granules", type=lambda s: [int(v) for v in s.split(",")], default=[64, 512, 8192, 65536], help="Records per index entry (G).")
    p.add_argument("--lookups", type=int, default=20_000, help="Random point lookups per G.")
    p.add_argument("--path", default=os.path.join(tempfile.gettempdir(), "sparse-index.bin"), help="The data file.")
    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()
    import atexit, shutil
    atexit.register(lambda: shutil.rmtree(args.path, ignore_errors=True) if os.path.isdir(args.path) else os.path.exists(args.path) and os.remove(args.path))   # leave nothing behind
    random.seed(args.seed)
    REC = 16
    F_NOCACHE = 48
    
    if not os.path.exists(args.path) or os.path.getsize(args.path) != args.records * REC:
        with open(args.path, "wb") as f:
            step = 10_000_000
            for s in range(0, args.records, step):
                keys = np.arange(s, min(s + step, args.records), dtype=np.int64) * 3       # sorted keys, gaps of 3
                f.write(np.stack([keys, keys ^ 0x5555], axis=1).tobytes())
    
    fd = os.open(args.path, os.O_RDONLY)
    if platform.system() == "Darwin":
        import fcntl; fcntl.fcntl(fd, F_NOCACHE, 1)
    else:
        os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
    
    rows = []
    for G in args.granules:
        first_keys = np.arange(0, args.records, G, dtype=np.int64) * 3               # the sparse index: first key of each granule
        lat = []
        for _ in range(args.lookups):
            key = random.randrange(args.records) * 3
            t = time.perf_counter()
            g = int(np.searchsorted(first_keys, key, side="right")) - 1
            data = os.pread(fd, G * REC, g * G * REC)
            recs = np.frombuffer(data, dtype=np.int64).reshape(-1, 2)
            found = int(np.searchsorted(recs[:, 0], key))
            lat.append(time.perf_counter() - t)
        lat.sort()
        rows.append({"G": G, "index_kb": first_keys.nbytes * 2 / 1e3, "granule_kb": G * REC / 1e3,
                     "p50_us": lat[len(lat) // 2] * 1e6, "p99_us": lat[int(len(lat) * 0.99)] * 1e6})
        r = rows[-1]
        print(f"G = {G:>6,}: index {r['index_kb']:>10,.0f} KB, granule {r['granule_kb']:>7,.1f} KB   lookup p50 {r['p50_us']:7.0f} µs  p99 {r['p99_us']:7.0f} µs")
    os.close(fd)
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"granules": 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 sparse-index-granule-size.py
    uv run sparse-index-granule-size.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--records100,000,000 Records in the file (16 bytes each).
--granules64, 512, 8192, 65,536 Records per index entry (G).
--lookups20,000 Random point lookups per G.
--pathos.path.join(tempfile.gettempdir(), 'sparse-index.bin') The data file.
--seed0 Random seed.

Our run

Lookup time by granule sizeApple M2 · 8 cores · 8 GB
p50p99
Data table
Lookup time by granule size
Records per granule (G)p50p99
6487.8448
51287.6115
8,190188262
65.5K8301,230
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20

Index size and lookups

Index size and lookups
GIndex (KB)Granule (KB)p50 (µs)p99 (µs)
6425K1.0287.8448
5123.13K8.1987.6115
8,190195131188262
65.5K24.41,0508301,230
Output · uv run sparse-index-granule-size.py
G =     64: index     25,000 KB, granule     1.0 KB   lookup p50      88 µs  p99     448 µs
G =    512: index      3,125 KB, granule     8.2 KB   lookup p50      88 µs  p99     115 µs
G =  8,192: index        195 KB, granule   131.1 KB   lookup p50     188 µs  p99     262 µs
G = 65,536: index         24 KB, granule 1,048.6 KB   lookup p50     830 µs  p99    1226 µs

What we found

The index shrank exactly as 1/G, from 25K KB at G = 64 to 24.4 KB at G = 65,536. The median lookup was flat from G = 64 to G = 512 (87.8 and 87.6 µs) and then rose with the granule, to 830 µs. The tail told the other half of the story: at G = 64 the p99 was 448 µs against 115 µs at G = 512, the cost of searching an index of 25K KB. The best G was 512, whose granule (8.19 KB) is about the size an SSD reads in one request.


Read it

Both ends lose for different reasons. A fine index is mostly a memory and cache problem: it is big enough that searching it misses, and it competes with the data for memory. A coarse index is a bandwidth problem: every lookup reads and scans a megabyte to find one record. The optimum sits at the medium's natural read size, which is why columnar engines settle on granules of a few thousand rows and why that default is worth revisiting when the medium changes (object storage wants much larger reads than a local SSD).