Skip to content
About
Main content

Fan-out as a batch-size decision

Measure your machineNeeds Python, Docker

Problem

Every task an 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. 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 thousand tiny tasks: how many times faster are 10 batches of 100 than one subprocess per task?

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.

    fan-out-as-a-batch-size.py58 linesDownload
    View 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)
    
  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 fan-out-as-a-batch-size.py
    uv run fan-out-as-a-batch-size.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--tasks1000 Tiny tasks.
--batches10 Batches in the batched run.
--dockerauto | yes | no (auto)Also time a container start.

Our run

Launch overhead

Launch overhead
LauncherFTask length for < 10% overhead
Python subprocess22.3 ms200 ms
docker run --rm alpine true212 ms1.91 s

1,000 tiny tasks

1,000 tiny tasks
RunTime
1000 tasks, one subprocess each23.1 s
10 batches of 100224 ms
Reference runApple M2 · 8 cores · 8 GB
Output · uv run fan-out-as-a-batch-size.py
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 s
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

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