Skip to content
About
Main content

Put your own code on the roofline

Measure your machineNeeds Python with NumPy

Problem

The says an operation's speed is capped by either memory or arithmetic , whichever it hits first. Which one depends on its , 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

How close does the plain scan, sum(x), get to the machine's peak memory bandwidth?

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.

    put-your-own-code-on-the.py51 linesDownload
    View 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)
    
  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 put-your-own-code-on-the.py
    uv run put-your-own-code-on-the.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--n50,000,000 Values per operator (float64).
--repeats5 Runs per measurement (best is kept).

Our run

Three operators on the roofline

Three operators on the roofline
OperatorFLOP per byteGB/sOf peak bandwidthGFLOP/sSide
scan: sum(x)0.12540.254.7%5.03memory
filter: sum(x[x > 0.5])0.1252.223.02%0.278memory
hash aggregation: group-by sum, 1,000 keys0.06311.615.8%0.728memory
Reference runApple M2 · 8 cores · 8 GB
Output · uv run put-your-own-code-on-the.py
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 side
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · run 2026-10-20

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