Supersteps and the straggler tax
Simulate a modelNeeds Python with NumPy
Problem
Bulk synchronous graph processing runs in supersteps separated by barriers, so each superstep takes as long as its slowest worker. Partitioning by vertex gives the worker that owns a hub all of the hub's messages; partitioning by edge spreads them, at the cost of replicating the hub's state. A combiner sums messages to the same destination before sending them. Algorithms also differ in how many supersteps they need: PageRank converges gradually and can stop early, connected components must run until no label changes.
Split a power-law graph across 8 workers two ways: by vertex (v % 8) and by edge (hash of the edge). Run 10 PageRank supersteps, recording each worker's messages and time per superstep (workers run one after another, each timed on its own share). Report the sum over supersteps of the slowest worker's time against the sum of the average. Add a combiner and repeat. Also compare 10, 20 and 30 PageRank iterations, and count the supersteps connected components needs.
The question. How large is the straggler tax under each partitioning, what does the combiner change, and how do PageRank and connected components differ in rounds?
Predict
With vertex partitioning, the worker that owns the hub is the slowest in every round, so total time is far above the average-based estimate. Edge partitioning narrows the gap. The combiner cuts message counts sharply for hub destinations. 20 and 30 PageRank iterations barely differ; connected components must run until no label changes.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
supersteps-and-the-straggler-tax.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Supersteps and the straggler tax. A power-law graph of 200,000 vertices split across 8 workers by vertex (v % 8) and by edge (hash of the edge). 10 PageRank supersteps; each worker's messages and processing time are measured per superstep (workers run one after another, each timed on its own share). The sum of the slowest worker's times against the sum of the average; then a combiner that sums messages per destination; then PageRank at 10, 20 and 30 iterations, and connected components, which must run until no label changes. """ import argparse, json, time import numpy as np p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--n", type=int, default=200_000, help="Vertices.") p.add_argument("--m", type=int, default=5, help="Edges per new vertex.") p.add_argument("--workers", type=int, default=8, help="Workers.") p.add_argument("--steps", type=int, default=10, help="Supersteps.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() rng = np.random.default_rng(0); W = args.workers targets = np.empty(args.n * args.m * 2, dtype=np.int64); src, dst = [], []; filled = 0 for v in range(args.m, args.n): picks = targets[rng.integers(0, filled, args.m)] if filled else np.arange(args.m) src.append(np.full(args.m, v)); dst.append(picks); targets[filled:filled + args.m] = picks; targets[filled + args.m:filled + 2 * args.m] = v; filled += 2 * args.m s = np.concatenate(src); d = np.concatenate(dst); s, d = np.concatenate([s, d]), np.concatenate([d, s]) outdeg = np.bincount(s, minlength=args.n) def partition(mode): if mode == "vertex": return s % W # the worker that owns the source sends its messages h = (s * 0x9E3779B1 + d * 0x85EBCA77) & 0xFFFFFFFF; return h % W # edge-cut-free: edges spread by hash def run(mode, combiner): part = partition(mode); rank = np.full(args.n, 1 / args.n); slow, avg, msgs = 0.0, 0.0, [] for _ in range(args.steps): times, counts, incoming = [], [], np.zeros(args.n) for w in range(W): mask = part == w; t = time.perf_counter() ss, dd = s[mask], d[mask]; contrib = rank[ss] / outdeg[ss] if combiner: # sum per destination before sending u, inv = np.unique(dd, return_inverse=True); summed = np.bincount(inv, weights=contrib); n_msg = len(u); np.add.at(incoming, u, summed) else: n_msg = len(dd); np.add.at(incoming, dd, contrib) times.append(time.perf_counter() - t); counts.append(n_msg) rank = 0.15 / args.n + 0.85 * incoming slow += max(times); avg += sum(times) / W; msgs.append(counts) return {"partitioning": f"by {mode}", "combiner": "yes" if combiner else "no", "slowest_sum_s": slow, "average_sum_s": avg, "straggler_ratio": slow / avg, "max_worker_msgs": int(max(max(c) for c in msgs)), "mean_worker_msgs": float(np.mean(msgs))}, rank rows = [] for mode in ("vertex", "edge"): for comb in (False, True): r, _ = run(mode, comb); rows.append(r) def pagerank(iters): rank = np.full(args.n, 1 / args.n) for _ in range(iters): inc = np.bincount(d, weights=rank[s] / outdeg[s], minlength=args.n); rank = 0.15 / args.n + 0.85 * inc return rank r10, r20, r30 = pagerank(10), pagerank(20), pagerank(30) label = np.arange(args.n); rounds = 0 while True: new = label.copy(); np.minimum.at(new, d, label[s]); rounds += 1 if (new == label).all(): break label = new summary = {"pr_10_vs_30_l1": float(np.abs(r10 - r30).sum()), "pr_20_vs_30_l1": float(np.abs(r20 - r30).sum()), "cc_rounds": rounds, "top10_same_10_vs_30": "yes" if set(np.argsort(-r10)[:10]) == set(np.argsort(-r30)[:10]) else "no"} for r in rows: print(f"{r['partitioning']:<10} combiner {r['combiner']:<3}: slowest worker summed {r['slowest_sum_s']:.3f} s vs average {r['average_sum_s']:.3f} s ({r['straggler_ratio']:.2f}x); busiest worker {r['max_worker_msgs']:,} messages vs mean {r['mean_worker_msgs']:,.0f}") print(f"PageRank: L1 difference 10 vs 30 iterations {summary['pr_10_vs_30_l1']:.2e}, 20 vs 30 {summary['pr_20_vs_30_l1']:.2e}; same top 10: {summary['top10_same_10_vs_30']}; connected components needed {rounds} rounds") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"partitions": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run supersteps-and-the-straggler-tax.py uv run supersteps-and-the-straggler-tax.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --n200,000 | Vertices. |
| --m5 | Edges per new vertex. |
| --workers8 | Workers. |
| --steps10 | Supersteps. |
Our run
10 supersteps over 8 workers
| Partitioning | Combiner | Slowest ÷ average | Busiest worker messages | Mean |
|---|---|---|---|---|
| by vertex | no | 1.09× | 251K | 250K |
| by vertex | yes | 1.02× | 128K | 128K |
| by edge | no | 1.01× | 251K | 250K |
| by edge | yes | 1.01× | 128K | 128K |
by vertex combiner no : slowest worker summed 0.075 s vs average 0.068 s (1.09x); busiest worker 251,380 messages vs mean 249,994
by vertex combiner yes: slowest worker summed 0.196 s vs average 0.192 s (1.02x); busiest worker 128,435 messages vs mean 127,924
by edge combiner no : slowest worker summed 0.094 s vs average 0.093 s (1.01x); busiest worker 250,685 messages vs mean 249,994
by edge combiner yes: slowest worker summed 0.218 s vs average 0.216 s (1.01x); busiest worker 128,240 messages vs mean 127,924
PageRank: L1 difference 10 vs 30 iterations 5.80e-05, 20 vs 30 3.33e-08; same top 10: yes; connected components needed 6 roundsWhat we found
On this graph there was almost no straggler tax: partitioned by vertex, the slowest worker summed to 1.09× the average, and the busiest worker handled 251K messages against a mean of 250K. With 200,000 vertices and 5 edges per new vertex, even the hub's edges are a small share of a worker's load, so the expectation's imbalance did not appear; it needs a graph whose top hubs hold a large share of all edges. The combiner cut the busiest worker's messages to (128K) but cost more time than it saved here, since combining runs on one machine without a network to save. PageRank at 10 and 30 iterations differed by 0 in total (L1) with the same top 10 (yes); connected components needed 6 rounds.
Read it
A barrier converts imbalance directly into idle time: every superstep pays for its slowest worker. Balance the work, not the vertices, and reduce messages at the source. Then decide how many supersteps the answer needs: an approximate PageRank can stop when ranks stop moving, a reachability result cannot.