Grant quantization: count the passes
Run a real systemNeeds Docker, Python
Problem
An external merge sort writes sorted runs of memory size M, then merges them with a fan-in of about M/B − 1 (B is the buffer per run). The number of merge passes is ⌈log_(M/B − 1)(N/M)⌉, a staircase in M: doubling memory inside a step changes nothing, while crossing a step removes a whole pass, which is a full read and write of the data.
Claim to test: the grant curve for external merge sort is a staircase. Compute the pass count for 1 TB of data with 1 MB buffers and with 100 MB buffers across memory grants. Then confirm in a real engine: sort a 5-million-row table in PostgreSQL across work_mem settings and read, from EXPLAIN ANALYZE and the sort trace (trace_sort), the sort method, the number of initial runs, the merge passes and the temp data written.
The question. Where are the steps in the model, and does PostgreSQL's temp I/O fall in steps as work_mem grows?
Predict
With 1 MB buffers, M = 1 GB gives 2 passes and every larger M gives 1 pass. With B = 0.1 the staircase is 4, 3, 2, 2, 1 passes for M = 1, 2, 4, 8, 16. Doubling memory inside one step changes nothing and crossing the boundary halves the I/O.
Run it
1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.
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
grant-quantization-count-the-passes.py # /// script # requires-python = ">=3.10" # dependencies = ["psycopg[binary]"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Grant quantization: count the passes. Prints the external merge sort's pass count ⌈log_{M/B − 1}(N/M)⌉ for 1 TB of data across memory grants M, with 1 MB and with 100 MB buffers. Then sorts a table in PostgreSQL across a range of work_mem settings and reads, from EXPLAIN ANALYZE and the server's sort trace (trace_sort), the sort method, the number of initial runs, the merge passes and the disk space used. Needs the PostgreSQL service from the compose file (port 55602). """ import argparse, json, math, re, time import psycopg p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--dsn", default="postgresql://postgres:lab@localhost:55602/postgres", help="Connection string.") p.add_argument("--rows", type=int, default=5_000_000, help="Rows in the table to sort.") p.add_argument("--work-mem", default="64kB,128kB,256kB,512kB,1MB,4MB,16MB,64MB,1GB", help="work_mem settings to sort under.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() model = [] for B in (0.001, 0.1): for M in (1, 2, 4, 8, 16, 32, 64, 128, 256): N = 1000; runs = math.ceil(N / M); fan = M / B - 1 passes = math.ceil(math.log(runs, fan) - 1e-9) if runs > 1 else 0 model.append({"buffer": f"{B * 1000:g} MB buffers", "M_gb": M, "runs": runs, "fan_in": fan, "passes": passes}) c = psycopg.connect(args.dsn, autocommit=True) c.execute("DROP TABLE IF EXISTS s"); c.execute("CREATE TABLE s (k double precision, pad text)") c.execute(f"INSERT INTO s SELECT random(), md5(g::text) FROM generate_series(1, {args.rows}) g"); c.execute("VACUUM ANALYZE s") table_mb = float(c.execute("SELECT pg_relation_size('s') / 1048576.0").fetchone()[0]) c.execute("SET max_parallel_workers_per_gather = 0"); c.execute("SET trace_sort = on"); c.execute("SET client_min_messages = log") notices = []; c.add_notice_handler(lambda d: notices.append(d.message_primary or "")) rows = [] for wm in args.work_mem.split(","): c.execute(f"SET work_mem = '{wm}'"); notices.clear() t = time.perf_counter() plan = "\n".join(r[0] for r in c.execute("EXPLAIN (ANALYZE, BUFFERS) SELECT k, pad FROM s ORDER BY k").fetchall()) el = time.perf_counter() - t method = re.search(r"Sort Method: ([\w -]+?)\s+(Disk|Memory): (\d+)kB", plan) runs = max([int(x) for n in notices for x in re.findall(r"finished writing run (\d+)", n)] or [0]) passes = sum(1 for n in notices if re.search(r"starting merge pass", n)) final = any(re.search(r"(final merge|performsort .* done.*merge|starting final merge)", n) for n in notices) temp_w = sum(int(x) for x in re.findall(r"temp read=\d+ written=(\d+)", plan)) * 8 / 1024 rows.append({"work_mem": wm, "method": method.group(1).strip() if method else "?", "space_kb": int(method.group(3)) if method else None, "initial_runs": runs, "merge_passes": passes + (1 if runs > 1 else 0), "temp_written_mb": temp_w, "seconds": el}) summary = {"table_mb": table_mb, "rows": args.rows} for r in model: if r["M_gb"] <= 16: print(f"{r['buffer']:<17} M = {r['M_gb']:>3} GB: {r['runs']:>4} runs, fan-in {r['fan_in']:>6.0f}, {r['passes']} pass{'es' if r['passes'] != 1 else ''}") print(f"PostgreSQL: sorting {args.rows:,} rows ({table_mb:.0f} MB)") for r in rows: print(f" work_mem {r['work_mem']:>6}: {r['method']:<15} runs {r['initial_runs']:>5} merge passes {r['merge_passes']} temp written {r['temp_written_mb']:7.1f} MB {r['seconds']:.2f} s") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"model": model, "postgres": rows}}, f)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide the code
grant-quantization-count-the-passes.compose.yml # PostgreSQL for the grant-quantization lab. services: postgres: image: postgres:17.6 environment: POSTGRES_PASSWORD: lab ports: ["55602:5432"] healthcheck: test: ["CMD", "pg_isready", "-U", "postgres"] interval: 1s retries: 304Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.
Terminal docker compose -f grant-quantization-count-the-passes.compose.yml up -d --wait uv run grant-quantization-count-the-passes.py uv run grant-quantization-count-the-passes.py --help # every knob docker compose -f grant-quantization-count-the-passes.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --dsnpostgresql://postgres:lab@localhost:55602/postgres | Connection string. |
| --rows5,000,000 | Rows in the table to sort. |
| --work-mem64kB, 128kB, 256kB, 512kB, 1MB, 4MB, 16MB, 64MB, 1GB | work_mem settings to sort under. |
Our run
Data table
| work_mem | Temp written (MB) |
|---|---|
| — | 1,520 |
| — | 1,270 |
| — | 1,240 |
| — | 982 |
| — | 978 |
| — | 488 |
| — | 244 |
| — | 244 |
| — | 0 |
Model: 1 TB of data
| Buffers | M (GB) | Runs | Passes |
|---|---|---|---|
| 1 MB buffers | 1 | 1K | 2 |
| 1 MB buffers | 2 | 500 | 1 |
| 1 MB buffers | 4 | 250 | 1 |
| 1 MB buffers | 8 | 125 | 1 |
| 1 MB buffers | 16 | 63 | 1 |
| 1 MB buffers | 32 | 32 | 1 |
| 1 MB buffers | 64 | 16 | 1 |
| 1 MB buffers | 128 | 8 | 1 |
| 1 MB buffers | 256 | 4 | 1 |
| 100 MB buffers | 1 | 1K | 4 |
| 100 MB buffers | 2 | 500 | 3 |
| 100 MB buffers | 4 | 250 | 2 |
| 100 MB buffers | 8 | 125 | 2 |
| 100 MB buffers | 16 | 63 | 1 |
| 100 MB buffers | 32 | 32 | 1 |
| 100 MB buffers | 64 | 16 | 1 |
| 100 MB buffers | 128 | 8 | 1 |
| 100 MB buffers | 256 | 4 | 1 |
PostgreSQL, 5 million rows
| work_mem | Method | Initial runs | Merge passes | Temp written (MB) |
|---|---|---|---|---|
| 64kB | external merge | 7.81K | 7 | 1,520 |
| 128kB | external merge | 6.92K | 6 | 1,270 |
| 256kB | external merge | 2.26K | 6 | 1,240 |
| 512kB | external merge | 964 | 5 | 982 |
| 1MB | external merge | 449 | 5 | 978 |
| 4MB | external merge | 110 | 3 | 488 |
| 16MB | external merge | 28 | 2 | 244 |
| 64MB | external merge | 7 | 2 | 244 |
| 1GB | quicksort | 0 | 0 | 0 |
1 MB buffers M = 1 GB: 1000 runs, fan-in 999, 2 passes
1 MB buffers M = 2 GB: 500 runs, fan-in 1999, 1 pass
1 MB buffers M = 4 GB: 250 runs, fan-in 3999, 1 pass
1 MB buffers M = 8 GB: 125 runs, fan-in 7999, 1 pass
1 MB buffers M = 16 GB: 63 runs, fan-in 15999, 1 pass
100 MB buffers M = 1 GB: 1000 runs, fan-in 9, 4 passes
100 MB buffers M = 2 GB: 500 runs, fan-in 19, 3 passes
100 MB buffers M = 4 GB: 250 runs, fan-in 39, 2 passes
100 MB buffers M = 8 GB: 125 runs, fan-in 79, 2 passes
100 MB buffers M = 16 GB: 63 runs, fan-in 159, 1 pass
PostgreSQL: sorting 5,000,000 rows (365 MB)
work_mem 64kB: external merge runs 7813 merge passes 7 temp written 1523.1 MB 3.14 s
work_mem 128kB: external merge runs 6925 merge passes 6 temp written 1272.5 MB 2.84 s
work_mem 256kB: external merge runs 2261 merge passes 6 temp written 1236.1 MB 2.51 s
work_mem 512kB: external merge runs 964 merge passes 5 temp written 982.3 MB 2.22 s
work_mem 1MB: external merge runs 449 merge passes 5 temp written 978.2 MB 2.16 s
work_mem 4MB: external merge runs 110 merge passes 3 temp written 488.3 MB 1.80 s
work_mem 16MB: external merge runs 28 merge passes 2 temp written 244.0 MB 1.70 s
work_mem 64MB: external merge runs 7 merge passes 2 temp written 243.7 MB 1.76 s
work_mem 1GB: quicksort runs 0 merge passes 0 temp written 0.0 MB 1.97 sWhat we found
The model reproduced both staircases: with 100 MB buffers, 4, 3, 2, 2 and 1 passes for 1 to 16 GB. In PostgreSQL the temp data written fell in steps too: 982 MB at 512 kB and 978 MB at 1 MB (the same step), then 488 MB at 4 MB, and 244 and 244 MB at 16 and 64 MB, again the same step; at 1 GB the sort ran in memory. Doubling memory within a step changed almost nothing; crossing one halved the I/O.
Read it
Memory for sorts is worth buying in steps: find the grant that crosses the next pass boundary for your data size, and do not pay for memory between boundaries. The same arithmetic explains why a modest grant increase sometimes transforms a query and sometimes does nothing.