The one ratio: bytes touched versus bytes needed on your own disk
Run a real systemNeeds Docker, Python
Problem
Storage never moves just the bytes you ask for. A disk reads whole blocks (4 KiB or more), so a 200-byte record costs a block; object storage charges a round trip per request, so a small range read costs tens of milliseconds before its first byte. The one ratio that decides layout is bytes touched over bytes needed, and it is set by the medium's unit of access.
Create a 1 GB file of random data. Read 200-byte records at random offsets with (a) plain 200-byte reads and (b) 4 KiB aligned reads, bypassing the page cache. Count the bytes the OS reports as read from the disk. Then repeat the idea against object storage (SeaweedFS in Docker): time 1,000 range GETs of 4 KiB against 10 range GETs of 4 MB.
The question. How many bytes does the disk read per byte requested, and does object storage reward small or large reads?
Predict
Expect (b) and the device counters to show about 20x more bytes than the records you asked for. Expect the small object-store reads to be dominated by fixed latency (about 1,000 x 50 ms = 50 s if sequential on real S3), and the large reads to be dominated by bandwidth.
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
the-one-ratio-bytes-touched-versus.py # /// script # requires-python = ">=3.10" # dependencies = ["boto3", "psutil"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """The one ratio: bytes touched against bytes needed. Local part: a 1 GB file of random data, 20,000 records of 200 bytes read at random offsets, as plain 200-byte reads and as 4 KiB aligned reads, bypassing the page cache (F_NOCACHE on macOS, posix_fadvise elsewhere); the bytes the OS reports read from the disk against the bytes asked for. Object-store part: 1,000 range GETs of 4 KiB against 10 range GETs of 4 MB from SeaweedFS. Needs the S3 service from the compose file (port 59200) for the second part. """ import argparse, json, os, random, sys, tempfile, time import boto3, psutil from botocore.config import Config p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--file-mb", type=int, default=1024, help="Local test file (MB).") p.add_argument("--reads", type=int, default=20_000, help="Random record reads.") p.add_argument("--endpoint", default="http://localhost:59200", help="S3 endpoint.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) path = os.path.join(tempfile.mkdtemp(), "ratio.bin") with open(path, "wb") as f: for _ in range(args.file_mb): f.write(os.urandom(1 << 20)) size = os.path.getsize(path) def nocache(fd): if sys.platform == "darwin": import fcntl; fcntl.fcntl(fd, fcntl.F_NOCACHE, 1) elif hasattr(os, "posix_fadvise"): os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED) offsets = [random.randrange(0, size - 4096) for _ in range(args.reads)] local = [] for label, aligned in (("200-byte reads", False), ("4 KiB aligned reads", True)): fd = os.open(path, os.O_RDONLY); nocache(fd) before = psutil.disk_io_counters().read_bytes; t = time.perf_counter(); got = 0 for off in offsets: if aligned: got += len(os.pread(fd, 4096, off - off % 4096)) else: got += len(os.pread(fd, 200, off)) el = time.perf_counter() - t; disk = psutil.disk_io_counters().read_bytes - before; os.close(fd) local.append({"reads": label, "asked_mb": args.reads * 200 / 2**20, "returned_mb": got / 2**20, "disk_mb": disk / 2**20, "disk_over_asked": disk / (args.reads * 200), "seconds": el}) os.remove(path) s3 = boto3.client("s3", endpoint_url=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="ratio") except Exception: pass s3.put_object(Bucket="ratio", Key="obj", Body=os.urandom(64 << 20)) remote = [] for label, n, sz in (("1,000 GETs of 4 KiB", 1000, 4096), ("10 GETs of 4 MB", 10, 4 << 20)): t = time.perf_counter() for i in range(n): off = random.randrange(0, (64 << 20) - sz); s3.get_object(Bucket="ratio", Key="obj", Range=f"bytes={off}-{off + sz - 1}")["Body"].read() el = time.perf_counter() - t remote.append({"gets": label, "mb": n * sz / 2**20, "seconds": el, "ms_per_get": el / n * 1000, "mb_per_s": n * sz / 2**20 / el}) for r in local: print(f"{r['reads']:<20} asked {r['asked_mb']:6.1f} MB, OS read from disk {r['disk_mb']:7.1f} MB ({r['disk_over_asked']:.1f}x), {r['seconds']:.2f} s") for r in remote: print(f"{r['gets']:<20} {r['mb']:6.1f} MB in {r['seconds']:6.2f} s ({r['ms_per_get']:.1f} ms per GET, {r['mb_per_s']:.0f} MB/s)") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"local": local, "remote": remote}}, f)3Get the services it runs against, pinned to the versions we measured. Save it next to the script.
View the codeHide the code
the-one-ratio-bytes-touched-versus.compose.yml # A local S3-compatible object store (SeaweedFS with its S3 gateway) for the range-read lab. services: s3: image: chrislusf/seaweedfs:3.97 command: ["server", "-s3", "-dir=/data", "-ip.bind=0.0.0.0"] ports: ["59200: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 the-one-ratio-bytes-touched-versus.compose.yml up -d --wait uv run the-one-ratio-bytes-touched-versus.py uv run the-one-ratio-bytes-touched-versus.py --help # every knob docker compose -f the-one-ratio-bytes-touched-versus.compose.yml down -v
| Knob · default | What it changes |
|---|---|
| --file-mb1024 | Local test file (MB). |
| --reads20,000 | Random record reads. |
| --endpointhttp://localhost:59200 | S3 endpoint. |
| --seed0 | Random seed. |
Our run
Local disk, 20,000 random records
| Reads | Asked (MB) | Read from disk (MB) | Disk ÷ asked |
|---|---|---|---|
| 200-byte reads | 3.81 | 285 | 74.6× |
| 4 KiB aligned reads | 3.81 | 287 | 75.2× |
Object store range reads
| GETs | MB | Time | ms per GET | MB/s |
|---|---|---|---|---|
| 1,000 GETs of 4 KiB | 3.91 | 2.35 s | 2.35 | 1.66 |
| 10 GETs of 4 MB | 40 | 213 ms | 21.3 | 188 |
200-byte reads asked 3.8 MB, OS read from disk 284.5 MB (74.6x), 1.51 s
4 KiB aligned reads asked 3.8 MB, OS read from disk 286.9 MB (75.2x), 1.57 s
1,000 GETs of 4 KiB 3.9 MB in 2.35 s (2.3 ms per GET, 2 MB/s)
10 GETs of 4 MB 40.0 MB in 0.21 s (21.3 ms per GET, 188 MB/s)What we found
Asking for 3.81 MB of records made the OS read 285 MB from the disk (74.6×), and 4 KiB aligned reads cost the same (75.2×): more than the expected 20×, because macOS reads ahead in units larger than 4 KiB, and the counter is system-wide. From the object store, 1,000 GETs of 4 KiB took 2.35 s (2.35 ms each, 1.66 MB/s) and 10 GETs of 4 MB 213 ms (188 MB/s). The store runs on the same laptop, so its per-request latency is a few milliseconds rather than the tens of a real S3 bucket; the direction is the same.
Read it
The unit of access sets the floor on what a read costs, so layouts are designed in that unit: pages for disks, megabytes for object storage. Reading a record that is smaller than the unit wastes the rest of it; laying out data so that what is read together sits together is how the ratio approaches one.