Files
TheRock/rocm-systems/projects/rocprof-trace-decoder/scripts/sqtt_flamegraph.py
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

631 lines
23 KiB
Python
Executable File

#!/usr/bin/env python3
# MIT License
#
# Copyright (c) 2026 Advanced Micro Devices, Inc. All rights reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""
Generate a flamegraph from SQTT auto-instrumentation traces.
Reads shaderdata JSON files (s_ttracedata markers) and .sqtt_funcmap sections
from code objects, then outputs folded stacks suitable for flamegraph.pl or
speedscope.
Usage:
# Basic -- point at the base output directory, everything is auto-detected
python3 sqtt_flamegraph.py build/test/ --demangle
# Explicit code objects
python3 sqtt_flamegraph.py build/test/ -c build/test/*/code_object_id_2.out
# Filter to a single wave
python3 sqtt_flamegraph.py build/test/ --demangle --cu 0 --simd 0 --wave 0
# Save folded stacks to file (also printed to stdout)
python3 sqtt_flamegraph.py build/test/ > stacks.folded
# Output speedscope JSON
python3 sqtt_flamegraph.py build/test/ --format speedscope -o trace.speedscope.json
# Open interactive flamegraph in the browser
python3 sqtt_flamegraph.py build/test/ --demangle --show
Auto-discovery:
The base directory is searched recursively for:
- ui_*/shaderdata_*.json (trace data)
- *code_object_id*.out (code objects with .sqtt_funcmap sections)
Each ui_* directory is processed as an independent time domain (SQTT
time resets between collections). Folded stack counts are merged after
per-directory processing.
Output:
Folded stacks are always printed to stdout. An SVG flamegraph is
always written to disk (default: sqtt_flamegraph.svg). Use --show
to open the SVG in a browser. Use -o to specify an output path
for SVG or speedscope JSON.
"""
import argparse
import html
import json
import os
import sys
import webbrowser
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Optional
from sqtt_data import (
FuncMap,
ShaderRecord,
WaveSpan,
decode_marker,
demangle_name,
discover_base_dir,
find_wave_span_at,
load_funcmaps,
load_occupancy,
load_shaderdata,
build_kernel_to_co,
make_funcmap_resolver,
merge_folded,
merge_funcmaps,
preprocess_records,
)
# ---------------------------------------------------------------------------
# Flamegraph generation
# ---------------------------------------------------------------------------
# A unique wave identity
WaveKey = tuple[int, int, int, int, int] # (se, cu, simd, wave_id, instance)
def build_stacks(
records: list[ShaderRecord],
per_co: dict[int, FuncMap],
dispatches: dict[str, str],
wave_spans: dict[tuple, list[WaveSpan]],
kernel_to_co: dict[str, int],
do_demangle: bool = False,
filter_cu: Optional[int] = None,
filter_simd: Optional[int] = None,
filter_wave: Optional[int] = None,
) -> dict[str, int]:
"""
Process shaderdata records into folded stacks weighted by time.
For each wave, maintain a call stack. On ENTER markers, push.
On EXIT markers, pop. Accumulate time spent in each unique stack.
Uses occupancy data to prepend code_object_id and kernel name frames.
Returns a dict mapping "frame1;frame2;...;frameN" -> (total_cycles, exec_count).
"""
# Merged funcmap for fallback resolution.
merged = merge_funcmaps(per_co.values())
# Build sorted launch times per (SE, CU, SIMD, wave_slot) from occupancy.
slot_launches: dict[tuple, list[int]] = defaultdict(list)
for wk, spans in wave_spans.items():
for span in spans:
slot_launches[wk].append(span.launch_time)
for wk in slot_launches:
slot_launches[wk].sort()
def _wave_instance(shader_engine: int, cu: int, simd: int, wave_id: int, time: int) -> int:
"""Find which wave instance (launch index) a record belongs to."""
launches = slot_launches.get((shader_engine, cu, simd, wave_id), [])
if not launches:
return 0
lo, hi = 0, len(launches) - 1
result = 0
while lo <= hi:
mid = (lo + hi) // 2
if launches[mid] <= time:
result = mid
lo = mid + 1
else:
hi = mid - 1
return result
# Group records by (SE, CU, SIMD, wave_id, instance)
wave_records: dict[tuple, list[ShaderRecord]] = defaultdict(list)
for rec in records:
if rec.flags & 2:
continue
if filter_cu is not None and rec.cu != filter_cu:
continue
if filter_simd is not None and rec.simd != filter_simd:
continue
if filter_wave is not None and rec.wave_id != filter_wave:
continue
inst = _wave_instance(rec.shader_engine, rec.cu, rec.simd, rec.wave_id, rec.time)
key = (rec.shader_engine, rec.cu, rec.simd, rec.wave_id, inst)
wave_records[key].append(rec)
# (total_cycles, exec_count) per stack
folded_cycles: dict[str, int] = defaultdict(int)
folded_execs: dict[str, int] = defaultdict(int)
def _dispatch_prefix(dispatch_id):
"""Build prefix frames and select funcmap for a dispatch."""
raw_name = dispatches.get(str(dispatch_id), "")
if not raw_name or raw_name.startswith("0 /"):
return [], merged
co_id = kernel_to_co.get(raw_name)
display = demangle_name(raw_name) if do_demangle else raw_name
if co_id is not None:
return [f"code_object_{co_id}", display], per_co.get(co_id, merged)
return [display], merged
def _flush(prefix, stack, last_time, now):
"""Accumulate elapsed time for the current stack."""
if last_time is not None and stack:
dt = now - last_time
if dt > 0:
key = ";".join(prefix + stack)
folded_cycles[key] += dt
folded_execs[key] += 1
for wave_key, recs in wave_records.items():
recs.sort(key=lambda r: r.time)
stack: list[str] = []
last_time: Optional[int] = None
cur_prefix: list[str] = []
cur_dispatch_id: Optional[int] = None
cur_funcmap: FuncMap = merged
base_wk = wave_key[:4]
spans = wave_spans.get(base_wk, [])
for rec in recs:
# Check dispatch context
span = find_wave_span_at(spans, rec.time)
dispatch_id = span.dispatch_id if span else None
if dispatch_id != cur_dispatch_id:
_flush(cur_prefix, stack, last_time, rec.time)
cur_dispatch_id = dispatch_id
stack = []
cur_prefix, cur_funcmap = _dispatch_prefix(dispatch_id)
marker_id, enter, exit_prev = decode_marker(rec.value, cur_funcmap)
if marker_id == 0 and not exit_prev:
continue
name, mtype = cur_funcmap.resolve(marker_id)
# Point markers (barriers, memory ops, user points) carry no
# cycle attribution -- they're presence-only events. Skip them
# entirely so they don't pollute the flamegraph with synthetic
# 1-cycle samples. They're still in the raw shaderdata for
# other tools that want to consume them.
if mtype == "point":
continue
_flush(cur_prefix, stack, last_time, rec.time)
last_time = rec.time
# Handle exit_prev first (pop previous scope before entering new one)
if exit_prev and stack:
stack.pop()
if enter:
stack.append(name)
return {k: (folded_cycles[k], folded_execs[k]) for k in folded_cycles}
# ---------------------------------------------------------------------------
# Output formats
# ---------------------------------------------------------------------------
def write_folded(folded: dict[str, tuple[int, int]], out):
"""Write folded stack format: 'frame1;frame2 total_cycles execs avg_cycles'."""
for stack, (cycles, execs) in sorted(folded.items(), key=lambda x: -x[1][0]):
avg = cycles / execs if execs else 0
out.write(f"{stack} {cycles} {execs} {avg:.0f}\n")
def write_speedscope(folded: dict[str, tuple[int, int]], out):
"""Write speedscope JSON format."""
all_frames: list[str] = []
frame_index: dict[str, int] = {}
for stack_str in folded:
for frame in stack_str.split(";"):
if frame not in frame_index:
frame_index[frame] = len(all_frames)
all_frames.append(frame)
samples = []
weights = []
for stack_str, (cycles, execs) in sorted(folded.items(), key=lambda x: -x[1][0]):
frames = stack_str.split(";")
sample = [frame_index[f] for f in frames]
samples.append(sample)
weights.append(cycles)
speedscope = {
"$schema": "https://www.speedscope.app/file-format-schema.json",
"shared": {
"frames": [{"name": f} for f in all_frames],
},
"profiles": [{
"type": "sampled",
"name": "SQTT Trace",
"unit": "cycles",
"startValue": 0,
"endValue": sum(weights),
"samples": samples,
"weights": weights,
}],
"name": "SQTT Flamegraph",
"activeProfileIndex": 0,
"exporter": "sqtt_flamegraph.py",
}
json.dump(speedscope, out, indent=2)
out.write("\n")
# ---------------------------------------------------------------------------
# Interactive SVG flamegraph (no external dependencies)
# ---------------------------------------------------------------------------
@dataclass
class _FlameNode:
name: str
total: int = 0 # total cycles (self + children)
execs: int = 0 # total execution count
children: dict = field(default_factory=dict) # name -> _FlameNode
def _build_flame_tree(folded: dict[str, tuple[int, int]]) -> _FlameNode:
"""Build a tree from folded stacks."""
root = _FlameNode(name="all")
for stack_str, (cycles, execs) in folded.items():
frames = stack_str.split(";")
node = root
node.total += cycles
node.execs += execs
for frame in frames:
if frame not in node.children:
node.children[frame] = _FlameNode(name=frame)
node = node.children[frame]
node.total += cycles
node.execs += execs
return root
def _color_for(name: str) -> str:
"""Deterministic flame-themed color from name hash (stable across runs).
Hue stays in the red-yellow range to keep the classic flamegraph look,
but saturation and lightness vary widely so adjacent boxes contrast
even when their hues are close.
"""
import hashlib
digest = hashlib.md5(name.encode("utf-8")).digest()
h = int.from_bytes(digest[:4], "little")
hue = h % 50 # 0-49: red → orange → yellow
sat = 60 + ((h >> 8) % 40) # 60-99%
lit = 50 + ((h >> 16) % 30) # 50-79%
return f"hsl({hue},{sat}%,{lit}%)"
def render_flamegraph_svg(folded: dict[str, tuple[int, int]], title: str = "SQTT Flamegraph") -> str:
"""Render folded stacks as an interactive SVG flamegraph."""
root = _build_flame_tree(folded)
if root.total == 0:
return "<svg></svg>"
frame_h = 18
font_size = 11
min_width_px = 1
pad_top = 76
pad_bottom = 30
canvas_w = 1200
max_d = 0
depth_stack = [(root, 0)]
while depth_stack:
node, d = depth_stack.pop()
if not node.children:
max_d = max(max_d, d)
else:
for c in node.children.values():
depth_stack.append((c, d + 1))
max_d += 1
canvas_h = pad_top + max_d * frame_h + pad_bottom
rects: list[str] = []
total = root.total
def _render_iterative(start_nodes: list[tuple[_FlameNode, int, float]]):
work = list(start_nodes)
while work:
node, depth, x_offset = work.pop()
w = (node.total / total) * canvas_w
if w < min_width_px:
continue
y = canvas_h - pad_bottom - (depth + 1) * frame_h
esc_name = html.escape(node.name, quote=True)
pct = 100.0 * node.total / total
avg = node.total / node.execs if node.execs else 0
title_text = (f"{esc_name} ({node.total:,} cycles, {pct:.1f}%, "
f"{node.execs:,} execs, avg {avg:,.0f} cycles)")
color = _color_for(node.name)
max_chars = max(0, int(w / (font_size * 0.6)) - 1)
label = node.name[:max_chars] if max_chars >= 2 else ""
esc_label = html.escape(label, quote=True)
rects.append(
f'<g class="fr" data-name="{esc_name}">'
f'<title>{title_text}</title>'
f'<rect x="{x_offset:.1f}" y="{y}" width="{w:.1f}" '
f'height="{frame_h - 1}" fill="{color}" rx="1"/>'
f'<text x="{x_offset + 2:.1f}" y="{y + frame_h - 5}" '
f'font-size="{font_size}" fill="#000">{esc_label}</text>'
f'</g>'
)
cx = x_offset
for child in sorted(node.children.values(), key=lambda c: -c.total):
work.append((child, depth + 1, cx))
cx += (child.total / total) * canvas_w
cx = 0.0
esc_title = html.escape(title)
rects.append(
f'<g class="fr" data-name="all">'
f'<title>all ({root.total:,} cycles, {root.execs:,} execs, 100.0%)</title>'
f'<rect x="0" y="{canvas_h - pad_bottom - frame_h}" '
f'width="{canvas_w}" height="{frame_h - 1}" fill="#eee" rx="1"/>'
f'<text x="2" y="{canvas_h - pad_bottom - 5}" '
f'font-size="{font_size}" fill="#000">all ({root.total:,} cycles)</text>'
f'</g>'
)
start_nodes = []
for child in sorted(root.children.values(), key=lambda c: -c.total):
start_nodes.append((child, 1, cx))
cx += (child.total / total) * canvas_w
_render_iterative(start_nodes)
svg = f'''<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{canvas_w}" height="{canvas_h}"
viewBox="0 0 {canvas_w} {canvas_h}" font-family="monospace">
<style>
.fr:hover rect {{ stroke: #000; stroke-width: 1; cursor: pointer; }}
.fr.dim rect {{ opacity: 0.3; }}
.fr.dim text {{ opacity: 0.3; }}
.fr.hl rect {{ stroke: #f00; stroke-width: 1.5; }}
#search {{ font-family: monospace; font-size: 13px; padding: 2px 6px;
border: 1px solid #aaa; border-radius: 3px; width: 300px; }}
#info {{ font-size: 12px; fill: #333; }}
</style>
<text x="10" y="22" font-size="16" font-weight="bold" fill="#000">{esc_title}</text>
<foreignObject x="10" y="30" width="400" height="26">
<input xmlns="http://www.w3.org/1999/xhtml" id="search" type="text"
placeholder="Search frames (regex)..." />
</foreignObject>
<text id="info" x="10" y="68"></text>
{"".join(rects)}
<script><![CDATA[
(function() {{
var info = document.getElementById("info");
var search = document.getElementById("search");
var frames = document.querySelectorAll(".fr");
frames.forEach(function(g) {{
g.addEventListener("mouseenter", function() {{
info.textContent = g.querySelector("title").textContent;
}});
g.addEventListener("mouseleave", function() {{
info.textContent = "";
}});
}});
search.addEventListener("input", function() {{
var q = search.value;
if (!q) {{
frames.forEach(function(g) {{ g.classList.remove("dim","hl"); }});
return;
}}
try {{ var re = new RegExp(q, "i"); }} catch(e) {{ return; }}
frames.forEach(function(g) {{
var name = g.getAttribute("data-name");
if (re.test(name)) {{
g.classList.add("hl");
g.classList.remove("dim");
}} else {{
g.classList.add("dim");
g.classList.remove("hl");
}}
}});
}});
}})();
]]></script>
</svg>'''
return svg
def save_flamegraph_svg(folded: dict[str, tuple[int, int]], title: str = "SQTT Flamegraph",
output_path: Optional[str] = None) -> str:
"""Render SVG flamegraph to disk. Returns the file path."""
svg = render_flamegraph_svg(folded, title)
path = output_path or os.path.join(os.getcwd(), "sqtt_flamegraph.svg")
with open(path, "w") as f:
f.write(svg)
print(f"Wrote flamegraph to {path}", file=sys.stderr)
return path
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Generate flamegraph from SQTT auto-instrumentation traces")
parser.add_argument("base_dir",
help="Base directory containing trace output. Auto-discovers "
"ui_*/shaderdata_*.json and *code_object_id*.out files "
"recursively.")
parser.add_argument("--code-objects", "-c", nargs="+",
help="Code object files (.out) with .sqtt_funcmap sections. "
"If omitted, auto-detected from base_dir.")
parser.add_argument("--demangle", "-d", action="store_true",
help="Demangle C++ function names")
parser.add_argument("--cu", type=int, default=None,
help="Filter to specific CU/WGP ID")
parser.add_argument("--simd", type=int, default=None,
help="Filter to specific SIMD ID")
parser.add_argument("--wave", type=int, default=None,
help="Filter to specific wave ID")
parser.add_argument("--format", "-f", choices=["folded", "speedscope"],
default="folded",
help="Output format (default: folded)")
parser.add_argument("--output", "-o", default=None,
help="Output file (default: stdout)")
parser.add_argument("--show", "-s", action="store_true",
help="Render interactive SVG flamegraph and open in browser")
args = parser.parse_args()
base_dir = args.base_dir
if not os.path.isdir(base_dir):
print(f"Error: {base_dir} is not a directory", file=sys.stderr)
sys.exit(1)
trace_dirs, discovered_cos = discover_base_dir(base_dir)
code_objects = args.code_objects or discovered_cos
if not trace_dirs:
print(f"Error: no shaderdata_*.json files found under {base_dir}",
file=sys.stderr)
sys.exit(1)
if not code_objects:
print("Error: no code objects found. Use --code-objects.", file=sys.stderr)
sys.exit(1)
print(f"Found {len(trace_dirs)} trace dir(s), "
f"{len(code_objects)} code object(s)", file=sys.stderr)
for td in trace_dirs:
print(f" trace: {td}", file=sys.stderr)
for co in code_objects:
print(f" code: {co}", file=sys.stderr)
# Load funcmaps (unmangled first for kernel->code_object lookup)
per_co_raw = load_funcmaps(code_objects, do_demangle=False)
kernel_to_co = build_kernel_to_co(per_co_raw)
per_co = load_funcmaps(code_objects, args.demangle) if args.demangle else per_co_raw
n_markers = sum(len(fm.markers) for fm in per_co.values())
n_kernels = sum(len(fm.kernels) for fm in per_co.values())
print(f"Loaded funcmaps from {len(per_co)} code object(s): "
f"{n_markers} markers, {n_kernels} kernels", file=sys.stderr)
if n_markers == 0:
print("Warning: no instrumented functions found in code objects. "
"Was the pass plugin loaded during compilation?", file=sys.stderr)
# Build merged funcmap for preprocessing.
merged = merge_funcmaps(per_co.values())
# Process each trace directory independently
all_folded: list[dict[str, tuple[int, int]]] = []
total_records = 0
all_waves: set[tuple] = set()
for td in trace_dirs:
records = load_shaderdata(td)
if not records:
continue
dispatches, wave_spans = load_occupancy(td)
n_dispatches = len([v for v in dispatches.values()
if not v.startswith("0 /")])
# Filter out address trace records before building stacks
resolver = make_funcmap_resolver(per_co, dispatches, kernel_to_co, wave_spans, merged)
records, addr_traces = preprocess_records(records, merged, resolver)
waves = set((record.shader_engine, record.cu, record.simd, record.wave_id) for record in records)
total_records += len(records)
all_waves.update(waves)
n_addr = len(addr_traces)
addr_info = f", {n_addr} addr traces" if n_addr else ""
print(f" {os.path.basename(td)}: {len(records)} records, "
f"{len(waves)} waves, {n_dispatches} dispatch(es), "
f"{len(wave_spans)} wave slots{addr_info}", file=sys.stderr)
folded = build_stacks(
records, per_co, dispatches, wave_spans, kernel_to_co,
do_demangle=args.demangle,
filter_cu=args.cu, filter_simd=args.simd, filter_wave=args.wave,
)
all_folded.append(folded)
if total_records == 0:
print("Error: no shaderdata records found", file=sys.stderr)
sys.exit(1)
print(f"Total: {total_records} records across {len(all_waves)} waves "
f"in {len(trace_dirs)} directory(ies)", file=sys.stderr)
folded = merge_folded(all_folded)
if not folded:
print("Warning: no stacks generated. Check filters or marker data.",
file=sys.stderr)
sys.exit(1)
print(f"Generated {len(folded)} unique stacks", file=sys.stderr)
write_folded(folded, sys.stdout)
svg_path = args.output
if svg_path and not svg_path.endswith(".svg"):
svg_path = None
svg_file = save_flamegraph_svg(folded, title="SQTT Flamegraph",
output_path=svg_path)
if args.output and args.output != svg_file:
with open(args.output, "w") as out:
if args.format == "speedscope":
write_speedscope(folded, out)
else:
write_folded(folded, out)
print(f"Wrote {args.output}", file=sys.stderr)
if args.show:
webbrowser.open(f"file://{os.path.abspath(svg_file)}")
if __name__ == "__main__":
main()