Optimizing Pipeline Cost and Performance

A cloud raster pipeline that works on a laptop can become surprisingly expensive at scale: idle CPUs waiting on network round-trips, workers thrashing on oversized chunks, and a monthly bill dominated by S3 egress and per-request charges you never see in a profiler. Making it cheap and fast is a distinct engineering task from making it correct — it means aligning compute placement, worker shape, and read strategy with how object storage and GDAL actually behave. For the full architectural context of where this tuning sits, see the parent section Cloud Execution & Orchestration.

This guide is for remote sensing analysts and data engineers already running distributed jobs who want to drive down cost per scene without rewriting their science code. Every technique below is measurable: establish a baseline, change one variable, and confirm the improvement in the Dask dashboard or the bill.


Where the money and time actually go

Before tuning anything, understand the three cost centres of a cloud raster job. Compute (worker vCPU-hours) is the one everyone watches, but data-transfer egress and per-request charges frequently dominate for I/O-heavy index and mosaic workloads. The diagram contrasts a naive full-resolution, cross-region pipeline against a tuned one that reads overviews in-region.

Cost breakdown: naive versus tuned cloud raster pipeline Two stacked bars. The naive pipeline is dominated by cross-region egress and per-request charges with a moderate compute segment. The tuned pipeline is much shorter: egress falls to near zero from in-region compute, requests shrink from merged ranges and overview reads, and compute is slightly lower from better worker packing. cost relative $ per run Naive compute cross-region egress requests Tuned compute req in-region egress ~ $0 Same-region compute removes egress; overviews + merged ranges cut requests. Levers, largest first: 1. Same-region compute → egress free 2. Read overviews, not full-res → fewer bytes + requests 3. Merge ranges + GDAL cache → fewer round-trips 4. Right-size workers + spot → lower compute $

The ordering matters: chasing a 10% compute saving while paying cross-region egress on every full-resolution byte is optimizing the wrong term. Fix data placement and read volume first, then tune the workers.


Prerequisites

Install the profiling and distributed stack. Pin versions so scheduler and worker images stay in lockstep — a Dask version skew between client and cluster silently degrades scheduling:

pip install "dask[distributed]>=2024.1" "distributed>=2024.1" \
            "rasterio>=1.3" "rioxarray>=0.15" "bokeh>=3.1" \
            "s3fs>=2024.2"
Library Min version Why required
dask[distributed] 2024.1 Task scheduling, work stealing, spot-worker resilience
distributed 2024.1 Client, performance_report, live dashboard
bokeh 3.1 Renders the Dask dashboard and the HTML performance report
rasterio 1.3 GDAL VSI reads, overview access, range-merge env vars
rioxarray 0.15 overview_level on open, CRS-aware chunked reads
s3fs 2024.2 Region-aware S3 access and request accounting

Conceptual prerequisites: You should understand how Cloud-Optimized GeoTIFF structure places overviews and tile offsets at the head of the file, and how to read COG headers without downloading full files — both are the mechanical foundation for reading less. Familiarity with scaling raster processing with Dask is assumed.


Step-by-step workflow

Step 1 — Establish a cost and time baseline

You cannot optimize what you have not measured. Wrap a representative run in performance_report and capture wall-clock, bytes read, and request count so every later change has a reference point:

from dask.distributed import Client, performance_report
import time

client = Client("tcp://scheduler:8786")  # or LocalCluster() for a dry run

def run_pipeline():
    # your real workload: build a lazy graph, then .compute()
    ...

start = time.perf_counter()
with performance_report(filename="baseline.html"):
    run_pipeline()
elapsed = time.perf_counter() - start

print(f"wall-clock: {elapsed:.1f}s")
# The HTML report records task durations, transfer time, and worker memory.

Record the S3 request count and bytes transferred from CloudWatch or the storage console for the same window. Egress and GET charges never appear in the Dask report, so the bill is the second half of your baseline.

Step 2 — Right-size Dask workers: threads, processes, and memory_limit

Raster reads are I/O- and decompression-bound, and GDAL releases the GIL during both, so threads inside a worker parallelize reads while sharing one GDAL cache and one HTTP connection pool. Prefer a few processes each with a handful of threads rather than many single-threaded processes, which duplicate the cache and multiply connections:

from dask.distributed import LocalCluster, Client

# On an 8-vCPU node: 4 worker processes x 2 threads each.
cluster = LocalCluster(
    n_workers=4,           # separate processes → memory isolation
    threads_per_worker=2,  # GIL-releasing GDAL reads run concurrently
    memory_limit="6GB",    # per-worker cap; total must fit the node RAM
    processes=True,
)
client = Client(cluster)

Set memory_limit deliberately: too high and a spike triggers the OS OOM killer, losing the worker and its in-flight tasks; too low and Dask pauses or spills to disk, wrecking throughput. Target roughly 70–80% of node_RAM / n_workers, leaving headroom for the GDAL block cache configured in Step 4.

Step 3 — Read overviews instead of full-resolution pixels

Most analyses do not need native resolution. A COG stores decimated overviews; reading the level that matches your output resolution can cut transferred bytes by 16x or 64x. Select an overview explicitly with rioxarray, or let GDAL pick via OVERVIEW_LEVEL:

import rioxarray

# Read the second overview (typically 1/4 resolution) instead of full-res.
da = rioxarray.open_rasterio(
    "s3://sentinel-cogs/.../B04.tif",
    overview_level=1,          # 0 = first overview, -1 = full resolution
    chunks={"x": 2048, "y": 2048},
    lock=False,                # allow threaded reads within a worker
)
print(da.rio.resolution())     # coarser pixel size, far fewer bytes

If you only need a spatial subset, combine overviews with windowed reads so you fetch neither unnecessary resolution nor unnecessary extent — see optimizing rasterio window reads for memory efficiency. Confirm overviews exist before you depend on them; absent overviews are a COG validation failure you can catch from the header alone.

Step 4 — Tune the GDAL VSI cache and merge consecutive ranges

Every worker runs its own GDAL. Two settings dominate remote-read efficiency: the in-memory block cache (GDAL_CACHEMAX) and range merging (GDAL_HTTP_MERGE_CONSECUTIVE_RANGES), which coalesces adjacent tile requests into one HTTP round-trip. Enable the /vsicurl/ chunk cache (VSI_CACHE) so re-reads of the same header or tile do not hit the network twice:

import rasterio
from rasterio.env import Env

gdal_opts = {
    "GDAL_CACHEMAX": "512",                     # MB of decoded-block cache per process
    "GDAL_HTTP_MERGE_CONSECUTIVE_RANGES": "YES", # coalesce adjacent tile ranges
    "VSI_CACHE": "TRUE",                        # cache /vsicurl/ byte ranges
    "VSI_CACHE_SIZE": "26214400",               # 25 MB per-file range cache
    "GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR", # skip directory listing requests
    "GDAL_HTTP_MULTIPLEX": "YES",               # HTTP/2 multiplexed range requests
}

with Env(**gdal_opts):
    with rasterio.open("s3://sentinel-cogs/.../B04.tif") as src:
        window_data = src.read(1, window=((0, 1024), (0, 1024)))

Size GDAL_CACHEMAX against the per-worker memory_limit from Step 2 — it is allocated per process, so 4 workers x 512 MB is 2 GB of RAM you must budget. On a Dask cluster, apply these globally with dask.config or a worker plugin so every process inherits them, not just the client.

Step 5 — Colocate compute in the bucket region and add spot workers

The single largest lever is placement. Data transferred from S3 to compute in the same AWS region incurs no egress charge; cross-region or internet egress is billed per GB and often exceeds compute cost. Launch workers in the bucket’s region, then let the cheapest capacity do the bulk work:

# Cluster config sketch (Coiled / dask-cloudprovider style)
cluster_spec = {
    "region": "us-west-2",        # MUST match the bucket region → egress free
    "worker_options": {
        "nthreads": 2,
        "memory_limit": "6GB",
    },
    "scheduler_instance": "on-demand",   # keep the brain stable
    "worker_instance": "spot",           # 60-90% cheaper, interruptible
    "worker_spot_fraction": 0.8,         # cap so a reclaim can't drain the cluster
}

Spot and preemptible workers are safe for idempotent, chunked raster work: when a worker is reclaimed, Dask reschedules only its in-flight tasks onto survivors. Keep the scheduler on on-demand capacity, write results atomically, and colocate outputs in the same region. The egress mechanics deserve their own treatment — see reducing S3 egress costs in raster pipelines.

Step 6 — Profile the run with the Dask dashboard and performance_report

Re-run the tuned pipeline under performance_report and compare against baseline.html. In the live dashboard, three panels tell you what is left to fix: the task-stream (are workers saturated or gapped by I/O?), the memory panel (spilling to disk?), and the workers table (skewed load?):

from dask.distributed import performance_report

with performance_report(filename="tuned.html"):
    run_pipeline()

# Open http://<scheduler>:8787 during the run:
#  - Task Stream: red gaps = idle workers waiting on network → read less / merge ranges
#  - Bytes stored: climbing toward memory_limit → shrink chunks or overview_level
#  - Workers: one worker hot, others idle → chunk sizes uneven, re-tile the read

Long red gaps in the task stream mean workers idle on network round-trips: revisit Steps 3 and 4. A memory curve pressing the limit means chunks are too large: coarsen overview_level or shrink chunk edges, as covered in tuning Dask chunk sizes for raster cubes.


Parameter reference

Parameter / setting Type Default Usage note
threads_per_worker int 1 2–4 for GDAL reads; too many oversubscribe the network and CPU
n_workers int node-dependent Fewer, fatter processes share one GDAL cache; start at vCPU / threads_per_worker
memory_limit str/int auto ~70–80% of node_RAM / n_workers; leave room for GDAL_CACHEMAX
overview_level int -1 (full res) 0,1,2… select decimated pyramid levels to cut bytes 4x–64x
GDAL_CACHEMAX str (MB) 5% of RAM Per-process decoded-block cache; multiply by n_workers when budgeting RAM
GDAL_HTTP_MERGE_CONSECUTIVE_RANGES str NO Set YES to coalesce adjacent tile requests into one round-trip
VSI_CACHE / VSI_CACHE_SIZE str FALSE Cache /vsicurl/ byte ranges so header re-reads skip the network
worker_spot_fraction float 0 Fraction of workers on spot/preemptible capacity; cap at 0.7–0.9
region (cluster) str account default Must equal the bucket region to make S3 egress free

Verification & testing

Prove each change paid off with numbers, not intuition. Assert that the tuned run beats the baseline on both wall-clock and request count:

import json
from pathlib import Path

def assert_improved(baseline_s: float, tuned_s: float,
                    baseline_reqs: int, tuned_reqs: int) -> None:
    """Fail CI if a 'tuning' change regressed the pipeline."""
    assert tuned_s < baseline_s, (
        f"wall-clock regressed: {tuned_s:.1f}s vs baseline {baseline_s:.1f}s"
    )
    assert tuned_reqs <= baseline_reqs, (
        f"request count rose: {tuned_reqs} vs baseline {baseline_reqs}"
    )
    saving = 1.0 - tuned_s / baseline_s
    print(f"wall-clock improved {saving:.0%}; "
          f"requests {baseline_reqs}{tuned_reqs}")

# Example: pulled from performance reports + CloudWatch request metrics
assert_improved(baseline_s=812.0, tuned_s=143.0,
                baseline_reqs=41000, tuned_reqs=6200)

For a physical sanity check, confirm the overview read returned the resolution you expected (da.rio.resolution()), and confirm egress dropped to near zero in the billing console after moving compute into the bucket region.


Troubleshooting

Workers idle but the job is slow — the task stream shows red gaps between compute blocks. Workers are blocked on network round-trips. Enable GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES, raise GDAL_CACHEMAX, drop to a coarser overview_level, and confirm compute is in the bucket region. Low CPU with a long wall-clock is the signature of an I/O-bound, not compute-bound, pipeline.

KilledWorker / repeated OOM killsmemory_limit is set above what the node can back, or GDAL_CACHEMAX x n_workers overran RAM. Lower memory_limit, shrink chunk edges or coarsen the overview, and remember the GDAL cache is per process. Watch the dashboard memory panel for spill-to-disk before the kill.

Egress bill unchanged after tuning reads — compute is still in a different region (or on-premises) from the bucket. Reading fewer bytes helps, but any cross-region byte is billed. Move the Dask cluster into the bucket’s region; in-region transfer is free.

Spot reclaims stall the whole run — the scheduler or a non-idempotent write step ran on spot capacity, or worker_spot_fraction was 1.0 and a coordinated reclaim drained every worker. Pin the scheduler to on-demand, cap the spot fraction, and make result writes atomic so a retried task cannot corrupt output.

GDAL_CACHEMAX seems ignored — the env vars were set on the client but not on the workers. Distribute them with a dask.distributed worker plugin or dask.config.set so every worker process inherits the GDAL environment, then verify with rasterio.Env().options inside a task.


FAQ

Should Dask workers for raster processing use threads or processes?

Use a few processes each with several threads. GDAL and rasterio release the GIL during I/O and decompression, so threads give real parallelism for reads while sharing one GDAL cache and one HTTP connection pool. Processes add memory isolation but duplicate that cache. A common shape is 4 workers x 2 threads on an 8-vCPU node; profile before assuming more threads help.

Why is my pipeline slow even though CPU usage is low?

Low CPU with a slow wall-clock almost always means the workers are blocked on network I/O. Each un-merged HTTP range request to S3 costs a round-trip of tens of milliseconds. Read overviews instead of full resolution, set GDAL_HTTP_MERGE_CONSECUTIVE_RANGES=YES, raise GDAL_CACHEMAX, and run compute in the same region as the bucket to remove that latency.

Are spot or preemptible workers safe for a raster pipeline?

Yes, when the work is idempotent and chunked. Dask reschedules tasks from an interrupted worker onto survivors, so a spot reclaim costs only the in-flight tasks, not the whole run. Keep the scheduler and any result-writing step on on-demand capacity, write outputs atomically, and cap the fraction of spot workers so a coordinated reclaim cannot drain the Dask cluster.


Deep-Dive Articles