When does shuffle compression pay? And a non-deterministic partition function
Measure your machineBreak it on purposeNeeds Python
Problem
Compressing shuffle data costs CPU time and saves network time. A codec that compresses at C MB/s with ratio r sends S bytes in S/C + (S/r)/BW instead of S/BW over a network of bandwidth BW, so compressing wins only if C > BW · r/(r − 1). Faster networks raise the bar, which is why a choice that was clearly right in 2010 is workload-dependent today.
A shuffle task can also run more than once: after a failure, or as a speculative duplicate. If the function that picks each row's partition gives a different answer on the retry, consumers that read the first attempt and consumers that read the retry disagree about which rows they own. Some rows are read twice, some never, and nothing raises an error.
Part A. Measure codec throughput and ratio on shuffle-like data, then apply C > BW · r/(r−1).
Part B. Show the silent corruption. Partition 1,000 rows with hash(row_id + random.random()) % 4 in a "first attempt" and a "retry", and compare partition contents: rows are lost from some partitions and duplicated in others, with no error. Replace the random term by a hash of the row id alone and confirm both attempts agree.
In CPython, hash(row_id + random.random()) % 4 happens to equal row_id % 4 (a side effect of how floats are hashed), so the script mixes the random term in with hash((row_id, random.random())). You will measure C and r for zlib (level 1) and lz4 on one core against 1, 10 and 100 Gbps, and how many rows the random partitioner loses or duplicates across a retry.
The question. At which network speed does compressing the shuffle stop paying for each codec, and what does a non-deterministic partitioner do to the result?
Predict
Expect the decision to flip between 1 Gbps and 10 Gbps for a single core.
In Part B, expect the random partitioner to place many rows differently on the retry, and the deterministic one to agree on every row.
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
shuffle-compression-and-determinism.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy", "lz4"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """When does shuffle compression pay? And a non-deterministic partition function. Part A: codec throughput C and ratio r on shuffle-like data, against the rule C > BW * r/(r-1). Part B: a partitioner with a random term, retried. """ import argparse, json, random, time, zlib import numpy as np p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--codecs", type=lambda s: s.split(","), default=["zlib", "lz4"], help="Codecs to measure: zlib and/or lz4.") p.add_argument("--values", type=int, default=50_000_000, help="Part A: 4-byte values in the shuffle-like block.") p.add_argument("--distinct", type=int, default=1000, help="Part A: distinct values (fewer compress better).") p.add_argument("--bandwidths", type=lambda s: [float(v) for v in s.split(",")], default=[1, 10, 100], help="Part A: network speeds in Gbps, comma-separated.") p.add_argument("--rows", type=int, default=1000, help="Part B: rows to partition.") p.add_argument("--parts", type=int, default=4, help="Part B: partitions.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() # Part A data = np.random.default_rng(0).integers(0, args.distinct, args.values, dtype=np.int32).tobytes() import lz4.frame # imported up front: not part of the timing def compress(codec, b): if codec == "zlib": return zlib.compress(b, 1) return lz4.frame.compress(b) codecs, decisions = [], [] for codec in args.codecs: t = time.perf_counter(); z = compress(codec, data); dt = time.perf_counter() - t C, r = len(data) / dt / 1e6, len(data) / len(z) # MB/s, ratio codecs.append({"codec": codec, "C_MBps": C, "ratio": r, "factor": r / (r - 1)}) print(f"{codec:>5}: C={C:.0f} MB/s r={r:.2f} threshold factor r/(r-1)={r / (r - 1):.2f}") for gbps in args.bandwidths: bw = gbps * 1000 / 8 # Gbps -> MB/s need = bw * r / (r - 1) verdict = "compress wins" if C > need else "send raw" decisions.append({"codec": codec, "gbps": gbps, "needed_MBps": need, "verdict": verdict}) print(f" {gbps:>5g} Gbps: needs C > {need:,.0f} MB/s -> {verdict}") # Part B: first attempt and retry of the same map task def partition(rows, deterministic): out = [set() for _ in range(args.parts)] for row_id in rows: # hash((row_id, random)): CPython's hash(row_id + random.random()) % parts equals row_id % parts, # so the random term must go through a tuple hash to actually move rows h = hash(row_id) if deterministic else hash((row_id, random.random())) out[h % args.parts].add(row_id) return out rows = range(args.rows) half = args.parts // 2 print() partitioners = [] for deterministic in (False, True): first, retry = partition(rows, deterministic), partition(rows, deterministic) moved = sum(len(f ^ r) for f, r in zip(first, retry)) // 2 # reducers 0..half-1 fetched from the first attempt, the rest from the retry seen = [row for p in first[:half] + retry[half:] for row in p] lost, duplicated = args.rows - len(set(seen)), len(seen) - len(set(seen)) name = "hash(row_id)" if deterministic else "hash((row_id, random()))" partitioners.append({"partitioner": name, "rows_moved": moved, "lost": lost, "duplicated": duplicated}) print(f"{name:<24} rows in a different partition on retry: {moved:>4} " f"after mixed fetch: lost {lost:>3}, duplicated {duplicated:>3}") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"codecs": codecs, "decisions": decisions, "partitioners": partitioners}}, 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 shuffle-compression-and-determinism.py uv run shuffle-compression-and-determinism.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --codecszlib, lz4 | Codecs to measure: zlib and/or lz4. |
| --values50,000,000 | Part A: 4-byte values in the shuffle-like block. |
| --distinct1000 | Part A: distinct values (fewer compress better). |
| --bandwidths1, 10, 100 | Part A: network speeds in Gbps, comma-separated. |
| --rows1000 | Part B: rows to partition. |
| --parts4 | Part B: partitions. |
Our run
Data table
| Network (Gbps) | series | MB/s |
|---|---|---|
| 1 | zlib: speed needed to win | 245 |
| 10 | zlib: speed needed to win | 2,450 |
| 100 | zlib: speed needed to win | 24.5K |
| 1 | lz4: speed needed to win | 309 |
| 10 | lz4: speed needed to win | 3,090 |
| 100 | lz4: speed needed to win | 30.9K |
Part A: codecs
| Codec | C (MB/s) | Ratio r | r / (r − 1) |
|---|---|---|---|
| zlib | 99 | 2.04 | 1.96 |
| lz4 | 405 | 1.68 | 2.48 |
Part B: first attempt against retry
| Partitioner | Rows in another partition | Lost | Duplicated |
|---|---|---|---|
| hash((row_id, random())) | 731 | 238 | 239 |
| hash(row_id) | 0 | 0 | 0 |
zlib: C=99 MB/s r=2.04 threshold factor r/(r-1)=1.96
1 Gbps: needs C > 245 MB/s -> send raw
10 Gbps: needs C > 2,454 MB/s -> send raw
100 Gbps: needs C > 24,539 MB/s -> send raw
lz4: C=405 MB/s r=1.68 threshold factor r/(r-1)=2.48
1 Gbps: needs C > 309 MB/s -> compress wins
10 Gbps: needs C > 3,094 MB/s -> send raw
100 Gbps: needs C > 30,943 MB/s -> send raw
hash((row_id, random())) rows in a different partition on retry: 731 after mixed fetch: lost 238, duplicated 239
hash(row_id) rows in a different partition on retry: 0 after mixed fetch: lost 0, duplicated 0What we found
zlib at level 1 compressed at 99 MB/s: it never beats sending raw, not even at 1 Gbps, where it would need 245 MB/s. lz4 compressed at 405 MB/s with a smaller ratio (1.68), enough to win at 1 Gbps but not at 10 Gbps, where it would need 3,090 MB/s. That is the flip the chapter expects, for the fast codec only. On the chart a codec wins where its measured-speed line sits above its needed-speed line.
In Part B the random partitioner put 731 of 1,000 rows in a different partition on the retry. Mixing the two attempts lost 238 rows and duplicated 239, with no error. The deterministic partitioner moved 0.
Read it
The compression decision inverted as networks got faster. This is the fixed-cost idea with its migration clause: when hardware gets faster, the break-even moves. Measure C on your own data before turning shuffle compression on or off; the ratio r depends on the data as much as on the codec.
If a task is non-deterministic and a downstream consumer has already read part of the losing attempt's output, the two attempts' outputs are not interchangeable, and the result is silently wrong. Speculation requires determinism, and that is a correctness requirement, not a performance one. Seed any "random" partitioning from stable data, such as the task id and the row's position, so every attempt makes the same choice.