Right-Sizing Workers for Raster Mosaic Jobs
To right-size a worker for a raster mosaic job, compute the decoded peak memory as width × height × bands × dtype_size, divide by a safe utilization fraction to get a memory ceiling, then pick the smallest instance tier that clears it — and if the ceiling is too high, switch to windowed reads so peak memory tracks the window instead of the whole scene. This page gives the exact formula, runnable Python, and a gdalinfo-based verification so you never OOM-kill an undersized worker or pay for an oversized one.
When to Use This Pattern
Reach for explicit peak-memory sizing when any of these hold:
- You are provisioning a dedicated or spot worker pool for
rasterio.merge,gdalwarp, or a tiled mosaic build. - Jobs are OOM-killed (exit code 137) even though CPU sits idle — a classic under-RAM, over-CPU mismatch.
- You cannot explain why a given mosaic needs the instance tier it is running on.
- You are moving batch raster work onto cheaper, smaller spot instances and need to know how small you can safely go.
- Input scene dimensions, band count, or dtype vary across the batch and you want a per-job tier rather than one worst-case tier for all.
Complete Working Example
The function below estimates full-scene peak memory from a raster’s metadata (no pixels read), derives a memory ceiling, checks it against a candidate instance, and — if the scene is too large — computes the largest block-aligned window that fits the budget. It reads only headers, so it is cheap enough to run as a gate before every mosaic task.
from dataclasses import dataclass
from typing import Optional
import numpy as np
import rasterio
from rasterio.io import DatasetReader
# Fraction of instance RAM a single read buffer may safely occupy.
# The remainder is headroom for GDAL's block cache, warp buffers, and the OS.
SAFE_UTILIZATION: float = 0.65
@dataclass
class SizingResult:
peak_full_bytes: int # decoded size of a full-scene read
budget_bytes: int # usable RAM on the candidate instance
fits_full_read: bool # can we read the whole scene at once?
max_window_px: Optional[int] # largest square window side if we cannot
def estimate_peak_bytes(width: int, height: int, bands: int, dtype: str) -> int:
"""Peak resident bytes for one full read: width * height * bands * dtype_size.
GeoTIFFs are compressed on disk but decoded to a dense array in RAM, so the
on-disk file size is irrelevant — only the decoded array size sets the ceiling.
"""
dtype_size: int = np.dtype(dtype).itemsize
return width * height * bands * dtype_size
def size_worker(src_path: str, instance_ram_gb: float) -> SizingResult:
with rasterio.open(src_path) as src: # type: DatasetReader
peak: int = estimate_peak_bytes(src.width, src.height, src.count, src.dtypes[0])
budget: int = int(instance_ram_gb * 1e9 * SAFE_UTILIZATION)
fits: bool = peak <= budget
max_window: Optional[int] = None
if not fits:
# Bytes per pixel across all bands.
bpp: int = src.count * np.dtype(src.dtypes[0]).itemsize
# Largest square window (in pixels per side) whose buffer fits the budget.
max_window = int((budget / bpp) ** 0.5)
# Snap down to the file's internal block size to avoid re-reading blocks.
block_x: int = src.block_shapes[0][1]
if block_x > 0:
max_window = (max_window // block_x) * block_x
return SizingResult(peak, budget, fits, max_window)
if __name__ == "__main__":
# A 40k x 40k, 3-band uint16 scene on an 8 GB worker.
result = size_worker("s3://imagery/scene_40k.tif", instance_ram_gb=8.0)
print(f"full-scene peak : {result.peak_full_bytes / 1e9:.2f} GB")
print(f"usable budget : {result.budget_bytes / 1e9:.2f} GB")
print(f"fits full read : {result.fits_full_read}")
if not result.fits_full_read:
print(f"use windows <= : {result.max_window_px} px per side")
For the 40000×40000 three-band uint16 example, the full-scene peak is 40000 × 40000 × 3 × 2 = 9.6 GB, which exceeds the 8 × 0.65 = 5.2 GB budget — so the function returns a maximum block-aligned window (roughly 25000 px per side snapped to the block grid, or smaller if you want more headroom for warp buffers). Choosing a 2048-px window keeps peak read memory near 2048 × 2048 × 3 × 2 ≈ 25 MB, letting the whole job run comfortably on the 8 GB tier instead of demanding a 128 GB one.
Parameter & Option Reference
| Parameter | Type | Default | Spatial notes |
|---|---|---|---|
width, height |
int |
from src.width/height |
Pixel dimensions of the decoded scene, not the file byte size |
bands |
int |
from src.count |
Multiband imagery multiplies memory linearly; a 12-band scene is 4× a 3-band one |
dtype |
str |
from src.dtypes[0] |
uint8=1, uint16=2, float32=4, float64=8 bytes; dtype choice can quadruple RAM |
SAFE_UTILIZATION |
float |
0.65 |
Leave 30–40% for GDAL block cache (GDAL_CACHEMAX), warp buffers, and the OS |
instance_ram_gb |
float |
— | Physical RAM of the candidate tier; the ceiling is this × utilization |
max_window_px |
int |
derived | Square window side; snapped down to block_shapes to avoid re-reading blocks |
Verification & Testing
Verify the metadata your estimate relies on with gdalinfo, then assert the arithmetic in a test.
Inspect the source scene’s dimensions, band count, dtype and internal block size:
gdalinfo s3://imagery/scene_40k.tif | grep -E "Size is|Band|Type|Block"
# Size is 40000, 40000
# Band 1 Block=512x512 Type=UInt16, ColorInterp=Gray
Confirm the estimator matches the hand calculation and that the windowed path caps memory:
import numpy as np
def test_peak_bytes_matches_hand_calc() -> None:
# 40000 * 40000 * 3 bands * 2 bytes (uint16) = 9.6 GB
peak = estimate_peak_bytes(40000, 40000, 3, "uint16")
assert peak == 40000 * 40000 * 3 * 2
assert peak == 9_600_000_000
def test_scene_does_not_fit_8gb() -> None:
result = size_worker("s3://imagery/scene_40k.tif", instance_ram_gb=8.0)
assert not result.fits_full_read
assert result.max_window_px is not None
# A windowed buffer at the returned side must fit the budget.
bpp = 3 * np.dtype("uint16").itemsize
assert result.max_window_px ** 2 * bpp <= result.budget_bytes
To validate empirically, run the mosaic under /usr/bin/time -v (Linux) and confirm Maximum resident set size stays under the instance RAM — if peak RSS approaches the physical limit, lower the window size or SAFE_UTILIZATION and rerun.
Common Pitfalls
- Sizing from on-disk file size. A 400 MB compressed GeoTIFF can decode to 9.6 GB in RAM. Always size from decoded
width × height × bands × dtype_size, never from the file’s byte size on object storage. - Ignoring dtype width. Casting a
uint8pipeline tofloat32for analysis quadruples peak memory silently. Checksrc.dtypesand keep the narrowest dtype that preserves your values, and never drop thenodatavalue when you do. - Unaligned windows. Choosing a window size that is not a multiple of the file’s internal block (
src.block_shapes) makes GDAL re-read overlapping blocks, inflating both I/O and egress. Snap window sides down to the block grid. - Forgetting warp/merge overhead.
rasterio.mergeandgdalwarpallocate output and resampling buffers on top of the read buffer, so a budget that exactly equals the read size still OOMs. The 0.65 utilization fraction exists precisely to absorb this — do not raise it toward 1.0.
Related: This how-to backs the sizing decisions in cost optimization for spatial compute, feeds the checkpoint boundaries in checkpointing large raster mosaics, and complements how to structure a DAG for raster processing. See the rasterio windowed reading docs for the block-alignment details.
← Back to Cost Optimization for Spatial Compute