Processing Large DEMs with Dask and rioxarray

rioxarray opens a raster as a lazily-chunked xarray object, and Dask executes operations on those chunks in parallel. For the local and neighbourhood stages of a hydrologic pipeline that combination does real work with very little code. For the accumulation stage it produces confidently wrong answers. This guide covers both halves — as part of the tiled and parallel watershed processing topic within watershed delineation and catchment synchronization.

Prerequisites

  • A Cloud-Optimized GeoTIFF, or at least an internally tiled GeoTIFF. A stripe-oriented file defeats chunked reading entirely.
  • rioxarray, dask, distributed, and richdem or whitebox for the hydrology.
  • A stated per-worker memory budget. Everything below depends on it.

Core Technique: Chunks That Match the File

The single decision that determines whether a chunked pipeline is fast is whether the chunks align with the file’s internal blocks.

Aligned Chunks Read Whole Blocks On the left, a chunk boundary that coincides with the internal 512-pixel blocks, so each read decompresses exactly the blocks it needs. On the right, a chunk offset by 200 pixels overlaps four blocks for every one it wants, so most of the decompressed data is discarded. aligned — chunk = 2 × block chunk 4 blocks read, 4 blocks used no decompression wasted misaligned — chunk offset by 200 px chunk 9 blocks read, 4 blocks used most decompression discarded grid lines are the file's internal 512-pixel blocks

chunks="auto" in rioxarray.open_rasterio reads the internal blocking from the file and aligns to it, which is almost always what you want. An explicit chunk size chosen for tidiness — 1000, say — is the most common cause of a chunked pipeline that runs slower than a serial one.

Annotated Code Example

python
import logging

import dask.array as da
import numpy as np
import richdem as rd
import rioxarray
from dask.distributed import Client, LocalCluster

log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")


def condition_dem_chunked(
    dem_path: str,
    out_path: str,
    halo: int = 128,
    n_workers: int = 4,
    memory_limit: str = "4GB",
) -> dict:
    """
    Fill depressions and compute slope across a larger-than-memory DEM.

    Both operations have a bounded neighbourhood, so map_overlap expresses
    them correctly. Flow accumulation deliberately does NOT appear here.
    """
    cluster = LocalCluster(n_workers=n_workers, threads_per_worker=1,
                           memory_limit=memory_limit)
    client = Client(cluster)
    log.info("Dask cluster: %d workers × %s", n_workers, memory_limit)

    try:
        # --- chunks="auto" aligns to the file's internal blocking. An explicit
        # size that straddles blocks makes every read decompress data it
        # immediately discards. ---
        dem = rioxarray.open_rasterio(dem_path, chunks="auto", masked=True).squeeze()
        arr = dem.data
        log.info("Opened %s: shape %s, chunks %s, dtype %s",
                 dem_path, arr.shape, arr.chunksize, arr.dtype)

        nbytes_per_chunk = np.prod(arr.chunksize) * arr.dtype.itemsize
        log.info("Chunk footprint %.1f MB before intermediates; peak per worker "
                 "will be roughly 5× that", nbytes_per_chunk / 1e6)

        nodata = float(dem.rio.nodata) if dem.rio.nodata is not None else -9999.0

        def _fill(block: np.ndarray) -> np.ndarray:
            """Fill depressions on one chunk plus its halo."""
            a = np.nan_to_num(block, nan=nodata).astype(np.float32)
            grid = rd.rdarray(a, no_data=nodata)
            rd.FillDepressions(grid, epsilon=True, in_place=True)
            return np.asarray(grid, dtype=np.float32)

        # --- The halo must exceed the widest depression that straddles a chunk
        # boundary. Measure it on a sample tile rather than guessing: too narrow
        # and the two sides of a boundary depression fill to different levels. ---
        filled = da.map_overlap(
            _fill, arr.astype(np.float32),
            depth=halo, boundary="nearest", dtype=np.float32,
        )

        # --- Slope is a 3×3 operation, so a halo of 1 would do; reusing the
        # same halo keeps the graph simple and costs one extra ring of cells. ---
        def _slope(block: np.ndarray) -> np.ndarray:
            grid = rd.rdarray(block.astype(np.float32), no_data=nodata)
            return np.asarray(rd.TerrainAttribute(grid, attrib="slope_degrees"),
                              dtype=np.float32)

        slope = da.map_overlap(
            _slope, filled, depth=halo, boundary="nearest", dtype=np.float32,
        )

        out = dem.copy(data=filled)
        out.rio.write_nodata(nodata, inplace=True)
        out.rio.to_raster(out_path, tiled=True, blockxsize=512, blockysize=512,
                          compress="LZW", BIGTIFF="IF_SAFER")
        log.info("Wrote conditioned DEM: %s", out_path)

        # --- Reductions are safe on the Dask graph: they need no neighbourhood. ---
        mean_slope = float(slope.mean().compute())
        log.info("Mean slope over the domain: %.2f degrees", mean_slope)

        return {"output": out_path, "chunks": str(arr.chunksize),
                "mean_slope_deg": mean_slope}
    finally:
        client.close()
        cluster.close()


# --- Example usage ---
# condition_dem_chunked("state_1m_cog.tif", "state_1m_filled.tif",
#                       halo=128, n_workers=6, memory_limit="6GB")

Parameter Reference

Parameter Recommended Effect
chunks "auto" Aligns to internal blocks; an arbitrary size triples read time
depth (halo) 64–256 cells Must exceed the largest boundary-straddling depression
boundary "nearest" Avoids introducing a nodata ring that fills as a depression
threads_per_worker 1 The hydrology libraries release the GIL inconsistently; processes are safer
memory_limit 4–8 GB Must cover roughly five times the chunk size
Output blocking 512 × 512, tiled Makes the output usable as input to the next chunked stage
Chunk Size Against Read Throughput Read throughput plotted against chunk size for a file with 512-pixel internal blocks. Throughput peaks at chunk sizes that are whole multiples of 512 and drops by more than half at sizes such as 1000 or 1500 that straddle block boundaries. 512 700 1024 1500 2048 3000 4096 chunk size in pixels — file has 512-pixel internal blocks throughput multiple of 512 straddles block boundaries A chunk size chosen for tidiness rather than alignment costs more than half the read throughput.

Worked Example: What Belongs in the Graph

Building the pipeline as one Dask graph is tempting and partly wrong. The table below is the division that works.

Stage In the Dask graph? Why
Read, reproject, mask Yes Purely local
Depression filling Yes, via map_overlap Bounded neighbourhood
Flat resolution Yes, via map_overlap Bounded neighbourhood
Flow direction Yes, via map_overlap 3 × 3 neighbourhood
Slope, curvature, wetness inputs Yes 3 × 3 neighbourhood
Flow accumulation No Unbounded dependency across chunks
Watershed labelling No Same
Summary statistics Yes Reductions need no neighbourhood

The two “no” rows are the whole reason this page has a companion topic page. They need the ordered edge-exchange pass described in tiled and parallel watershed processing, which cannot be expressed as a chunk-local function.

Peak memory is the number that decides how many workers fit, and it is several times the chunk size. Counting the arrays that exist simultaneously is the only reliable way to size a cluster.

What One Worker Actually Holds For a 4096 by 4096 chunk the input elevation is 67 megabytes, the filled surface another 67, the fill algorithm working copy 67, the direction grid 17 and the accumulation 134, totalling about 352 megabytes at peak — five times the input. input elevation, f32 67 MB filled surface, f32 67 MB fill working copy, f32 67 MB direction grid, u8 17 MB accumulation, f64 134 MB peak ≈ 352 MB per worker for a 4096² chunk — five times the input Eight workers on that chunk size need 2.8 GB before Dask's own overhead.

Watching the dashboard rather than the log

Dask’s diagnostic dashboard is the fastest way to see what a chunked hydrology pipeline is actually doing, and the two panels worth watching are the task stream and the memory plot. A healthy conditioning run shows solid blocks of compute with brief transfer gaps; a run whose chunks are misaligned with the file shows long, thin read tasks dominating the stream, which is the visual signature of the block-alignment problem.

The memory plot answers a different question. Workers that climb steadily toward their limit and then spill to disk are being asked to hold too much, and the fix is a smaller chunk rather than a bigger machine — spilling turns a compute-bound stage into a disk-bound one and can make a run several times slower than the serial equivalent.

Keeping the graph small enough to schedule

Dask’s scheduler holds the whole task graph in memory on the client, and a graph with hundreds of thousands of tasks costs real time to build and traverse before any compute starts. A pipeline that chunks a large raster into very small pieces and then applies a dozen operations to each can spend minutes constructing a graph for a job that runs in seconds.

The practical rule is to keep the task count in the thousands rather than the hundreds of thousands, which usually means larger chunks rather than fewer operations. Where the chunk size is fixed by memory, fusing operations — doing the fill and the direction computation inside one map_overlap callable rather than two — halves the graph without changing the result, and it also avoids materialising the intermediate array.

Gotchas and Edge Cases

  • map_overlap for accumulation. Every chunk’s accumulation restarts near zero at its upstream edge. The numbers are plausible chunk by chunk, which is what makes this the most dangerous mistake in the whole area.
  • Chunk size unaligned to blocks. Halves or thirds the read throughput on a job that is usually I/O bound to begin with.
  • boundary="constant" with nodata. Surrounds each chunk with a nodata ring that the fill algorithm treats as terrain, producing an artificial rim. "nearest" avoids it.
  • Memory sized from the file. The peak is roughly five times the chunk size per worker, not one. A cluster sized from the input size will spill to disk and then die.
  • threads_per_worker above 1. The compiled hydrology libraries do not consistently release the GIL, so threads contend rather than parallelise.
  • Writing untiled output. The next stage in the pipeline then cannot read it chunk-wise, and the problem compounds along the chain. Always write tiled, compressed output.
  • Computing inside a loop. Calling .compute() per chunk in Python defeats the scheduler entirely. Build the graph, then compute once.