Skip to content
About
Main content

Find the best batch size on your machine

Measure your machineNeeds Python with NumPy

Problem

Batching makes the per-call cost F/N small, so why not use very large batches? Because each writes its output batch to memory before the next operator reads it. While a batch fits in the CPU's L1 or L2 , that hand-off is nearly free; once it does not, every operator makes a round trip to main memory. Too small, and F/N comes back. Too large, and a free in-cache hand-off becomes a trip to memory.

Check the claim "batch size is chosen so the working set stays in cache, about L1 size ÷ row width". Use C or NumPy. Process 64 million 8-byte values through a pipeline of three operators (multiply, add, compare), where each operator reads the whole batch and writes a new batch. Sweep the batch size from 64 to 16 million values (512 bytes to 128 MB per column).

You will measure the time to push all 64 million values through the pipeline at each batch size. The script also reads your L1 and L2 cache sizes from the operating system and marks them on the chart, in values of 8 bytes.

The question. Which batch size is fastest on your machine, and where does it sit against your L1 and L2 cache sizes?


Predict

In NumPy on this Apple M2, at which batch size is the pipeline fastest?

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.

    best-batch-size.py73 linesDownload
    View the code
    best-batch-size.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Find the best batch size on your machine.
    
    8-byte values through a pipeline of three operators (multiply, add, compare),
    each reading the whole batch and writing a new one, across a sweep of batch sizes.
    """
    import argparse, json, platform, statistics, subprocess, time
    from pathlib import Path
    import numpy as np
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--values", type=int, default=64_000_000, help="8-byte values pushed through the pipeline.")
    p.add_argument("--batch-sizes", type=lambda s: [int(v) for v in s.split(",")],
                   default=[64, 256, 1024, 4096, 16384, 131072, 1_048_576, 16_777_216],
                   help="Batch sizes to try (values per batch), comma-separated.")
    p.add_argument("--repeats", type=int, default=3, help="Runs per batch size; the median time is reported.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    
    def cache_sizes():
        """L1d and L2 in bytes, from the OS (sysctl on macOS, sysfs on Linux)."""
        if platform.system() == "Darwin":
            def q(key):
                out = subprocess.run(["sysctl", "-n", key], capture_output=True, text=True).stdout.strip()
                return int(out) if out.isdigit() else 0
            # Apple silicon reports the performance cores as perflevel0
            return {"L1d": q("hw.perflevel0.l1dcachesize") or q("hw.l1dcachesize"),
                    "L2": q("hw.perflevel0.l2cachesize") or q("hw.l2cachesize")}
        sizes = {}
        for d in sorted(Path("/sys/devices/system/cpu/cpu0/cache").glob("index*")):
            level, kind = (d / "level").read_text().strip(), (d / "type").read_text().strip()
            raw = (d / "size").read_text().strip()
            n = int(raw[:-1]) * (1024 if raw.endswith("K") else 1024 * 1024 if raw.endswith("M") else 1)
            if level == "1" and kind == "Data": sizes["L1d"] = n
            if level == "2": sizes["L2"] = n
        return sizes
    
    
    x = np.random.default_rng(0).random(args.values)
    
    def run(size):
        acc = 0
        for i in range(0, len(x), size):
            b = x[i:i+size]
            acc += np.count_nonzero((b * 2.0 + 1.0) > 2.0)   # 3 operators, 2 temporary arrays
        return acc
    
    rows = []
    for size in args.batch_sizes:
        runs = []
        for _ in range(args.repeats):
            t = time.perf_counter(); run(size); runs.append(time.perf_counter() - t)
        rows.append({"batch_size": size, "seconds": statistics.median(runs)})
        print(f"{size:>12,} values  {rows[-1]['seconds']:7.3f} s")
    
    best = min(rows, key=lambda r: r["seconds"])
    print(f"\nfastest: {best['batch_size']:,} values ({best['batch_size'] * 8 / 1024:,.0f} KB per column)")
    caches = [{"cache": k, "bytes": v, "values": v // 8} for k, v in cache_sizes().items() if v]
    for c in caches:
        print(f"{c['cache']:>3} = {c['bytes'] / 1024:,.0f} KB  ->  {c['values']:,} values of 8 bytes")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"batches": rows, "caches": caches, "summary": [{
                "best_batch": best["batch_size"], "best_bytes": best["batch_size"] * 8, "best_s": best["seconds"],
                "smallest_batch": rows[0]["batch_size"], "smallest_s": rows[0]["seconds"]}]}}, 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 best-batch-size.py
    uv run best-batch-size.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--values64,000,000 8-byte values pushed through the pipeline.
--batch-sizes64, 256, 1024, 4096, 16,384, 131,072, 1,048,576, 16,777,216 Batch sizes to try (values per batch), comma-separated.
--repeats3 Runs per batch size; the median time is reported.

Our run

Pipeline time, by batch sizeApple M2 · 8 cores · 8 GB
Data table
Pipeline time, by batch size
Batch size (8-byte values)Seconds (median of runs)
641.58 s
256439 ms
1.02K163 ms
4.1K77.4 ms
16.4K52.7 ms
131K43 ms
1.05M42.3 ms
16.8M49.2 ms
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20
Output · uv run best-batch-size.py
          64 values    1.578 s
         256 values    0.439 s
       1,024 values    0.163 s
       4,096 values    0.077 s
      16,384 values    0.053 s
     131,072 values    0.043 s
   1,048,576 values    0.042 s
  16,777,216 values    0.049 s

fastest: 1,048,576 values (8,192 KB per column)
L1d = 128 KB  ->  16,384 values of 8 bytes
 L2 = 16,384 KB  ->  2,097,152 values of 8 bytes

What we found

Our curve has the expected U shape, but its minimum sits at 1.05M values (8 MB per column), far above "a few thousand to a few tens of thousands". On the Apple M2 the performance core's L1 data cache is 128 KB and its L2 is 16 MB: the fastest batches fit in L2, not in L1. On a chip with a large, fast L2, staying in L2 is what matters, so "L1 size ÷ row width" undershoots here.

The left side of the U is where the real penalty is. Batches of 64 values took 1.58 s against 42.3 ms at the minimum, while the right side rises only slightly once batches outgrow L2.


Read it

Look up your L1 and L2 sizes (lscpu or sysctl hw.l1dcachesize) and mark them on the chart. The script does this for you; on Apple silicon it reports the performance cores.

Each step of the pipeline holds about three arrays of the batch size at once (its input and two temporaries), so a batch fits a cache level while roughly 3 × 8 bytes × N stays below that level's size. Left of the minimum you pay the per-call cost F/N; right of it you pay main-memory bandwidth on every operator. Between the two the curve is flat across a wide band, which is why an engine can ship one default batch size: the batch size is a granularity chosen so that materializing between operators costs nothing.