
Rust Profiling
- 5 installs
- 17 repo stars
- Updated July 16, 2026
- blacktop/dotfiles
Rust-profiling is a skill for profiling Rust code with samply to identify CPU bottlenecks.
About
Rust-profiling helps profile Rust binaries with samply to identify CPU bottlenecks. A developer uses it when performance is slower than expected, before optimizing to measure first, or after optimization to verify improvement. It covers building with debug symbols, recording with samply into the Firefox Profiler UI, and analyzing saved profile.json files via a bundled script.
- Profiles Rust binaries with samply to find CPU bottlenecks
- Covers the profiling Cargo profile, samply record, and Firefox Profiler UI
- Includes a Python script to analyze saved profile.json files
Rust Profiling by the numbers
- 5 all-time installs (skills.sh)
- Ranked #93 of 121 Rust skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
rust-profiling capabilities & compatibility
- Capabilities
- rust profiling · instruments profiling · cpu profiling
- Use cases
- debugging
What rust-profiling says it does
Profile Rust code using samply to identify CPU bottlenecks.
Profile Rust binaries to find CPU bottlenecks using [samply]
Before optimizing (measure first!)
npx skills add https://github.com/blacktop/dotfiles --skill rust-profilingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 5 |
|---|---|
| repo stars | ★ 17 |
| Last updated | July 16, 2026 |
| Repository | blacktop/dotfiles ↗ |
What it does
Profile a Rust binary with samply to find CPU bottlenecks before and after optimization.
Who is it for?
Profiling Rust binaries to find CPU bottlenecks before and after optimizing.
When should I use this skill?
Performance is slow, before optimizing, or when the user asks to profile.
What you get
CPU bottlenecks in a Rust binary are identified via a samply profile before optimizing.
Files
Rust Profiling with Samply
Profile Rust binaries to find CPU bottlenecks using samply.
Quick Start
# 1. Ensure profiling profile exists in Cargo.toml (see reference.md)
# 2. Build with debug symbols
cargo build --profile profiling
# 3. Profile (opens Firefox Profiler UI)
samply record ./target/profiling/<binary> [args...]
# 4. Or save for CLI analysis
samply record --save-only -o profile.json ./target/profiling/<binary>
python3 ~/.agents/skills/rust-profiling/scripts/analyze_profile.py profile.jsonSkill Files
| File | Purpose |
|---|---|
reference.md | Cargo.toml setup, samply options, troubleshooting |
examples.md | Common profiling scenarios and analysis patterns |
optimization.md | Post-profiling fixes: source patterns, release-profile tuning, PGO, BOLT, what doesn't work |
scripts/analyze_profile.py | CLI tool to analyze saved profile.json files |
When to Use
- Performance is slower than expected
- Before optimizing (measure first!)
- After optimization (verify improvement)
- Investigating CPU-bound operations
What to Look For
| Pattern | Meaning | Action |
|---|---|---|
| High self-time | Function itself is slow | Direct optimization target |
| High total-time | Called often or slow callees | Check call frequency |
malloc/alloc in hot path | Allocation overhead | Pool, arena, or stack allocate |
pthread_mutex/parking_lot | Lock contention | Reduce lock scope or use lock-free |
Examples
Profiling Scenarios
Profile a CLI Command
cargo build --profile profiling
samply record ./target/profiling/ipsw pkg extract payload.pkg --output /tmp/outProfile with Higher Resolution
For short-running commands, increase sampling rate:
samply record --rate 4000 ./target/profiling/myappProfile Multiple Iterations
samply record --iteration-count 10 ./target/profiling/myappSave for Later / CI Analysis
samply record --save-only -o profile.json ./target/profiling/myapp
# Later:
samply load profile.json
# Or analyze via CLI:
python3 ~/.agents/skills/rust-profiling/scripts/analyze_profile.py profile.jsonProfile Tests
# Build tests without running
cargo test --profile profiling --no-run
# Find the test binary
ls target/profiling/deps/<crate>-*
# Profile specific test
samply record ./target/profiling/deps/<test-binary> --test specific_test_nameProfile a Benchmark
cargo bench --no-run
samply record ./target/release/deps/<bench-binary> --benchAnalysis Examples
Basic Analysis
python3 ~/.agents/skills/rust-profiling/scripts/analyze_profile.py profile.jsonOutput:
======================================================================
PROFILE SUMMARY
======================================================================
Total samples: 42,831
Unique functions: 1,247
Libraries: 12
======================================================================
LIBRARY BREAKDOWN (by self time)
======================================================================
Library Self % Total % Funcs
----------------------------------------------------------------------
ipsw 67.2% 89.4% 423
liblzma.5.dylib 18.3% 18.3% 12
libsystem_malloc.dylib 8.1% 8.1% 24
======================================================================
HOT FUNCTIONS (by self time)
======================================================================
Samples Self% Total% Function
----------------------------------------------------------------------
12847 30.0% 30.0% lzma_decode
4892 11.4% 45.2% ota::pbzx::decompress_chunk
2341 5.5% 5.5% _malloc_zone_mallocFilter to Your Code
python3 ~/.agents/skills/rust-profiling/scripts/analyze_profile.py profile.json --lib ipswFind Who Calls a Hot Function
python3 ~/.agents/skills/rust-profiling/scripts/analyze_profile.py profile.json --callers decompress_chunkOutput:
======================================================================
CALLERS OF: decompress_chunk
======================================================================
4892 ( 11.4%) ota::pbzx::ParallelPbzxReader::decompress_parallel
127 ( 0.3%) ota::pbzx::PbzxReader::fill_bufferShow Call Tree
python3 ~/.agents/skills/rust-profiling/scripts/analyze_profile.py profile.json --treeOutput:
======================================================================
CALL TREE (min 1.0% of samples, depth 5)
======================================================================
89.4% (12.1% self) main
└── 76.3% ( 8.2% self) pkg::reader::PackageReader::with_payload_parallel
└── 67.1% ( 0.1% self) ota::pbzx::ParallelPbzxReader::new
└── 67.0% ( 0.0% self) decompress_parallel
└── 66.9% (30.0% self) decompress_chunkCompare Before/After
# Profile before optimization
samply record --save-only -o before.json ./target/profiling/myapp
# Make changes, rebuild
cargo build --profile profiling
# Profile after
samply record --save-only -o after.json ./target/profiling/myapp
# Compare
python3 ~/.agents/skills/rust-profiling/scripts/analyze_profile.py before.json --diff after.jsonOutput:
======================================================================
PROFILE COMPARISON
======================================================================
Before: 42,831 samples
After: 10,247 samples
======================================================================
BIGGEST CHANGES (by self time %)
======================================================================
Before% After% Diff Function
----------------------------------------------------------------------
30.0% 2.1% -27.9% lzma_decode
11.4% 45.2% +33.8% rayon_core::job::StackJob::run_inline
5.5% 1.2% -4.3% _malloc_zone_malloc
Summary: 15 functions improved, 3 regressed (>0.5% change)Export for CI/Automation
python3 ~/.agents/skills/rust-profiling/scripts/analyze_profile.py profile.json --json > analysis.jsonThen in CI, check for regressions:
jq '.functions[] | select(.name | contains("my_hot_function")) | .self_pct' analysis.jsonOptimization Workflow
1. Baseline: Profile current code, save as baseline.json 2. Identify: Find top 3 functions by self-time 3. Analyze: Use --callers to understand call patterns 4. Optimize: Make targeted change 5. Verify: Profile again, compare with --diff baseline.json 6. Repeat: Until satisfied with performance
Optimization (after profiling)
Once samply identifies hotspots, this is the toolkit for fixing them. Order of operations matters — flamegraph-driven source fixes beat compiler tools on already-optimized code.
Core principle: profile first, optimize source, then reach for compiler tools. PGO can regress code that's already been hand-tuned because the recorded profile no longer matches hot paths. (Reference: SeqPacker case study — manual fixes from flamegraph yielded 16.3% vs PGO's 15.2% on the same baseline, with PGO regressing the optimized code by ~1.2%.)
---
1. Cargo.toml release profile
[profile.release]
opt-level = 3
lto = "fat" # cross-crate inlining + interprocedural opt — most impactful
codegen-units = 1 # serial LLVM pipeline → better codegen, slower compiles
panic = "abort" # smaller binary, no unwind tables
strip = true # strip symbols from final binary
[profile.profiling]
inherits = "release"
debug = true # keep symbols for samply / perf
strip = falselto = "fat" is the single highest-leverage knob. codegen-units = 1 matters for tight inner loops.
---
2. Source-level patterns for hot paths
These are the changes flamegraph analysis typically points to:
| Pattern | When | Example |
|---|---|---|
Pre-allocate Vec::with_capacity(n) | hot loop showing realloc/grow in flamegraph | Vec::with_capacity((total / capacity) + 1) |
| `SmallVec<[T; N]>` | small collections (≤16 items typical) — avoids heap alloc | pub items: SmallVec<[usize; 8]> |
| `#[inline(always)]` | tiny hot functions called in inner loops where call overhead dominates | #[inline(always)] fn find_best_fit(...) |
| `#[cold]` | error/slow paths so the optimizer pushes them out of the icache footprint | #[cold] fn open_new_bin(...) |
| Early termination in propagation loops | tree/graph updates where ancestors don't need touching once a value stabilises | if self.tree[idx] == new_val { break; } |
| Avoid `clone()` in hot loops | flamegraph shows Drop / __rust_dealloc near a loop body | reuse via &mut, swap with mem::replace, or use indices |
Apply one at a time, re-profile to confirm the win — don't shotgun.
---
3. PGO (Profile-Guided Optimization)
Use after source-level fixes, only if the profile still shows broad time across many warm functions (compiler can do better global decisions with workload data).
# 1. Instrumented build
RUSTFLAGS="-Cprofile-generate=$PWD/pgo-data" \
cargo build --release
# 2. Run a representative workload (longer + more varied = better)
./target/release/binary <typical-args>
# 3. Merge raw profiles
llvm-profdata merge -o pgo-data/merged.profdata pgo-data/*.profraw
# 4. Rebuild using the profile
RUSTFLAGS="-Cprofile-use=$PWD/pgo-data/merged.profdata" \
cargo build --releaseCaveats:
- Stale profile data → silent regressions. Re-record after major source changes.
- Workload must mirror production input distribution; synthetic micro-benchmarks mislead.
llvm-profdataships withrustup component add llvm-tools-preview.
---
4. BOLT (Binary Optimization & Layout Tool, Linux only)
Reorders basic blocks and functions in the linked binary using runtime perf data.
# 1. Record cycles with perf (Linux only)
perf record -e cycles:u -o perf.data -- ./binary <args>
# 2. Convert to BOLT format
perf2bolt -p perf.data -o perf.fdata ./binary
# 3. Optimize binary
llvm-bolt ./binary -o binary.bolt \
-data=perf.fdata \
-reorder-blocks=ext-tsp \
-reorder-functions=hfsortWhen BOLT helps: large binaries with many cold paths (browsers, databases, compilers) where icache miss dominates.
When it doesn't: tight loops over small data already fitting in L1. SeqPacker's case showed 0% gain over PGO alone for integer arithmetic / tree traversal workloads.
---
5. What DOESN'T usually help
| Knob | Reality |
|---|---|
RUSTFLAGS="-C target-cpu=native" | ~0% on scalar integer/pointer code; can regress 5-8% on already-optimized code due to AVX-512/AVX2 register pressure. Breaks portability — distribution wheels must use generic x86_64/aarch64. |
Switching to nightly for -Zthreads=N | Compile-time only; runtime unchanged. |
Replacing Vec with Box<[T]> | Marginal. The grow path is what with_capacity already fixes. |
| Custom global allocators (mimalloc, jemalloc) | High variance — sometimes wins, sometimes loses. Measure per-workload; don't cargo-cult. |
---
6. Decision tree
samply shows hot function
│
├─ >5% of self time in alloc/dealloc?
│ → Pre-allocate, SmallVec, or pool
│
├─ Tight inner loop, small function called often?
│ → #[inline(always)] (re-profile to verify)
│
├─ Many warm functions, no single dominant hotspot?
│ → Try PGO with realistic workload
│
├─ Large binary, cold-path-heavy (parsers, CLIs with many subcommands)?
│ → BOLT after PGO (Linux only)
│
└─ Already optimized, profile is "flat"?
→ Stop. Further wins need algorithmic changes, not micro-opt.---
7. Benchmarking discipline
Don't trust a single run. Variance from background processes, CPU throttling, and ASLR can swamp small wins.
# Statistical rigor
cargo install cargo-criterion
cargo criterion
# Or for end-to-end timing across input sizes
hyperfine --warmup 3 --runs 20 \
'./target/release/binary small.input' \
'./target/release/binary large.input'For PGO/BOLT comparisons, always benchmark the same workload the optimizer was trained on AND a held-out workload — divergence reveals overfitting.
Reference
Prerequisites
Install Samply
cargo install --locked samply
# OR
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/mstange/samply/releases/download/samply-v0.13.1/samply-installer.sh | shCargo.toml Profiling Profile
Add to your Cargo.toml:
[profile.profiling]
inherits = "release"
debug = trueThis gives:
- Release-level optimizations (accurate performance)
- Debug symbols (readable function names in profiler)
Samply Commands
samply record
Record a profile of command execution.
samply record [OPTIONS] <COMMAND> [ARGS...]| Option | Description |
|---|---|
--rate <HZ> | Sampling rate in Hz (default: 1000) |
--save-only | Don't open browser, just save profile |
-o <FILE> | Output file path (default: profile.json) |
--iteration-count <N> | Run command N times |
-p <PID> | Attach to existing process (Linux only) |
samply load
Open a previously saved profile.
samply load profile.jsonsamply setup (macOS only)
Configure code signing for process attachment.
samply setupanalyze_profile.py Options
python3 ~/.agents/skills/rust-profiling/scripts/analyze_profile.py [OPTIONS] <profile.json>| Option | Description |
|---|---|
--top, -n <N> | Show top N functions (default: 20) |
--lib, -l <NAME> | Filter to functions in library matching NAME |
--thread, -t <NAME> | Filter to thread matching NAME |
--callers, -c <FUNC> | Show callers of FUNC |
--callees <FUNC> | Show callees of FUNC |
--tree | Show call tree visualization |
--tree-depth <N> | Max tree depth (default: 5) |
--min-pct <PCT> | Minimum % threshold (default: 1.0) |
--json, -j | Output as JSON |
--diff, -d <FILE> | Compare against another profile |
Troubleshooting
"No symbols" or mangled names
1. Ensure debug = true in [profile.profiling] 2. Rebuild: cargo build --profile profiling 3. Verify binary has debug info: file target/profiling/<binary>
Permission denied (macOS)
samply setupVery short runs show little data
- Increase sampling rate:
--rate 10000 - Run operation in a loop
- Use
--iteration-countto repeat command
Profile is too large
- Lower sampling rate:
--rate 100 - Profile shorter duration
- Filter to specific thread with
--thread
Understanding the Output
Self Time vs Total Time
- Self time: Time spent in the function itself (excluding callees)
- Total time: Time spent in function + all its callees
| Metric | High Value Means |
|---|---|
| High self, low total | Function itself is slow |
| Low self, high total | Function calls slow code |
| Both high | Hot path, optimize this |
Firefox Profiler UI Views
| View | Best For |
|---|---|
| Call Tree | Understanding hierarchy |
| Flame Graph | Visual hot spot identification |
| Timeline | Finding slow phases |
| Stack Chart | Time-based call visualization |
#!/usr/bin/env python3
"""
Samply Profile Analyzer
Analyzes Firefox Profiler JSON files (from samply) to identify performance bottlenecks.
Features:
- Self time and total time analysis
- Call tree visualization
- Caller/callee relationships
- Library breakdown
- Rust symbol demangling
- Filtering by library or threshold
- JSON output for automation
- Diff mode for comparing profiles
Usage:
analyze_profile.py profile.json # Basic analysis
analyze_profile.py profile.json --top 30 # Show top 30 functions
analyze_profile.py profile.json --lib mylib # Filter to specific library
analyze_profile.py profile.json --callers main # Show callers of 'main'
analyze_profile.py profile.json --tree # Show call tree
analyze_profile.py profile.json --json # Output as JSON
analyze_profile.py before.json --diff after.json # Compare two profiles
"""
import json
import sys
import argparse
import re
from pathlib import Path
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class FunctionStats:
"""Statistics for a single function."""
name: str
self_samples: int = 0
total_samples: int = 0
callers: dict = field(default_factory=lambda: defaultdict(int))
callees: dict = field(default_factory=lambda: defaultdict(int))
library: str = "unknown"
def demangle_rust(name: str) -> str:
"""Simplify Rust mangled names for readability."""
if not name:
return "unknown"
# Remove hash suffixes like ::h1234abcd
name = re.sub(r'::h[0-9a-f]{16}$', '', name)
# Simplify common patterns
name = name.replace('$LT$', '<').replace('$GT$', '>')
name = name.replace('$u20$', ' ').replace('$u27$', "'")
name = name.replace('$RF$', '&').replace('$BP$', '*')
name = name.replace('$C$', ',').replace('$SP$', '@')
# Shorten very long generic parameters
if len(name) > 120:
# Truncate long generic params but keep function name visible
if '<' in name:
base = name.split('<')[0]
name = f"{base}<...>"
return name
def shorten_name(name: str, max_len: int = 80) -> str:
"""Shorten function name for display."""
if len(name) <= max_len:
return name
return name[:max_len-3] + "..."
class ProfileAnalyzer:
"""Analyzes samply/Firefox Profiler JSON files."""
def __init__(self, data: dict):
self.data = data
self.libs_by_index, self.libs_by_addr = self._load_libs()
self.threads = data.get('threads', [])
self.functions: dict[str, FunctionStats] = {}
self.total_samples = 0
def _load_libs(self) -> tuple[list[tuple], list[tuple]]:
"""Load library address ranges and preserve original order for libIndex."""
libs = self.data.get('libs', [])
lib_ranges = []
for lib in libs:
name = lib.get('name') or lib.get('debugName') or Path(lib.get('path', '')).name or 'unknown'
start = lib.get('start', 0)
end = lib.get('end')
lib_ranges.append((start, end, name))
lib_ranges_by_addr = sorted(lib_ranges, key=lambda x: x[0])
return lib_ranges, lib_ranges_by_addr
def _addr_to_lib(self, addr: int) -> str:
"""Map address to library name."""
for start, end, name in self.libs_by_addr:
if start <= addr:
if end is None or addr < end:
return name
return "unknown"
def _resolve_frame_name(self, thread: dict, frame_idx: int) -> tuple[str, str]:
"""Resolve frame index to (function_name, library)."""
string_table = thread.get('stringArray', [])
# Try native symbols first
ns = thread.get('nativeSymbols', {})
ns_names = ns.get('name', [])
ns_libs = ns.get('libIndex', [])
frame_table = thread.get('frameTable', {})
frame_ns_indices = frame_table.get('nativeSymbol', [])
frame_func_indices = frame_table.get('func', [])
frame_addresses = frame_table.get('address', [])
name = None
lib = "unknown"
# Try native symbol
if frame_idx < len(frame_ns_indices):
ns_idx = frame_ns_indices[frame_idx]
if ns_idx is not None and ns_idx < len(ns_names):
name_idx = ns_names[ns_idx]
if isinstance(name_idx, int) and name_idx < len(string_table):
name = string_table[name_idx]
if ns_idx < len(ns_libs):
lib_idx = ns_libs[ns_idx]
if lib_idx is not None and lib_idx < len(self.libs_by_index):
lib = self.libs_by_index[lib_idx][2]
# Fallback to func table
if not name:
func_table = thread.get('funcTable', {})
func_names = func_table.get('name', [])
if frame_idx < len(frame_func_indices):
func_idx = frame_func_indices[frame_idx]
if func_idx is not None and func_idx < len(func_names):
name_idx = func_names[func_idx]
if isinstance(name_idx, int) and name_idx < len(string_table):
name = string_table[name_idx]
# Fallback to address
if not name and frame_idx < len(frame_addresses):
addr = frame_addresses[frame_idx]
if addr is not None:
lib = self._addr_to_lib(addr)
name = f"0x{addr:x}"
return (demangle_rust(name) if name else "unknown", lib)
def analyze(self, thread_filter: Optional[str] = None):
"""Analyze all threads (or filtered thread)."""
for thread in self.threads:
thread_name = thread.get('name', 'Unknown')
if thread_filter and thread_filter.lower() not in thread_name.lower():
continue
self._analyze_thread(thread)
def _analyze_thread(self, thread: dict):
"""Analyze a single thread."""
samples = thread.get('samples', {})
stack_indices = samples.get('stack', [])
stack_table = thread.get('stackTable', {})
stack_frames = stack_table.get('frame', [])
stack_prefixes = stack_table.get('prefix', [])
for stack_idx in stack_indices:
if stack_idx is None:
continue
self.total_samples += 1
# Walk the stack
seen_in_stack = set()
prev_name = None
current_idx = stack_idx
is_leaf = True
while current_idx is not None and current_idx < len(stack_frames):
frame_idx = stack_frames[current_idx]
name, lib = self._resolve_frame_name(thread, frame_idx)
# Get or create function stats
if name not in self.functions:
self.functions[name] = FunctionStats(name=name, library=lib)
stats = self.functions[name]
# Self time (leaf frame only)
if is_leaf:
stats.self_samples += 1
is_leaf = False
# Total time (count once per stack)
if name not in seen_in_stack:
stats.total_samples += 1
seen_in_stack.add(name)
# Track caller/callee relationships
if prev_name and prev_name != name:
stats.callees[prev_name] += 1
if prev_name in self.functions:
self.functions[prev_name].callers[name] += 1
prev_name = name
# Move to parent frame
if current_idx < len(stack_prefixes):
current_idx = stack_prefixes[current_idx]
else:
break
def get_hot_functions(self, by: str = "self", top_n: int = 20,
lib_filter: Optional[str] = None,
min_pct: float = 0.0) -> list[FunctionStats]:
"""Get hottest functions sorted by self or total time."""
funcs = list(self.functions.values())
# Filter by library
if lib_filter:
funcs = [f for f in funcs if lib_filter.lower() in f.library.lower()]
# Filter by minimum percentage
if min_pct > 0 and self.total_samples > 0:
threshold = self.total_samples * (min_pct / 100.0)
key = 'self_samples' if by == 'self' else 'total_samples'
funcs = [f for f in funcs if getattr(f, key) >= threshold]
# Sort
if by == "self":
funcs.sort(key=lambda f: f.self_samples, reverse=True)
else:
funcs.sort(key=lambda f: f.total_samples, reverse=True)
return funcs[:top_n]
def get_library_breakdown(self) -> dict[str, dict]:
"""Get samples grouped by library."""
libs = defaultdict(lambda: {"self": 0, "total": 0, "functions": 0})
for func in self.functions.values():
libs[func.library]["self"] += func.self_samples
libs[func.library]["total"] += func.total_samples
libs[func.library]["functions"] += 1
return dict(sorted(libs.items(), key=lambda x: x[1]["self"], reverse=True))
def get_callers(self, func_name: str, top_n: int = 10) -> list[tuple[str, int]]:
"""Get top callers of a function."""
for name, stats in self.functions.items():
if func_name.lower() in name.lower():
callers = sorted(stats.callers.items(), key=lambda x: x[1], reverse=True)
return callers[:top_n]
return []
def get_callees(self, func_name: str, top_n: int = 10) -> list[tuple[str, int]]:
"""Get top callees of a function."""
for name, stats in self.functions.items():
if func_name.lower() in name.lower():
callees = sorted(stats.callees.items(), key=lambda x: x[1], reverse=True)
return callees[:top_n]
return []
def print_summary(self, top_n: int = 20, lib_filter: Optional[str] = None):
"""Print analysis summary."""
print(f"\n{'='*70}")
print(f"PROFILE SUMMARY")
print(f"{'='*70}")
print(f"Total samples: {self.total_samples:,}")
print(f"Unique functions: {len(self.functions):,}")
print(f"Libraries: {len(self.libs_by_index)}")
# Library breakdown
print(f"\n{'='*70}")
print(f"LIBRARY BREAKDOWN (by self time)")
print(f"{'='*70}")
print(f"{'Library':<40} {'Self %':>10} {'Total %':>10} {'Funcs':>8}")
print(f"{'-'*70}")
libs = self.get_library_breakdown()
for lib, stats in list(libs.items())[:10]:
self_pct = (stats['self'] / self.total_samples * 100) if self.total_samples else 0
total_pct = (stats['total'] / self.total_samples * 100) if self.total_samples else 0
print(f"{shorten_name(lib, 40):<40} {self_pct:>9.1f}% {total_pct:>9.1f}% {stats['functions']:>8}")
# Hot functions by self time
print(f"\n{'='*70}")
print(f"HOT FUNCTIONS (by self time){' - filtered: ' + lib_filter if lib_filter else ''}")
print(f"{'='*70}")
print(f"{'Samples':>8} {'Self%':>7} {'Total%':>7} {'Function'}")
print(f"{'-'*70}")
hot = self.get_hot_functions(by="self", top_n=top_n, lib_filter=lib_filter)
for func in hot:
self_pct = (func.self_samples / self.total_samples * 100) if self.total_samples else 0
total_pct = (func.total_samples / self.total_samples * 100) if self.total_samples else 0
print(f"{func.self_samples:>8} {self_pct:>6.1f}% {total_pct:>6.1f}% {shorten_name(func.name)}")
def print_callers(self, func_name: str):
"""Print callers of a function."""
callers = self.get_callers(func_name)
if not callers:
print(f"No function matching '{func_name}' found.")
return
print(f"\n{'='*70}")
print(f"CALLERS OF: {func_name}")
print(f"{'='*70}")
for caller, count in callers:
pct = (count / self.total_samples * 100) if self.total_samples else 0
print(f"{count:>8} ({pct:>5.1f}%) {shorten_name(caller)}")
def print_callees(self, func_name: str):
"""Print callees of a function."""
callees = self.get_callees(func_name)
if not callees:
print(f"No function matching '{func_name}' found.")
return
print(f"\n{'='*70}")
print(f"CALLEES OF: {func_name}")
print(f"{'='*70}")
for callee, count in callees:
pct = (count / self.total_samples * 100) if self.total_samples else 0
print(f"{count:>8} ({pct:>5.1f}%) {shorten_name(callee)}")
def print_call_tree(self, max_depth: int = 5, min_pct: float = 1.0):
"""Print a simplified call tree from hot roots."""
print(f"\n{'='*70}")
print(f"CALL TREE (min {min_pct}% of samples, depth {max_depth})")
print(f"{'='*70}")
threshold = self.total_samples * (min_pct / 100.0)
# Find root functions (high total time, few/no callers)
roots = []
for func in self.functions.values():
if func.total_samples >= threshold:
caller_samples = sum(func.callers.values())
if caller_samples < func.total_samples * 0.5: # Less than 50% from tracked callers
roots.append(func)
roots.sort(key=lambda f: f.total_samples, reverse=True)
def print_tree(func: FunctionStats, depth: int, prefix: str):
if depth > max_depth:
return
pct = (func.total_samples / self.total_samples * 100) if self.total_samples else 0
self_pct = (func.self_samples / self.total_samples * 100) if self.total_samples else 0
marker = "└── " if depth > 0 else ""
print(f"{prefix}{marker}{pct:>5.1f}% ({self_pct:>4.1f}% self) {shorten_name(func.name, 50)}")
# Print significant callees
callees = sorted(func.callees.items(), key=lambda x: x[1], reverse=True)
child_prefix = prefix + (" " if depth > 0 else "")
for callee_name, count in callees[:3]:
if count >= threshold and callee_name in self.functions:
print_tree(self.functions[callee_name], depth + 1, child_prefix)
for root in roots[:5]:
print_tree(root, 0, "")
print()
def to_json(self) -> dict:
"""Export analysis as JSON."""
return {
"total_samples": self.total_samples,
"libraries": self.get_library_breakdown(),
"functions": [
{
"name": f.name,
"library": f.library,
"self_samples": f.self_samples,
"total_samples": f.total_samples,
"self_pct": round(f.self_samples / self.total_samples * 100, 2) if self.total_samples else 0,
"total_pct": round(f.total_samples / self.total_samples * 100, 2) if self.total_samples else 0,
}
for f in sorted(self.functions.values(), key=lambda x: x.self_samples, reverse=True)[:100]
]
}
def compare_profiles(before: ProfileAnalyzer, after: ProfileAnalyzer, top_n: int = 20):
"""Compare two profiles and show differences."""
print(f"\n{'='*70}")
print(f"PROFILE COMPARISON")
print(f"{'='*70}")
print(f"Before: {before.total_samples:,} samples")
print(f"After: {after.total_samples:,} samples")
# Normalize to percentages for comparison
def get_pct(analyzer: ProfileAnalyzer) -> dict[str, float]:
total = analyzer.total_samples or 1
return {name: (f.self_samples / total * 100) for name, f in analyzer.functions.items()}
before_pct = get_pct(before)
after_pct = get_pct(after)
all_funcs = set(before_pct.keys()) | set(after_pct.keys())
diffs = []
for name in all_funcs:
b = before_pct.get(name, 0)
a = after_pct.get(name, 0)
diff = a - b
if abs(diff) >= 0.1: # At least 0.1% change
diffs.append((name, b, a, diff))
# Sort by absolute diff
diffs.sort(key=lambda x: abs(x[3]), reverse=True)
print(f"\n{'='*70}")
print(f"BIGGEST CHANGES (by self time %)")
print(f"{'='*70}")
print(f"{'Before%':>8} {'After%':>8} {'Diff':>8} {'Function'}")
print(f"{'-'*70}")
for name, b, a, diff in diffs[:top_n]:
sign = "+" if diff > 0 else ""
color = ""
print(f"{b:>7.1f}% {a:>7.1f}% {sign}{diff:>7.1f}% {shorten_name(name, 50)}")
# Summary
improved = sum(1 for _, _, _, d in diffs if d < -0.5)
regressed = sum(1 for _, _, _, d in diffs if d > 0.5)
print(f"\nSummary: {improved} functions improved, {regressed} regressed (>0.5% change)")
def main():
parser = argparse.ArgumentParser(
description="Analyze Samply/Firefox Profiler JSON files",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
parser.add_argument("profile", help="Path to profile.json")
parser.add_argument("--top", "-n", type=int, default=20, help="Number of top functions to show")
parser.add_argument("--lib", "-l", help="Filter to functions in this library")
parser.add_argument("--thread", "-t", help="Filter to thread name containing this string")
parser.add_argument("--callers", "-c", help="Show callers of function matching this name")
parser.add_argument("--callees", help="Show callees of function matching this name")
parser.add_argument("--tree", action="store_true", help="Show call tree")
parser.add_argument("--tree-depth", type=int, default=5, help="Max call tree depth")
parser.add_argument("--min-pct", type=float, default=1.0, help="Minimum percentage for tree/filtering")
parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
parser.add_argument("--diff", "-d", help="Compare against another profile")
args = parser.parse_args()
# Load profile
path = Path(args.profile)
if not path.exists():
print(f"Error: File not found: {path}", file=sys.stderr)
sys.exit(1)
print(f"Loading {path}...", file=sys.stderr)
with open(path) as f:
data = json.load(f)
analyzer = ProfileAnalyzer(data)
analyzer.analyze(thread_filter=args.thread)
# Handle diff mode
if args.diff:
diff_path = Path(args.diff)
if not diff_path.exists():
print(f"Error: Diff file not found: {diff_path}", file=sys.stderr)
sys.exit(1)
print(f"Loading {diff_path}...", file=sys.stderr)
with open(diff_path) as f:
diff_data = json.load(f)
diff_analyzer = ProfileAnalyzer(diff_data)
diff_analyzer.analyze(thread_filter=args.thread)
compare_profiles(analyzer, diff_analyzer, top_n=args.top)
return
# JSON output
if args.json:
print(json.dumps(analyzer.to_json(), indent=2))
return
# Callers/callees
if args.callers:
analyzer.print_callers(args.callers)
return
if args.callees:
analyzer.print_callees(args.callees)
return
# Call tree
if args.tree:
analyzer.print_call_tree(max_depth=args.tree_depth, min_pct=args.min_pct)
return
# Default: summary
analyzer.print_summary(top_n=args.top, lib_filter=args.lib)
if __name__ == "__main__":
main()