Chris Parmer — home

Flame graph

Chrome DevTools CPU profile rendered as go.Icicle

Example from the compendium of canonical charts

Flame graph — Chrome DevTools CPU profile rendered as go.Icicle

Python Code

"""Flame graph — Chrome DevTools CPU profile rendered as go.Icicle.

Capture a profile in your browser and drop it into this folder; it gets picked
up automatically (newest wins). Two formats are supported:

    Performance panel → record → stop → ⤓ "Save profile"   → Trace-*.json(.gz)
    ⋮ → More tools → JavaScript Profiler → record → Save   → *.cpuprofile

The Performance trace embeds its CPU samples across many "ProfileChunk" events;
the .cpuprofile is the same sample/node data in one object. Both reduce to the
same thing: a node tree + a stream of (sample, timeDelta) pairs that we fold
into stacks weighted by sampled CPU time. With no profile present the chart
falls back to a synthetic web server profile so it always renders.
"""
import json
import gzip


import plotly.graph_objects as go
import warnings

HERE = Path(__file__).parent

# Meta frames the V8 profiler injects but which only add noise to the flames —
# "(idle)" in particular dominates an interactive recording. "_readCallback" is
# the flat page-load network-read leaf: lots of self-time, no call tree beneath
# it, so it just crowds out the actual JS flames. Drop them all.
SKIP_FRAMES = {"(idle)", "(root)", "(program)", "_readCallback"}


def find_profile():
    """Find a profile capture next to this script (newest wins)."""
    candidates = sorted(
        [
            *HERE.glob("*.cpuprofile"),
            *HERE.glob("*[Tt]race*.json"),
            *HERE.glob("*[Tt]race*.json.gz"),
            *HERE.glob("*profile*.json"),
        ],
        key=lambda p: p.stat().st_mtime,
        reverse=True,
    )
    return candidates[0] if candidates else None


def read_json(path):
    """Load JSON from a plain or gzipped file."""
    opener = gzip.open if path.suffix == ".gz" else open
    with opener(path, "rt", encoding="utf-8") as f:
        return json.load(f)


def frame_name(call_frame):
    """Human label for a V8 callFrame: function name, or url:line for anon."""
    name = (call_frame.get("functionName") or "").strip()
    if name:
        return name
    url = call_frame.get("url") or ""
    if url:
        tail = url.rsplit("/", 1)[-1] or url
        line = call_frame.get("lineNumber", -1)
        return f"{tail}:{line + 1}" if line >= 0 else tail
    return "(anonymous)"


def extract_samples(data):
    """Normalize either profile format to (nodes, samples, timeDeltas).

    A .cpuprofile carries nodes/samples/timeDeltas at the top level. A
    Performance trace spreads them across "ProfileChunk" trace events, one per
    chunk — so merge every chunk belonging to the busiest sampled thread.
    """
    if "traceEvents" in data:
        chunks = [e for e in data["traceEvents"] if e.get("name") == "ProfileChunk"]
        if not chunks:
            raise ValueError("trace has no ProfileChunk events (was CPU profiling on?)")
        # Pick the thread with the most chunks — the main / busiest one.
        threads = {}
        for e in chunks:
            threads.setdefault((e["pid"], e["tid"]), []).append(e)
        main = max(threads.values(), key=len)
        nodes, samples, deltas = {}, [], []
        for e in main:
            d = e["args"]["data"]
            cp = d.get("cpuProfile", {})
            for n in cp.get("nodes", []):
                nodes[n["id"]] = n
            samples += cp.get("samples", [])
            deltas += d.get("timeDeltas", [])
        return list(nodes.values()), samples, deltas

    return data["nodes"], data.get("samples"), data.get("timeDeltas")


def fold_profile(nodes, samples, deltas):
    """Node tree + samples → folded stacks weighted by sampled CPU time.

    Handles both node-link conventions: a `parent` pointer on each node (trace
    format) or a `children` list on the parent (.cpuprofile format). Falls back
    to per-node hitCount when there is no sample timeline.
    """
    parent_of, label_of = {}, {}
    for node in nodes:
        nid = node["id"]
        label_of[nid] = frame_name(node["callFrame"])
        if "parent" in node:
            parent_of[nid] = node["parent"]
        for child in node.get("children", []):
            parent_of[child] = nid

    def stack_for(nid):
        """Frame list root→leaf for a node, dropping meta frames."""
        frames, seen = [], set()
        while nid is not None and nid not in seen and nid in label_of:
            seen.add(nid)
            label = label_of[nid]
            if label not in SKIP_FRAMES:
                frames.append(label)
            nid = parent_of.get(nid)
        frames.reverse()
        return frames

    folded = {}
    if samples and deltas and len(samples) == len(deltas):
        for nid, dt in zip(samples, deltas):
            if dt <= 0:
                continue
            frames = stack_for(nid)
            if frames:
                key = tuple(frames)
                folded[key] = folded.get(key, 0) + dt
    else:
        # No timeline — weight each node by its own hitCount instead.
        for node in nodes:
            hits = node.get("hitCount", 0)
            if hits <= 0:
                continue
            frames = stack_for(node["id"])
            if frames:
                key = tuple(frames)
                folded[key] = folded.get(key, 0) + hits

    return [(list(frames), count) for frames, count in folded.items()]


def build_tree(stacks):
    """Convert flat stacks into a tree of {name → {children, self_count, total_count}}."""
    tree = {"name": "root", "children": {}, "self": 0, "total": 0}

    for frames, count in stacks:
        node = tree
        node["total"] += count
        for frame in frames:
            if frame not in node["children"]:
                node["children"][frame] = {"name": frame, "children": {}, "self": 0, "total": 0}
            node = node["children"][frame]
            node["total"] += count
        node["self"] += count

    return tree


def flatten_tree(root, max_depth=8, min_samples=1, top_n=20):
    """Flatten tree to parallel lists for go.Icicle.

    Node ids are short integers (as strings), not full slash-paths — the paths
    averaged ~230 chars each and, duplicated across ids + parents, were ~70% of
    the output JSON. Integer ids carry the same tree shape for a fraction of the
    bytes. Returns ids, labels, parents, values, depths.
    """
    ids, labels, parents, values, depths = [], [], [], [], []
    counter = [0]

    def walk(node, parent_id, depth):
        my_id = str(counter[0])
        counter[0] += 1
        ids.append(my_id)
        labels.append(node["name"])
        parents.append(parent_id)
        values.append(node["self"] if node["self"] > 0 else 0)
        depths.append(depth)
        if depth < max_depth:
            # Sort children by total descending, keep top N above the floor.
            for child in sorted(node["children"].values(), key=lambda x: x["total"], reverse=True)[:top_n]:
                if child["total"] < min_samples:
                    continue
                walk(child, my_id, depth + 1)

    walk(root, "", 0)
    return ids, labels, parents, values, depths


def synthetic_stacks():
    """Fallback web server profile so the chart always renders."""
    rows = [
        ("main;http_serve;read_request;syscall_read", 120),
        ("main;http_serve;parse_headers;malloc", 45),
        ("main;http_serve;route_dispatch;db_query;pg_exec;syscall_write", 310),
        ("main;http_serve;route_dispatch;db_query;pg_exec;hash_table_lookup", 95),
        ("main;http_serve;route_dispatch;serialize_json;malloc", 88),
        ("main;http_serve;route_dispatch;serialize_json;snprintf", 42),
        ("main;http_serve;write_response;syscall_write", 130),
        ("main;http_serve;write_response;gzip_compress;deflate", 175),
        ("main;http_serve;write_response;gzip_compress;malloc", 30),
        ("main;event_loop;epoll_wait;syscall", 200),
        ("main;event_loop;timer_tick;gc_sweep;free", 60),
        ("main;event_loop;timer_tick;gc_sweep;mark_phase", 40),
        ("main;event_loop;timer_tick;gc_mark;malloc", 20),
        ("main;tls_handshake;ssl_read;syscall_read", 50),
        ("main;tls_handshake;ssl_write;syscall_write", 35),
    ]
    return [(s.split(";"), c) for s, c in rows]


def generate():
    warnings.filterwarnings("ignore")

    profile = find_profile()
    if profile is not None:
        print(f"parsing browser profile {profile.name} …")
        nodes, samples, deltas = extract_samples(read_json(profile))
        stacks = fold_profile(nodes, samples, deltas)
        # Clean label: drop .json/.gz/.cpuprofile suffixes for the title.
        source = profile.name
        for suffix in (".gz", ".json", ".cpuprofile"):
            if source.endswith(suffix):
                source = source[: -len(suffix)]
        # Real profiles are deep and bushy. Keep plenty of structure but prune
        # the long tail of slivers (<0.25% of total) — they're invisible at this
        # size and nobody zooms in, yet they dominate the node count / bytes.
        max_depth, top_n, min_frac = 18, 50, 0.0025
    else:
        print("no profile found — using synthetic web server profile")
        stacks = synthetic_stacks()
        source = "Synthetic web server profile"
        max_depth, top_n, min_frac = 8, 20, 1 / 200

    print(f"  parsed {len(stacks)} stacks")

    tree = build_tree(stacks)
    total = tree["total"]
    ids, labels, parents, values, depths = flatten_tree(
        tree, min_samples=max(1, int(total * min_frac)), max_depth=max_depth, top_n=top_n
    )

    print(f"  tree nodes: {len(ids)}")

    # Color by depth
    colors = [VIOLET, GREEN, TEAL, PINK, TEAL]
    marker_colors = [colors[d % len(colors)] for d in depths]

    # Sampled profiles weight by time (µs → ms); the synthetic fallback is raw
    # sample counts. Carry the self-cost in display units for the hover.
    if profile is not None:
        customdata = [v / 1000 for v in values]
        unit = "ms"
    else:
        customdata = list(values)
        unit = "samples"

    fig = go.Figure(go.Icicle(
        ids=ids,
        labels=labels,
        parents=parents,
        values=values,
        customdata=customdata,
        branchvalues="remainder",
        tiling=dict(orientation="v"),  # root at top, stacks grow downward
        marker=dict(colors=marker_colors),
        textfont=dict(size=10),
        hovertemplate=(
            "<b>%{label}</b><br>Self: %{customdata:.1f} " + unit
            + "<br>%{percentRoot:.1%} of total<extra></extra>"
        ),
        maxdepth=14,
    ))

    fig.update_layout(
        title=dict(text=f"Flame Graph — {source}", x=0.5),
        margin=dict(t=60, b=20, l=0, r=0),
        height=700,
    )
    return fig


if __name__ == "__main__":
    generate()

Made with Plotly