Fit a USL curve to something real
Measure your machineNeeds Python with NumPy, SciPy
Problem
The Universal Scalability Law fits throughput at N workers with two parameters: α, the share of work that serializes (contention), and β, the cost of keeping workers coherent, which grows with every pair. With β > 0 the fitted curve has a peak and falls after it. A fit is a model of the measured range; whether its prediction beyond that range holds depends on whether the cause it assumes (coherence) is the one at work.
Take a parallel workload you control and measure throughput at several values of N, then fit α and β. Here the workload is N processes that each scan their own 64 MB array, a load that shares nothing but memory bandwidth. Fit at N = 1, 2, 3, 4, 6, 8, then test beyond the predicted peak (N = 12, 16, 24).
The question. What α and β does the fit give, and does throughput really fall beyond the predicted peak?
Predict
Test beyond the predicted peak. Does throughput really fall?
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
fit-a-usl-curve-to-something.py # /// script # requires-python = ">=3.10" # dependencies = ["numpy", "scipy"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Fit a USL curve to something you run. A parallel workload: N worker processes each scan their own 64 MB array (a memory-bandwidth-bound sum) for a fixed time; throughput is the total GB scanned per second. Measured at N = 1, 2, 3, 4, 6, 8; the Universal Scalability Law is fitted to those points, and then throughput is measured beyond them (N = 12, 16, 24) to test the fit's prediction. """ import argparse, json, multiprocessing as mp, time import numpy as np from scipy.optimize import curve_fit p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--fit-n", default="1,2,3,4,6,8", help="Worker counts used for the fit.") p.add_argument("--test-n", default="12,16,24", help="Worker counts beyond the fit.") p.add_argument("--seconds", type=float, default=3.0, help="Seconds per measurement.") p.add_argument("--mb", type=int, default=64, help="Array per worker (MB).") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() def worker(ready, seconds, out): a = np.ones(args.mb * 2**20 // 8); a.sum(); n = 0 ready.wait() # every worker is up and has its array stop_at = time.time() + seconds while time.time() < stop_at: a.sum(); n += 1 out.put(n * a.nbytes) def measure(n): out = mp.Queue(); ready = mp.Barrier(n) ps = [mp.Process(target=worker, args=(ready, args.seconds, out)) for _ in range(n)] for q in ps: q.start() total = sum(out.get() for _ in ps) for q in ps: q.join() return total / args.seconds / 1e9 if __name__ == "__main__": fit_n = [int(x) for x in args.fit_n.split(",")]; test_n = [int(x) for x in args.test_n.split(",")] X = {n: measure(n) for n in fit_n + test_n} N = np.array(fit_n, dtype=float); C = np.array([X[n] for n in fit_n]) / X[1] usl = lambda n, a, b: n / (1 + a * (n - 1) + b * n * (n - 1)) (a, b), _ = curve_fit(usl, N, C, bounds=(0, 1)) peak = float(np.sqrt((1 - a) / b)) if b > 0 else None rows = [{"N": n, "gb_s": X[n], "relative": X[n] / X[1], "fit": float(usl(n, a, b)), "used_for_fit": "yes" if n in fit_n else "no (test)"} for n in fit_n + test_n] summary = {"alpha": float(a), "beta": float(b), "peak_n": peak, "best_n": max(X, key=X.get), "best_gb_s": max(X.values())} print(f"alpha={a:.4f} beta={b:.6f} " + (f"peak at N*={peak:.0f}" if peak else "no peak (beta = 0)")) for r in rows: print(f"N={r['N']:>3} {r['gb_s']:6.1f} GB/s relative {r['relative']:5.2f} USL fit {r['fit']:5.2f} ({r['used_for_fit']})") if args.json: with open(args.json, "w") as f: json.dump({"summary": summary, "tables": {"throughput": 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 fit-a-usl-curve-to-something.py uv run fit-a-usl-curve-to-something.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --fit-n1, 2, 3, 4, 6, 8 | Worker counts used for the fit. |
| --test-n12, 16, 24 | Worker counts beyond the fit. |
| --seconds3 | Seconds per measurement. |
| --mb64 | Array per worker (MB). |
Our run
Data table
| Workers N | Measured | USL fit (fitted on N ≤ 8) |
|---|---|---|
| 1 | 1 | 1 |
| 2 | 1.4 | 1.35 |
| 3 | 1.48 | 1.48 |
| 4 | 1.5 | 1.53 |
| 6 | 1.48 | 1.51 |
| 8 | 1.47 | 1.44 |
| 12 | 1.47 | 1.28 |
| 16 | 1.46 | 1.14 |
| 24 | 1.44 | 0.922 |
Throughput
| N | GB/s | Relative | USL fit | Used for fit |
|---|---|---|---|---|
| 1 | 43.2 | 1 | 1 | yes |
| 2 | 60.7 | 1.4 | 1.35 | yes |
| 3 | 63.8 | 1.48 | 1.48 | yes |
| 4 | 64.7 | 1.5 | 1.53 | yes |
| 6 | 64.1 | 1.48 | 1.51 | yes |
| 8 | 63.5 | 1.47 | 1.44 | yes |
| 12 | 63.6 | 1.47 | 1.28 | no (test) |
| 16 | 63.2 | 1.46 | 1.14 | no (test) |
| 24 | 62.3 | 1.44 | 0.922 | no (test) |
alpha=0.4310 beta=0.027373 peak at N*=5
N= 1 43.2 GB/s relative 1.00 USL fit 1.00 (yes)
N= 2 60.7 GB/s relative 1.40 USL fit 1.35 (yes)
N= 3 63.8 GB/s relative 1.48 USL fit 1.48 (yes)
N= 4 64.7 GB/s relative 1.50 USL fit 1.53 (yes)
N= 6 64.1 GB/s relative 1.48 USL fit 1.51 (yes)
N= 8 63.5 GB/s relative 1.47 USL fit 1.44 (yes)
N= 12 63.6 GB/s relative 1.47 USL fit 1.28 (no (test))
N= 16 63.2 GB/s relative 1.46 USL fit 1.14 (no (test))
N= 24 62.3 GB/s relative 1.44 USL fit 0.92 (no (test))What we found
The fit gave α = 0.431 and β = 0.027, with a peak at N* = 4.56. Beyond it, throughput did not fall: 62.3 GB/s at 24 workers against 64.7 at 4, where the fit predicted 0.922 of one worker's throughput and the measurement was 1.44. The workload saturates memory bandwidth and stays flat; the fit read that plateau, plus noise, as coherence cost.
Read it
A USL fit always produces a β, and a β always produces a peak. Whether the decline is real depends on the mechanism: coherence traffic (locks, shared counters, cache-line ping-pong) does make systems slower as workers are added; a saturated shared resource only makes them stop getting faster. Test beyond the peak before acting on it.