Find the small-file crossover on your own bucket
Run a real systemNeeds Docker, Python
Problem
Object storage charges per request as well as per byte. For an object of size 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 latency, 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
With 32 threads the wall time falls by roughly an order of magnitude (latency is hidden by concurrency), but the request count is the same 1,000.
Run it
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 | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide 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)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide 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: 604Start 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
| Knob · default | What 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
| Read | Time | GET requests | GET fee |
|---|---|---|---|
| 1,000 small objects, 1 thread | 2.3 s | 1,000 | 0 |
| 1,000 small objects, 8 threads | 714 ms | 1,000 | 0 |
| 1,000 small objects, 32 threads | 665 ms | 1,000 | 0 |
| one large object | 44.5 ms | 1 | 0 |
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 bytesWhat 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 pruning (a large file cannot be skipped in part) and from parallelism (enough files to keep workers busy). Compaction jobs exist to move files back into that band.