Measure the cost of a boundary: tuple-at-a-time against batches
Measure your machineNeeds Python with NumPy
Problem
Inside a query engine, each operator (scan, filter, sum) hands rows to the next across a boundary: a call the compiler cannot inline. In the classic iterator model (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 fixed cost 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 tuple, (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
Expect the tuple-at-a-time run to be 50 to 200 times slower than the batched runs. The batch times should fall steeply from size 1 to about 1,024 and then flatten.
The flat part is where F/N has become negligible next to the work each row needs, so the batched version runs as fast as the single NumPy expression.
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
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)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
| Knob · default | What 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
Data table
| Batch size (values per call) | Seconds (median of runs) |
|---|---|
| 1 | 7.2 s |
| 16 | 459 ms |
| 256 | 51.4 ms |
| 1.02K | 31.1 ms |
| 16.4K | 24.1 ms |
| 1M | 24.2 ms |
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 nsWhat 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 cache, which is the next lab.