Sparse index granule size
Measure your machineNeeds Python
Problem
A sparse index 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 SSD. 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 p99 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
You should see the index size fall as 1/G, while lookup time falls at first (smaller index, fewer misses) and then rises as you scan more records per granule. The best G sits where the granule is about the medium's efficient read size.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide 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)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
| Knob · default | What 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
Data table
| Records per granule (G) | p50 | p99 |
|---|---|---|
| 64 | 87.8 | 448 |
| 512 | 87.6 | 115 |
| 8,190 | 188 | 262 |
| 65.5K | 830 | 1,230 |
Index size and lookups
| G | Index (KB) | Granule (KB) | p50 (µs) | p99 (µs) |
|---|---|---|---|---|
| 64 | 25K | 1.02 | 87.8 | 448 |
| 512 | 3.13K | 8.19 | 87.6 | 115 |
| 8,190 | 195 | 131 | 188 | 262 |
| 65.5K | 24.4 | 1,050 | 830 | 1,230 |
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 µsWhat 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).