Profiling Dask Graph Overhead on Tile Workloads
A gridding job runs for four hours on a sixteen-worker cluster. The workers are at 30% CPU, the network is quiet, and the obvious conclusion is that the cluster is too small. Adding eight more workers makes it slower. The job is scheduler-bound: it has more tasks than the scheduler can dispatch, and every extra worker adds coordination rather than throughput. This page is the measurement that distinguishes that case from the others, within the Dask versus Ray comparison.
Root cause: the scheduler costs about a millisecond per task
Dask’s central scheduler tracks every task’s state, dependencies and location. That bookkeeping costs on the order of one millisecond per task, which is negligible for a task that runs for a second and dominant for one that runs for two milliseconds.
A bathymetric workload reaches large task counts easily. A 40,000 × 40,000 grid in 512-pixel chunks is 6,241 chunks; put it through eight stages and the graph is 50,000 tasks before any overlap is added. Add map_overlap for a slope kernel and each output chunk depends on nine input chunks, so the graph grows again.
import dask.array as da
import numpy as np
for chunk in (256, 512, 2048, 4096):
x = da.zeros((40_000, 40_000), chunks=chunk)
y = (x + 1) * 2
y = y.map_overlap(lambda b: b, depth=1, boundary="none")
print(f"chunk {chunk:>5}: {len(y.dask):>8,} tasks, "
f"{x.nbytes / x.npartitions / 2**20:>6.1f} MiB per chunk")
# chunk 256: 117,406 tasks, 0.5 MiB per chunk
# chunk 512: 29,566 tasks, 2.0 MiB per chunk
# chunk 2048: 1,918 tasks, 32.0 MiB per chunk
# chunk 4096: 502 tasks, 128.0 MiB per chunk
At 256-pixel chunks the scheduler alone needs roughly two minutes just to dispatch, before a single byte is read. At 2048 it needs two seconds. The compute is identical.
Step-by-step diagnosis with production code
1. Count the tasks before you run anything
The cheapest diagnostic runs in milliseconds and needs no cluster:
import logging
logger = logging.getLogger(__name__)
SCHEDULER_COST_S = 1e-3 # roughly, per task, for the distributed scheduler
def graph_budget(collection, *, expected_task_s: float) -> dict[str, float]:
"""Predicted scheduler overhead against predicted compute, before running."""
n = len(collection.dask)
sched_s = n * SCHEDULER_COST_S
compute_s = n * expected_task_s
ratio = sched_s / max(compute_s, 1e-9)
logger.info("%d tasks: ~%.0f s scheduling vs ~%.0f s compute (%.0f%% overhead)",
n, sched_s, compute_s, ratio * 100)
if ratio > 0.2:
logger.warning(
"scheduling is >20%% of predicted compute — increase the chunk size "
"before adding workers, which will not help")
return {"tasks": n, "scheduler_s": sched_s, "compute_s": compute_s, "ratio": ratio}
expected_task_s is a measurement, not a guess: run one chunk through the pipeline and time it. That single number, multiplied by the task count, predicts the job well enough to catch a bad chunk size before a cluster is provisioned.
2. Split the measured wall clock three ways
from contextlib import contextmanager
@contextmanager
def profile_run(client, path: str = "dask-report.html"):
"""Capture a performance report and log the three-way time split."""
from distributed import performance_report
with performance_report(filename=path):
yield
tasks = client.run_on_scheduler(
lambda dask_scheduler: {
"n": len(dask_scheduler.tasks),
"compute_s": sum(dask_scheduler.cumulative_worker_metrics.get(("execute", k), 0.0)
for k in ("thread-cpu", "thread-noncpu")),
"transfer_s": dask_scheduler.cumulative_worker_metrics.get(
("get-data", "network"), 0.0),
})
logger.info("tasks %d · compute %.0f s · transfer %.0f s · report %s",
tasks["n"], tasks["compute_s"], tasks["transfer_s"], path)
The three numbers separate the three fixes. High compute is a real workload and wants more workers. High transfer means chunks are being shuffled between workers, and the fix is usually a chunk alignment problem rather than a size one. High residual — wall clock minus compute minus transfer — is scheduler overhead, and only a larger chunk size touches it.
3. Fuse the cheap stages
Dask fuses adjacent elementwise operations automatically, and stops fusing at anything it cannot see through — a map_blocks with an opaque function, a rechunk, a persist. A pipeline written as eight separate map_blocks calls has eight tasks per chunk where one would do:
def fused_stage(block):
"""One task per chunk instead of six. Same arithmetic, a sixth of the graph."""
block = apply_tide(block)
block = apply_svp(block)
block = mask_nodata(block)
block = to_mllw(block)
block = clip_range(block)
return round_to_cm(block)
result = dem.map_blocks(fused_stage, dtype="float32")
The readability cost is real and the graph cost is a factor of six. On a scheduler-bound job that is the whole difference; on a compute-bound one it changes nothing, which is why the measurement comes first.
Verification and acceptance test
def test_the_graph_is_not_scheduler_bound(pipeline, single_chunk_seconds):
budget = graph_budget(pipeline, expected_task_s=single_chunk_seconds)
assert budget["ratio"] < 0.2, (
f"{budget['tasks']:,} tasks would spend {budget['ratio']:.0%} of the job "
"in the scheduler — raise the chunk size")
def test_chunks_are_in_the_useful_size_band(arr):
per_chunk_mib = arr.nbytes / arr.npartitions / 2**20
assert 1.0 <= per_chunk_mib <= 64.0, (
f"{per_chunk_mib:.1f} MiB per chunk is outside the 1–64 MiB band")
Both run without a cluster, in milliseconds, and both catch the problem at the point where it is a one-line fix rather than after a four-hour run.
Reading the dashboard without guessing
The performance report and the live dashboard show the same three regimes, and each has a signature that is unambiguous once you know what to look for.
Scheduler-bound shows a task-stream panel that is mostly white space: short coloured bars separated by gaps, with every worker idle between them. The scheduler cannot feed them fast enough. The progress bar advances smoothly and slowly, and CPU utilisation sits low and flat across all workers simultaneously — the tell that distinguishes it from a slow worker, which would show one low line among several high ones.
Transfer-bound shows long red transfer bars in the task stream and a communication panel with sustained throughput. Workers are busy, but busy moving data. On a bathymetric job this almost always means chunks are misaligned between two arrays being combined, so every output chunk needs input from several workers — the alignment problem described in the tidal datum topic, seen from the scheduler’s side.
Memory-bound shows the worker memory bars crossing into orange and grey, and disk-write appearing in the task stream. Once spilling starts, throughput collapses non-linearly, so a job that is 10% over the memory budget does not run 10% slower.
def classify_bottleneck(wall_s: float, compute_s: float, transfer_s: float,
spill_s: float, n_workers: int) -> str:
"""Name the bottleneck from the three-way split, rather than from a feeling."""
capacity_s = wall_s * n_workers
unexplained = capacity_s - compute_s - transfer_s - spill_s
fractions = {
"compute": compute_s / capacity_s,
"transfer": transfer_s / capacity_s,
"spill": spill_s / capacity_s,
"scheduler/idle": max(unexplained, 0.0) / capacity_s,
}
worst = max(fractions, key=fractions.get)
logger.info("bottleneck: %s — %s", worst,
", ".join(f"{k} {v:.0%}" for k, v in fractions.items()))
return worst
Dividing by wall_s * n_workers rather than by wall_s is what makes the fractions add to one. Idle worker capacity is the quantity that matters, and a job whose workers are 70% idle is not a job that needs more workers.
Edge cases and gotchas
len(collection.dask)counts the unoptimised graph. Dask fuses at compute time, so the real task count can be lower.dask.optimize(collection)[0].daskgives the post-fusion count and is the honest number for this estimate.- A large chunk that spills is worse than a small one that does not. The right-hand slope of the curve above is steep. Size chunks against the worker’s memory share, not the node’s — the same trap described in handling OOM kills.
- Overlap multiplies the graph, not just the data.
map_overlapwith depth 1 adds a rechunk-like layer with several tasks per chunk. Where the overlap is the dominant cost, computing the derivative in a single fused pass over a larger chunk is cheaper than a small-chunk overlap.
Related
- Dask vs Ray for Bathymetric Processing at Scale — the parent comparison, and which framework the workload belongs in
- Computing Seafloor Slope From a DEM in Python — the halo that makes a windowed job’s graph grow
- Orchestrating Marine Pipelines With Dask and Prefect — the layer above, and why stage retries do not belong here