A Ray actor is a worker process that keeps state between calls. For geospatial inference that state is the expensive part: a GPU model, an ONNX session, a warmed CUDA context. Create a pool of actors sized to the hardware, stream tiles through it with ActorPool or a ray.wait loop, and have each call return an object key rather than an array.
This page is the Ray path. For the comparison with Dask and the memory arithmetic, see Scaling Batch Inference with Dask and Ray.
Why This Fails in Geospatial ML Pipelines
Submitting everything at once is the most common Ray mistake and it kills the driver rather than the workers. [actor.predict.remote(t) for t in tiles] for a hundred thousand tiles creates a hundred thousand pending futures; if each returns an array, the object store fills, spills to disk, and the run grinds to a halt with no error that points at the cause. The fix is backpressure — keep a bounded window of in-flight calls — and returning object keys rather than data.
The second failure is fractional GPU arithmetic that ignores memory. num_gpus=0.25 is a scheduling hint: Ray will place four actors on one device, but it does not partition VRAM. Four actors each holding a 6 GB model on a 16 GB card produces an out-of-memory error at the fourth actor’s first call, long after the pool appeared to start cleanly. The fraction must be derived from measured footprint, not chosen for tidiness.
Third, actor death is silent to the pool. An actor killed by the OOM killer leaves its in-flight call raising RayActorError, and code that does not catch it loses those tiles. With max_restarts and per-call exception handling, the same failure costs one tile; without them it costs whatever was in flight, and the gap only appears at the validation gate — which is exactly what that gate exists for, as described in orchestrating geospatial ML pipelines.
Core Principles
- Put the expensive state in
__init__. That is the whole reason to use an actor. - Size the pool from measured VRAM, then declare the matching fraction.
- Apply backpressure with
ray.waitand a bounded in-flight window. - Return keys, not arrays. Keep the object store small.
- Set
max_restartsand catchRayActorErrorso a dead actor costs one tile. - Pin intra-op threads to 1 in the model runtime; Ray owns the parallelism.
Production-Ready Code
from __future__ import annotations
import logging
from dataclasses import dataclass
import numpy as np
import ray
logger = logging.getLogger(__name__)
@ray.remote(num_gpus=0.25, max_restarts=2, max_task_retries=1)
class TilePredictor:
"""Holds the model for its lifetime; tiles stream through it.
Returns the written object key, never the array, so the object store stays small.
"""
def __init__(self, model_uri: str, mean: np.ndarray, std: np.ndarray,
out_prefix: str):
import onnxruntime as ort # imported in the actor process
providers = ["CUDAExecutionProvider", "CPUExecutionProvider"]
opts = ort.SessionOptions()
opts.intra_op_num_threads = 1
self.sess = ort.InferenceSession(model_uri, sess_options=opts,
providers=providers)
self.mean, self.std = mean, std
self.out_prefix = out_prefix
self.n_done = 0
def predict_tile(self, tile_key: str) -> str:
from geoml.io import read_tile, write_tile # noqa: PLC0415
block = read_tile(tile_key) # (bands, y, x) float32
n_bands, ny, nx = block.shape
flat = block.reshape(n_bands, -1).T
out = np.full(flat.shape[0], 255, dtype="uint8")
valid = np.isfinite(flat).all(axis=1)
if valid.any():
x = ((flat[valid] - self.mean) / self.std).astype("float32")
logits = self.sess.run(None, {self.sess.get_inputs()[0].name: x})[0]
out[valid] = np.argmax(logits, axis=1).astype("uint8")
self.n_done += 1
return write_tile(out.reshape(1, ny, nx), tile_key, self.out_prefix)
def stats(self) -> dict:
return {"tiles": self.n_done}
def run_pool(tile_keys: list[str], model_uri: str, mean, std, out_prefix: str,
n_actors: int = 8, in_flight: int = 32) -> tuple[list[str], list[str]]:
"""Stream tiles through an actor pool with bounded backpressure.
Returns (written keys, failed tile keys).
"""
actors = [TilePredictor.remote(model_uri, mean, std, out_prefix)
for _ in range(n_actors)]
pending: dict = {}
queue = list(tile_keys)
written, failed = [], []
next_actor = 0
def submit() -> None:
nonlocal next_actor
while queue and len(pending) < in_flight:
key = queue.pop()
actor = actors[next_actor % len(actors)]
next_actor += 1
pending[actor.predict_tile.remote(key)] = key
submit()
while pending:
done, _ = ray.wait(list(pending), num_returns=1, timeout=None)
ref = done[0]
tile = pending.pop(ref)
try:
written.append(ray.get(ref))
except ray.exceptions.RayActorError:
logger.warning("actor died processing %s — requeueing once", tile)
queue.append(tile) if tile not in failed else failed.append(tile)
except Exception as exc: # deterministic tile failure
logger.error("tile %s failed: %s", tile, exc)
failed.append(tile)
submit()
for a in actors:
logger.info("actor processed %s", ray.get(a.stats.remote()))
return written, failedStep-by-Step Walkthrough
Step 1 — Measure the model’s footprint before sizing the pool
import subprocess
import ray
ray.init(address="auto")
print(ray.cluster_resources()) # {'GPU': 2.0, 'CPU': 32.0, ...}
# Start ONE actor and read the device memory it actually uses.
probe = TilePredictor.remote(MODEL_URI, mean, std, "s3://out/probe/")
ray.get(probe.predict_tile.remote(tile_keys[0]))
print(subprocess.run(["nvidia-smi", "--query-gpu=memory.used",
"--format=csv"], capture_output=True, text=True).stdout)
ray.kill(probe)If one actor uses 3.4 GB on a 16 GB card, four per device is safe and num_gpus=0.25 is honest. Guessing here is what produces the out-of-memory at actor four.
Step 2 — Run with backpressure
written, failed = run_pool(tile_keys, MODEL_URI, mean, std,
out_prefix="s3://predictions/_staging/2026-08-04/",
n_actors=8, in_flight=32)
print(f"{len(written)} tiles written, {len(failed)} failed")in_flight=32 for eight actors keeps four calls queued per actor — enough that no actor idles, small enough that the object store never grows.
Step 3 — Watch the right dashboard panels
ray dashboard # actor view: state, restarts, per-actor task countTwo signals matter: actors sitting in IDLE while tiles remain (the in-flight window is too small, or reads are the bottleneck), and a rising restart count (memory pressure).
Step 4 — Hand the failures to the gate
if failed:
quarantine_tiles(failed, run_id=spec.run_id)
report = validate_with_tolerance(spec, written, max_missing_frac=0.002)
if not report["ok"]:
raise RuntimeError(report["failures"])The pool’s job is to process what it can and report honestly; the decision about whether the run publishes belongs to the validation gate.
Verification
import numpy as np
import pytest
import ray
def test_actor_loads_the_model_once():
a = TilePredictor.remote(MODEL_URI, mean, std, "s3://out/test/")
ray.get([a.predict_tile.remote(k) for k in tile_keys[:5]])
assert ray.get(a.stats.remote())["tiles"] == 5
def test_pool_returns_keys_not_arrays():
written, _ = run_pool(tile_keys[:20], MODEL_URI, mean, std, "s3://out/test/",
n_actors=2, in_flight=4)
assert all(isinstance(w, str) and w.startswith("s3://") for w in written)
def test_in_flight_window_is_respected(monkeypatch):
seen = []
real_wait = ray.wait
def spy(refs, **kw):
seen.append(len(refs))
return real_wait(refs, **kw)
monkeypatch.setattr(ray, "wait", spy)
run_pool(tile_keys[:50], MODEL_URI, mean, std, "s3://out/test/",
n_actors=2, in_flight=8)
assert max(seen) <= 8
def test_dead_actor_does_not_lose_the_run():
written, failed = run_pool(tile_keys[:30], MODEL_URI, mean, std, "s3://out/test/",
n_actors=2, in_flight=4)
assert len(written) + len(failed) == 30
def test_predictions_match_single_process(small_tiles):
ref = [predict_single_process(k) for k in small_tiles]
got, _ = run_pool(small_tiles, MODEL_URI, mean, std, "s3://out/test/",
n_actors=2, in_flight=2)
assert sorted(read_all(got)) == sorted(ref)The accounting test — written plus failed equals submitted — is the one that catches silently dropped tiles, which is the failure mode most likely to reach production unnoticed.
FAQ
When is an actor better than a plain Ray task?
When per-call setup is expensive and reusable — a GPU model, a warmed session, an open connection. Tasks rebuild that state every call. For stateless work, tasks are simpler and schedule better.
How do I share one GPU between several actors?
Declare a fraction such as num_gpus=0.25. Ray schedules accordingly but does not partition VRAM, so derive the fraction from measured memory use, as in Step 1.
Why does my driver run out of memory submitting the work?
Because every future and every returned object is being held. Bound the in-flight window with ray.wait and return object keys instead of arrays.
Related
- Scaling Batch Inference with Dask and Ray — the comparison and memory budgeting
- Running Block-Wise Raster Inference with Dask Array — the array-shaped alternative
- Retrying Failed Tile Tasks Without Reprocessing Everything — what to do with the failures this returns
- Serving an ONNX Land Cover Model with onnxruntime — the session each actor holds
Part of: Scaling Batch Inference with Dask and Ray Part of: Geospatial MLOps and Model Deployment