A trained model that takes 40 ms per tile is fast. Applied to a national mosaic of 180,000 tiles it is two hours of pure compute, plus the reading, the feature derivation, and the writing — and none of it fits in memory. Distributed batch inference is the engineering that turns a working model into a production map, and almost all of its difficulty is in memory, chunk geometry and where the model object lives, not in the parallelism itself.
This topic is part of Geospatial MLOps and Model Deployment. It is the compute layer underneath orchestrating geospatial ML pipelines: the orchestrator decides when a run happens and what to do when it fails, while Dask or Ray decides how the tiles inside that run are spread across cores. It usually consumes the portable artifacts produced by ONNX export for geospatial model inference, because a serialisable, thread-safe session is far easier to distribute than a full training framework.
Problem Framing
Distributed raster inference fails in four characteristic ways, and none of them is “not enough cores”.
Chunk boundaries fight the file layout. A GeoTIFF stores pixels in internal blocks, typically 256×256 or 512×512, each independently compressed. If a Dask chunk is 300×300, every chunk read decompresses parts of four blocks and the same block is decompressed by multiple chunks. Aligning chunks to an exact multiple of the internal block size routinely doubles throughput with no other change.
Peak memory is several times chunk size. A uint16 chunk becomes a float32 feature array, which spawns intermediates during band math, all of which coexist with the model. Multiply by threads per worker. Workers that die with “memory limit exceeded” at 25% of the nominal budget are almost always over-threaded rather than over-chunked.
The model gets serialised into every task. Capturing a model in a closure means Dask pickles it into each of the 180,000 task payloads. The graph becomes gigabytes, the scheduler stalls, and CPU time disappears into deserialisation. The model must be loaded by the worker, not sent to it.
Neighbourhood features silently break at chunk edges. Anything with a kernel — focal statistics, terrain derivatives, a convolutional model — needs a halo. Without it, every chunk boundary gets a band of wrong values, which is invisible at national zoom and obvious the moment somebody inspects a tile edge. The halo mechanics are the same as in DEM and terrain derivative features.
Prerequisites & Environment Setup
# Pinned requirements for distributed raster inference
dask==2024.6.2
distributed==2024.6.2
ray==2.31.0
rioxarray==0.15.7
xarray==2024.6.0
rasterio==1.3.10
onnxruntime==1.18.0
numpy==1.26.4
Install with:
pip install "dask==2024.6.2" "distributed==2024.6.2" "rioxarray==0.15.7" \
"xarray==2024.6.0" "rasterio==1.3.10" "numpy==1.26.4" \
"onnxruntime==1.18.0"
pip install "ray==2.31.0" # only if you use the Ray pathGDAL/PROJ system dependencies (Ubuntu/Debian):
sudo apt-get install -y gdal-bin libgdal-dev libproj-devEvery worker needs an identical geospatial stack. A PROJ version mismatch between the client and a worker produces silently different reprojections on different machines — the strongest practical argument for shipping one container image to all of them, as covered in containerizing geospatial inference pipelines.
Step-by-Step Implementation
Step 1 — Read the file’s own block size and chunk to a multiple of it
import rasterio
import rioxarray
def native_chunks(path: str, target_mb: int = 128, dtype_bytes: int = 4,
n_bands: int = 6) -> dict:
"""Chunk sizes that are exact multiples of the GeoTIFF's internal blocks."""
with rasterio.open(path) as src:
bx = src.block_shapes[0][1] or 256
by = src.block_shapes[0][0] or 256
px_budget = (target_mb * 1024 ** 2) / (dtype_bytes * n_bands)
side = int(px_budget ** 0.5)
cx = max(bx, (side // bx) * bx)
cy = max(by, (side // by) * by)
return {"x": cx, "y": cy}
chunks = native_chunks("features.tif")
da = rioxarray.open_rasterio("features.tif", chunks=chunks, lock=False)
assert da.chunks is not None, "array is not chunked — the whole scene would load eagerly"
print("chunk grid:", [len(c) for c in da.chunks])Step 2 — Load the model once per worker
import threading
import numpy as np
import onnxruntime as ort
_SESSION = None
_LOCK = threading.Lock()
def get_session(model_uri: str) -> ort.InferenceSession:
"""Module-level cache: one session per worker process, created on first use."""
global _SESSION
if _SESSION is None:
with _LOCK:
if _SESSION is None:
opts = ort.SessionOptions()
opts.intra_op_num_threads = 1 # let Dask own the parallelism
_SESSION = ort.InferenceSession(model_uri, sess_options=opts,
providers=["CPUExecutionProvider"])
return _SESSIONThe double-checked lock matters: threaded Dask workers will call this concurrently, and building two sessions doubles peak memory at exactly the wrong moment.
Step 3 — Map the prediction over blocks
import dask.array as darr
NODATA = 255
def predict_block(block: np.ndarray, model_uri: str, mean, std) -> np.ndarray:
"""Predict one chunk. Shape (bands, y, x) in, (1, y, x) out."""
sess = get_session(model_uri)
bands, ny, nx = block.shape
flat = block.reshape(bands, -1).T.astype("float32")
valid = np.isfinite(flat).all(axis=1)
out = np.full(flat.shape[0], NODATA, dtype="uint8")
if valid.any():
x = (flat[valid] - mean) / std # stats from the TRAINING artifact
logits = sess.run(None, {sess.get_inputs()[0].name: x})[0]
out[valid] = logits.argmax(axis=1).astype("uint8")
return out.reshape(1, ny, nx)
preds = darr.map_blocks(
predict_block,
da.data,
model_uri="s3://models/landcover/v7/model.onnx",
mean=mean, std=std,
dtype="uint8",
chunks=((1,), da.data.chunks[1], da.data.chunks[2]),
)
assert preds.dtype == np.uint8map_blocks keeps the graph embarrassingly parallel: no chunk depends on any other, so there is no shuffle and no scheduler bottleneck.
Step 4 — Write block by block
from dask.distributed import Client
import rasterio
from rasterio.windows import Window
def write_blocks(preds, template_path: str, out_path: str) -> None:
"""Stream chunks to a tiled GeoTIFF; the full array is never materialised."""
with rasterio.open(template_path) as src:
profile = src.profile.copy()
profile.update(count=1, dtype="uint8", nodata=NODATA, compress="deflate",
tiled=True, blockxsize=512, blockysize=512, predictor=2)
ychunks, xchunks = preds.chunks[1], preds.chunks[2]
with rasterio.open(out_path, "w", **profile) as dst:
y0 = 0
for iy, ylen in enumerate(ychunks):
x0 = 0
for ix, xlen in enumerate(xchunks):
tile = preds.blocks[0, iy, ix].compute()
dst.write(tile[0], 1, window=Window(x0, y0, xlen, ylen))
x0 += xlen
y0 += ylen
client = Client(n_workers=8, threads_per_worker=1, memory_limit="6GB")
write_blocks(preds, "features.tif", "landcover_v7.tif")threads_per_worker=1 with more processes is the right default for GDAL-heavy work: GDAL’s internal caches are per-process, and single-threaded workers make memory accounting predictable.
Step 5 — The Ray variant, for heavyweight per-worker state
When the model is a GPU network, an actor that owns the device and stays alive across thousands of tiles beats re-creating it per task.
import ray
@ray.remote(num_gpus=0.25)
class TilePredictor:
"""One long-lived model per GPU slice; tiles are streamed through it."""
def __init__(self, model_uri: str, mean, std):
import onnxruntime as ort
self.sess = ort.InferenceSession(
model_uri, providers=["CUDAExecutionProvider", "CPUExecutionProvider"])
self.mean, self.std = mean, std
def predict(self, tile_key: str) -> str:
block = read_tile(tile_key)
out = predict_block(block, self.sess, self.mean, self.std)
return write_tile(out, tile_key.replace("/features/", "/predictions/"))
ray.init(address="auto")
actors = [TilePredictor.remote(MODEL_URI, mean, std) for _ in range(8)]
futures = [actors[i % len(actors)].predict.remote(k) for i, k in enumerate(tile_keys)]
written = ray.get(futures)
assert len(written) == len(tile_keys)Verification & Testing
The one property distributed inference must preserve is that it changes nothing.
import numpy as np
def test_distributed_matches_single_threaded(small_scene, model_uri, mean, std):
"""A small scene must produce byte-identical predictions either way."""
arr = rioxarray.open_rasterio(small_scene).values
reference = predict_block(arr, model_uri, mean, std)
lazy = rioxarray.open_rasterio(small_scene, chunks={"x": 256, "y": 256}).data
distributed = darr.map_blocks(
predict_block, lazy, model_uri=model_uri, mean=mean, std=std,
dtype="uint8", chunks=((1,), lazy.chunks[1], lazy.chunks[2]),
).compute()
assert np.array_equal(reference, distributed), "chunking changed the predictions"
def test_chunks_align_to_internal_blocks():
ch = native_chunks("features.tif")
with rasterio.open("features.tif") as src:
by, bx = src.block_shapes[0]
assert ch["x"] % bx == 0 and ch["y"] % by == 0Beyond correctness, watch the dashboard. In a healthy run the task stream is a dense band of one colour; large white gaps mean workers are waiting on I/O, and a growing red memory bar means chunks are too large for the thread count. Both are diagnosable in a five-minute run on 1% of the tiles, which is always worth doing before launching the full job.
Troubleshooting & Common Errors
distributed.worker - WARNING - Memory use is high then workers restart — too many threads per worker, or chunks too large for the float32 expansion. Drop to one thread per worker and halve the chunk area; the wall-clock cost is far lower than the cost of restarts.
The graph takes minutes to build and the dashboard is idle — a large object is captured in the mapped function. Check len(pickle.dumps(fn)); anything above a few kilobytes means the model or a big array is riding along in the closure.
CPLE_OpenFailed on some workers only — GDAL configuration (credentials, AWS_REGION, GDAL_DISABLE_READDIR_ON_OPEN) is set on the client but not on the workers. Set it in the worker environment or with a WorkerPlugin, never with a client-side rasterio.Env that does not travel.
Predictions differ between a local run and the cluster — normalisation statistics are being computed per chunk instead of loaded from the training artifact. Load them from the file written during training, exactly as in scaling features consistently between training and inference.
Bands of wrong values along every chunk boundary — a neighbourhood operation without a halo. Use map_overlap with a depth of at least half the kernel width, and trim the halo before writing.
Ray actors sit idle while tasks queue — the actor pool is smaller than the available parallelism, or num_gpus fractions do not divide evenly into the devices. Size the pool from device count and per-actor memory, then submit with ray.util.ActorPool so work is balanced rather than round-robined.
Performance Optimisation
Fix the I/O before touching the compute. For cloud-optimized GeoTIFFs, set GDAL_DISABLE_READDIR_ON_OPEN=EMPTY_DIR, GDAL_HTTP_MULTIPLEX=YES and a GDAL_CACHEMAX of a few hundred megabytes per worker. On a remote-read pipeline this frequently matters more than doubling the worker count.
Convert once to a format built for chunked reads. If the same scene is read repeatedly, convert it to Zarr or to a tiled, overview-carrying COG first. Reading a striped GeoTIFF in blocks is pathological, and the conversion pays for itself on the second pass.
Batch rows into the model, not pixels. ONNX Runtime and most tree libraries amortise call overhead over batch size. Reshape a chunk into an (n_pixels, n_features) matrix and make one call per chunk — the difference against per-pixel calls is one to two orders of magnitude.
Cap the model’s internal threading. intra_op_num_threads=1 for ONNX Runtime, n_jobs=1 for scikit-learn, OMP_NUM_THREADS=1 in the worker environment. Otherwise every worker spawns a full thread pool and the machine thrashes.
Profile with a 1% sample. Run the pipeline over a hundred tiles, read the task stream, and only then launch the full job. The failure modes above all show up in that sample, and a two-minute diagnostic beats a six-hour job that dies at 80%.
FAQ
Dask or Ray for raster inference?
Dask fits array-shaped work — the raster is already a chunked array, and map_blocks preserves lazy evaluation and geometry. Ray fits task-shaped work with heavy per-worker state, such as a GPU model in a long-lived actor. Using Dask for feature computation and Ray for the model call is a common and reasonable split.
Why do workers run out of memory even though each chunk is small?
Because peak memory is the chunk, plus its float32 copy, plus intermediates, plus the model — multiplied by threads per worker. A 256 MB chunk on a four-threaded worker can peak past 4 GB. Reduce threads before reducing chunk size.
How do I keep the model from being serialised for every task?
Never capture it in the closure. Load it lazily from a module-level cache inside the worker (Step 2), or hold it in a Ray actor. Capturing it pickles the model into every task payload and stalls the scheduler.
Does distributed inference change the predictions?
It must not. Any difference means state is leaking across chunk boundaries — normalisation computed per chunk, or a neighbourhood feature without a halo. The equality test in the verification section should be part of your test suite.
Related
- Running Block-Wise Raster Inference with Dask Array — the Dask path end to end
- Parallelising Tile Inference with Ray Actors — long-lived model actors and pool sizing
- Orchestrating Geospatial ML Pipelines — the scheduling layer above this compute
- ONNX Export for Geospatial Model Inference — producing an artifact that distributes cleanly
- Optimizing Memory Usage for Large Vector Datasets — the vector-side equivalent of these memory budgets