Skip to content
About
Main content

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; 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 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 .

The question. How many bytes does the disk read per byte requested, and does object storage reward small or large reads?


Predict

Asking for 200-byte records, how many bytes does macOS read from disk per byte requested?

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.

    the-one-ratio-bytes-touched-versus.py67 linesDownload
    View 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)
    
  3. 3Get the services it runs against, pinned to the versions we measured. Save it next to the script.

    the-one-ratio-bytes-touched-versus.compose.yml11 linesDownload
    View 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: 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 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
Knobs
Knob · defaultWhat 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

Local disk, 20,000 random records
ReadsAsked (MB)Read from disk (MB)Disk ÷ asked
200-byte reads3.8128574.6×
4 KiB aligned reads3.8128775.2×

Object store range reads

Object store range reads
GETsMBTimems per GETMB/s
1,000 GETs of 4 KiB3.912.35 s2.351.66
10 GETs of 4 MB40213 ms21.3188
Reference runApple M2 · 8 cores · 8 GB
Output · uv run the-one-ratio-bytes-touched-versus.py
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)
macOS 26.6.2 · Python 3.13.3 · boto3 1.43.115 · psutil 7.2.2 · chrislusf/seaweedfs:3.97 · Docker 28.5.1 · run 2026-10-20

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.