Skip to content
About
Main content

Differential profile of two builds

Measure your machineNeeds Python

Problem

A small change rarely stands out in a : 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

With build B profiled under extra load, which is larger in the diff: the real change or the differences the load creates?

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.

    differential-profile-of-two-builds.py86 linesDownload
    View 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)
    
  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 differential-profile-of-two-builds.py
    uv run differential-profile-of-two-builds.py --help     # every knob
Knobs
Knob · defaultWhat 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, %)

A against B, same load (share of samples, %)
FrameABDifference (points)
count2.054.582.53
workload81.178.9-2.19
insert2.271.88-0.389
raw_decode7.858.130.28
decode5.144.95-0.191
parse0.3010.276-0.024
loads1.321.3-0.02

A against B, B under extra load

A against B, B under extra load
FrameABDifference (points)
workload81.156.4-24.7
raw_decode7.8516.38.47
count2.058.756.7
decode5.1410.75.56
insert2.274.11.83
loads1.323.111.78
parse0.3010.6510.35
Reference runApple M2 · 8 cores · 8 GB
Output · uv run differential-profile-of-two-builds.py
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)
macOS 26.6.2 · Python 3.13.3 · run 2026-10-20

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.