Files
2026-07-13 13:17:40 +08:00

762 lines
27 KiB
Python
Executable File

#!/usr/bin/env python3
# ABOUTME: Analyzes py-spy speedscope JSON profiles to extract leaf (self-time) and inclusive-time statistics.
# ABOUTME: Supports filtering by thread name and grouping leaf functions into configurable categories.
"""
Usage:
python analyze_pyspy_profile.py <speedscope.json> [--thread <substring>] [--top N] [--categories FILE]
Examples:
# Top 30 leaf functions for the StreamingExecutor thread:
python analyze_pyspy_profile.py pyspy_driver.speedscope.json --thread StreamingExecutor --top 30
# All threads, top 20 (default):
python analyze_pyspy_profile.py pyspy_driver.speedscope.json
# Inclusive time for specific functions:
python analyze_pyspy_profile.py pyspy_driver.speedscope.json --thread StreamingExecutor --inclusive invoke_detectors,detect,get_task
# With custom category groupings (JSON file mapping category -> [function names]):
python analyze_pyspy_profile.py pyspy_driver.speedscope.json --thread StreamingExecutor --categories categories.json
# Top 10 caller stacks for readinto:
python analyze_pyspy_profile.py pyspy_driver.speedscope.json --thread StreamingExecutor --callers readinto --top 10
# Caller stacks with max 8 frames of context:
python analyze_pyspy_profile.py pyspy_driver.speedscope.json --thread StreamingExecutor --callers readinto --depth 8
# Call graph (callers + callees) for detect:
python analyze_pyspy_profile.py pyspy_driver.speedscope.json --thread StreamingExecutor --call-graph detect
# Call graph with deeper context:
python analyze_pyspy_profile.py pyspy_driver.speedscope.json --thread StreamingExecutor --call-graph detect --depth 8 --top 10
"""
import argparse
import json
import sys
from collections import defaultdict
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
def load_profile(path: str) -> dict:
with open(path, encoding="utf-8", errors="replace") as f:
return json.load(f)
def find_matching_profiles(
data: dict, thread_filter: Optional[str]
) -> List[Tuple[str, dict]]:
"""Return (name, profile) pairs matching the thread filter substring."""
results = []
for profile in data["profiles"]:
name = profile.get("name", "")
if thread_filter is None or thread_filter.lower() in name.lower():
results.append((name, profile))
return results
def analyze_self_time(
frames: List[dict], samples: List[List[int]], weights: List[float]
) -> Dict[int, float]:
"""Count weighted self-time for each frame index (leaf of each sample stack)."""
self_time = defaultdict(float)
for stack, weight in zip(samples, weights):
if stack:
leaf = stack[-1]
self_time[leaf] += weight
return dict(self_time)
def analyze_inclusive_time(
frames: List[dict],
samples: List[List[int]],
weights: List[float],
target_names: Set[str],
) -> Dict[str, Tuple[float, int]]:
"""Count weighted inclusive time for functions matching target_names.
Returns {name: (total_weight, sample_count)}.
"""
# Build reverse lookup: frame_index -> name (only for targets)
target_indices = {}
for idx, frame in enumerate(frames):
if frame["name"] in target_names:
target_indices[idx] = frame["name"]
inclusive = defaultdict(lambda: [0.0, 0])
for stack, weight in zip(samples, weights):
# A function gets inclusive credit once per sample, even if it
# appears multiple times in the stack (recursion).
seen = set()
for frame_idx in stack:
if frame_idx in target_indices:
name = target_indices[frame_idx]
if name not in seen:
seen.add(name)
inclusive[name][0] += weight
inclusive[name][1] += 1
return {name: (vals[0], vals[1]) for name, vals in inclusive.items()}
def categorize_functions(
self_time_by_idx: Dict[int, float],
frames: List[dict],
categories: Dict[str, List[str]],
) -> Dict[str, Tuple[float, List[str]]]:
"""Group leaf functions into categories.
categories: {category_name: [function_name, ...]}
Returns: {category_name: (total_weight, [matched_function_names])}
"""
# Build function_name -> category lookup
func_to_cat = {}
for cat, funcs in categories.items():
for fn in funcs:
func_to_cat[fn] = cat
cat_totals = defaultdict(lambda: [0.0, []])
uncategorized_total = 0.0
for frame_idx, weight in self_time_by_idx.items():
fname = frames[frame_idx]["name"]
cat = func_to_cat.get(fname)
if cat:
cat_totals[cat][0] += weight
if fname not in cat_totals[cat][1]:
cat_totals[cat][1].append(fname)
else:
uncategorized_total += weight
result = {cat: (vals[0], vals[1]) for cat, vals in cat_totals.items()}
if uncategorized_total > 0:
result["Other"] = (uncategorized_total, [])
return result
def analyze_caller_stacks(
frames: List[dict],
samples: List[List[int]],
weights: List[float],
target_name: str,
max_depth: Optional[int] = None,
thread_names: Optional[List[str]] = None,
) -> List[Tuple[Tuple[str, ...], float, int, str]]:
"""Find unique caller stacks for a target function.
For each sample where target_name appears, extracts the stack from
the root down to (and including) the target function. Groups identical
stacks by (chain, thread_name) and sums their weights.
Returns [(stack_tuple, total_weight, sample_count, thread_name), ...]
sorted by descending weight.
"""
# Build frame_index -> bool for target function
target_indices = set()
for idx, frame in enumerate(frames):
if frame["name"] == target_name:
target_indices.add(idx)
# Key includes thread name so identical stacks from different threads
# are kept separate.
stack_weights = defaultdict(lambda: [0.0, 0])
for i, (stack, weight) in enumerate(zip(samples, weights)):
# Find the deepest (last) occurrence of the target in this stack.
# Using the deepest handles recursion — we get the full caller chain.
target_pos = None
for pos in range(len(stack) - 1, -1, -1):
if stack[pos] in target_indices:
target_pos = pos
break
if target_pos is None:
continue
# Extract caller chain: root -> ... -> target
caller_chain = stack[: target_pos + 1]
# Apply depth limit (keep the N frames closest to the target)
if max_depth is not None and len(caller_chain) > max_depth:
caller_chain = caller_chain[-(max_depth):]
# Convert to tuple of function names for grouping
chain_key = tuple(frames[idx]["name"] for idx in caller_chain)
tname = thread_names[i] if thread_names else ""
group_key = (chain_key, tname)
stack_weights[group_key][0] += weight
stack_weights[group_key][1] += 1
result = [
(chain, vals[0], vals[1], tname)
for (chain, tname), vals in stack_weights.items()
]
result.sort(key=lambda x: -x[1])
return result
def analyze_callee_stacks(
frames: List[dict],
samples: List[List[int]],
weights: List[float],
target_name: str,
max_depth: Optional[int] = None,
) -> List[Tuple[Tuple[str, ...], float, int]]:
"""Find unique callee stacks for a target function.
For each sample where target_name appears, extracts the stack from
the target function down to the leaf (what it calls). Uses the
shallowest (first) occurrence to maximize callee chain length.
Groups identical stacks and sums their weights.
Returns [(stack_tuple, total_weight, sample_count), ...] sorted by
descending weight.
"""
target_indices = set()
for idx, frame in enumerate(frames):
if frame["name"] == target_name:
target_indices.add(idx)
stack_weights = defaultdict(lambda: [0.0, 0])
for stack, weight in zip(samples, weights):
# Find the shallowest (first) occurrence of the target.
target_pos = None
for pos in range(len(stack)):
if stack[pos] in target_indices:
target_pos = pos
break
if target_pos is None:
continue
# Extract callee chain: target -> ... -> leaf
callee_chain = stack[target_pos:]
# Apply depth limit (keep the N frames closest to the target)
if max_depth is not None and len(callee_chain) > max_depth:
callee_chain = callee_chain[:max_depth]
chain_key = tuple(frames[idx]["name"] for idx in callee_chain)
stack_weights[chain_key][0] += weight
stack_weights[chain_key][1] += 1
result = [(chain, vals[0], vals[1]) for chain, vals in stack_weights.items()]
result.sort(key=lambda x: -x[1])
return result
DEFAULT_CATEGORIES = {
"Actor state": [
"__hash__",
"_actor_id",
"_get_local_state",
"refresh_actor_state",
"_update_running_actor_state",
"running_actors",
"get_actor_id",
"max_tasks_in_flight_per_actor",
],
"HTTP/detect": [
"readinto",
"_make_http_get_request",
"ray_address_to_api_server_url",
"internal_kv_get_with_retry",
"_initialize_internal_kv",
"check_connected",
],
"List/set comprehensions": [
"<listcomp>",
"<setcomp>",
"<dictcomp>",
],
"HeapDict": [
"_decrease_key",
"_swap",
"__setitem__",
"_build_actor_busyness_heap",
],
"Scheduling misc": [
"wait",
"safe_round",
"select_operator_to_run",
],
}
def format_table(
headers: List[str], rows: List[List[str]], align: Optional[List[str]] = None
) -> str:
"""Format a markdown table with column alignment.
align: list of 'l' or 'r' per column. Defaults to left.
"""
if align is None:
align = ["l"] * len(headers)
col_widths = [len(h) for h in headers]
for row in rows:
for i, cell in enumerate(row):
col_widths[i] = max(col_widths[i], len(cell))
def pad(s, w, a):
return s.rjust(w) if a == "r" else s.ljust(w)
sep_parts = []
for i, w in enumerate(col_widths):
if align[i] == "r":
sep_parts.append("-" * (w - 1) + ":")
else:
sep_parts.append("-" * w)
header_line = (
"| "
+ " | ".join(pad(h, col_widths[i], align[i]) for i, h in enumerate(headers))
+ " |"
)
sep_line = "| " + " | ".join(sep_parts) + " |"
data_lines = []
for row in rows:
data_lines.append(
"| "
+ " | ".join(
pad(cell, col_widths[i], align[i]) for i, cell in enumerate(row)
)
+ " |"
)
return "\n".join([header_line, sep_line] + data_lines)
def main():
parser = argparse.ArgumentParser(
description="Analyze py-spy speedscope JSON profiles"
)
parser.add_argument("profile", help="Path to speedscope JSON file")
parser.add_argument(
"--thread", "-t", help="Filter profiles by thread name substring"
)
parser.add_argument(
"--top", "-n", type=int, default=20, help="Number of top leaf functions to show"
)
parser.add_argument(
"--inclusive",
"-i",
help="Comma-separated function names to compute inclusive time for",
)
parser.add_argument(
"--categories",
"-c",
help="JSON file with category groupings: {category: [func_names]}",
)
parser.add_argument(
"--no-categories",
action="store_true",
help="Skip category breakdown (show only leaf functions)",
)
parser.add_argument(
"--callers",
help="Show top caller stacks for this function name",
)
parser.add_argument(
"--depth",
type=int,
default=5,
help="Max stack depth to show for --callers (default: 5, 0=unlimited)",
)
parser.add_argument(
"--call-graph",
help="Show callers, self-time, and callees for this function name",
)
parser.add_argument(
"--full",
action="store_true",
help="Print all unique stack traces sorted by weight",
)
parser.add_argument(
"--list-threads",
action="store_true",
help="List all threads/profiles and exit",
)
args = parser.parse_args()
data = load_profile(args.profile)
frames = data["shared"]["frames"]
if args.list_threads:
print(f"\nThreads in {args.profile}:\n")
entries = []
for i, p in enumerate(data["profiles"]):
name = p.get("name", "(unnamed)")
n_samples = len(p.get("samples", []))
total_weight = sum(p.get("weights", []))
entries.append((i, n_samples, total_weight, name))
grand_total = sum(w for _, _, w, _ in entries)
entries.sort(key=lambda e: -e[2])
rows = []
for i, n_samples, total_weight, name in entries:
pct = 100 * total_weight / grand_total if grand_total else 0
rows.append([str(n_samples), f"{total_weight:.2f}s", f"{pct:.1f}%", name])
print(
format_table(
["Samples", "CPU time", "% total", "Thread name"],
rows,
["r", "r", "r", "l"],
)
)
return
# Find matching profiles
matches = find_matching_profiles(data, args.thread)
if not matches:
print(f"No profiles matching thread filter '{args.thread}'", file=sys.stderr)
print("Use --list-threads to see available threads", file=sys.stderr)
sys.exit(1)
# Merge samples from all matching profiles
all_samples = []
all_weights = []
all_thread_names = []
profile_names = []
for name, profile in matches:
profile_names.append(name)
n_samples = len(profile["samples"])
all_samples.extend(profile["samples"])
all_weights.extend(profile["weights"])
all_thread_names.extend([name] * n_samples)
total_weight = sum(all_weights)
total_samples = len(all_samples)
print(f"\nProfile: {args.profile}")
print(f"Thread filter: {args.thread or '(all threads)'}")
print(f"Matching profiles: {len(matches)}")
for name in profile_names:
print(f" - {name}")
print(f"Total samples: {total_samples:,}")
print(f"Total CPU time: {total_weight:.2f}s")
# If --callers is the only analysis requested, skip leaf/category output
callers_only = (args.callers or args.full or args.call_graph) and not args.inclusive
# --- Self-time (leaf function) analysis ---
self_time = analyze_self_time(frames, all_samples, all_weights)
# Sort by descending self-time
sorted_self = sorted(self_time.items(), key=lambda x: -x[1])
if not callers_only:
print(f"\n{'=' * 72}")
print(f"Top {args.top} Leaf Functions (self-time)")
print(f"{'=' * 72}\n")
rows = []
for frame_idx, weight in sorted_self[: args.top]:
frame = frames[frame_idx]
pct = 100 * weight / total_weight if total_weight else 0
# Count samples where this frame is the leaf
sample_count = sum(1 for s in all_samples if s and s[-1] == frame_idx)
loc = f"{Path(frame.get('file', '')).name}:{frame.get('line', '?')}"
rows.append(
[
f"{sample_count:,}",
f"{pct:.1f}%",
f"{weight:.2f}s",
frame["name"],
loc,
]
)
print(
format_table(
["Samples", "Self %", "Self time", "Function", "Location"],
rows,
["r", "r", "r", "l", "l"],
)
)
# --- Category breakdown ---
if not args.no_categories:
if args.categories:
with open(args.categories) as f:
categories = json.load(f)
else:
categories = DEFAULT_CATEGORIES
print(f"\n{'=' * 72}")
print("Self-Time by Category")
print(f"{'=' * 72}\n")
cat_results = categorize_functions(self_time, frames, categories)
sorted_cats = sorted(cat_results.items(), key=lambda x: -x[1][0])
rows = []
for cat, (weight, func_names) in sorted_cats:
pct = 100 * weight / total_weight if total_weight else 0
funcs_str = ", ".join(func_names[:6])
if len(func_names) > 6:
funcs_str += ", ..."
rows.append([f"{pct:.1f}%", f"{weight:.2f}s", cat, funcs_str])
print(
format_table(
["Self %", "Self time", "Category", "Functions"],
rows,
["r", "r", "l", "l"],
)
)
# --- Caller stack analysis ---
if args.callers:
depth = args.depth if args.depth != 0 else None
callers_top = args.top if args.top != 20 else 5
caller_stacks = analyze_caller_stacks(
frames,
all_samples,
all_weights,
args.callers,
depth,
thread_names=all_thread_names,
)
if not caller_stacks:
print(
f"\nFunction '{args.callers}' not found in any sample stack.",
file=sys.stderr,
)
else:
# Total weight across all stacks containing the target
callers_total_weight = sum(w for _, w, _, _ in caller_stacks)
callers_total_samples = sum(c for _, _, c, _ in caller_stacks)
print(f"\n{'=' * 72}")
depth_str = f", depth={args.depth}" if depth else ""
print(f"Top {callers_top} Caller Stacks for '{args.callers}'{depth_str}")
print(f"{'=' * 72}")
print(
f"\n{callers_total_samples:,} samples ({callers_total_weight:.2f}s) "
f"contain '{args.callers}' "
f"({100 * callers_total_weight / total_weight:.1f}% of thread CPU)\n"
)
for rank, (chain, weight, count, tname) in enumerate(
caller_stacks[:callers_top], 1
):
pct_of_thread = 100 * weight / total_weight if total_weight else 0
pct_of_func = (
100 * weight / callers_total_weight if callers_total_weight else 0
)
# Extract short thread name from "Process NNNN Thread 0xHEX "name""
short_tname = tname.rsplit('"', 2)[-2] if '"' in tname else tname
print(
f"--- Stack #{rank}: {count:,} samples, "
f"{weight:.2f}s ({pct_of_thread:.1f}% of thread, "
f"{pct_of_func:.1f}% of '{args.callers}') "
f"[{short_tname}] ---"
)
# Print stack with indentation showing call depth
for depth_idx, fname in enumerate(chain):
if depth_idx == len(chain) - 1:
print(f" {' ' * depth_idx}** {fname} **")
else:
print(f" {' ' * depth_idx}{fname}")
print()
# --- Call graph analysis ---
if args.call_graph:
target = args.call_graph
depth = args.depth if args.depth != 0 else None
cg_top = args.top if args.top != 20 else 5
# Find all frame indices matching the target
target_indices = set()
for idx, frame in enumerate(frames):
if frame["name"] == target:
target_indices.add(idx)
if not target_indices:
print(f"\nFunction '{target}' not found in any frame.", file=sys.stderr)
else:
# Compute self-time: samples where target is the leaf
func_self_weight = 0.0
func_self_count = 0
for stack, weight in zip(all_samples, all_weights):
if stack and stack[-1] in target_indices:
func_self_weight += weight
func_self_count += 1
# Compute inclusive time: samples where target appears anywhere
func_inclusive_weight = 0.0
func_inclusive_count = 0
for stack, weight in zip(all_samples, all_weights):
for frame_idx in stack:
if frame_idx in target_indices:
func_inclusive_weight += weight
func_inclusive_count += 1
break
# Get caller and callee stacks
# For callers: use shallowest (first) occurrence to be consistent
# with callee analysis (split at the same point)
caller_weights = defaultdict(lambda: [0.0, 0])
callee_weights = defaultdict(lambda: [0.0, 0])
for stack, weight in zip(all_samples, all_weights):
# Find shallowest occurrence
target_pos = None
for pos in range(len(stack)):
if stack[pos] in target_indices:
target_pos = pos
break
if target_pos is None:
continue
# Callers: root -> ... -> target
caller_chain = stack[: target_pos + 1]
if depth is not None and len(caller_chain) > depth:
caller_chain = caller_chain[-depth:]
caller_key = tuple(frames[idx]["name"] for idx in caller_chain)
caller_weights[caller_key][0] += weight
caller_weights[caller_key][1] += 1
# Callees: target -> ... -> leaf
callee_chain = stack[target_pos:]
if depth is not None and len(callee_chain) > depth:
callee_chain = callee_chain[:depth]
callee_key = tuple(frames[idx]["name"] for idx in callee_chain)
callee_weights[callee_key][0] += weight
callee_weights[callee_key][1] += 1
sorted_callers = sorted(caller_weights.items(), key=lambda x: -x[1][0])
sorted_callees = sorted(callee_weights.items(), key=lambda x: -x[1][0])
# --- Print header ---
print(f"\n{'=' * 72}")
depth_str = f", depth={args.depth}" if depth else ""
print(f"Call Graph for '{target}'{depth_str}")
print(f"{'=' * 72}")
self_pct = 100 * func_self_weight / total_weight if total_weight else 0
inc_pct = 100 * func_inclusive_weight / total_weight if total_weight else 0
print(
f"\nInclusive: {func_inclusive_count:,} samples, "
f"{func_inclusive_weight:.2f}s ({inc_pct:.1f}% of thread)"
)
print(
f"Self-time: {func_self_count:,} samples, "
f"{func_self_weight:.2f}s ({self_pct:.1f}% of thread)"
)
# --- Callers ---
print(
f"\n--- Callers ({len(sorted_callers):,} unique chains, "
f"showing top {min(cg_top, len(sorted_callers))}) ---\n"
)
for rank, (chain, (weight, count)) in enumerate(sorted_callers[:cg_top], 1):
pct = (
100 * weight / func_inclusive_weight if func_inclusive_weight else 0
)
print(f" #{rank}: {count:,} samples, {weight:.2f}s ({pct:.1f}%)")
for d, fname in enumerate(chain):
if fname == target:
print(f" {' ' * d}** {fname} **")
else:
print(f" {' ' * d}{fname}")
print()
# --- Callees ---
# Separate self-time entries (chain length 1 = target is the leaf)
callee_stacks = [(c, w, n) for c, (w, n) in sorted_callees if len(c) > 1]
self_entries = [(c, w, n) for c, (w, n) in sorted_callees if len(c) == 1]
print(
f"--- Callees ({len(callee_stacks):,} unique chains, "
f"showing top {min(cg_top, len(callee_stacks))}) ---\n"
)
if self_entries:
sw = sum(w for _, w, _ in self_entries)
sc = sum(n for _, _, n in self_entries)
pct = 100 * sw / func_inclusive_weight if func_inclusive_weight else 0
print(
f" (self): {sc:,} samples, {sw:.2f}s ({pct:.1f}%) "
f"-- '{target}' is the leaf\n"
)
for rank, (chain, weight, count) in enumerate(callee_stacks[:cg_top], 1):
pct = (
100 * weight / func_inclusive_weight if func_inclusive_weight else 0
)
print(f" #{rank}: {count:,} samples, {weight:.2f}s ({pct:.1f}%)")
for d, fname in enumerate(chain):
if d == 0:
print(f" ** {fname} **")
elif d == len(chain) - 1:
print(f" {' ' * d}-> {fname}")
else:
print(f" {' ' * d}{fname}")
print()
# --- Full stack trace dump ---
if args.full:
# Group all samples by their complete stack trace
stack_groups = defaultdict(lambda: [0.0, 0])
for stack, weight in zip(all_samples, all_weights):
key = tuple(frames[idx]["name"] for idx in stack)
stack_groups[key][0] += weight
stack_groups[key][1] += 1
sorted_stacks = sorted(stack_groups.items(), key=lambda x: -x[1][0])
full_top = args.top if args.top != 20 else len(sorted_stacks)
print(f"\n{'=' * 72}")
print(
f"All Unique Stack Traces by Weight ({len(sorted_stacks):,} unique stacks)"
)
print(f"{'=' * 72}\n")
for rank, (chain, (weight, count)) in enumerate(sorted_stacks[:full_top], 1):
pct = 100 * weight / total_weight if total_weight else 0
print(
f"--- Stack #{rank}: {count:,} samples, {weight:.2f}s ({pct:.1f}%) ---"
)
for depth_idx, fname in enumerate(chain):
if depth_idx == len(chain) - 1:
print(f" {' ' * depth_idx}** {fname} **")
else:
print(f" {' ' * depth_idx}{fname}")
print()
# --- Inclusive time analysis ---
if args.inclusive:
target_names = set(args.inclusive.split(","))
print(f"\n{'=' * 72}")
print("Inclusive Time (function appears anywhere in stack)")
print(f"{'=' * 72}\n")
inclusive = analyze_inclusive_time(
frames, all_samples, all_weights, target_names
)
sorted_inc = sorted(inclusive.items(), key=lambda x: -x[1][0])
rows = []
for name, (weight, count) in sorted_inc:
pct = 100 * weight / total_weight if total_weight else 0
rows.append([f"{count:,}", f"{pct:.1f}%", f"{weight:.2f}s", name])
# Show any requested names that weren't found
missing = target_names - set(inclusive.keys())
if missing:
for name in sorted(missing):
rows.append(["0", "0.0%", "0.00s", f"{name} (not found)"])
print(
format_table(
["Samples", "Inclusive %", "Inclusive time", "Function"],
rows,
["r", "r", "r", "l"],
)
)
if __name__ == "__main__":
main()