Tuning Dask Chunk Sizes for Raster Cubes
Target 256–512 MB of uncompressed data per chunk, chunk the spatial dimensions while keeping band=-1 and time=1, and verify the byte size with a one-line nbytes calculation before you compute anything. For a float32 (time, band, y, x) cube that lands on a roughly 2048×2048 spatial tile:
import numpy as np, rioxarray
da = rioxarray.open_rasterio(
"s2_cube.tif",
chunks={"band": -1, "y": 2048, "x": 2048}, # spatial-first; bands whole
)
chunk_mb = np.prod(da.data.chunksize) * da.dtype.itemsize / 1e6
print(f"{chunk_mb:.0f} MB per chunk") # keep this in the 256–512 band
This is the single highest-leverage setting when scaling raster processing with Dask: get it right and the graph runs smoothly; get it wrong and you either drown in scheduler overhead or blow the worker memory budget.
Why This Arises When Scaling Raster Work
Dask has to pick a granularity. Every chunk becomes one task in the graph, and the scheduler carries a fixed per-task cost — roughly a millisecond of bookkeeping plus the memory to track the key. Make chunks tiny and a continental cube explodes into millions of tasks whose overhead dwarfs the actual arithmetic. Make chunks huge and each task needs gigabytes of RAM, so workers spill to disk or get killed by the nanny. The sweet spot balances these two failure modes, and for raster cubes it sits in the hundreds-of-megabytes range.
The complication specific to a (time, band, y, x) cube is that the four dimensions are not interchangeable. Bands are read together and consumed together by index math; scenes come from separate files at separate timestamps; the spatial dimensions are where the data volume lives. So chunk sizing is not one number but a shape decision, and the right shape follows the access pattern. This page focuses narrowly on that shape. For cluster setup, persist versus compute, and writing results, see the parent guide, Scaling Raster Processing with Dask, inside the broader Cloud Execution & Orchestration section.
The chunk shape, visualised
The diagram shows how a (time, band, y, x) cube is sliced: whole in band, one step in time, tiled in y and x. Each shaded block is one chunk — one task the scheduler dispatches.
Environment & Setup
Only three packages are needed to reason about and set chunk sizes:
| Package | Minimum version | Why required |
|---|---|---|
dask |
2024.1 | Chunked array engine and the .rechunk operation |
xarray |
2024.1 | Labeled cube whose .chunk() accepts per-dimension sizes |
rioxarray |
0.15 | Opens rasters directly into a Dask-backed DataArray with chunks= |
pip install "dask[array]>=2024.1" "xarray>=2024.1" "rioxarray>=0.15"
Complete Working Example
The helper below picks a spatial chunk edge that lands inside the 256–512 MB target for a given cube shape and dtype, then reports the resulting task count so you can sanity-check granularity before launching a job:
import numpy as np
import xarray as xr
def choose_spatial_chunk(
da: xr.DataArray,
target_mb: float = 384.0, # midpoint of the 256–512 MB band
edge_step: int = 256, # round to multiples of the COG internal tile
) -> dict[str, int]:
"""
Return a chunks dict for a (time, band, y, x) DataArray.
Keeps band whole (-1) and time at 1, then sizes the square spatial
edge so one chunk's uncompressed bytes sit near target_mb.
"""
itemsize = da.dtype.itemsize # float32 → 4 bytes
n_band = da.sizes.get("band", 1) # all bands live in one chunk
# bytes = n_band * edge * edge * itemsize → solve edge for target_mb
target_bytes = target_mb * 1e6
raw_edge = (target_bytes / (n_band * itemsize)) ** 0.5
# Round DOWN to a multiple of edge_step so chunks align to COG tiles
edge = max(edge_step, int(raw_edge // edge_step) * edge_step)
# Never exceed the actual array extent
edge = min(edge, da.sizes["y"], da.sizes["x"])
return {"time": 1, "band": -1, "y": edge, "x": edge}
def report_chunks(da: xr.DataArray) -> None:
"""Print per-chunk size and total task count for a chunked DataArray."""
cs = da.data.chunksize
mb = np.prod(cs) * da.dtype.itemsize / 1e6
print(f"chunk shape {cs} → {mb:.0f} MB/chunk, {da.data.npartitions} tasks")
if __name__ == "__main__":
# A synthetic 12-scene, 6-band, 10980×10980 Sentinel-2-sized float32 cube
shape = (12, 6, 10980, 10980)
lazy = xr.DataArray(
__import__("dask.array", fromlist=["zeros"]).zeros(shape, dtype="float32"),
dims=("time", "band", "y", "x"),
)
chunks = choose_spatial_chunk(lazy)
print("chosen chunks:", chunks) # e.g. {'time':1,'band':-1,'y':3840,'x':3840}
report_chunks(lazy.chunk(chunks))
The edge_step rounding matters: aligning chunk edges to the raster’s internal 256- or 512-pixel Cloud-Optimized GeoTIFF tiles means each Dask read maps to whole file blocks, avoiding partial-block reads that re-fetch the same bytes.
Variant Patterns
1. Rechunking before a temporal reduction
A median or percentile over time with time=1 forces Dask to gather every scene for each spatial tile — many small tasks feeding one reduction. Widen the spatial chunk first so there are fewer, fatter reduction tasks:
# Fewer spatial tiles → fewer time-gather tasks in the median
composite = (
cube.chunk({"time": -1, "y": 1024, "x": 1024}) # collapse time, shrink space
.median(dim="time")
)
Rechunk deliberately and once: rechunking moves data between workers over the network and is one of the most expensive things you can ask a Dask graph to do.
2. Diagnosing chunks that are too small
If report_chunks shows hundreds of thousands of tasks and the dashboard Task Stream is a blizzard of sub-second blocks, the chunks are too fine. Symptoms are high scheduler CPU, slow graph construction (not execution), and memory growth on the scheduler itself:
# Merge neighbouring tiles to cut task count 4× without changing the result
coarser = fine_cube.chunk({"y": 4096, "x": 4096})
report_chunks(coarser) # verify it stays under ~512 MB after merging
3. Diagnosing chunks that are too large
The opposite symptom — a worker jumping to 90%+ memory on a single task, red spill blocks, or KilledWorker — means a chunk exceeds the per-worker budget. Halve the edge and recheck the byte size:
mb = np.prod(cube.data.chunksize) * cube.dtype.itemsize / 1e6
if mb > 512:
cube = cube.chunk({"y": cube.chunksizes["y"][0] // 2,
"x": cube.chunksizes["x"][0] // 2})
Chunk sizing and worker memory_limit are two sides of one budget; a chunk should be well under the RAM one worker thread can hold, because several intermediates coexist during a computation. Once sizing is right, the end-to-end pattern in Computing NDVI with xarray on a Dask Cluster runs without spilling.
Common Errors
distributed.worker - WARNING - Memory use is high but worker has no data to store to disk
One chunk is larger than the memory a single task can hold. Reduce the spatial edge length so per-chunk bytes drop below ~256 MB, and confirm with the nbytes formula.
Graph takes seconds to build before any compute starts
Too many chunks — the task count is in the hundreds of thousands. Increase the spatial edge (or collapse an over-fine time chunking) so npartitions drops into the thousands.
PerformanceWarning: Slicing is producing a large chunk
An operation is unifying chunks along a dimension you split, often an .sel across time. Rechunk that dimension to -1 deliberately before the operation rather than letting Dask do it implicitly mid-graph.
Related
- Scaling Raster Processing with Dask — the parent guide covering cluster setup, persist versus compute, and writing results
- Computing NDVI with xarray on a Dask Cluster — apply this chunk shape in an end-to-end composite
- Understanding Cloud-Optimized GeoTIFF Structure — align chunk edges to the file’s internal tiling for block-aligned reads
- Optimizing Rasterio Window Reads for Memory Efficiency — the single-file analogue of chunk-aware reading