Batching GPU Inference from a Rasterio Window Reader
To stop a GPU idling while rasterio reads, move the reads onto threads that fill a bounded queue and consume batches from it:
import queue, threading
q: queue.Queue = queue.Queue(maxsize=4) # bounded: readers block when ahead
def reader():
for tile in tiles:
q.put((src.read(window=tile.read, boundless=True, fill_value=0), tile))
q.put(None)
threading.Thread(target=reader, daemon=True).start()
while (item := q.get()) is not None:
arrays, tile = item
write(model(arrays)) # device stays busy
GDAL releases the GIL during decompression, so a reader thread genuinely overlaps with the model call rather than merely interleaving with it. This page extends running model inference over large rasters in Raster Machine Learning & Model Inference.
Where the Idle Time Comes From
A naive loop alternates three phases that cannot overlap: read and decompress, run the model, crop and write. For a 640-pixel six-band tile from a compressed COG the read is tens of milliseconds, the model call on a modern GPU a few, and the write a handful. The device is therefore idle for most of the run, and buying a faster card changes nothing.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
rasterio |
>=1.3.0 |
Thread-safe windowed reads from per-thread dataset handles |
torch |
>=2.1 |
Pinned memory, non-blocking transfers, inference mode |
numpy |
>=1.23 |
Batch stacking |
pip install "rasterio>=1.3.0" "torch>=2.1" "numpy>=1.23"
Complete Working Example
import queue
import threading
import numpy as np
import rasterio
import torch
def reader_worker(src_path: str, tiles: list, out_q: queue.Queue,
scale: float = 10_000.0) -> None:
"""One dataset handle per thread — handles must not be shared."""
with rasterio.open(src_path) as src:
fill = src.nodata if src.nodata is not None else 0
for tile in tiles:
arr = src.read(window=tile.read, boundless=True, fill_value=fill)
out_q.put((arr.astype("float32") / scale, tile))
out_q.put(None)
def batched(out_q: queue.Queue, n_readers: int, batch_size: int):
"""Yield batches, ending when every reader has signalled completion."""
buf, done = [], 0
while done < n_readers:
item = out_q.get()
if item is None:
done += 1
continue
buf.append(item)
if len(buf) == batch_size:
yield buf
buf = []
if buf:
yield buf
@torch.inference_mode()
def run(model, src_path: str, dst_path: str, tiles: list, *,
batch_size: int = 16, n_readers: int = 4, device: str = "cuda") -> None:
q: queue.Queue = queue.Queue(maxsize=batch_size * 3)
chunks = [tiles[i::n_readers] for i in range(n_readers)]
for chunk in chunks:
threading.Thread(target=reader_worker, args=(src_path, chunk, q),
daemon=True).start()
model = model.to(device).eval()
with rasterio.open(src_path) as src:
profile = src.profile | {"count": 1, "dtype": "uint8", "nodata": 0,
"compress": "deflate", "tiled": True}
profile.pop("photometric", None)
with rasterio.open(dst_path, "w", **profile) as dst:
for batch in batched(q, n_readers, batch_size):
arrays = np.stack([a for a, _ in batch])
# pin_memory + non_blocking overlaps the host-to-device copy
x = torch.from_numpy(arrays).pin_memory().to(device, non_blocking=True)
pred = model(x).argmax(dim=1).to("uint8").cpu().numpy() + 1
for out, (_, tile) in zip(pred, batch):
t, b, l, r = tile.crop
h, w = out.shape
dst.write(out[t:h - b, l:w - r], 1, window=tile.write)
The structure has one more benefit that only shows up on long runs: it fails cleanly. Because nothing is buffered beyond the queue bound and every prediction is written as soon as it is cropped, killing the process loses at most one batch of work, and the partially written output is a valid raster covering the blocks completed so far.
The bounded queue is what makes this safe. An unbounded queue lets fast readers run ahead of a slow model until the process is holding thousands of tiles and the machine swaps; maxsize makes the readers block instead, which is exactly the back-pressure you want.
One dataset handle per thread is non-negotiable. A rasterio.DatasetReader is not thread-safe, and sharing one produces corrupted reads that look like random noise in a handful of tiles — a bug that survives casual testing because most tiles are fine.
Tuning It
Three measurements tell you what to change. Device utilisation below 70% means the readers are behind — add threads, or check whether the source is a striped GeoTIFF forcing full-width reads. A queue that is persistently full means the readers are ahead and the device is the limit, so a larger batch or a smaller model is the lever. And host memory climbing steadily means the queue is unbounded or predictions are being accumulated rather than written.
Half precision is worth trying once the pipeline is otherwise balanced. Casting the model and inputs to float16 typically halves the model time on hardware with tensor cores, which matters only if the device was the bottleneck — on a read-bound pipeline it changes nothing, which is itself a useful diagnostic.
Common Errors
Occasional tiles come back as noise
A dataset handle was shared between threads. Open the file inside each worker, as the example does.
RuntimeError: CUDA out of memory partway through
The batch is too large for the activations, or a variable-shape edge batch was larger than expected. Pad edge tiles to the nominal size and keep the batch fixed.
Throughput is the same with and without threads
The source is a striped GeoTIFF, so every window read pulls full-width rows and the storage saturates immediately. Convert it with the guidance in converting a GeoTIFF to a COG with rio-cogeo.
Frequently Asked Questions
Q: Why threads rather than processes for the readers? Because GDAL releases the GIL while decompressing, so threads genuinely overlap, and because the arrays stay in one address space — a process pool would have to pickle every tile back to the parent, which costs more than the read.
Q: How many reader threads should I use? Two to four for local NVMe, eight to sixteen for object storage where latency rather than bandwidth is the limit. Measure the queue depth: if it is usually full the readers are ahead, and if it is usually empty add more.
Q: Does a bigger batch always help? Only until the device is saturated, which for a small segmentation network is usually between eight and thirty-two tiles. Beyond that the throughput curve is flat and the memory cost is real, so stop at the knee.
Q: Does this pattern help on CPU-only machines?
Yes, though less dramatically. A CPU model competes with the readers for cores, so the gain comes from overlapping decompression with compute rather than from filling an idle device. Use one or two readers rather than four, and set OMP_NUM_THREADS so the model does not oversubscribe the machine and starve the readers it depends on.
Related
- Running Model Inference over Large Rasters — the loop this feeds.
- Running ONNX Models on Raster Tiles — the same pipeline without a training framework installed.
- Benchmarking COG Read Throughput from Object Storage — measuring the read side properly.
- Choosing Between Threads and Processes for GDAL Workloads — the general rule behind the choice made here.