Skip to content
About
Main content

Grant quantization: count the passes

Run a real systemNeeds Docker, Python

Problem

An 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

In PostgreSQL, raising work_mem from 512 kB to 1 MB: how much less temp data does the sort write?

Run it

  1. 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 | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    grant-quantization-count-the-passes.py61 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    grant-quantization-count-the-passes.compose.yml12 linesDownload
    View 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: 30
    
  4. 4Start 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
Knobs
Knob · defaultWhat 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

PostgreSQL: temp data written against work_memApple M2 · 8 cores · 8 GB
Data table
PostgreSQL: temp data written against work_mem
work_memTemp written (MB)
—1,520
—1,270
—1,240
—982
—978
—488
—244
—244
—0
macOS 26.6.2 · Python 3.13.3 · psycopg 3.3.6 · postgres:17.6 · Docker 28.5.1 · run 2026-10-20

Model: 1 TB of data

Model: 1 TB of data
BuffersM (GB)RunsPasses
1 MB buffers11K2
1 MB buffers25001
1 MB buffers42501
1 MB buffers81251
1 MB buffers16631
1 MB buffers32321
1 MB buffers64161
1 MB buffers12881
1 MB buffers25641
100 MB buffers11K4
100 MB buffers25003
100 MB buffers42502
100 MB buffers81252
100 MB buffers16631
100 MB buffers32321
100 MB buffers64161
100 MB buffers12881
100 MB buffers25641

PostgreSQL, 5 million rows

PostgreSQL, 5 million rows
work_memMethodInitial runsMerge passesTemp written (MB)
64kBexternal merge7.81K71,520
128kBexternal merge6.92K61,270
256kBexternal merge2.26K61,240
512kBexternal merge9645982
1MBexternal merge4495978
4MBexternal merge1103488
16MBexternal merge282244
64MBexternal merge72244
1GBquicksort000
Output · uv run grant-quantization-count-the-passes.py
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 s

What 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.