[RFC PATCH v2 1/1] perf scripts flamegraph: Add --asm option
From: Tudor-Stefan Magirescu
Date: Thu Aug 27 2026 - 11:24:49 EST
The flamegraph script reports samples at function granularity, so a wide
frame shows which function is hot but not which part of it. Obtaining
that requires perf annotate, which reports per-instruction counts without
the calling context of a flame graph.
Add an --asm option to emit more fine-grained flame graphs by including
instruction-level information. By enabling this option, instructions
appear as the leaves in the flame graph call stacks:
main
a(int)
imul $0x3b9aca07,%rax,%rdx [0x13ad]
Signed-off-by: Tudor-Stefan Magirescu <tudor.magirescu@xxxxxxxxx>
---
tools/perf/scripts/python/flamegraph.py | 161 ++++++++++++++++++++++++
1 file changed, 161 insertions(+)
diff --git a/tools/perf/scripts/python/flamegraph.py b/tools/perf/scripts/python/flamegraph.py
index ad735990c5be..60ace98e73ad 100755
--- a/tools/perf/scripts/python/flamegraph.py
+++ b/tools/perf/scripts/python/flamegraph.py
@@ -19,14 +19,18 @@
# pylint: disable=missing-function-docstring
import argparse
+from dataclasses import dataclass
import hashlib
import io
import json
import os
+import re
import subprocess
import sys
from typing import Dict, Optional, Union
import urllib.request
+from perf_trace_context import perf_config_get
+
MINIMAL_HTML = """<head>
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/d3-flame-graph@4.1.3/dist/d3-flamegraph.css">
@@ -67,10 +71,146 @@ class Node:
}
+@dataclass
+class Symbol:
+ start: int
+ size: int
+
+
+@dataclass
+class ResolvedDso:
+ symtab_file: Optional[str]
+ code_file: Optional[str]
+
+
+class Instructions:
+ def __init__(self):
+ self.symbols: dict[str, dict[str, Symbol]] = {}
+ self.instructions: dict[tuple[str, str], dict[int, str]] = {}
+ self.resolved_dsos: dict[str, ResolvedDso] = {}
+ self.disasm_re: re.Pattern = re.compile(r"^\s+([0-9a-f]+):\s+(.*)")
+ self.symtab_re: re.Pattern = re.compile(
+ r"^([0-9a-f]+) (.{7})\s+\S+\s+([0-9a-f]+)\s+(.*)$")
+ self.objdump: Optional[str] = self.default_objdump()
+
+ @staticmethod
+ def resolve_dso(dso_bid: str, filenames: tuple[str, str]) -> Optional[str]:
+ buildid_dir = os.environ.get('PERF_BUILDID_DIR',
+ os.path.expanduser('~/.debug'))
+ for filename in filenames:
+ path = (f"{buildid_dir}/.build-id/"
+ f"{dso_bid[:2]}/{dso_bid[2:]}/{filename}")
+ if os.path.isfile(path):
+ return path
+ return None
+
+ @staticmethod
+ def default_objdump() -> Optional[str]:
+ config = perf_config_get("annotate.objdump")
+ cmd = config if config else "objdump"
+ try:
+ subprocess.run([cmd, "--version"], capture_output=True, check=False)
+ except FileNotFoundError as err:
+ print(f"Error running objdump: {err}; "
+ "instruction annotations will be skipped", file=sys.stderr)
+ return None
+ return cmd
+
+ def load_symbols(self, resolved_dso: str) -> dict[str, Symbol]:
+ result = subprocess.run(
+ [self.objdump, "--demangle", "-t", resolved_dso],
+ capture_output=True, text=True, check=False
+ )
+
+ if result.returncode != 0:
+ return {}
+
+ symbols: dict[str, Symbol] = {}
+
+ for line in result.stdout.splitlines():
+ match = self.symtab_re.match(line)
+ if not match:
+ continue
+
+ value, flags, size_str, name = match.groups()
+ # the 7th flag column holds the symbol type, "F" for a function
+ if flags[6] != "F":
+ continue
+
+ size = int(size_str, 16)
+ if size == 0:
+ continue
+
+ # a name may be preceded by its visibility
+ parts = name.split(maxsplit=1)
+ if len(parts) == 2 and parts[0] in (".hidden", ".protected",
+ ".internal"):
+ name = parts[1]
+
+ symbols[name.strip()] = Symbol(start=int(value, 16), size=size)
+
+ return symbols
+
+ def load_instructions(self, resolved_dso: str, sym: Symbol) -> dict[int, str]:
+ result = subprocess.run(
+ [
+ self.objdump, "-d",
+ "--no-show-raw-insn",
+ f"--start-address=0x{sym.start:x}",
+ f"--stop-address=0x{sym.start + sym.size:x}",
+ resolved_dso,
+ ],
+ capture_output=True, text=True, check=False
+ )
+
+ instructions: dict[int, str] = {}
+
+ for line in result.stdout.splitlines():
+ match = self.disasm_re.match(line)
+ if not match:
+ continue
+ addr = int(match.group(1), 16)
+ instruction = match.group(2).strip()
+ instructions[addr] = instruction
+
+ return instructions
+
+ def lookup_instruction(self, dso: str, dso_bid: str, func: str,
+ off: int) -> Optional[tuple[int, Optional[str]]]:
+ if self.objdump is None:
+ return None
+
+ if dso not in self.resolved_dsos:
+ self.resolved_dsos[dso] = ResolvedDso(
+ symtab_file=self.resolve_dso(dso_bid, ('debug', 'elf')),
+ code_file=self.resolve_dso(dso_bid, ('elf', 'debug')),
+ )
+
+ resolved_dso = self.resolved_dsos[dso]
+ if resolved_dso.symtab_file is None or resolved_dso.code_file is None:
+ return None
+
+ if (dso, func) in self.instructions:
+ addr = self.symbols[dso][func].start + off
+ return (addr, self.instructions[(dso, func)].get(addr))
+
+ if dso not in self.symbols:
+ self.symbols[dso] = self.load_symbols(resolved_dso.symtab_file)
+
+ sym = self.symbols[dso].get(func)
+ if sym is None:
+ return None
+
+ self.instructions[(dso, func)] = self.load_instructions(resolved_dso.code_file, sym)
+ addr = sym.start + off
+ return (addr, self.instructions[(dso, func)].get(addr))
+
+
class FlameGraphCLI:
def __init__(self, args):
self.args = args
self.stack = Node("all", "root")
+ self.instructions = Instructions() if args.asm else None
@staticmethod
def get_libtype_from_dso(dso: Optional[str]) -> str:
@@ -119,6 +259,23 @@ class FlameGraphCLI:
name = event.get("symbol", "[unknown]")
libtype = self.get_libtype_from_dso(event.get("dso"))
node = self.find_or_create_node(node, name, libtype)
+
+ if self.args.asm:
+ # use the sample IP directly rather than callchain[0], since with
+ # precise event recording (i.e. :pp) the top callchain entry may
+ # point to the next instruction rather than the sampled IP
+ sym_name = event.get("symbol")
+ sym_off = event.get("symoff")
+ dso = event.get("dso")
+ dso_bid = event.get("dso_bid")
+
+ if sym_name and sym_off is not None and dso and dso_bid:
+ found = self.instructions.lookup_instruction(dso, dso_bid, sym_name, sym_off)
+ if found is not None:
+ addr, instruction = found
+ name = f"{instruction} [0x{addr:x}]" if instruction else f"[0x{addr:x}]"
+ libtype = self.get_libtype_from_dso(dso)
+ node = self.find_or_create_node(node, name, libtype)
node.value += 1
def get_report_header(self) -> str:
@@ -259,6 +416,10 @@ if __name__ == "__main__":
dest="event_name",
type=str,
help="specify the event to generate flamegraph for")
+ parser.add_argument("--asm",
+ default=False,
+ action="store_true",
+ help="annotate leaf frames with instruction-level nodes")
cli_args = parser.parse_args()
cli = FlameGraphCLI(cli_args)
--
2.43.0