Fan-out as a batch-size decision
Measure your machineNeeds Python, Docker
Problem
Every task an orchestrator launches pays a fixed overhead F before doing any work: starting a process, a container or a pod. The useful share of a task of duration W is W ÷ (W + F), so fanning out into many tiny tasks spends most of the time launching them. Fan-out is a batch-size decision, and the right batch makes each task long compared with F.
Measure launch overhead on your own machine: time how long it takes to start a trivial container (docker run --rm alpine true) and a Python subprocess. Call it F. Then compute the useful share W ÷ (W + F) for task durations W = 1 s, 10 s, 60 s and 600 s. Finally, run 1,000 tiny tasks with a subprocess each, and as 10 batches of 100. Find the task duration at which the overhead falls below 10%.
The question. What does a launch cost, how long must a task be for the overhead to fall below 10%, and what does batching save?
Predict
A container start is often 0.5 to several seconds, a subprocess about 20 to 50 ms. The batch version is much faster, and the gap matches your computed share.
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
fan-out-as-a-batch-size.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Fan-out as a batch size. Measures the launch overhead F of a Python subprocess (and, if Docker is available, of `docker run --rm alpine true`), computes the useful share W / (W + F) for tasks of 1 s to 600 s, and the task duration at which overhead falls below 10%. Then runs 1,000 tiny tasks with one subprocess each, and as 10 batches of 100. """ import argparse, json, os, shutil, statistics, subprocess, sys, tempfile, time p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--tasks", type=int, default=1000, help="Tiny tasks.") p.add_argument("--batches", type=int, default=10, help="Batches in the batched run.") p.add_argument("--docker", default="auto", choices=["auto", "yes", "no"], help="Also time a container start.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() TASK = "import sys; sum(range(1000))" def launch_times(cmd, n, env=None): ts = [] for _ in range(n): t = time.perf_counter(); subprocess.run(cmd, check=True, capture_output=True, env=env); ts.append(time.perf_counter() - t) return statistics.median(ts) launchers = [{"launcher": "Python subprocess", "F_s": launch_times([sys.executable, "-c", TASK], 30)}] if args.docker != "no" and shutil.which("docker"): cfg = tempfile.mkdtemp(); open(os.path.join(cfg, "config.json"), "w").write("{}") # no credential helper env = {**os.environ, "DOCKER_CONFIG": cfg} try: subprocess.run(["docker", "pull", "-q", "alpine:3.20"], check=True, capture_output=True, env=env, timeout=300) launchers.append({"launcher": "docker run --rm alpine true", "F_s": launch_times(["docker", "run", "--rm", "alpine:3.20", "true"], 10, env)}) except Exception as e: print("docker unavailable:", str(e)[:80]) shares = [{"launcher": l["launcher"], "W_s": W, "useful_pct": W / (W + l["F_s"]) * 100} for l in launchers for W in (1, 10, 60, 600)] for l in launchers: l["W_for_10pct_s"] = 9 * l["F_s"] # F / (W + F) < 0.1 <=> W > 9F t = time.perf_counter() for _ in range(args.tasks): subprocess.run([sys.executable, "-c", TASK], check=True) one_each = time.perf_counter() - t per = args.tasks // args.batches t = time.perf_counter() for _ in range(args.batches): subprocess.run([sys.executable, "-c", f"for _ in range({per}): sum(range(1000))"], check=True) batched = time.perf_counter() - t runs = [{"run": f"{args.tasks} tasks, one subprocess each", "seconds": one_each}, {"run": f"{args.batches} batches of {per}", "seconds": batched}] summary = {"speedup": one_each / batched} for l in launchers: print(f"{l['launcher']:<30} F = {l['F_s'] * 1000:7.1f} ms; overhead below 10% for tasks longer than {l['W_for_10pct_s']:.2f} s") for s in shares: print(f" {s['launcher']:<30} W = {s['W_s']:>4} s: useful share {s['useful_pct']:6.2f}%") for r in runs: print(f"{r['run']:<36} {r['seconds']:6.2f} s") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"launchers": launchers, "shares": shares, "runs": runs}}, 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 fan-out-as-a-batch-size.py uv run fan-out-as-a-batch-size.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --tasks1000 | Tiny tasks. |
| --batches10 | Batches in the batched run. |
| --dockerauto | yes | no (auto) | Also time a container start. |
Our run
Launch overhead
| Launcher | F | Task length for < 10% overhead |
|---|---|---|
| Python subprocess | 22.3 ms | 200 ms |
| docker run --rm alpine true | 212 ms | 1.91 s |
1,000 tiny tasks
| Run | Time |
|---|---|
| 1000 tasks, one subprocess each | 23.1 s |
| 10 batches of 100 | 224 ms |
Python subprocess F = 22.3 ms; overhead below 10% for tasks longer than 0.20 s
docker run --rm alpine true F = 212.1 ms; overhead below 10% for tasks longer than 1.91 s
Python subprocess W = 1 s: useful share 97.82%
Python subprocess W = 10 s: useful share 99.78%
Python subprocess W = 60 s: useful share 99.96%
Python subprocess W = 600 s: useful share 100.00%
docker run --rm alpine true W = 1 s: useful share 82.50%
docker run --rm alpine true W = 10 s: useful share 97.92%
docker run --rm alpine true W = 60 s: useful share 99.65%
docker run --rm alpine true W = 600 s: useful share 99.96%
1000 tasks, one subprocess each 23.09 s
10 batches of 100 0.22 sWhat we found
A Python subprocess cost 22.3 ms to launch, within the expected range; a container 212 ms, below it, because the image was already local and Docker Desktop's VM was warm. Overhead falls below 10% for tasks longer than 1.91 s with containers. A thousand tiny tasks took 23.1 s one per subprocess and 224 ms in ten batches (103×): the work itself was negligible, so nearly all of the first run was launching.
Read it
The rule of thumb is W > 9F for overhead under 10%. On a real cluster F includes scheduling, image pulls and pod start, often several seconds, which puts the minimum sensible task at tens of seconds. Fan out to the number of tasks the work needs, not to the number of items.