Put your own code on the roofline
Measure your machineNeeds Python with NumPy
Problem
The roofline model says an operation's speed is capped by either memory bandwidth or arithmetic throughput, whichever it hits first. Which one depends on its arithmetic intensity, the FLOPs it performs per byte it reads: below the ridge point (peak FLOP/s ÷ peak bytes/s) it is memory-bound and faster arithmetic cannot help it. Most data-processing operators do a few operations per value read, far below the ridge.
Measure achieved GB/s and FLOPs for a scan, a filter and a hash aggregation, and plot them against your machine's memory and compute limits. The script measures the ceilings itself (a large array copy for bandwidth, a float64 matrix multiply for compute) and runs each operator over 50 million float64 values.
The question. Where is this machine's ridge point, and where do the three operators sit relative to it?
Predict
You will probably find everything far on the memory side.
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
put-your-own-code-on-the.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Put your own code on the roofline. Measures the machine's two ceilings (peak memory bandwidth from a large array copy, peak compute from a float64 matrix multiply), then the achieved GB/s and FLOP/s of a scan (sum), a filter (sum where x > t) and a hash aggregation (group-by sum over 1,000 keys) on 50 million float64 values. Places each on the roofline by its arithmetic intensity (FLOPs per byte). """ import argparse, json, time import numpy as np p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--n", type=int, default=50_000_000, help="Values per operator (float64).") p.add_argument("--repeats", type=int, default=5, help="Runs per measurement (best is kept).") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() def best(fn): ts = [] for _ in range(args.repeats): t = time.perf_counter(); fn(); ts.append(time.perf_counter() - t) return min(ts) rng = np.random.default_rng(0) x = rng.random(args.n); keys = rng.integers(0, 1000, args.n); dst = np.empty_like(x) bw_peak = 2 * x.nbytes / best(lambda: np.copyto(dst, x)) / 1e9 # read + write m = rng.random((2048, 2048)); flops_peak = 2 * 2048**3 / best(lambda: m @ m) / 1e9 ops = [("scan: sum(x)", lambda: x.sum(), x.nbytes, args.n), ("filter: sum(x[x > 0.5])", lambda: x[x > 0.5].sum(), x.nbytes + args.n // 2 * 8, 1.5 * args.n), ("hash aggregation: group-by sum, 1,000 keys", lambda: np.bincount(keys, weights=x, minlength=1000), x.nbytes + keys.nbytes, args.n)] rows = [] for name, fn, nbytes, flops in ops: t = best(fn) ai = flops / nbytes; gbs = nbytes / t / 1e9; gflops = flops / t / 1e9 roof = min(flops_peak, ai * bw_peak) rows.append({"operator": name, "intensity": ai, "gb_s": gbs, "gflop_s": gflops, "roof_gflop_s": roof, "pct_of_roof": gflops / roof * 100, "bound": "memory" if ai * bw_peak < flops_peak else "compute", "pct_of_bandwidth": gbs / bw_peak * 100}) ridge = flops_peak / bw_peak summary = {"bw_peak": bw_peak, "flops_peak": flops_peak, "ridge": ridge} print(f"ceilings: memory {bw_peak:.1f} GB/s, compute {flops_peak:.0f} GFLOP/s -> ridge point {ridge:.1f} FLOPs per byte") for r in rows: print(f"{r['operator']:<44} {r['intensity']:.3f} FLOP/B {r['gb_s']:6.1f} GB/s ({r['pct_of_bandwidth']:5.1f}% of bandwidth) {r['gflop_s']:6.2f} GFLOP/s -> {r['bound']}-bound side") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"operators": 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 put-your-own-code-on-the.py uv run put-your-own-code-on-the.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --n50,000,000 | Values per operator (float64). |
| --repeats5 | Runs per measurement (best is kept). |
Our run
Three operators on the roofline
| Operator | FLOP per byte | GB/s | Of peak bandwidth | GFLOP/s | Side |
|---|---|---|---|---|---|
| scan: sum(x) | 0.125 | 40.2 | 54.7% | 5.03 | memory |
| filter: sum(x[x > 0.5]) | 0.125 | 2.22 | 3.02% | 0.278 | memory |
| hash aggregation: group-by sum, 1,000 keys | 0.063 | 11.6 | 15.8% | 0.728 | memory |
ceilings: memory 73.6 GB/s, compute 250 GFLOP/s -> ridge point 3.4 FLOPs per byte
scan: sum(x) 0.125 FLOP/B 40.2 GB/s ( 54.7% of bandwidth) 5.03 GFLOP/s -> memory-bound side
filter: sum(x[x > 0.5]) 0.125 FLOP/B 2.2 GB/s ( 3.0% of bandwidth) 0.28 GFLOP/s -> memory-bound side
hash aggregation: group-by sum, 1,000 keys 0.062 FLOP/B 11.6 GB/s ( 15.8% of bandwidth) 0.73 GFLOP/s -> memory-bound sideWhat we found
The machine's ceilings were 73.6 GB/s and 250 GFLOP/s, a ridge point of 3.4 FLOPs per byte. All three operators sat far on the memory side, at an eighth of a FLOP per byte or less. Only the scan came close to its roof: 40.2 GB/s, 54.7% of peak bandwidth. The filter reached 3.02% and the hash aggregation 15.8%: they are memory-bound in principle and limited in practice by something else, the filter's unpredictable branch and copy, the aggregation's scattered writes.
Read it
That turns "compression trades CPU for I/O" from a saying into a measurement about your ridge point: an operator far below the ridge has CPU to spare for decoding compressed data. And an operator well below its own roof is not limited by the hardware at all, but by its implementation, which is where to look first.