Skip to content
About
Main content

When does shuffle compression pay? And a non-deterministic partition function

Measure your machineBreak it on purposeNeeds Python

Problem

Compressing 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 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 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

At 1 Gbps, does compressing the shuffle with zlib (level 1, one core) beat sending raw?

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.

    shuffle-compression-and-determinism.py76 linesDownload
    View 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)
    
  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 shuffle-compression-and-determinism.py
    uv run shuffle-compression-and-determinism.py --help     # every knob
Knobs
Knob · defaultWhat 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

Part A: compressor speed needed to beat sending rawApple M2 · 8 cores · 8 GB
zlib: speed needed to winlz4: speed needed to win
Data table
Part A: compressor speed needed to beat sending raw
Network (Gbps)seriesMB/s
1zlib: speed needed to win245
10zlib: speed needed to win2,450
100zlib: speed needed to win24.5K
1lz4: speed needed to win309
10lz4: speed needed to win3,090
100lz4: speed needed to win30.9K
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · lz4 4.4.5 · run 2026-10-20

Part A: codecs

Part A: codecs
CodecC (MB/s)Ratio rr / (r − 1)
zlib992.041.96
lz44051.682.48

Part B: first attempt against retry

Part B: first attempt against retry
PartitionerRows in another partitionLostDuplicated
hash((row_id, random()))731238239
hash(row_id)000
Output · uv run shuffle-compression-and-determinism.py
 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   0

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