Right-Sizing Workers for Raster Mosaic Jobs

Worker memory is not a property of the data; it is a product of two numbers you choose — the per-unit peak and how many units run at once — plus a headroom factor. Fleets are almost always sized for the largest input ever seen, at every level of concurrency, which means paying four times over for a case that occurs once in a hundred. Measuring the distribution and routing its tail to a second pool typically halves the compute bill without touching the code that does the work.

When to Use This Pattern

  • Workers are provisioned by guess and nobody can say what the per-tile peak actually is.
  • Out-of-memory kills happen occasionally, and the response so far has been to double the memory.
  • Input sizes vary by an order of magnitude, which is normal for scene-based sources.
  • Compute is a visible line on the bill and the fleet runs well below its provisioned memory.

Complete Working Example

Measure per unit, estimate before allocating, and route by the estimate.

from __future__ import annotations

import resource
from dataclasses import dataclass

import rasterio
from prefect import flow, task, get_run_logger
from prometheus_client import Histogram

PEAK_MB = Histogram(
    "tile_peak_memory_mb", "Marginal peak RSS per tile task",
    buckets=(64, 128, 256, 512, 1024, 2048, 4096, 8192), labelnames=("layer",),
)


@dataclass(frozen=True)
class SizeEstimate:
    megapixels: float
    bands: int
    bytes_per_px: int

    @property
    def working_set_mb(self) -> float:
        # Source window, warped destination, and one working copy: three times the
        # naive figure is the estimate that has matched measurement most closely.
        return 3 * self.megapixels * self.bands * self.bytes_per_px


@task(timeout_seconds=120)
def estimate(src_uri: str, tile: Tile) -> SizeEstimate:
    """Header read only. Costs one range request and decides which pool to use."""
    with rasterio.open(src_uri) as src:
        w = src.window(*tile.bounds_in(src.crs))
        return SizeEstimate(
            megapixels=(w.width * w.height) / 1e6,
            bands=src.count,
            bytes_per_px=rasterio.dtypes.dtype_ranges[src.dtypes[0]] and src.dtypes[0].itemsize,
        )


@task(retries=2, timeout_seconds=900, tags=["tile"])
def build_tile(tile: Tile, layer: str, src_uri: str, dest: str) -> str:
    start = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    warp_window(src_uri, tile, dest)
    peak = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    # Marginal, not absolute: in a reused process the absolute figure only rises.
    PEAK_MB.labels(layer=layer).observe(max(peak - start, 0) / 1024)
    return dest


@flow(name="mosaic")
def mosaic(tiles: list[Tile], layer: str, src_uri: str, dest: str, big_mb: float = 1500):
    sizes = estimate.map(src_uri=src_uri, tile=tiles)
    small = [t for t, s in zip(tiles, sizes) if s.result().working_set_mb < big_mb]
    large = [t for t, s in zip(tiles, sizes) if s.result().working_set_mb >= big_mb]
    get_run_logger().info("routing: %d small, %d large", len(small), len(large))

    build_tile.with_options(task_run_name="small-{tile.path}").map(
        small, layer=layer, src_uri=src_uri, dest=dest)
    build_tile.with_options(tags=["tile", "bigmem"]).map(
        large, layer=layer, src_uri=src_uri, dest=dest)

The worker size itself is then arithmetic rather than judgement:

def worker_memory_gb(p99_peak_mb: float, concurrency: int, headroom: float = 1.3) -> float:
    """The only three numbers that matter, and two of them are settings."""
    return (p99_peak_mb * concurrency * headroom) / 1024


assert worker_memory_gb(p99_peak_mb=420, concurrency=8, headroom=1.3) == pytest.approx(4.3)
One fleet for the worst case, or two for the real oneSizing every worker for the largest tile provisions thirty-two gigabytes across the fleet. Sizing for the p99 and adding a small large-memory pool provisions far less in total.provisioned memory across a 12-worker fleetsized for the max12 x 32 GiB = 384 GiBsized for the p9911 x 8 GiB1 x 32= 120 GiB, and the same throughputThe large-memory worker is idle most of the night. That is fine: it is one worker, and it existsso the other eleven do not have to carry headroom they never touch.
Two pools is a small amount of configuration and it is where almost all of the saving in this pattern comes from.

The three-times factor in the estimate is empirical and worth explaining, because a naive calculation of width times height times bands times bytes is invariably too low. A warp holds the source window and the destination array simultaneously, resampling needs working space proportional to the kernel, and the library will typically hold a copy during any type conversion. Three times the naive figure has matched measured peaks closely enough across bilinear and cubic resampling to be a usable routing threshold; it is not a substitute for measuring, but it is a good enough estimate to decide which pool a unit belongs in before allocating anything.

Parameter & Option Reference

Setting Typical Spatial notes
Per-worker concurrency 4–8 The multiplier on per-unit peak. Raising it is the fastest way to run out of memory.
Headroom factor 1.2–1.4 Covers the interpreter, the driver’s cache and measurement error. Below 1.2 is optimistic.
Routing threshold ~1.5 GiB estimated Anything above goes to the large pool. Tune from the histogram, not from intuition.
Large-pool size 1–2 workers It handles the tail, so it is idle most of the time by design.
GDAL_CACHEMAX 5–10% of worker RAM The default is often a large fraction of the machine and it is per process, not per worker.
Memory metric marginal RSS Absolute ru_maxrss in a reused process is a high-water mark, not a per-task figure.
Concurrency is the multiplierMemory grows linearly with concurrent tiles. At the measured per-tile peak the worker limit is crossed between six and eight, which is where the out-of-memory kills come from.GiB2468worker limit — 8 GiBconcurrency 4 — comfortableThe line is straight because the tiles are independent. That is the property that makes this arithmetic rather than guesswork.
Concurrency and memory are one setting seen twice, which is why raising throughput by raising concurrency so often shows up as an out-of-memory kill.

Verification & Testing

def test_estimate_is_conservative_against_measurement(sample_tiles) -> None:
    for tile in sample_tiles:
        est = estimate.fn(SRC, tile).working_set_mb
        actual = measure_peak(lambda: warp_window(SRC, tile, "/dev/null"))
        assert est >= actual * 0.8, f"estimate {est:.0f} well below measured {actual:.0f}"


def test_routing_sends_big_tiles_to_the_big_pool(monkeypatch) -> None:
    routed: list[str] = []
    monkeypatch.setattr("mymodule.submit", lambda t, tags: routed.append(tags[-1]))
    mosaic(mixed_tiles(), layer="ortho", src_uri=SRC, dest=DEST)
    assert routed.count("bigmem") == 3, "the tail is not being routed"


def test_worker_memory_formula() -> None:
    assert worker_memory_gb(420, 8) == pytest.approx(4.27, abs=0.05)
    assert worker_memory_gb(420, 16) == pytest.approx(8.53, abs=0.05)


def test_gdal_cache_is_bounded() -> None:
    # The default can be a large fraction of the machine, per process.
    assert int(os.environ["GDAL_CACHEMAX"]) <= 512

The GDAL_CACHEMAX assertion catches a genuinely confusing failure. The cache is per process and its default on some builds is a percentage of physical memory, so a worker running eight tiles in eight processes can allocate eight large caches on top of eight working sets — and the resulting out-of-memory kill looks like the tiles being bigger than measured, because nothing in the task’s own memory accounting includes the driver’s cache. Pinning it explicitly makes the arithmetic in worker_memory_gb true rather than approximately true.

What the driver cache addsEight concurrent tiles at four hundred and twenty megabytes each fit comfortably. Adding an unbounded per-process driver cache to each takes the total past the worker limit.8 concurrent tiles on an 8 GiB workercache pinnedworking sets 3.4 GiBcache 0.5cache defaultworking sets 3.4 GiBcaches 6.4 GiBThe second row is killed by the kernel, and the tiles look innocent in every metric the task itself records.Pin it in the container image, not in the flow: a task that forgets is a worker that dies.
This is the commonest reason a per-tile memory measurement and a worker’s actual behaviour disagree.

There is one more reason to prefer two pools over one large fleet, and it has nothing to do with money. A worker sized for the outlier will happily run the outlier at concurrency eight, which means eight outliers at once — and that configuration has never been tested, because outliers are rare enough that they almost never coincide by chance. The first night they do, the fleet discovers a memory profile nobody has measured. Routing the tail to a pool with a concurrency of one removes the coincidence entirely: the large tiles are serialised by construction, which is both cheaper and more predictable than trusting that they will stay rare.

The same logic argues for keeping the routing threshold well below the large pool’s actual capacity. A threshold set at the exact point where the small pool fails leaves no margin for an estimate that came in low, and estimates do come in low — a source with an unexpected alpha band, a nodata mask that materialises as an extra array. Setting the threshold at roughly half the small worker’s per-slot budget sends slightly too much work to the large pool and costs very little, because the large pool is one machine and the alternative is an out-of-memory kill in the middle of a nightly window.

Common Pitfalls

  • Sizing for the largest input. It multiplies every worker’s memory to accommodate a case that happens once in a hundred.
  • Using absolute ru_maxrss in a reused process. It is a high-water mark and will size the fleet for the biggest task the worker ever ran.
  • Leaving the driver cache at its default. Per-process caches on a concurrent worker can exceed the working sets they accompany.
  • Raising concurrency to raise throughput. Memory scales with it linearly; the limit is usually reached long before the CPU is.
  • Estimating from width times height times bands. A warp holds several arrays at once; a factor of about three matches measurement.
  • Routing after allocation. The decision must come from a header read, or the first thing the outlier does is exhaust the small worker.

Frequently Asked Questions

Should the estimate ever be trusted over the measurement?

No — it exists only to route. The measurement sizes the fleet and the estimate decides which fleet a unit goes to, and confusing the two produces either a fleet sized from a heuristic or routing that happens too late to help.

What about CPU?

It is rarely the binding constraint for warping, which is memory- and I/O-bound. Where it is — heavy resampling, complex vector overlays — the same method applies with a different metric, and the routing threshold becomes an estimated cost rather than an estimated working set.

How does this interact with spot instances?

Well, because a right-sized worker is cheaper to lose. A reclaimed 8 GiB worker running four tiles loses four tiles of work; a reclaimed 32 GiB worker running sixteen loses sixteen. See using spot instances for interruptible raster jobs.

Does a process pool change the arithmetic?

It makes it easier to trust, because each process gets its own peak and ru_maxrss becomes meaningful without differencing. It also multiplies the driver cache by the pool size, so pinning the cache matters more rather than less. See offloading GDAL work to a process pool.

How often should the histogram be reviewed?

After any source change and otherwise quarterly. A p99 drifting upward is a fleet moving closer to its limit, which becomes a reliability problem before it becomes a cost one.

Cost Optimization for Spatial Compute