Skip to content
About
Main content

Measure the cost of a boundary: tuple-at-a-time against batches

Measure your machineNeeds Python with NumPy

Problem

Inside a query engine, each (scan, filter, sum) hands rows to the next across a boundary: a call the compiler cannot inline. In the classic (Volcano), every operator asks its child for the next row with a next() call, so the boundary is crossed once per row and every row pays a F on top of a few cycles of useful work. A vectorized engine crosses the same boundary once per batch of N rows, so the overhead per row becomes F/N.

See the "fixed cost per control transfer" with your own numbers. Python has a large per-call cost, which makes the effect easy to see. Build the same query three ways over 5 million values: (a) a chain of iterator objects with one next() call per , (b) the same chain passing lists of 1,024 values, (c) one NumPy expression.

The query sums the kwh value of every row whose status is OK. You will measure the time for each way, with the batched version swept from 1 value per call to 1,000,000.

The question. How much slower is one call per row than one call per batch of 1,024 rows, and at what batch size does a bigger batch stop helping?


Predict

How many times slower is one call per row than one call per batch of 1,024 rows?

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.

    cost-of-a-boundary.py98 linesDownload
    View the code
    cost-of-a-boundary.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Measure the cost of a boundary: tuple-at-a-time against batches.
    
    The same query three ways over one column: (a) a chain of iterator objects
    with one next() call per tuple, (b) the same chain passing batches,
    (c) one NumPy expression.
    """
    import argparse, json, statistics, time
    import numpy as np
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--rows", type=int, default=5_000_000, help="Values in the column.")
    p.add_argument("--batch-sizes", type=lambda s: [int(v) for v in s.split(",")],
                   default=[1, 16, 256, 1024, 16384, 1_000_000], help="Batch sizes to try, comma-separated.")
    p.add_argument("--repeats", type=int, default=3, help="Runs per variant; the median time is reported.")
    p.add_argument("--seed", type=int, default=0, help="Random seed for the generated column.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    N = args.rows
    rng = np.random.default_rng(args.seed)
    status = rng.integers(0, 2, N).astype(np.int8)       # 1 = 'OK'
    kwh = rng.random(N)
    
    
    def timed(fn):
        runs = []
        for _ in range(args.repeats):
            t = time.perf_counter(); fn(); runs.append(time.perf_counter() - t)
        return statistics.median(runs)
    
    
    # (a) tuple-at-a-time: one next() call per tuple
    class Scan:
        def __init__(self): self.i = 0
        def next(self):
            if self.i >= N: return None
            r = (status[self.i], kwh[self.i]); self.i += 1; return r
    
    class Filter:
        def __init__(self, c): self.c = c
        def next(self):
            while (r := self.c.next()) is not None:
                if r[0] == 1: return r
            return None
    
    def tuple_at_a_time():
        f = Filter(Scan()); tot = 0.0
        while (r := f.next()) is not None: tot += r[1]
        return tot
    
    
    # (b) batches: one call per batch
    def batches(size):
        for i in range(0, N, size):
            s, k = status[i:i+size], kwh[i:i+size]
            yield k[s == 1]
    
    def batched(size):
        return sum(b.sum() for b in batches(size))
    
    
    # (c) one expression
    def one_expression():
        return kwh[status == 1].sum()
    
    
    t_tuple = timed(tuple_at_a_time)
    print(f"tuple-at-a-time      {t_tuple:8.3f} s")
    rows = []
    for size in args.batch_sizes:
        t = timed(lambda: batched(size))
        rows.append({"batch_size": size, "seconds": t})
        print(f"batch {size:>12,}   {t:8.3f} s")
    t_expr = timed(one_expression)
    print(f"one expression       {t_expr:8.3f} s")
    
    by_size = {r["batch_size"]: r["seconds"] for r in rows}
    summary = {"tuple_s": t_tuple, "expr_s": t_expr}
    if 1 in by_size and 1024 in by_size:
        summary |= {"ratio_vs_1024": t_tuple / by_size[1024], "F_ns": (by_size[1] - by_size[1024]) / N * 1e9}
        print(f"\ntuple-at-a-time / batch 1,024: {summary['ratio_vs_1024']:.0f}x")
        print(f"F (batch 1 minus batch 1,024, per value): {summary['F_ns']:.0f} ns")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {
                "batches": rows,
                "references": [{"variant": "tuple-at-a-time", "seconds": t_tuple},
                               {"variant": "one expression", "seconds": t_expr}],
                "summary": [summary],
            }}, 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 cost-of-a-boundary.py
    uv run cost-of-a-boundary.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--rows5,000,000 Values in the column.
--batch-sizes1, 16, 256, 1024, 16,384, 1,000,000 Batch sizes to try, comma-separated.
--repeats3 Runs per variant; the median time is reported.
--seed0 Random seed for the generated column.

Our run

Time per run, by batch sizeApple M2 · 8 cores · 8 GB
Data table
Time per run, by batch size
Batch size (values per call)Seconds (median of runs)
17.2 s
16459 ms
25651.4 ms
1.02K31.1 ms
16.4K24.1 ms
1M24.2 ms
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20
Output · uv run cost-of-a-boundary.py
tuple-at-a-time         1.026 s
batch            1      7.196 s
batch           16      0.459 s
batch          256      0.051 s
batch        1,024      0.031 s
batch       16,384      0.024 s
batch    1,000,000      0.024 s
one expression          0.025 s

tuple-at-a-time / batch 1,024: 33x
F (batch 1 minus batch 1,024, per value): 1433 ns

What we found

On our machine the tuple-at-a-time run took 1.03 s, 33× slower than batches of 1,024 (31.1 ms). That is below the 50 to 200 times the chapter expects: the ratio depends on how expensive one call is in your interpreter and on your CPU, so it moves from machine to machine. The shape is the one the mechanism predicts. Times fall steeply up to about 1,024 values per call, then sit on the one-expression time (24.8 ms).

Batch size 1 was the slowest run of all (7.2 s), slower than tuple-at-a-time. Slicing a NumPy array costs more per call than the iterator's next(), so with N = 1 the batched path pays a larger F. From the batch-1 and batch-1,024 times, F comes out at about 1,430 ns per call.


Read it

The overhead per tuple behaves like F/N. Estimate F from the difference between the batch-size-1 and batch-size-1024 times.

Nothing about the boundary itself changed between the runs: the same function is called, only less often. That is the whole mechanism of vectorization. It works in pull and push engines alike, because it does not change the direction of the hand-off, only how many rows cross it at once. It also explains why engines settle on batches of about 1,000 to 2,000 rows: big enough that F/N disappears, small enough that the batch still fits in , which is the next lab.