Skip to content
About
Main content

Find the small-file crossover on your own bucket

Run a real systemNeeds Docker, Python

Problem

charges per request as well as per byte. For an object of S, a read costs a request fee plus S times the byte fee, so below S* = request fee ÷ byte fee the request fee dominates. Small files are therefore expensive in two currencies: each request also has a minimum , so a thousand small reads take far longer than one large read of the same bytes. Concurrency hides the latency. It cannot hide the fee, because the number of requests does not change.

Create 1,000 objects of 10 KB and one object of 10 MB (any S3-compatible store; a local MinIO also works). Time reading all of them with one thread and with 32 threads.

The script uses a local S3-compatible store (SeaweedFS) from the compose file, adds an 8-thread run, and prices the GETs at the S3 Standard list price. A local store has a fraction of a cloud store's per-request latency, so the absolute times are much shorter than on S3.

The question. How much faster are 32 threads than one at reading 1,000 small objects, and how does the request fee compare with one GET of the same bytes in one object?


Predict

Against a local object store, how much faster do 32 threads read the 1,000 small objects than one thread?

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.

    find-the-small-file-crossover-on.py60 linesDownload
    View the code
    find-the-small-file-crossover-on.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["boto3"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Find the small-file crossover on object storage.
    
    1,000 objects of 10 KB and one object of 10 MB in an S3-compatible store.
    Read all the small ones with one thread and with 32, and the large one once:
    concurrency changes the time, never the request count, and the fee follows
    the request count.
    Needs the S3 service from the compose file (SeaweedFS, port 59000), or set --endpoint.
    """
    import argparse, json, os, time
    from concurrent.futures import ThreadPoolExecutor
    import boto3
    from botocore.config import Config
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--endpoint", default="http://localhost:59000", help="S3 endpoint (empty for AWS).")
    p.add_argument("--bucket", default="lab", help="Bucket.")
    p.add_argument("--small", type=int, default=1000, help="Number of small objects.")
    p.add_argument("--small-kb", type=int, default=10, help="Size of each small object, KB.")
    p.add_argument("--threads", type=lambda s: [int(v) for v in s.split(",")], default=[1, 8, 32], help="Thread counts to try.")
    p.add_argument("--get-price", type=float, default=0.0004, help="Price per 1,000 GET requests (S3 Standard list price).")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    s3 = boto3.client("s3", endpoint_url=args.endpoint or None, aws_access_key_id="lab", aws_secret_access_key="lablablab",
                      region_name="us-east-1", config=Config(max_pool_connections=64))
    try: s3.create_bucket(Bucket=args.bucket)
    except s3.exceptions.BucketAlreadyOwnedByYou: pass
    blob = os.urandom(args.small_kb * 1024)
    keys = [f"small/{i}.bin" for i in range(args.small)]
    for k in keys: s3.put_object(Bucket=args.bucket, Key=k, Body=blob)
    s3.put_object(Bucket=args.bucket, Key="large.bin", Body=os.urandom(args.small * args.small_kb * 1024))
    
    def get(k): return len(s3.get_object(Bucket=args.bucket, Key=k)["Body"].read())
    
    rows = []
    for n in args.threads:
        t = time.perf_counter()
        with ThreadPoolExecutor(n) as ex: total = sum(ex.map(get, keys))
        dt = time.perf_counter() - t
        rows.append({"read": f"{args.small:,} small objects, {n} thread{'s' if n > 1 else ''}", "threads": n, "seconds": dt,
                     "requests": args.small, "fee": args.small / 1000 * args.get_price})
    t = time.perf_counter(); get("large.bin"); dt = time.perf_counter() - t
    rows.append({"read": "one large object", "threads": 1, "seconds": dt, "requests": 1, "fee": 1 / 1000 * args.get_price})
    for r in rows:
        print(f"{r['read']:<34} {r['seconds']:7.3f} s   {r['requests']:>5,} GETs   fee {r['fee']:.6f}")
    small1 = rows[0]["seconds"]; small32 = next(r["seconds"] for r in rows if r["threads"] == max(args.threads))
    print(f"\n{max(args.threads)} threads: {small1 / small32:.1f}x faster than 1 thread, same {args.small:,} requests; "
          f"the large object costs 1/{args.small:,} of the fee for the same bytes")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"tables": {"reads": rows, "summary": [{"speedup": small1 / small32, "requests": args.small,
                       "fee_ratio": args.small, "large_s": rows[-1]["seconds"]}]}}, f)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    find-the-small-file-crossover-on.compose.yml11 linesDownload
    View the code
    find-the-small-file-crossover-on.compose.yml
    # A local S3-compatible object store (SeaweedFS with its S3 gateway) for the small-file lab.
    services:
      s3:
        image: chrislusf/seaweedfs:3.97
        command: ["server", "-s3", "-dir=/data", "-ip.bind=0.0.0.0"]
        ports: ["59000: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 find-the-small-file-crossover-on.compose.yml up -d --wait
    uv run find-the-small-file-crossover-on.py
    uv run find-the-small-file-crossover-on.py --help     # every knob
    docker compose -f find-the-small-file-crossover-on.compose.yml down -v
Knobs
Knob · defaultWhat it changes
--endpointhttp://localhost:59000 S3 endpoint (empty for AWS).
--bucketlab Bucket.
--small1000 Number of small objects.
--small-kb10 Size of each small object, KB.
--threads1, 8, 32 Thread counts to try.
--get-price0.0004 Price per 1,000 GET requests (S3 Standard list price).

Our run

Reading 10 MB as 1,000 objects or as one

Reading 10 MB as 1,000 objects or as one
ReadTimeGET requestsGET fee
1,000 small objects, 1 thread2.3 s1,0000
1,000 small objects, 8 threads714 ms1,0000
1,000 small objects, 32 threads665 ms1,0000
one large object44.5 ms10
Reference runApple M2 · 8 cores · 8 GB
Output · uv run find-the-small-file-crossover-on.py
1,000 small objects, 1 thread        2.299 s   1,000 GETs   fee 0.000400
1,000 small objects, 8 threads       0.714 s   1,000 GETs   fee 0.000400
1,000 small objects, 32 threads      0.665 s   1,000 GETs   fee 0.000400
one large object                     0.045 s       1 GETs   fee 0.000000

32 threads: 3.5x faster than 1 thread, same 1,000 requests; the large object costs 1/1,000 of the fee for the same bytes
macOS 26.6.2 · Python 3.13.3 · boto3 1.43.115 · chrislusf/seaweedfs:3.97 · Docker 28.5.1 · run 2026-10-20

What we found

Thirty-two threads read the 1,000 small objects 3.46× faster than one thread, and the request count stayed at 1,000. The single 10 MB object took 44.5 ms and one request, so its fee was 1/1,000 of the small files' fee for the same bytes. Against a local store each request costs well under a millisecond, so concurrency had less latency to hide than it would against a cloud store, where the speedup from threads is larger.


Read it

Multiply by your vendor's per-GET fee, and compare with one GET of the 10 MB object. That is the point: concurrency changes the time, never the fee.

The economic minimum object size is the request fee divided by the byte fee, and it coincides with the latency-derived minimum: both say "make files large enough that the per-request cost disappears". The upper limit comes from elsewhere, from (a large file cannot be skipped in part) and from parallelism (enough files to keep workers busy). jobs exist to move files back into that band.