Differential profile of two builds
Measure your machineNeeds Python
Problem
A small change rarely stands out in a flamegraph: a frame that grows from 2% to 5% of samples looks much the same. A differential profile subtracts one profile from another, frame by frame, so that the change is the largest difference. The diff is only meaningful if the two profiles were taken under the same conditions; a difference in load or in what else runs on the machine produces differences that have nothing to do with the code.
A small program parses JSON records and inserts them into a hash map; build B adds a lock around the record counter. Profile both with the same load and the same duration (30 seconds each), and diff the share of samples per frame. Diff A against a second profile of A to see the noise. Then repeat the A/B diff with B profiled while CPU-heavy processes run alongside. The script writes folded stacks for flamegraph.pl next to its JSON output.
The question. Does the changed frame stand out in the diff, how large is the noise between two identical profiles, and what does different load do to the diff?
Predict
Expect the changed frames to stand out in the diff while they are hard to see in either graph. With the two profiles taken under different load, note the false differences.
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
differential-profile-of-two-builds.py # /// script # requires-python = ">=3.10" # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """Differential profile of two builds. A small program parses JSON records and inserts them into a hash map. Build B makes one small change: a lock around the record counter. Both are profiled for the same time under the same load by a sampling profiler (a CPU-time timer signal that records the Python stack), then diffed frame by frame. The diff is repeated with B profiled while CPU-heavy processes run alongside. Writes folded stacks (the input format of flamegraph.pl) next to --json. """ import argparse, collections, json, multiprocessing as mp, os, signal, threading, time p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--seconds", type=float, default=30, help="Profiled seconds per run.") p.add_argument("--hz", type=int, default=999, help="Sampling rate.") p.add_argument("--load-procs", type=int, default=8, help="CPU-heavy processes in the different-load run.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() RECORDS = [json.dumps({"id": i, "user": f"u{i % 997}", "amount": i * 1.5, "tags": ["a", "b", str(i % 7)]}) for i in range(20_000)] lock = threading.Lock(); n = 0; LOCKED = False def parse(s): return json.loads(s) def insert(table, rec): table[rec["user"]] = table.get(rec["user"], 0) + rec["amount"] def count(): global n if LOCKED: with lock: n += 1 # build B: the one change else: n += 1 def workload(seconds): table = {}; end = time.process_time() + seconds while time.process_time() < end: for s in RECORDS[:2000]: insert(table, parse(s)); count() stacks = collections.Counter() def sample(signum, frame): names = [] while frame is not None: names.append(frame.f_code.co_name); frame = frame.f_back stacks[";".join(reversed(names))] += 1 def burn(stop): x = 0 while not stop.is_set(): x += 1 def profile(locked, load=0): global LOCKED LOCKED = locked; stacks.clear(); stop = mp.Event(); procs = [mp.Process(target=burn, args=(stop,)) for _ in range(load)] for q in procs: q.start() signal.signal(signal.SIGPROF, sample); signal.setitimer(signal.ITIMER_PROF, 1 / args.hz, 1 / args.hz) workload(args.seconds) signal.setitimer(signal.ITIMER_PROF, 0); stop.set() for q in procs: q.join() return collections.Counter(stacks) def leaf_shares(folded): """Share of samples per frame (self time): the width of each frame's top edge in a flamegraph.""" total = sum(folded.values()); c = collections.Counter() for st, n in folded.items(): c[st.split(";")[-1]] += n return {k: v / total * 100 for k, v in c.items()} def diff(a, b, name): sa, sb = leaf_shares(a), leaf_shares(b) rows = [{"diff": name, "frame": f, "share_a": sa.get(f, 0), "share_b": sb.get(f, 0), "delta_pts": sb.get(f, 0) - sa.get(f, 0)} for f in set(sa) | set(sb)] return sorted(rows, key=lambda r: -abs(r["delta_pts"]))[:8] if __name__ == "__main__": a = profile(False); b = profile(True); a2 = profile(False); b_load = profile(True, args.load_procs) same, noise, loaded = diff(a, b, "A vs B, same load"), diff(a, a2, "A vs A (no change)"), diff(a, b_load, "A vs B, B under load") for title, rows in (("A vs B, same load", same), ("A vs A, no change", noise), ("A vs B, B under extra load", loaded)): print(title) for r in rows[:5]: print(f" {r['frame']:<18} {r['share_a']:6.2f}% -> {r['share_b']:6.2f}% ({r['delta_pts']:+.2f} pts)") if args.json: base = os.path.splitext(args.json)[0] for tag, folded in (("a", a), ("b", b), ("b-loaded", b_load)): with open(f"{base}.{tag}.folded", "w") as f: f.writelines(f"{k} {v}\n" for k, v in folded.items()) with open(args.json, "w") as f: json.dump({"tables": {"same": same, "noise": noise, "loaded": loaded}}, 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 differential-profile-of-two-builds.py uv run differential-profile-of-two-builds.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --seconds30 | Profiled seconds per run. |
| --hz999 | Sampling rate. |
| --load-procs8 | CPU-heavy processes in the different-load run. |
Our run
A against B, same load (share of samples, %)
| Frame | A | B | Difference (points) |
|---|---|---|---|
| count | 2.05 | 4.58 | 2.53 |
| workload | 81.1 | 78.9 | -2.19 |
| insert | 2.27 | 1.88 | -0.389 |
| raw_decode | 7.85 | 8.13 | 0.28 |
| decode | 5.14 | 4.95 | -0.191 |
| parse | 0.301 | 0.276 | -0.024 |
| loads | 1.32 | 1.3 | -0.02 |
A against B, B under extra load
| Frame | A | B | Difference (points) |
|---|---|---|---|
| workload | 81.1 | 56.4 | -24.7 |
| raw_decode | 7.85 | 16.3 | 8.47 |
| count | 2.05 | 8.75 | 6.7 |
| decode | 5.14 | 10.7 | 5.56 |
| insert | 2.27 | 4.1 | 1.83 |
| loads | 1.32 | 3.11 | 1.78 |
| parse | 0.301 | 0.651 | 0.35 |
A vs B, same load
count 2.05% -> 4.58% (+2.53 pts)
workload 81.07% -> 78.88% (-2.19 pts)
insert 2.27% -> 1.88% (-0.39 pts)
raw_decode 7.85% -> 8.13% (+0.28 pts)
decode 5.14% -> 4.95% (-0.19 pts)
A vs A, no change
workload 81.07% -> 81.86% (+0.78 pts)
decode 5.14% -> 4.79% (-0.35 pts)
raw_decode 7.85% -> 7.61% (-0.24 pts)
insert 2.27% -> 2.16% (-0.11 pts)
parse 0.30% -> 0.23% (-0.07 pts)
A vs B, B under extra load
workload 81.07% -> 56.39% (-24.69 pts)
raw_decode 7.85% -> 16.31% (+8.47 pts)
count 2.05% -> 8.75% (+6.70 pts)
decode 5.14% -> 10.70% (+5.56 pts)
insert 2.27% -> 4.10% (+1.83 pts)What we found
Under the same load, the counter frame was the largest difference, from 2.05% to 4.58% of samples (2.53 points); two identical profiles of A differed by at most 0.785 points. With B under extra load, the diff was dominated by false differences: the loop frame fell by -24.7 points and the JSON decoding frames rose, larger than the real change, which still showed (6.7 points) but no longer stood out.
Read it
Diff profiles, do not compare them by eye, and control the conditions as carefully as for any benchmark: the same input, duration and machine load. A diff between profiles taken under different conditions measures the conditions.