blob: 1b06a11fa86c76a8d9acc1f0572bbe15d58a45dc [file]
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-2.0
"""
flamegraph.py - create flame graphs from perf samples using perf python module
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import urllib.request
from typing import Dict, Optional, Union
import perf
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" integrity="sha256-fXXWjLZigZo3QBJIhjEA3Z2zUfS+vzMQhF2Rlvurfio=" crossorigin="anonymous">
</head>
<body>
<div id="chart"></div>
<script type="text/javascript" src="https://d3js.org/d3.v7.js" integrity="sha256-DA1YviEGwU4kL8dbwJoXRngAYwdRWAZl9ulVKgwGTqI=" crossorigin="anonymous"></script>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/d3-flame-graph@4.1.3/dist/d3-flamegraph.min.js" integrity="sha256-GEbx5jbtG9LDL8h9HiTWOv9+Txhim0u9gZNR8J6iI1M=" crossorigin="anonymous"></script>
<script type="text/javascript">
const stacks = [/** @flamegraph_json **/];
// Note, options is unused.
const options = [/** @options_json **/];
var chart = flamegraph();
d3.select("#chart")
.datum(stacks[0])
.call(chart);
</script>
</body>
"""
class Node:
"""A node in the flame graph tree."""
def __init__(self, name: str, libtype: str):
self.name = name
self.libtype = libtype
self.value: int = 0
self.children: dict[str, Node] = {}
def to_json(self) -> Dict[str, Union[str, int, list[Dict]]]:
"""Convert the node to a JSON-serializable dictionary."""
return {
"n": self.name,
"l": self.libtype,
"v": self.value,
"c": [x.to_json() for x in self.children.values()]
}
class FlameGraphCLI:
"""Command-line interface for generating flame graphs."""
def __init__(self, args):
self.args = args
self.stack = Node("all", "root")
self.session = None
@staticmethod
def get_libtype_from_dso(dso: Optional[str]) -> str:
"""Determine the library type from the DSO name."""
if dso and (dso == "[kernel.kallsyms]" or dso.endswith("/vmlinux") or dso == "[kernel]"):
return "kernel"
return ""
@staticmethod
def find_or_create_node(node: Node, name: str, libtype: str) -> Node:
"""Find a child node with the given name or create a new one."""
if name in node.children:
return node.children[name]
child = Node(name, libtype)
node.children[name] = child
return child
def process_event(self, sample) -> None:
"""Process a single perf sample event."""
if self.args.event_name and self.args.event_name not in str(sample.evsel):
return
pid = sample.sample_pid
dso_type = ""
try:
thread = self.session.find_thread(sample.sample_pid, sample.sample_tid)
comm = (thread.comm() if thread else None) or "[unknown]"
except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError):
comm = "[unknown]"
if pid == 0:
comm = comm if comm != "[unknown]" else "swapper"
dso_type = "kernel"
else:
comm = f"{comm} ({pid})"
node = self.find_or_create_node(self.stack, comm, dso_type)
callchain = sample.callchain
if callchain:
# We want to traverse from root to leaf.
# perf callchain iterator gives leaf to root.
# We collect them and reverse.
frames = list(callchain)
for entry in reversed(frames):
name = entry.symbol or "[unknown]"
libtype = self.get_libtype_from_dso(entry.dso)
node = self.find_or_create_node(node, name, libtype)
else:
# Fallback if no callchain
name = (sample.symbol or '[unknown]')
libtype = self.get_libtype_from_dso((sample.dso or '[unknown]'))
node = self.find_or_create_node(node, name, libtype)
node.value += 1
def get_report_header(self) -> str:
"""Get the header from the perf report."""
try:
input_file = self.args.input or "perf.data"
if input_file == "-":
return ""
mode = os.stat(input_file).st_mode
import stat
if stat.S_ISFIFO(mode) or stat.S_ISCHR(mode):
return ""
output = subprocess.check_output(["perf", "report", "--header-only", "-i", input_file])
result = output.decode("utf-8")
if self.args.event_name:
result += "\nFocused event: " + self.args.event_name
return result
except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError,
subprocess.CalledProcessError):
return ""
def run(self) -> None:
"""Run the flame graph generation."""
input_file = self.args.input or "perf.data"
if input_file != "-" and not os.path.exists(input_file):
print(f"Error: {input_file} not found. (try 'perf record' first)", file=sys.stderr)
sys.exit(1)
try:
self.session = perf.session(perf.data(input_file),
sample=self.process_event)
except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError) as e:
print(f"Error opening session: {e}", file=sys.stderr)
sys.exit(1)
try:
self.session.process_events()
finally:
# perf.session is a C extension type that holds a strong reference to the
# bound method self.process_event (which references self) and does not
# implement cyclic GC (tp_traverse). Explicitly clear self.session to break
# the reference cycle and free the underlying session resources deterministically.
self.session = None
stacks_json = json.dumps(self.stack, default=lambda x: x.to_json())
# Escape HTML special characters to prevent XSS
stacks_json = stacks_json.replace("<", "\\u003c") \
.replace(">", "\\u003e").replace("&", "\\u0026")
if self.args.format == "html":
report_header = self.get_report_header()
options = {
"colorscheme": self.args.colorscheme,
"context": report_header
}
options_json = json.dumps(options)
options_json = options_json.replace("<", "\\u003c") \
.replace(">", "\\u003e").replace("&", "\\u0026")
template = self.args.template
template_sha256sum = None
output_str = None
if not os.path.isfile(template):
if template.startswith("http://") or template.startswith("https://"):
if not self.args.allow_download:
print("Warning: Downloading templates is disabled. "
"Use --allow-download.", file=sys.stderr)
template = None
else:
print(f"Warning: Template file '{template}' not found.", file=sys.stderr)
if self.args.allow_download:
print("Using default CDN template.", file=sys.stderr)
template = (
"https://cdn.jsdelivr.net/npm/d3-flame-graph@4.1.3/dist/templates/"
"d3-flamegraph-base.html"
)
template_sha256sum = (
"f6a4aa7edffda4fb9bd71eb0eb75bc44d0bb34cd9efbd053f6095bc5c28d702b"
)
else:
template = None
use_minimal = False
try:
if not template:
use_minimal = True
elif template.startswith(("http://", "https://")):
with urllib.request.urlopen(template) as url_template:
output_str = "".join([l.decode("utf-8") for l in url_template.readlines()])
else:
with open(template, "r", encoding="utf-8") as f:
output_str = f.read()
except (OSError, ValueError, KeyError, RuntimeError, TypeError, AttributeError) as err:
print(f"Error reading template {template}: {err}\n", file=sys.stderr)
use_minimal = True
if use_minimal:
print("Using internal minimal HTML that refers to d3's web site. JavaScript " +
"loaded this way from a local file may be blocked unless your " +
"browser has relaxed permissions. Run with '--allow-download' to fetch " +
"the full D3 HTML template.", file=sys.stderr)
output_str = MINIMAL_HTML
elif template_sha256sum:
assert output_str is not None
download_sha256sum = hashlib.sha256(
output_str.encode("utf-8")
).hexdigest()
if download_sha256sum != template_sha256sum:
s = None
while s not in ["y", "n"]:
try:
s = input(f"""Unexpected template sha256sum.
{download_sha256sum} != {template_sha256sum}, for:
{template}
continue?[yn] """).lower()
except EOFError:
s = "n"
if s == "n":
sys.exit(1)
assert output_str is not None
replacements = {
"/** @options_json **/": options_json,
"/** @flamegraph_json **/": stacks_json,
}
# Use a lambda replacement so re.sub does not interpret backslashes
# in the JSON strings (such as \n or \u003c) as regex escape sequences.
output_str = re.sub(
r"/\*\* @(?:options_json|flamegraph_json) \*\*/",
lambda m: replacements[m.group(0)],
output_str,
)
output_fn = self.args.output or "flamegraph.html"
else:
output_str = stacks_json
output_fn = self.args.output or "stacks.json"
if output_fn == "-":
with open(sys.stdout.fileno(), "w", encoding="utf-8", closefd=False) as out:
out.write(output_str)
else:
print(f"dumping data to {output_fn}")
with open(output_fn, "w", encoding="utf-8") as out:
out.write(output_str)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Create flame graphs using perf python module.")
parser.add_argument("-f", "--format", default="html", choices=["json", "html"],
help="output file format")
parser.add_argument("-o", "--output", help="output file name")
parser.add_argument("--template",
default="/usr/share/d3-flame-graph/d3-flamegraph-base.html",
help="path to flame graph HTML template")
parser.add_argument("--colorscheme", default="blue-green",
help="flame graph color scheme", choices=["blue-green", "orange"])
parser.add_argument("-i", "--input", help="input perf.data file")
parser.add_argument("--allow-download", default=False, action="store_true",
help="allow unprompted downloading of HTML template")
parser.add_argument("-e", "--event", default="", dest="event_name", type=str,
help="specify the event to generate flamegraph for")
cli_args = parser.parse_args()
cli = FlameGraphCLI(cli_args)
cli.run()