Skip to content
About
Main content

Measure the per-file overhead

Run a real systemNeeds Docker, Python with DuckDB

Problem

Every file a query opens costs a fixed amount before any data flows: a request round trip, a footer read, a little planning. On local disk that cost is small; on each file is at least one request with tens of milliseconds of latency. With many small files the fixed cost dominates; past some size it disappears into the transfer time. The share of time spent on overhead for a file of size s is F / (F + s/b), with F the fixed cost per file and b the bandwidth.

Write the same data (about 300 MB of synthetic events) as Parquet in three layouts: 800 files of about 0.5 MB, 8 files and 1 file. Time a full scan of each with DuckDB, from local disk and from an S3-compatible store running in Docker (SeaweedFS). Fit F and b from the points using time = files × F + bytes / b.

The question. How much slower is the small-file layout, and what fixed cost per file and bandwidth does the fit give?


Predict

From the local object store, how much slower are 800 small files than one file of the same data?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you. This lab also runs a real system, so you need Docker.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    measure-the-per-file-overhead.py70 linesDownload
    View the code
    measure-the-per-file-overhead.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["duckdb", "numpy", "pyarrow", "boto3"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Measure the per-file overhead.
    
    The same data (about 400 MB of synthetic events) written as Parquet in three
    layouts: 800 files of 0.5 MB, 8 files of 50 MB, and 1 file of 400 MB. A full
    scan of each with DuckDB, from local disk and from an S3-compatible object
    store (SeaweedFS), then F (fixed cost per file) and b (bandwidth) fitted from
    time = files × F + bytes / b.
    Needs the S3 service from the compose file (port 59100) for the S3 half.
    """
    import argparse, json, os, shutil, tempfile, time
    import boto3, duckdb, numpy as np, pyarrow as pa, pyarrow.parquet as pq
    from botocore.config import Config
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--rows", type=int, default=25_000_000, help="Rows in all (about 16 bytes each, compressed).")
    p.add_argument("--layouts", default="800,8,1", help="File counts.")
    p.add_argument("--endpoint", default="localhost:59100", help="S3 endpoint (host:port).")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    rng = np.random.default_rng(0); work = tempfile.mkdtemp()
    tbl = pa.table({"id": np.arange(args.rows), "amount": rng.integers(1, 10_000, args.rows), "ts": rng.integers(0, 10**9, args.rows)})
    s3 = boto3.client("s3", endpoint_url=f"http://{args.endpoint}", aws_access_key_id="lab", aws_secret_access_key="lablablab",
                      config=Config(s3={"addressing_style": "path"}), region_name="us-east-1")
    try: s3.create_bucket(Bucket="lake")
    except Exception: pass
    counts = [int(x) for x in args.layouts.split(",")]
    for n in counts:
        d = os.path.join(work, f"f{n}"); os.makedirs(d); step = args.rows // n
        for i in range(n):
            path = os.path.join(d, f"part-{i:05d}.parquet"); pq.write_table(tbl.slice(i * step, step), path)
            s3.upload_file(path, "lake", f"f{n}/part-{i:05d}.parquet")
    c = duckdb.connect(); c.execute("INSTALL httpfs"); c.execute("LOAD httpfs")
    c.execute(f"SET s3_endpoint='{args.endpoint}'"); c.execute("SET s3_use_ssl=false"); c.execute("SET s3_url_style='path'")
    c.execute("SET s3_access_key_id='lab'"); c.execute("SET s3_secret_access_key='lablablab'"); c.execute("SET s3_region='us-east-1'")
    for setting in ("enable_external_file_cache", "enable_http_metadata_cache", "parquet_metadata_cache"):     # every scan must go to the store
        try: c.execute(f"SET {setting} = false")
        except duckdb.Error: pass
    rows = []
    for where in ("local disk", "S3 (SeaweedFS)"):
        for n in counts:
            src = os.path.join(work, f"f{n}", "*.parquet") if where == "local disk" else f"s3://lake/f{n}/*.parquet"
            q = f"SELECT count(*), sum(id), sum(amount), sum(ts) FROM read_parquet('{src}')"            # every column is read
            c.execute(q).fetchall(); ts = []
            for _ in range(3):
                t = time.perf_counter(); c.execute(q).fetchall(); ts.append(time.perf_counter() - t)
            mb = sum(os.path.getsize(os.path.join(work, f"f{n}", f)) for f in os.listdir(os.path.join(work, f"f{n}"))) / 2**20
            rows.append({"storage": where, "files": n, "file_mb": mb / n, "total_mb": mb, "seconds": min(ts)})
    fits = []
    for where in ("local disk", "S3 (SeaweedFS)"):
        pts = [r for r in rows if r["storage"] == where]
        X = np.array([[r["files"], r["total_mb"]] for r in pts]); y = np.array([r["seconds"] for r in pts])
        (F, inv_b), *_ = np.linalg.lstsq(X, y, rcond=None)
        for r in pts:
            s = r["file_mb"]; r["fit_overhead_pct"] = F / (F + s * inv_b) * 100 if F > 0 else 0.0
        fits.append({"storage": where, "F_ms": F * 1000, "b_mb_s": 1 / inv_b if inv_b > 0 else None})
    shutil.rmtree(work, ignore_errors=True)
    for r in rows: print(f"{r['storage']:<15} {r['files']:>4} files of {r['file_mb']:7.1f} MB: {r['seconds']:6.3f} s   per-file overhead {r['fit_overhead_pct']:5.1f}%")
    for f_ in fits: print(f"{f_['storage']:<15} fit: F = {f_['F_ms']:.1f} ms per file, b = {f_['b_mb_s'] or 0:,.0f} MB/s")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"scans": rows, "fits": fits}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    measure-the-per-file-overhead.compose.yml11 linesDownload
    View the code
    measure-the-per-file-overhead.compose.yml
    # A local S3-compatible object store (SeaweedFS with its S3 gateway) for the per-file overhead lab.
    services:
      s3:
        image: chrislusf/seaweedfs:3.97
        command: ["server", "-s3", "-dir=/data", "-ip.bind=0.0.0.0"]
        ports: ["59100:8333"]
        healthcheck:
          test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:8333/healthz || wget -q -S -O /dev/null http://localhost:8333/ 2>&1 | grep -q HTTP"]
          interval: 2s
          retries: 60
    
  4. 4Start the services, run the script with the defaults first, then change a knob from the table below and run it again. Remove the services when you are done.

    Terminal
    docker compose -f measure-the-per-file-overhead.compose.yml up -d --wait
    uv run measure-the-per-file-overhead.py
    uv run measure-the-per-file-overhead.py --help     # every knob
    docker compose -f measure-the-per-file-overhead.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--rows25,000,000 Rows in all (about 16 bytes each, compressed).
--layouts800, 8, 1 File counts.
--endpointlocalhost:59100 S3 endpoint (host:port).

Our run

Full scans

Full scans
StorageFilesFile (MB)ScanPer-file overhead
local disk8000.501309 ms0%
local disk836.8335 ms0%
local disk1294206 ms0%
S3 (SeaweedFS)8000.5012.99 s13.6%
S3 (SeaweedFS)836.81.99 s0.214%
S3 (SeaweedFS)12941.81 s0.027%

Fitted constants

Fitted constants
StorageF (ms per file)b (MB/s)
local disk-0.0741.09K
S3 (SeaweedFS)0.509155
Reference runApple M2 · 8 cores · 8 GB
Output · uv run measure-the-per-file-overhead.py
local disk       800 files of     0.5 MB:  0.309 s   per-file overhead   0.0%
local disk         8 files of    36.8 MB:  0.335 s   per-file overhead   0.0%
local disk         1 files of   294.4 MB:  0.206 s   per-file overhead   0.0%
S3 (SeaweedFS)   800 files of     0.5 MB:  2.989 s   per-file overhead  13.6%
S3 (SeaweedFS)     8 files of    36.8 MB:  1.987 s   per-file overhead   0.2%
S3 (SeaweedFS)     1 files of   294.4 MB:  1.815 s   per-file overhead   0.0%
local disk      fit: F = -0.1 ms per file, b = 1,087 MB/s
S3 (SeaweedFS)  fit: F = 0.5 ms per file, b = 155 MB/s
macOS 26.6.2 · Python 3.13.3 · duckdb 1.5.6 · numpy 2.5.4 · pyarrow 26.0.0 · boto3 1.43.115 · chrislusf/seaweedfs:3.97 · Docker 28.5.1 · run 2026-10-20

What we found

From the object store, 800 small files took 2.99 s against 1.99 s for 8 files and 1.81 s for one. The fit gave F = 0.509 ms per file and b = 155 MB/s, so small files spent 13.6% of their time on overhead. From local disk the same layouts took 309 ms, 335 ms and 206 ms. The object store here runs on the same laptop, so its per-request latency is far below a real S3 bucket's.


Read it

Fit F and b once for your storage and the small-file question becomes arithmetic: the file size at which overhead falls below 10% is 9 × F × b. On real object storage, with F in the tens of milliseconds, that size is tens to hundreds of megabytes, which is why compact toward files of that size.