Skip to content
About
Main content

Supersteps and the straggler tax

Simulate a modelNeeds Python with NumPy

Problem

Bulk synchronous graph processing runs in 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 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, 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

Partitioned by vertex on a 200,000-vertex scale-free graph, how much slower is the slowest worker than the average?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    supersteps-and-the-straggler-tax.py76 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat it changes
--n200,000 Vertices.
--m5 Edges per new vertex.
--workers8 Workers.
--steps10 Supersteps.

Our run

10 supersteps over 8 workers

10 supersteps over 8 workers
PartitioningCombinerSlowest ÷ averageBusiest worker messagesMean
by vertexno1.09×251K250K
by vertexyes1.02×128K128K
by edgeno1.01×251K250K
by edgeyes1.01×128K128K
Reference runApple M2 · 8 cores · 8 GB
Output · uv run supersteps-and-the-straggler-tax.py
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 rounds
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20

What 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.