Benchmarking COG Read Throughput from Object Storage
A useful raster benchmark measures three things per configuration — time to open, time to read a window, and bytes transferred — repeated enough times to see past the noise:
import time
import rasterio
from rasterio.windows import Window
with rasterio.Env(GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR"):
t0 = time.perf_counter()
with rasterio.open(url) as src:
t_open = time.perf_counter() - t0
t1 = time.perf_counter()
arr = src.read(1, window=Window(2048, 2048, 512, 512))
t_read = time.perf_counter() - t1
print(f"open {t_open*1000:.0f} ms · read {t_read*1000:.0f} ms · {arr.nbytes/1e6:.1f} MB decoded")
Measurement is what turns the guidance in Optimizing Pipeline Cost and Performance into decisions for your data, your region and your machine.
Why This Arises in Remote Sensing Workflows
Advice about COG reads is full of numbers — tile sizes, ingest sizes, thread counts — and all of them are contingent. They depend on the file’s internal layout, the distance between compute and storage, the object size distribution, and the shape of the windows the pipeline actually requests. A setting that halves the runtime of one pipeline can do nothing for another.
Benchmarking resolves that, and it is cheap: the whole exercise is a few hundred reads against one representative file. The difficulty is not running it but running it so the result means something. Object storage has a long latency tail, several caches warm up during the first requests, and a benchmark that changes two variables at once produces a number nobody can act on.
The payoff is concrete. Knowing that your reads cost 30 milliseconds and four requests each, rather than assuming it, tells you whether a job is I/O-bound before you scale the cluster — and it tells you which of the settings in Caching PROJ Data and GDAL Config in Containers are worth setting.
Environment & Setup
| Package | Version | Why |
|---|---|---|
rasterio |
≥1.3.0 | The reads being measured |
numpy |
≥1.23 | Statistics over repeats |
pandas |
≥2.0 | Tabulating the sweep |
psutil (optional) |
≥5.9 | Process-level counters alongside timings |
pip install "rasterio>=1.3.0" "pandas>=2.0" "numpy>=1.23"
Complete Working Example
A benchmark harness that measures one configuration at a time, repeats it, and reports medians with spread.
import statistics
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import asdict, dataclass
import pandas as pd
import rasterio
from rasterio.windows import Window
BASE_ENV = {
"GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR",
"GDAL_HTTP_MULTIRANGE": "YES",
"GDAL_HTTP_MERGE_CONSECUTIVE_RANGES": "YES",
"VSI_CACHE": "FALSE", # OFF for benchmarking: caching hides the real cost
}
@dataclass
class Result:
label: str
window_px: int
concurrency: int
open_ms: float
read_ms: float
total_ms: float
mb_decoded: float
def one_read(url: str, size: int, offset: tuple[int, int] = (2048, 2048)) -> Result:
"""A single open-and-read, timed in two parts."""
t0 = time.perf_counter()
with rasterio.open(url) as src:
t_open = time.perf_counter() - t0
t1 = time.perf_counter()
arr = src.read(1, window=Window(offset[0], offset[1], size, size))
t_read = time.perf_counter() - t1
return Result("", size, 1, t_open * 1e3, t_read * 1e3,
(t_open + t_read) * 1e3, arr.nbytes / 1e6)
def sweep_window_size(url: str, sizes=(256, 512, 1024, 2048, 4096), repeats: int = 11,
env: dict | None = None, label: str = "default") -> pd.DataFrame:
"""One axis at a time: window size, everything else fixed."""
rows = []
with rasterio.Env(**(env or BASE_ENV)):
for size in sizes:
timings = []
for i in range(repeats):
r = one_read(url, size)
if i == 0:
continue # discard the warm-up
timings.append(r)
rows.append({
"label": label,
"window_px": size,
"open_ms_median": round(statistics.median(t.open_ms for t in timings), 1),
"read_ms_median": round(statistics.median(t.read_ms for t in timings), 1),
"read_ms_p90": round(sorted(t.read_ms for t in timings)[int(0.9 * len(timings))], 1),
"mb": round(timings[0].mb_decoded, 2),
"mb_per_s": round(timings[0].mb_decoded /
(statistics.median(t.read_ms for t in timings) / 1e3), 1),
})
return pd.DataFrame(rows)
def sweep_concurrency(url: str, size: int = 512, levels=(1, 2, 4, 8, 16, 32),
reads_per_level: int = 64) -> pd.DataFrame:
"""How much parallelism the storage backend actually gives you."""
rows = []
with rasterio.Env(**BASE_ENV):
for n in levels:
offsets = [(2048 + (i % 8) * 512, 2048 + (i // 8) * 512) for i in range(reads_per_level)]
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=n) as pool:
list(pool.map(lambda off: one_read(url, size, off), offsets))
elapsed = time.perf_counter() - t0
rows.append({"concurrency": n,
"reads": reads_per_level,
"elapsed_s": round(elapsed, 2),
"reads_per_s": round(reads_per_level / elapsed, 1)})
return pd.DataFrame(rows)
if __name__ == "__main__":
url = "https://example-bucket.s3.amazonaws.com/scene_cog.tif"
print(sweep_window_size(url).to_string(index=False))
print()
print(sweep_concurrency(url).to_string(index=False))
# Compare one setting change against the baseline, everything else fixed
tuned = BASE_ENV | {"GDAL_INGESTED_BYTES_AT_OPEN": "65536"}
print(sweep_window_size(url, env=tuned, label="ingest-64k").to_string(index=False))
VSI_CACHE=FALSE during benchmarking is deliberate and worth explaining: with caching on, the second read of the same window is free, which makes every configuration look excellent and tells you nothing. Turn it back on in production, where repeated access is real.
Variant Patterns
1. Measuring requests, not just time
Time conflates latency, bandwidth and request count. Counting requests separates them.
import rasterio
with rasterio.Env(CPL_CURL_VERBOSE="YES", CPL_DEBUG="ON"):
with rasterio.open(url) as src:
_ = src.profile
arr = src.read(1, window=Window(2048, 2048, 512, 512))
# GDAL writes one line per HTTP request to stderr; count them with `2>&1 | grep -c '^> GET'`
Requests per useful megabyte is the metric that predicts cost most directly, because object storage bills for both. A configuration with slightly worse latency but half the requests is usually the cheaper one at scale — the accounting laid out in Reducing S3 Egress Costs in Raster Pipelines.
2. What to sweep, and in what order
3. Benchmarking the pipeline, not just the read
A read benchmark answers “how fast can I get bytes”. It does not answer “is my pipeline I/O-bound”, which needs the arithmetic timed alongside.
import time
t0 = time.perf_counter()
arr = src.read(1, window=win, masked=True)
t_read = time.perf_counter() - t0
t1 = time.perf_counter()
ndvi = (nir - arr) / (nir + arr)
t_compute = time.perf_counter() - t1
print(f"I/O {t_read:.3f}s · compute {t_compute:.3f}s · ratio {t_read / max(t_compute, 1e-9):.1f}")
A ratio well above one says the job is I/O-bound and that concurrency or region placement is the lever. Below one says the arithmetic dominates, and no amount of read tuning will help — the distinction that decides between the executors in Scaling Raster Processing with Dask.
Making the Numbers Trustworthy
Four rules keep a benchmark from misleading the team that acts on it.
Change one thing at a time. A run that changes both the ingest size and the thread count produces a number with no attributable cause, and the next person will change both back.
Discard the warm-up and report the spread. The first read of a session pays for DNS, TLS and credential resolution; the median of ten subsequent reads, with a p90 alongside, describes what a job will actually experience.
Benchmark where the job runs. A measurement from a laptop over a home connection is a measurement of that connection. Run the harness inside the same container image and region as production — the reproducibility argument from Containerizing Geospatial Python Environments.
Record the configuration with the result. Region, instance type, image tag, GDAL version and the environment variables in force all belong in the output table. A benchmark whose configuration is not recorded cannot be compared with the next one, which is the only use a benchmark really has.
Common Errors
Every configuration looks identical
Caching is on, so only the first read touches the network. Set VSI_CACHE=FALSE for the benchmark and vary the window offset between repeats.
Results vary by a factor of three between runs
Too few repeats against a heavily skewed latency distribution. Use at least ten repeats per configuration and compare medians, not single timings.
Throughput collapses above a certain concurrency
You have found the backend’s throttling point, which is a useful result. Set the pipeline’s concurrency below it rather than adding retries — the cap-then-back-off pattern from Parameterizing Prefect Flows for Multi-Tile Runs.
Frequently Asked Questions
Q: Why are my benchmark numbers so variable? Object storage latency is heavily skewed, and caches at several layers warm up. Run each configuration at least ten times, discard the first, and report the median with an interquartile range rather than a single number.
Q: Should I benchmark from my laptop? Only to compare relative effects. Absolute numbers from a laptop are dominated by the route to the bucket, so a setting that looks decisive locally may be irrelevant in-region, and vice versa.
Q: What is the single most useful metric? Requests per useful megabyte. It captures both the per-request overhead and the wasted transfer, and it predicts cost on object storage better than either latency or throughput alone.
Related
- Optimizing Pipeline Cost and Performance — the parent topic, and where these numbers feed into decisions.
- Reducing S3 Egress Costs in Raster Pipelines — turning requests and bytes into money.
- Caching PROJ Data and GDAL Config in Containers — the settings this benchmark is for testing.
- Reading a COG over S3 Without Downloading — the read mechanics being measured.