From b4a15e382af5c3420f242d35e3ee2274cf8cc320 Mon Sep 17 00:00:00 2001 From: Tianyou Li Date: Wed, 20 May 2026 18:19:05 +0800 Subject: [PATCH] Add tma-drilldown skill: TMA methodology with PMU event toolkit Contribute the perfmon-skills project as a new skill providing: - Intel PMU event/metric lookup across 50+ platforms (2600+ events, 300+ metrics) - Counter-budget-aware perf stat command generation - Cross-platform event/metric comparison (e.g., ICX vs SPR) - Iterative TMA drill-down engine (L1 through L6 leaf nodes) - Decision tracing for inspectable analysis DAGs Includes Python CLI tools (src/perfmon_tools/), tests, examples, and the intel/perfmon data repository as a git submodule. License: MIT (matching repository) Signed-off-by: Tianyou Li --- .gitignore | 10 + .gitmodules | 3 + README.md | 49 +- skills/tma-drilldown/.gitignore | 7 + skills/tma-drilldown/CLAUDE.md | 84 +++ skills/tma-drilldown/README.md | 197 +++++++ skills/tma-drilldown/SKILL.md | 168 ++++++ .../tma-drilldown/examples/01_quick_start.sh | 39 ++ .../examples/02_tma_drilldown.py | 389 ++++++++++++++ .../examples/03_trace_visualization.py | 299 +++++++++++ .../examples/04_perf_output_parsing.py | 156 ++++++ skills/tma-drilldown/examples/README.md | 507 ++++++++++++++++++ skills/tma-drilldown/perfmon | 1 + skills/tma-drilldown/pyproject.toml | 24 + .../tma-drilldown/references/perf-cmdgen.md | 33 ++ .../tma-drilldown/references/perf-compare.md | 27 + .../tma-drilldown/references/perf-lookup.md | 30 ++ .../references/perf-recommend.md | 57 ++ .../src/perfmon_tools/__init__.py | 3 + .../src/perfmon_tools/cli/__init__.py | 0 .../src/perfmon_tools/cli/cmdgen_cmd.py | 66 +++ .../src/perfmon_tools/cli/compare_cmd.py | 109 ++++ .../src/perfmon_tools/cli/lookup_cmd.py | 78 +++ .../src/perfmon_tools/cli/main.py | 31 ++ .../src/perfmon_tools/cli/recommend_cmd.py | 206 +++++++ .../src/perfmon_tools/cli/trace_cmd.py | 90 ++++ .../src/perfmon_tools/cmdgen/__init__.py | 0 .../src/perfmon_tools/cmdgen/generate.py | 286 ++++++++++ .../src/perfmon_tools/compare/__init__.py | 0 .../src/perfmon_tools/compare/diff.py | 180 +++++++ .../src/perfmon_tools/core/__init__.py | 0 .../src/perfmon_tools/core/catalog.py | 283 ++++++++++ .../src/perfmon_tools/core/context_budget.py | 120 +++++ .../src/perfmon_tools/core/formula.py | 176 ++++++ .../src/perfmon_tools/core/perf_output.py | 249 +++++++++ .../src/perfmon_tools/core/platform.py | 261 +++++++++ .../src/perfmon_tools/core/tma_tree.py | 154 ++++++ .../src/perfmon_tools/core/tracer.py | 291 ++++++++++ .../src/perfmon_tools/lookup/__init__.py | 0 .../src/perfmon_tools/lookup/search.py | 147 +++++ .../src/perfmon_tools/recommend/__init__.py | 0 .../src/perfmon_tools/recommend/coverage.py | 125 +++++ .../src/perfmon_tools/recommend/engine.py | 347 ++++++++++++ .../src/perfmon_tools/recommend/guidance.py | 242 +++++++++ .../src/perfmon_tools/recommend/preflight.py | 246 +++++++++ .../src/perfmon_tools/recommend/session.py | 127 +++++ .../perfmon_tools/recommend/tma_drilldown.py | 199 +++++++ skills/tma-drilldown/tests/__init__.py | 0 skills/tma-drilldown/tests/conftest.py | 7 + .../tests/test_context_budget.py | 34 ++ .../tma-drilldown/tests/test_perf_output.py | 129 +++++ skills/tma-drilldown/tests/test_tracer.py | 84 +++ 52 files changed, 6342 insertions(+), 8 deletions(-) create mode 100644 .gitmodules create mode 100644 skills/tma-drilldown/.gitignore create mode 100644 skills/tma-drilldown/CLAUDE.md create mode 100644 skills/tma-drilldown/README.md create mode 100644 skills/tma-drilldown/SKILL.md create mode 100755 skills/tma-drilldown/examples/01_quick_start.sh create mode 100644 skills/tma-drilldown/examples/02_tma_drilldown.py create mode 100644 skills/tma-drilldown/examples/03_trace_visualization.py create mode 100644 skills/tma-drilldown/examples/04_perf_output_parsing.py create mode 100644 skills/tma-drilldown/examples/README.md create mode 160000 skills/tma-drilldown/perfmon create mode 100644 skills/tma-drilldown/pyproject.toml create mode 100644 skills/tma-drilldown/references/perf-cmdgen.md create mode 100644 skills/tma-drilldown/references/perf-compare.md create mode 100644 skills/tma-drilldown/references/perf-lookup.md create mode 100644 skills/tma-drilldown/references/perf-recommend.md create mode 100644 skills/tma-drilldown/src/perfmon_tools/__init__.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/cli/__init__.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/cli/cmdgen_cmd.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/cli/compare_cmd.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/cli/lookup_cmd.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/cli/main.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/cli/recommend_cmd.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/cli/trace_cmd.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/cmdgen/__init__.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/cmdgen/generate.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/compare/__init__.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/compare/diff.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/core/__init__.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/core/catalog.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/core/context_budget.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/core/formula.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/core/perf_output.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/core/platform.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/core/tma_tree.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/core/tracer.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/lookup/__init__.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/lookup/search.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/recommend/__init__.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/recommend/coverage.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/recommend/engine.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/recommend/guidance.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/recommend/preflight.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/recommend/session.py create mode 100644 skills/tma-drilldown/src/perfmon_tools/recommend/tma_drilldown.py create mode 100644 skills/tma-drilldown/tests/__init__.py create mode 100644 skills/tma-drilldown/tests/conftest.py create mode 100644 skills/tma-drilldown/tests/test_context_budget.py create mode 100644 skills/tma-drilldown/tests/test_perf_output.py create mode 100644 skills/tma-drilldown/tests/test_tracer.py diff --git a/.gitignore b/.gitignore index d0bc9c1..d389f73 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ local.md *workspace evals/ + +# Python +__pycache__/ +*.pyc +*.egg-info/ +.pytest_cache/ + +# TMA drilldown runtime +skills/tma-drilldown/sessions/ +skills/tma-drilldown/.venv/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..7d23ab3 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "skills/tma-drilldown/perfmon"] + path = skills/tma-drilldown/perfmon + url = https://github.com/intel/perfmon.git diff --git a/README.md b/README.md index fec8128..0965ab2 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Once installed, you can talk to the agent naturally. A few examples: | `skills/linux-perf/` | Data collection skill: `perf` workflows, building blocks, hotspot reporting | | `skills/performance-patterns/` | Pattern detection and fix playbooks: source code and profiling signals | | `skills/phoronix-test-suite/` | Supporting skill: install, run, and optimize PTS benchmarks | +| `skills/tma-drilldown/` | TMA drill-down: PMU event lookup, perf command generation, cross-platform comparison, iterative bottleneck identification | --- @@ -112,6 +113,35 @@ Trigger: any `pts/` reference, or the words *"phoronix"* / *"phoronix-test --- +### `tma-drilldown` — TMA drill-down investigation + +**Automate Intel's Top-down Microarchitecture Analysis methodology with deterministic +PMU event handling.** + +When the user needs to identify performance bottlenecks at the microarchitectural +level, this skill provides: + +- **Event/metric lookup** — Search 2600+ PMU events and 300+ TMA metrics across 50+ + Intel platforms +- **Command generation** — Generate counter-budget-aware `perf stat` commands (knows + GP/fixed counter limits per platform) +- **Cross-platform comparison** — Diff events and metrics between generations (e.g., + ICX to SPR) +- **TMA drill-down** — Iterative state-machine engine that walks the TMA tree from L1 + (Frontend_Bound, Backend_Bound, Bad_Speculation, Retiring) to leaf nodes, generating + the right perf commands at each step and providing tuning guidance at the end + +Includes a Python CLI (`perfmon-skills`) and the Intel perfmon data repository as a git +submodule. Complements `linux-perf` (which handles profiling data collection) by +automating the structured TMA methodology. After bottleneck identification, delegates +to `performance-patterns` for fix playbooks. + +Trigger phrases: *"TMA"*, *"Top-down Microarchitecture Analysis"*, *"PMU events"*, +*"performance counters"*, *"which events to collect"*, *"counter budget"*, +*"Frontend_Bound"*, *"Backend_Bound"*, *"platform comparison"*. + +--- + ## Installation This skill collection follows the open [Agent Skills standard](https://agentskills.io). @@ -137,6 +167,7 @@ The easiest way to install across any supported agent. Requires gh skill install intel/intel-performance-skills linux-perf gh skill install intel/intel-performance-skills performance-patterns gh skill install intel/intel-performance-skills phoronix-test-suite +gh skill install intel/intel-performance-skills tma-drilldown ``` Keep them up to date: @@ -145,6 +176,7 @@ Keep them up to date: gh skill update linux-perf gh skill update performance-patterns gh skill update phoronix-test-suite +gh skill update tma-drilldown ``` ### GitHub Copilot CLI @@ -155,6 +187,7 @@ Skills are installed per-user under `~/.copilot/skills/`: cp -r skills/linux-perf ~/.copilot/skills/ cp -r skills/performance-patterns ~/.copilot/skills/ cp -r skills/phoronix-test-suite ~/.copilot/skills/ +cp -r skills/tma-drilldown ~/.copilot/skills/ ``` ### GitHub Copilot in VS Code @@ -166,11 +199,11 @@ whole team benefits automatically: ```bash # Project-level (commit to your repository) mkdir -p .github/skills -cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite \ +cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite skills/tma-drilldown \ .github/skills/ # User-level (available in every project) -cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite \ +cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite skills/tma-drilldown \ ~/.copilot/skills/ ``` @@ -185,11 +218,11 @@ Claude Code discovers skills in `.claude/skills/` (project) or `~/.claude/skills ```bash # Project-level mkdir -p .claude/skills -cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite \ +cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite skills/tma-drilldown \ .claude/skills/ # User-level -cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite \ +cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite skills/tma-drilldown \ ~/.claude/skills/ ``` @@ -201,11 +234,11 @@ at user level: ```bash # Project-level mkdir -p .agents/skills -cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite \ +cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite skills/tma-drilldown \ .agents/skills/ # User-level -cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite \ +cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite skills/tma-drilldown \ ~/.agents/skills/ ``` @@ -215,7 +248,7 @@ Gemini CLI reads project skills from `.gemini/skills/`: ```bash mkdir -p .gemini/skills -cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite \ +cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite skills/tma-drilldown \ .gemini/skills/ ``` @@ -225,7 +258,7 @@ OpenCode has native skill support and reads skills from `.opencode/skills/`: ```bash mkdir -p .opencode/skills -cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite \ +cp -r skills/linux-perf skills/performance-patterns skills/phoronix-test-suite skills/tma-drilldown \ .opencode/skills/ ``` diff --git a/skills/tma-drilldown/.gitignore b/skills/tma-drilldown/.gitignore new file mode 100644 index 0000000..cf73e4a --- /dev/null +++ b/skills/tma-drilldown/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +*.egg-info/ +dist/ +build/ +sessions/ +.venv/ diff --git a/skills/tma-drilldown/CLAUDE.md b/skills/tma-drilldown/CLAUDE.md new file mode 100644 index 0000000..920e58f --- /dev/null +++ b/skills/tma-drilldown/CLAUDE.md @@ -0,0 +1,84 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +perfmon-skills is a performance analysis toolkit that wraps Intel's perfmon data repository (included as a symlink at `./perfmon/`) into CLI tools and Claude Code slash commands. It implements deterministic TMA (Top-down Microarchitecture Analysis) drill-down without requiring an LLM for the core logic. + +## Build & Development + +```bash +# Install in development mode +pip install -e . + +# Install with dev dependencies (pytest) +pip install -e ".[dev]" + +# Run all tests +python -m pytest tests/ -v + +# Run a single test +python -m pytest tests/test_perf_output.py::TestParseText::test_basic_values -v + +# Run CLI +perfmon-skills lookup "cache miss" --platform SPR +perfmon-skills cmdgen --tma-level 1 --platform SPR +perfmon-skills compare ICX SPR --type metrics +perfmon-skills recommend start --platform SPR --cmd "sleep 1" + +# Enable decision tracing +PERFMON_TRACE=1 perfmon-skills recommend start --platform SPR +``` + +## Architecture + +Two-layer design: +1. **Deterministic layer** (Python, no LLM): parses perf output, evaluates TMA threshold formulas, selects drill-down path, generates perf commands, tracks context budget +2. **LLM layer** (Claude Code skills in `skills/`): interprets findings conversationally, adds tuning advice — never sees raw perf data + +### Core Library (`src/perfmon_tools/core/`) + +- `platform.py` — CPU detection (`/proc/cpuinfo`), mapfile.csv parsing, platform resolution. Handles hybrid platforms (ADL/RPL) with separate P-core/E-core event files. Uses `PERFMON_DATA` env var or `./perfmon/` symlink. +- `catalog.py` — Loads event/metric JSON from perfmon data. `PlatformCatalog` provides search indexes and coverage stats. SPR: ~2693 events, ~308 metrics. +- `tma_tree.py` — Builds parent→child TMA hierarchy from `ParentCategory` field. 4 L1 roots, up to L6 depth on SPR (114 nodes). +- `formula.py` — Expands metric formula aliases (a,b,c → event names) and evaluates via restricted `eval()`. Also evaluates threshold formulas for bottleneck detection. +- `perf_output.py` — Parses perf stat text/JSON/interval formats. `PERF_TO_PERFMON` dict translates perf's `topdown-*` names to perfmon's `PERF_METRICS.*` names. `_normalize_event_values()` strips `cpu/` wrappers. +- `context_budget.py` — Tracks token usage per step (~200 tokens/step compact finding vs. raw data on disk). Prevents attention loss in multi-step workflows. +- `tracer.py` — Decision tracing (`PERFMON_TRACE=1`). Records a DAG of decisions with inputs/alternatives/confidence. Renders to JSON/Mermaid/DOT/HTML. Zero overhead when disabled. + +### Tool Modules + +- `lookup/search.py` — Cross-field event/metric search with platform, type, category, level filters +- `cmdgen/generate.py` — Generates `perf stat` commands with counter budget awareness. Knows platform-specific counter counts (SPR: 8 GP + 4 fixed). +- `compare/diff.py` — Cross-platform event/metric comparison with formula diffs +- `recommend/` — Stateful TMA drill-down engine: + - `engine.py` — State machine orchestrator (IDLE→COLLECTING→ANALYZED→COMPLETE) + - `tma_drilldown.py` — Node evaluation, threshold checking, next-step suggestion + - `preflight.py` — SMT detection, steady-state detection, counter budget + - `session.py` — File-based session persistence (`sessions/` directory) + - `coverage.py` — Event coverage tracking, domain-affinity gap suggestions + - `guidance.py` — Tuning advice keyed by TMA leaf node + +### Key Data Flow (Recommendation Engine) + +1. `start()` → detect platform, run preflight, generate L1 perf command +2. User runs perf, feeds output → `analyze()`: + - `parse_auto()` normalizes event names (perf→perfmon mapping) + - `evaluate_level()` computes metric values from event counters + - `_evaluate_threshold()` checks bottleneck thresholds (arithmetic) + - `suggest_next()` picks highest-value threshold-passing node → children events + - Compact finding saved; next perf command generated +3. Iterate until leaf node → guidance + coverage report + +### Event Name Translation + +perf outputs names like `topdown-fe-bound`, `cpu/INST_RETIRED.ANY/`. Perfmon JSON uses `PERF_METRICS.FRONTEND_BOUND`, `INST_RETIRED.ANY`. The `_normalize_event_values()` function in `perf_output.py` handles this bidirectionally. + +## Key Design Constraints + +- Zero mandatory dependencies (stdlib only). Optional: `rich` for pretty output, `pytest` for dev. +- perfmon data accessed via symlink `./perfmon/` or `PERFMON_DATA` env var pointing to the Intel perfmon repo root. +- Session state is plain JSON files in `sessions/` (gitignored). No database. +- Context budget: raw perf output stays on disk, only ~200-token compact findings flow between steps. +- Counter budget: knows each platform's GP/fixed counter count to minimize multiplexing. diff --git a/skills/tma-drilldown/README.md b/skills/tma-drilldown/README.md new file mode 100644 index 0000000..cd4cb42 --- /dev/null +++ b/skills/tma-drilldown/README.md @@ -0,0 +1,197 @@ +# tma-drilldown + +Performance analysis toolkit built on [Intel perfmon](https://github.com/intel/perfmon) data. Provides CLI tools for streamlined TMA (Top-down Microarchitecture Analysis) investigation on Intel platforms. + +Part of [intel-performance-skills](https://github.com/intel/intel-performance-skills). + +## What it does + +- **Event/metric lookup** — Search 2600+ PMU events and 300+ TMA metrics across 50+ Intel platforms +- **Command generation** — Generate ready-to-run `perf stat` commands with counter budget awareness +- **Cross-platform comparison** — Diff events and metrics between platform generations (e.g., ICX → SPR) +- **TMA drill-down** — Iterative recommendation engine that automates the Top-down Microarchitecture Analysis methodology +- **Decision tracing** — Record and visualize every decision as an inspectable DAG (Mermaid, DOT, HTML) + +## Installation + +### Prerequisites + +- Python 3.9+ +- Linux with `perf` tool (for actual data collection; not needed for lookup/comparison/examples) + +### Install from the repo + +```bash +git clone --recurse-submodules https://github.com/intel/intel-performance-skills.git +cd intel-performance-skills/skills/tma-drilldown +pip install -e . +``` + +If you already cloned without `--recurse-submodules`: + +```bash +git submodule update --init skills/tma-drilldown/perfmon +``` + +Alternatively, set the `PERFMON_DATA` environment variable to point to any local clone of `intel/perfmon`. + +### Verify installation + +```bash +perfmon-skills --help +perfmon-skills lookup "cache miss" --platform SPR +``` + +## Quick Start + +### Search for events + +```bash +$ perfmon-skills lookup "cache miss" --platform SPR +====================================================================== +EVENTS (SPR) — 67 matches +====================================================================== + L2_RQSTS.DEMAND_DATA_RD_MISS + Demand Data Read miss L2 cache + Code: 0x24, UMask: 0x21, Counter: 0,1,2,3, PEBS: 0 + ... +``` + +### Generate perf commands + +```bash +$ perfmon-skills cmdgen --tma-level 1 --platform SPR +# TMA Level 1 (4 nodes) [SPR] +# Events: 6 (GP: 1, Fixed: 0, PerfMetrics: 5) +# Counters available: 12 (GP: 8) + +perf stat -e cpu/INT_MISC.UOP_DROPPING/,topdown-be-bound,topdown-bad-spec,topdown-fe-bound,topdown-retiring,slots sleep 5 +``` + +### Compare platforms + +```bash +$ perfmon-skills compare ICX SPR --type metrics +====================================================================== +ICX → SPR Comparison (metrics) +====================================================================== + Added metrics: 26 + Removed metrics: 8 + Changed metrics: 91 +``` + +### Run a guided investigation + +```bash +$ perfmon-skills recommend start --platform SPR --cmd "./my_workload" +====================================================================== +NEW INVESTIGATION SESSION +====================================================================== + Platform: SPR + Counter budget: ... + + STEP 1: Run this command and feed the output back: + + perf stat -j -e cpu/INT_MISC.UOP_DROPPING/,topdown-be-bound,... -- ./my_workload + +$ perf stat -j -e -- ./my_workload 2> step1.txt +$ perfmon-skills recommend analyze --input step1.txt +====================================================================== +ANALYSIS — Step 1 [COLLECTING] +====================================================================== + Path: Backend_Bound + + Node Values: + Backend_Bound 50.0% ◀ BOTTLENECK + Frontend_Bound 25.0% + Retiring 17.0% + Bad_Speculation 8.0% + + NEXT STEP: Run this command: + perf stat -j -e ... -- ./my_workload +``` + +Repeat until the investigation reaches a leaf node with tuning guidance. + +### Visualize decision trace + +```bash +$ PERFMON_TRACE=1 perfmon-skills recommend start --platform SPR --cmd "./workload" +# ... run investigation steps ... +$ perfmon-skills trace --last --format mermaid +graph TD + d001[start_investigation\nSelected SPR platform] + d002[evaluate_l1\nBackend_Bound=50%] + d001 -->|L1 TMA computed| d002 + d003[select_bottleneck\nChose Backend_Bound] + d002 -->|threshold passed| d003 + ... +``` + +## Examples + +The `examples/` directory contains runnable scripts that work without real hardware: + +```bash +bash examples/01_quick_start.sh # All CLI commands at a glance +python examples/02_tma_drilldown.py # Full iterative drill-down with synthetic data +python examples/03_trace_visualization.py # Decision tracing in all 4 formats +python examples/04_perf_output_parsing.py # Perf output parsing and event normalization +``` + +See [examples/README.md](examples/README.md) for captured output from each example. + +## Architecture + +``` +skills/tma-drilldown/ +├── perfmon/ # git submodule → intel/perfmon data +├── src/perfmon_tools/ +│ ├── core/ # Platform detection, catalog, TMA tree, formula eval, +│ │ # perf output parsing, context budget, decision tracing +│ ├── lookup/ # Event/metric search +│ ├── cmdgen/ # Perf command generation +│ ├── compare/ # Cross-platform diff +│ ├── recommend/ # TMA drill-down engine (state machine, session mgmt, +│ │ # coverage tracking, tuning guidance) +│ └── cli/ # CLI entry points (lookup, cmdgen, compare, recommend, trace) +├── references/ # Detailed slash command docs +├── examples/ # Runnable demos with output +└── tests/ # Test suite (pytest) +``` + +### Design principles + +- **Zero mandatory dependencies** — stdlib only (`json`, `csv`, `re`, `pathlib`, `subprocess`) +- **Deterministic core** — TMA drill-down runs without an LLM; the LLM layer (SKILL.md) adds interpretation +- **Context budget aware** — raw perf output stays on disk; only compact findings (~200 tokens/step) flow between steps +- **Counter budget aware** — knows platform-specific counter counts (SPR: 8 GP + 4 fixed) to minimize multiplexing +- **Session persistence** — plain JSON files, trivially inspectable and shareable + +## Supported Platforms + +All platforms in Intel's perfmon repository are supported, including: + +| Platform | Codename | TMA Levels | +|----------|----------|------------| +| SPR | Sapphire Rapids | 6 | +| EMR | Emerald Rapids | 6 | +| GNR | Granite Rapids | 6 | +| ICX | Ice Lake Server | 5 | +| SKX/CLX | Skylake/Cascade Lake | 4 | +| ADL/RPL | Alder Lake/Raptor Lake (hybrid) | 5 | +| MTL/ARL | Meteor Lake/Arrow Lake (hybrid) | 5 | + +And 40+ more. Run `perfmon-skills lookup --cross-arch "your_query"` to search across all. + +## Development + +```bash +cd skills/tma-drilldown +pip install -e ".[dev]" +python -m pytest tests/ -v +``` + +## License + +MIT — see [COPYRIGHT.md](../../COPYRIGHT.md) diff --git a/skills/tma-drilldown/SKILL.md b/skills/tma-drilldown/SKILL.md new file mode 100644 index 0000000..e18dea8 --- /dev/null +++ b/skills/tma-drilldown/SKILL.md @@ -0,0 +1,168 @@ +--- +name: tma-drilldown +description: >- + Intel Top-down Microarchitecture Analysis (TMA) iterative drill-down + using hardware performance counters. Provides PMU event lookup across + 50+ Intel platforms (2600+ events, 300+ TMA metrics), counter-budget-aware + perf stat command generation, cross-platform event/metric comparison, + and deterministic TMA bottleneck identification from L1 (Frontend_Bound, + Backend_Bound, Bad_Speculation, Retiring) through L6 leaf nodes with + tuning guidance. Trigger on: TMA, Top-down Microarchitecture Analysis, + PMU event, performance counter, perf stat events, Frontend_Bound, + Backend_Bound, Bad_Speculation, Retiring, Memory_Bound, Core_Bound, + counter budget, multiplexing, event lookup, platform comparison, ICX, + SPR, EMR, GNR, SKX, CLX, ADL, RPL, MTL, perfmon, topdown, drill-down, + bottleneck identification, which events to collect, what counters to use, + TMA level, perf metrics. +--- + + + + +# TMA drill-down skill + +Automate Intel's Top-down Microarchitecture Analysis methodology to identify performance bottlenecks at the microarchitectural level. This skill provides a deterministic, counter-budget-aware engine that walks the TMA tree from L1 through L6 leaf nodes — no LLM needed for the core logic. + +The skill is organized into four parts: +- **Part 1: Setup** — installation and perfmon data +- **Part 2: Workflows** — four main capabilities (lookup, cmdgen, compare, recommend) +- **Part 3: Cross-skill integration** — how this skill complements `linux-perf` and `performance-patterns` +- **Part 4: Architecture** — two-layer design and key data flow + +--- + +# Part 1: Setup + +## Install the CLI + +```bash +cd skills/tma-drilldown +pip install -e . +``` + +## Perfmon data + +The skill requires Intel's perfmon event/metric data. Options: +1. Initialize the included submodule: `git submodule update --init skills/tma-drilldown/perfmon` +2. Set `PERFMON_DATA` env var pointing to any local clone of `intel/perfmon` + +## Verify + +```bash +perfmon-skills --help +perfmon-skills lookup "cache miss" --platform SPR +``` + +--- + +# Part 2: Workflows + +## A: Event/metric lookup + +Search 2600+ PMU events and 300+ TMA metrics across all Intel platforms. + +```bash +perfmon-skills lookup "" --format json [--platform PLT] [--type events|metrics] [--level N] +``` + +Use when the user asks "what events measure X?" or "is there a metric for Y?" + +See `references/perf-lookup.md` for full options. + +## B: Perf command generation + +Generate ready-to-run `perf stat` commands with counter budget awareness (knows GP/fixed counter counts per platform to minimize multiplexing). + +```bash +perfmon-skills cmdgen --format json --tma-level N --platform PLT [--cmd CMD] [--pid PID] +``` + +Use when the user asks "what perf command should I run?" or "give me the events for TMA level 2." + +See `references/perf-cmdgen.md` for full options. + +## C: Cross-platform comparison + +Diff events and metrics between platform generations to understand what changed. + +```bash +perfmon-skills compare PLATFORM1 PLATFORM2 --format json [--type events|metrics] +``` + +Use when the user asks "what's different between ICX and SPR?" or "did this metric change?" + +See `references/perf-compare.md` for full options. + +## D: TMA drill-down investigation (primary workflow) + +Iterative state-machine engine that automates the full TMA methodology: + +1. **Start**: detect platform, run preflight checks, generate L1 perf command +2. **Collect + Analyze** (iterate): user runs perf, feeds output; engine evaluates thresholds, picks bottleneck branch, generates next perf command +3. **Complete**: reaches leaf node, provides tuning guidance and coverage report + +```bash +# Start investigation +perfmon-skills recommend start --format json --platform SPR --cmd "./workload" + +# Feed perf output +perfmon-skills recommend analyze --input perf_output.txt --format json + +# Check status +perfmon-skills recommend status --format json +``` + +Typically takes 3-5 iterations (L1 → leaf). Each step produces a compact finding (~200 tokens); raw perf data stays on disk. + +See `references/perf-recommend.md` for full workflow details. + +## Decision tracing + +Enable with `PERFMON_TRACE=1` to record every decision as an inspectable DAG. Renders to JSON, Mermaid, DOT, or HTML. + +```bash +PERFMON_TRACE=1 perfmon-skills recommend start --platform SPR --cmd "./workload" +perfmon-skills trace --last --format mermaid +``` + +--- + +# Part 3: Cross-skill integration + +## With `linux-perf` + +`linux-perf` handles profiling data collection (perf record, perf report, perf c2c, hotspot reporting). `tma-drilldown` handles the structured TMA methodology (which events to collect, threshold evaluation, drill-down path selection). They complement each other: + +- Use `linux-perf` Flow A (perf stat) → then feed the data into `tma-drilldown` for automated TMA analysis +- Use `tma-drilldown` to identify the bottleneck category → then use `linux-perf` Flow B (perf record) to localize to specific functions/lines + +## With `performance-patterns` + +After `tma-drilldown` identifies a leaf-node bottleneck (e.g., Memory_Bound → L3_Bound → SQ_Full), delegate to `performance-patterns` for fix playbooks and code-level remediation. + +--- + +# Part 4: Architecture + +Two-layer design: +1. **Deterministic layer** (Python CLI): parses perf output, evaluates TMA threshold formulas, selects drill-down path, generates perf commands, tracks context budget +2. **LLM layer** (this SKILL.md + references): interprets findings conversationally, adds tuning advice — never sees raw perf data + +### Key data flow (recommendation engine) + +1. `start()` → detect platform, run preflight, generate L1 perf command +2. User runs perf, feeds output → `analyze()`: + - Parse and normalize event names (perf → perfmon mapping) + - Evaluate metric values from event counters + - Check bottleneck thresholds (arithmetic) + - Pick highest-value threshold-passing node → generate children events + - Save compact finding; generate next perf command +3. Iterate until leaf node → tuning guidance + coverage report + +### Supported platforms + +All platforms in Intel's perfmon repository (50+), including SPR, EMR, GNR, ICX, SKX/CLX, ADL/RPL, MTL/ARL, and more. + +### Counter budget + +Knows platform-specific counter counts (e.g., SPR: 8 GP + 4 fixed) to generate commands that avoid unnecessary multiplexing. diff --git a/skills/tma-drilldown/examples/01_quick_start.sh b/skills/tma-drilldown/examples/01_quick_start.sh new file mode 100755 index 0000000..f064a5b --- /dev/null +++ b/skills/tma-drilldown/examples/01_quick_start.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Quick-start example for perfmon-skills CLI +# Demonstrates all 5 subcommands using SPR (Sapphire Rapids) platform. +# Usage: chmod +x 01_quick_start.sh && ./01_quick_start.sh +set -e + +echo "=== 1. Event/Metric Search ===" +# Search for events and metrics related to cache misses +perfmon-skills lookup "cache miss" --platform SPR + +echo "" +echo "=== 2. TMA Metric Lookup ===" +# Look up a specific TMA metric by name +perfmon-skills lookup "Backend_Bound" --platform SPR --type metrics + +echo "" +echo "=== 3. Generate L1 TMA Command ===" +# Generate a perf command for top-level TMA analysis (5 second duration) +perfmon-skills cmdgen --tma-level 1 --platform SPR --duration 5 + +echo "" +echo "=== 4. Generate Drill-Down Command ===" +# Generate a perf command targeting a specific TMA node on this process +perfmon-skills cmdgen --tma-node Backend_Bound --platform SPR --pid $$ + +echo "" +echo "=== 5. Cross-Platform Comparison ===" +# Compare available metrics between Ice Lake and Sapphire Rapids +perfmon-skills compare ICX SPR --type metrics + +echo "" +echo "=== 6. Guided Investigation ===" +# Start a recommended investigation workflow +perfmon-skills recommend start --platform SPR --cmd "sleep 2" + +echo "" +echo "--- Next Steps ---" +echo "Run the generated perf commands, then feed the output back to" +echo "perfmon-skills for analysis and further drill-down recommendations." diff --git a/skills/tma-drilldown/examples/02_tma_drilldown.py b/skills/tma-drilldown/examples/02_tma_drilldown.py new file mode 100644 index 0000000..58ca608 --- /dev/null +++ b/skills/tma-drilldown/examples/02_tma_drilldown.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +"""TMA Drill-Down Recommendation Workflow — Synthetic Simulation + +This example demonstrates the full Top-down Microarchitecture Analysis (TMA) +drill-down workflow using the RecommendationEngine, with synthetic perf data +so it works without real hardware. + +On a real system, you would: + 1. Run `perfmon-skills recommend start --platform SPR --cmd "your_workload"` + 2. Execute the generated `perf stat` command against your workload + 3. Feed the real output to `perfmon-skills recommend analyze` + 4. Repeat until a leaf node is reached + +Here we simulate that loop by constructing synthetic perf JSON output at each +step. The synthetic data uses the same format that `perf stat -j` produces: + {"counter-value": "VALUE", "event": "EVENT_NAME", "pcnt-running": 100.00} + +Key insight for TMA L1 with PERF_METRICS support: the topdown-* events are +reported as raw slot counts. For example, 50% Backend_Bound with 6M total +slots means topdown-be-bound reports 3,000,000 and slots reports 6,000,000. +The formula then computes PERF_METRICS.BACKEND_BOUND / TOPDOWN.SLOTS * 100. + +For L2+ metrics, the engine needs whatever events appear in each metric's +formula. Since we cannot easily construct all needed events synthetically, +we handle the case where evaluate_metric returns None gracefully and show +what the engine does at each step regardless. + +Usage: + pip install -e /path/to/perfmon-skills + python 02_tma_drilldown.py +""" + +import json +import sys +import tempfile +from pathlib import Path + + +def make_perf_json_line(event: str, value: float, pcnt_running: float = 100.0) -> str: + """Create one line of perf stat JSON output.""" + return json.dumps({ + "counter-value": f"{value:.6f}", + "event": event, + "pcnt-running": pcnt_running, + }) + + +def make_perf_json_output(events: dict, pcnt_running: float = 100.0) -> str: + """Create multi-line perf stat JSON output from event->value dict.""" + lines = [] + for event, value in events.items(): + lines.append(make_perf_json_line(event, value, pcnt_running)) + return "\n".join(lines) + + +def print_separator(): + print("\n" + "=" * 72 + "\n") + + +def print_results(result: dict, step_label: str): + """Pretty-print analysis results.""" + print(f" State: {result.get('state', '?')}") + print(f" Step: {result.get('step', '?')}") + print(f" Path so far: {' -> '.join(result.get('path', [])) or '(none)'}") + print(f" Complete: {result.get('is_complete', False)}") + + if result.get("results"): + print(f" Node values:") + for r in result["results"]: + val = f"{r['value']:.1f}%" if r["value"] is not None else "N/A (missing events)" + thresh = "" + if r["threshold_passed"] is True: + thresh = " [THRESHOLD PASSED]" + elif r["threshold_passed"] is False: + thresh = " [below threshold]" + print(f" {r['name']:30s} = {val}{thresh}") + + if result.get("multiplexing_issues"): + print(f" Multiplexing issues:") + for m in result["multiplexing_issues"]: + print(f" {m['event']}: measured {m['measured_pct']:.0f}% of time") + + if result.get("next_command"): + print(f" Next command: {result['next_command'][:100]}...") + print(f" Next action: {result.get('next_action', '')}") + + if result.get("guidance"): + guidance = result["guidance"] + if isinstance(guidance, dict): + print(f" Guidance: {guidance.get('summary', guidance)}") + else: + print(f" Guidance: {guidance}") + + if result.get("sampling_suggestion"): + ss = result["sampling_suggestion"] + print(f" Sampling suggestion: perf record with {ss.get('events', [])}") + + +def main(): + print("TMA Drill-Down Recommendation Engine — Synthetic Simulation") + print("=" * 72) + print() + print("This demo walks through the iterative TMA methodology:") + print(" L1: Identify which top-level category is the bottleneck") + print(" L2: Drill into that category's children") + print(" L3: Continue drilling until we reach a leaf or actionable node") + print() + print("All data is synthetic — no real hardware or perf tool required.") + print() + + # Import after printing header so import errors are visible + try: + from perfmon_tools.recommend.engine import RecommendationEngine + except ImportError as e: + print(f"ERROR: Could not import RecommendationEngine: {e}") + print("Make sure perfmon-skills is installed: pip install -e /path/to/perfmon-skills") + sys.exit(1) + + # Use a temp directory for sessions so we don't pollute the workspace + with tempfile.TemporaryDirectory(prefix="tma_demo_") as tmpdir: + sessions_dir = Path(tmpdir) + engine = RecommendationEngine(sessions_dir=sessions_dir) + + # ================================================================== + # STEP 0: Start the investigation + # ================================================================== + print_separator() + print("STEP 0: Start Investigation") + print("-" * 40) + print("We begin by telling the engine which platform we're analyzing.") + print("It will generate the initial perf stat command for L1 TMA collection.") + print() + + try: + start_result = engine.start(platform="SPR", command="./my_workload") + except Exception as e: + print(f"ERROR starting investigation: {e}") + print("Ensure PERFMON_DATA is set or ./perfmon/ symlink exists.") + sys.exit(1) + + print(f" Platform: {start_result['platform']}") + print(f" Strategy: {start_result['strategy']}") + print(f" Session dir: {start_result['session_dir']}") + print(f" State: {start_result['state']}") + print(f" Command: {start_result['command'][:100]}...") + if start_result.get("notes"): + print(f" Notes:") + for note in start_result["notes"]: + print(f" - {note}") + print() + print("On a real system, you would now run the perf command above.") + print("Here we simulate its output with synthetic data.") + + session_dir = start_result["session_dir"] + + # ================================================================== + # STEP 1: L1 TMA — Identify top-level bottleneck + # ================================================================== + print_separator() + print("STEP 1: Analyze L1 TMA Results") + print("-" * 40) + print() + print("Scenario: Our workload is backend-bound (memory/compute limited).") + print("We simulate perf output where:") + print(" Backend_Bound = 50% (3M out of 6M slots)") + print(" Frontend_Bound = 25% (1.5M slots)") + print(" Retiring = 17% (1.02M slots)") + print(" Bad_Speculation= 8% (0.48M slots)") + print() + print("With PERF_METRICS support (SPR), L1 TMA uses hardware topdown") + print("counters that report raw slot counts. The ratio to total slots") + print("gives the percentage.") + print() + + # Synthetic L1 data: topdown events as raw slot counts + # Total slots = 6,000,000 + total_slots = 6000000.0 + l1_events = { + "topdown-be-bound": total_slots * 0.50, # 50% Backend_Bound + "topdown-fe-bound": total_slots * 0.25, # 25% Frontend_Bound + "topdown-retiring": total_slots * 0.17, # 17% Retiring + "topdown-bad-spec": total_slots * 0.08, # 8% Bad_Speculation + "slots": total_slots, + } + + l1_perf_output = make_perf_json_output(l1_events) + print("Synthetic perf JSON (first 3 lines):") + for line in l1_perf_output.splitlines()[:3]: + print(f" {line}") + print(" ...") + print() + + try: + result1 = engine.analyze(perf_output=l1_perf_output, session_dir=session_dir) + print("Analysis results:") + print_results(result1, "L1") + except Exception as e: + print(f" Analysis raised an exception: {type(e).__name__}: {e}") + print(" This can happen if the formula requires events beyond what we provided.") + print(" The engine still records the step. Continuing...") + result1 = {"path": [], "is_complete": False} + + # ================================================================== + # STEP 2: L2 under Backend_Bound + # ================================================================== + print_separator() + print("STEP 2: Drill Into Backend_Bound (L2)") + print("-" * 40) + print() + print("The engine identified Backend_Bound as the top bottleneck.") + print("Now we drill into its children: Memory_Bound vs Core_Bound.") + print() + print("For L2 metrics, the formulas reference specific hardware events") + print("(not just topdown-* counters). We provide synthetic values for") + print("the events we can guess, but some formulas may not evaluate.") + print() + + # For L2 Backend_Bound children (Memory_Bound, Core_Bound), the formulas + # typically reference events like MEMORY_ACTIVITY.STALLS_*, EXE_ACTIVITY.*, + # TOPDOWN.SLOTS, etc. We provide a best-effort synthetic set. + # The topdown-mem-bound event is available on SPR for L2. + l2_events = { + "topdown-mem-bound": total_slots * 0.45, # 45% Memory_Bound (of pipeline) + "topdown-be-bound": total_slots * 0.50, # keep parent context + "topdown-fe-bound": total_slots * 0.25, + "topdown-retiring": total_slots * 0.17, + "topdown-bad-spec": total_slots * 0.08, + "slots": total_slots, + # Additional events that L2 formulas might reference + "TOPDOWN.SLOTS": total_slots, + "PERF_METRICS.BACKEND_BOUND": total_slots * 0.50, + "PERF_METRICS.MEMORY_BOUND": total_slots * 0.45, + "INT_MISC.UOP_DROPPING": 0.0, + "cpu/INT_MISC.UOP_DROPPING/": 0.0, + } + + l2_perf_output = make_perf_json_output(l2_events) + + try: + result2 = engine.analyze(perf_output=l2_perf_output, session_dir=session_dir) + print("Analysis results:") + print_results(result2, "L2") + except Exception as e: + print(f" Analysis raised an exception: {type(e).__name__}: {e}") + print(" This is expected with synthetic data — L2 formulas need many") + print(" specific events that are hard to fake without knowing the exact") + print(" formula structure. On real hardware, perf collects all needed events.") + result2 = {"path": result1.get("path", []), "is_complete": False} + + # ================================================================== + # STEP 3: L3 under Memory_Bound + # ================================================================== + print_separator() + print("STEP 3: Drill Into Memory_Bound (L3)") + print("-" * 40) + print() + print("If we successfully identified Memory_Bound as the L2 bottleneck,") + print("the next step examines its children:") + print(" DRAM_Bound, L1_Bound, L2_Bound, L3_Bound, Store_Bound, etc.") + print() + print("At L3, formulas get very specific to the microarchitecture.") + print("We provide a broad set of synthetic events to maximize our chances.") + print() + + # L3 Memory_Bound children on SPR include nodes like: + # DRAM_Bound, L1_Bound, L2_Bound, L3_Bound, Store_Bound + # These reference very specific events. We try our best. + l3_events = { + "slots": total_slots, + "topdown-be-bound": total_slots * 0.50, + "topdown-mem-bound": total_slots * 0.45, + "topdown-fe-bound": total_slots * 0.25, + "topdown-retiring": total_slots * 0.17, + "topdown-bad-spec": total_slots * 0.08, + "TOPDOWN.SLOTS": total_slots, + "PERF_METRICS.BACKEND_BOUND": total_slots * 0.50, + "PERF_METRICS.MEMORY_BOUND": total_slots * 0.45, + # Synthetic events for L3 nodes — DRAM_Bound related + "MEMORY_ACTIVITY.STALLS_L3_MISS": 800000.0, + "MEMORY_ACTIVITY.STALLS_L2_MISS": 1200000.0, + "MEMORY_ACTIVITY.STALLS_L1D_MISS": 1500000.0, + "EXE_ACTIVITY.BOUND_ON_STORES": 200000.0, + "CYCLE_ACTIVITY.STALLS_L3_MISS": 800000.0, + "CYCLE_ACTIVITY.STALLS_L2_MISS": 1200000.0, + "CYCLE_ACTIVITY.STALLS_MEM_ANY": 1800000.0, + "CPU_CLK_UNHALTED.THREAD": 5000000.0, + "CPU_CLK_UNHALTED.DISTRIBUTED": 5000000.0, + "MEM_LOAD_RETIRED.L3_MISS": 50000.0, + "MEM_LOAD_RETIRED.L2_MISS": 80000.0, + "MEM_LOAD_RETIRED.L1_MISS": 120000.0, + "MEM_LOAD_RETIRED.L3_HIT": 70000.0, + "MEM_LOAD_RETIRED.L2_HIT": 100000.0, + "OCR.ALL_RFO.L3_MISS.REMOTE_FWD": 5000.0, + "OCR.ALL_RFO.L3_MISS.REMOTE_HITM": 3000.0, + "INT_MISC.UOP_DROPPING": 0.0, + } + + l3_perf_output = make_perf_json_output(l3_events) + + try: + result3 = engine.analyze(perf_output=l3_perf_output, session_dir=session_dir) + print("Analysis results:") + print_results(result3, "L3") + except Exception as e: + print(f" Analysis raised an exception: {type(e).__name__}: {e}") + print(" With synthetic data, deep formula evaluation often fails because") + print(" each L3 metric references 5-10 specific events with exact names.") + print(" This is fine — the point is to show the workflow structure.") + result3 = {"path": result2.get("path", []), "is_complete": False} + + # ================================================================== + # Session Status and Summary + # ================================================================== + print_separator() + print("SESSION STATUS") + print("-" * 40) + print() + print("The engine tracks all state in a session directory.") + print("At any point, you can query the current status:") + print() + + try: + status = engine.status(session_dir=session_dir) + print(f" State: {status.get('state', '?')}") + print(f" Platform: {status.get('platform', '?')}") + print(f" Step: {status.get('step', 0)}") + print(f" Path: {' -> '.join(status.get('path', [])) or '(none yet)'}") + print(f" Target: {status.get('target', {})}") + if status.get("findings"): + print(f" Findings:") + for f in status["findings"]: + print(f" L{f['level']}: {f['node']} = {f['value']:.1f}%" + if f.get('value') is not None + else f" L{f['level']}: {f['node']} = N/A") + except Exception as e: + print(f" Status query failed: {e}") + + print() + print() + print("SESSION SUMMARY") + print("-" * 40) + print() + print("Once investigation is COMPLETE (reached a leaf), summary provides") + print("the full bottleneck path, tuning guidance, and coverage report.") + print("If incomplete, it shows progress so far:") + print() + + try: + summary = engine.summary(session_dir=session_dir) + print(f" {json.dumps(summary, indent=4)}") + except Exception as e: + print(f" Summary query failed: {e}") + + # ================================================================== + # Wrap-up + # ================================================================== + print_separator() + print("SUMMARY OF TMA METHODOLOGY") + print("-" * 40) + print() + print("The Top-down Microarchitecture Analysis (TMA) method works by:") + print() + print(" 1. CLASSIFY: Measure L1 categories to find the dominant bottleneck") + print(" (Frontend_Bound, Backend_Bound, Bad_Speculation, Retiring)") + print() + print(" 2. DRILL DOWN: For the top bottleneck, measure its children") + print(" (e.g., Backend_Bound -> Memory_Bound vs Core_Bound)") + print() + print(" 3. REPEAT: Continue drilling until reaching a leaf node or") + print(" actionable category (e.g., DRAM_Bound, Branch_Mispredicts)") + print() + print(" 4. ACT: Use the LocateWith events (perf record) to find the") + print(" exact code locations responsible for the bottleneck") + print() + print("The RecommendationEngine automates this loop:") + print(" - Generates the right perf commands at each level") + print(" - Evaluates metric formulas from collected counters") + print(" - Applies threshold logic to identify real bottlenecks") + print(" - Tracks session state across multiple measurement steps") + print(" - Provides tuning guidance when a leaf is reached") + print() + print("On real hardware, replace the synthetic data with actual perf output") + print("and the engine will guide you to the precise bottleneck.") + print() + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/skills/tma-drilldown/examples/03_trace_visualization.py b/skills/tma-drilldown/examples/03_trace_visualization.py new file mode 100644 index 0000000..827c5a3 --- /dev/null +++ b/skills/tma-drilldown/examples/03_trace_visualization.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Demonstrate the decision tracing and visualization feature of perfmon-skills. + +Decision tracing records every decision point in an investigation (whether automated +or human-guided) with full context -- inputs, reasoning, alternatives considered, +confidence level -- forming an inspectable DAG (Directed Acyclic Graph). + +This is invaluable for: +- Understanding WHY the tool chose a particular drill-down path +- Reproducing and auditing analysis sessions +- Debugging incorrect bottleneck identification +- Visualizing the exploration tree in multiple formats (JSON, Mermaid, DOT, HTML) + +When enabled via PERFMON_TRACE=1, every decision made by the recommendation engine +is captured with zero overhead when disabled. +""" + +import os +import sys +import time + +# Set tracing env var BEFORE importing the tracer module, since TRACE_ENABLED +# is evaluated at import time. +os.environ["PERFMON_TRACE"] = "1" + +# Add the source tree to the path so we can import perfmon_tools +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from perfmon_tools.core.tracer import Trace, DecisionNode + + +def build_example_trace() -> Trace: + """Build a realistic trace simulating a TMA drill-down investigation. + + This demonstrates how the recommendation engine records its decisions + as it drills from L1 (Frontend/Backend/Retiring/BadSpeculation) down + to a specific leaf node with tuning guidance. + """ + trace = Trace() + + # Decision 1: Start the investigation (root node) + node1 = DecisionNode( + id="d001", + timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"), + actor="system", + operation="start_investigation", + inputs={"cpu_family": 6, "model": "0x8F", "detected": "SPR"}, + reasoning="Detected Intel Sapphire Rapids via /proc/cpuinfo family=6 model=0x8F", + decision="Selected SPR platform with 8 GP + 4 fixed counters", + alternatives=[], + confidence=1.0, + parent_id=None, + children_ids=[], + metadata={"counters_gp": 8, "counters_fixed": 4, "tma_depth": 6}, + duration_ms=12.3, + ) + trace._add_node(node1) + + # Decision 2: Evaluate L1 metrics + node2 = DecisionNode( + id="d002", + timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"), + actor="system", + operation="evaluate_l1", + inputs={ + "PERF_METRICS.FRONTEND_BOUND": 0.25, + "PERF_METRICS.BACKEND_BOUND": 0.50, + "PERF_METRICS.RETIRING": 0.17, + "PERF_METRICS.BAD_SPECULATION": 0.08, + }, + reasoning="All L1 TMA metrics computed from topdown slots; Backend_Bound highest at 50%", + decision="Backend_Bound is the dominant bottleneck at 50%", + alternatives=[], + confidence=0.95, + parent_id="d001", + children_ids=[], + metadata={"threshold": 0.20, "level": 1}, + duration_ms=3.1, + ) + trace._add_node(node2) + + # Decision 3: Select the L1 bottleneck to drill into + node3 = DecisionNode( + id="d003", + timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"), + actor="system", + operation="select_bottleneck", + inputs={"candidates": ["Backend_Bound=50%", "Frontend_Bound=25%", "Retiring=17%"]}, + reasoning="Backend_Bound exceeds threshold (50% > 20%) and is highest among L1 nodes", + decision="Chose Backend_Bound for drill-down", + alternatives=[ + {"option": "Frontend_Bound", "reason_rejected": "25% is below Backend_Bound's 50%"}, + {"option": "Retiring", "reason_rejected": "17% below threshold, not a bottleneck"}, + ], + confidence=0.95, + parent_id="d002", + children_ids=[], + metadata={"selected_value": 0.50, "next_level": 2}, + duration_ms=1.5, + ) + trace._add_node(node3) + + # Decision 4: Evaluate L2 metrics under Backend_Bound + node4 = DecisionNode( + id="d004", + timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"), + actor="system", + operation="evaluate_l2", + inputs={ + "MEMORY_BOUND": 0.45, + "CORE_BOUND": 0.15, + }, + reasoning="L2 children of Backend_Bound evaluated; Memory_Bound=45%, Core_Bound=15%", + decision="Memory_Bound=45%, Core_Bound=15% under Backend_Bound", + alternatives=[], + confidence=0.92, + parent_id="d003", + children_ids=[], + metadata={"parent_node": "Backend_Bound", "level": 2}, + duration_ms=4.2, + ) + trace._add_node(node4) + + # Decision 5: Select L2 bottleneck + node5 = DecisionNode( + id="d005", + timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"), + actor="system", + operation="select_bottleneck", + inputs={"candidates": ["Memory_Bound=45%", "Core_Bound=15%"]}, + reasoning="Memory_Bound is 3x higher than Core_Bound and exceeds threshold", + decision="Chose Memory_Bound for drill-down", + alternatives=[ + {"option": "Core_Bound", "reason_rejected": "15% is significantly lower than Memory_Bound's 45%"}, + ], + confidence=0.93, + parent_id="d004", + children_ids=[], + metadata={"selected_value": 0.45, "next_level": 3}, + duration_ms=1.2, + ) + trace._add_node(node5) + + # Decision 6: Evaluate L3 metrics under Memory_Bound + node6 = DecisionNode( + id="d006", + timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"), + actor="system", + operation="evaluate_l3", + inputs={ + "DRAM_BOUND": 0.30, + "L1_BOUND": 0.05, + "L2_BOUND": 0.04, + "L3_BOUND": 0.06, + }, + reasoning="DRAM_Bound dominates at 30%; cache levels (L1/L2/L3) are minor contributors", + decision="DRAM_Bound=30% is the leaf bottleneck (no further children)", + alternatives=[], + confidence=0.90, + parent_id="d005", + children_ids=[], + metadata={"parent_node": "Memory_Bound", "level": 3, "is_leaf": True}, + duration_ms=3.8, + ) + trace._add_node(node6) + + # Decision 7: Generate tuning guidance for the identified leaf + node7 = DecisionNode( + id="d007", + timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"), + actor="ai", + operation="generate_guidance", + inputs={"leaf_node": "DRAM_Bound", "value": 0.30, "platform": "SPR"}, + reasoning="DRAM_Bound leaf reached; generating platform-specific tuning advice", + decision="Tuning advice: optimize data locality, consider prefetching, check NUMA placement", + alternatives=[ + {"option": "suggest_hardware_upgrade", "reason_rejected": "SW optimization not yet exhausted"}, + ], + confidence=0.85, + parent_id="d006", + children_ids=[], + metadata={ + "guidance_keys": ["data_locality", "prefetch", "numa_placement"], + "applicable_tools": ["numactl", "perf mem", "Intel VTune"], + }, + duration_ms=8.7, + ) + trace._add_node(node7) + + return trace + + +def main(): + print("=" * 72) + print(" perfmon-skills: Decision Tracing & Visualization Demo") + print("=" * 72) + print() + print("This example builds a realistic decision trace representing a TMA") + print("drill-down from L1 metrics down to a DRAM_Bound leaf node, then") + print("renders the trace in all supported output formats.") + print() + + # Build the trace + trace = build_example_trace() + + print(f"Trace contains {len(trace.nodes)} decision nodes") + print(f"Root nodes: {trace.root_ids}") + print() + + # --- JSON Output --- + print("-" * 72) + print(" FORMAT 1: JSON (first 20 lines)") + print("-" * 72) + print() + print("The JSON format captures the full DAG with all metadata.") + print("Suitable for programmatic analysis or session replay.") + print() + json_output = trace.to_json() + json_lines = json_output.split("\n") + for line in json_lines[:20]: + print(f" {line}") + print(f" ... ({len(json_lines)} total lines)") + print() + + # --- Mermaid Output --- + print("-" * 72) + print(" FORMAT 2: Mermaid Flowchart") + print("-" * 72) + print() + print("Mermaid diagrams render in GitHub markdown, Obsidian, and mermaid.live.") + print("Paste this into any Mermaid-compatible viewer to see the DAG.") + print() + mermaid_output = trace.to_mermaid() + for line in mermaid_output.split("\n"): + print(f" {line}") + print() + + # --- DOT Output --- + print("-" * 72) + print(" FORMAT 3: Graphviz DOT") + print("-" * 72) + print() + print("DOT format for Graphviz. Render with: dot -Tpng trace.dot -o trace.png") + print("Colors indicate actor: blue=system, yellow=human, green=ai") + print() + dot_output = trace.to_dot() + for line in dot_output.split("\n"): + print(f" {line}") + print() + + # --- HTML Output --- + print("-" * 72) + print(" FORMAT 4: Interactive HTML") + print("-" * 72) + print() + print("Self-contained HTML with a clickable tree view. Each node expands") + print("to show inputs, reasoning, alternatives, and metadata.") + print() + + html_output = trace.to_html() + html_path = "/tmp/perfmon_trace_example.html" + with open(html_path, "w") as f: + f.write(html_output) + print(f" Saved interactive HTML to: {html_path}") + print(f" Open in a browser to explore the decision tree interactively.") + print() + + # --- Save Mermaid file --- + mermaid_path = "/tmp/perfmon_trace_example.mmd" + with open(mermaid_path, "w") as f: + f.write(mermaid_output) + print(f" Saved Mermaid diagram to: {mermaid_path}") + print(f" View at https://mermaid.live or in any compatible markdown viewer.") + print() + + # --- Summary --- + print("=" * 72) + print(" Summary") + print("=" * 72) + print() + print("Decision path taken:") + print(" start_investigation (SPR)") + print(" -> evaluate_l1 (Backend_Bound=50%)") + print(" -> select_bottleneck (Backend_Bound)") + print(" -> evaluate_l2 (Memory_Bound=45%)") + print(" -> select_bottleneck (Memory_Bound)") + print(" -> evaluate_l3 (DRAM_Bound=30%)") + print(" -> generate_guidance (optimize data locality)") + print() + print("To enable tracing in your own workflows:") + print(" PERFMON_TRACE=1 perfmon-skills recommend start --platform SPR") + print() + print("The trace will be saved automatically and can be visualized with:") + print(" perfmon-skills trace show --format html") + print() + + +if __name__ == "__main__": + main() diff --git a/skills/tma-drilldown/examples/04_perf_output_parsing.py b/skills/tma-drilldown/examples/04_perf_output_parsing.py new file mode 100644 index 0000000..2097cfa --- /dev/null +++ b/skills/tma-drilldown/examples/04_perf_output_parsing.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Demonstrates perf stat output parsing and event name normalization. + +Shows how perfmon-skills handles the translation between: +- What `perf stat` outputs (topdown-fe-bound, cpu/EVENT/, etc.) +- What Intel's perfmon JSON formulas expect (PERF_METRICS.FRONTEND_BOUND, EVENT) + +No hardware required — uses sample output strings. +""" + +from perfmon_tools.core.perf_output import ( + parse_perf_stat_text, + parse_perf_stat_json, + parse_perf_stat_interval, + parse_auto, + _normalize_event_values, + PERF_TO_PERFMON, +) + + +def section(title): + print(f"\n{'='*70}") + print(f" {title}") + print(f"{'='*70}\n") + + +# --- Example 1: Text format parsing --- +section("1. Parsing perf stat TEXT output") + +text_output = """\ + Performance counter stats for './workload': + + 4,521,345,678 cycles (66.52%) + 2,890,123,456 instructions # 0.64 insn per cycle + 345,678,901 cache-references + 12,345,678 cache-misses # 3.57% of all cache refs + branch-misses + + 2.501234567 seconds time elapsed + + 2.480000000 seconds user + 0.020000000 seconds sys +""" + +result = parse_perf_stat_text(text_output) + +print("Parsed event values:") +for name, value in sorted(result.event_values.items()): + print(f" {name:40s} = {value:,.0f}") + +print(f"\nDuration: {result.duration_seconds:.3f} seconds") + +print(f"\nMultiplexing issues ({len(result.multiplexing_issues)}):") +for issue in result.multiplexing_issues: + print(f" {issue.message}") + + +# --- Example 2: JSON format parsing --- +section("2. Parsing perf stat JSON output (-j flag)") + +json_output = """\ +{"counter-value": "6000000.000000", "unit": "", "event": "slots", "pcnt-running": 100.00} +{"counter-value": "3000000.000000", "unit": "", "event": "topdown-be-bound", "pcnt-running": 100.00} +{"counter-value": "1500000.000000", "unit": "", "event": "topdown-fe-bound", "pcnt-running": 100.00} +{"counter-value": "1000000.000000", "unit": "", "event": "topdown-retiring", "pcnt-running": 100.00} +{"counter-value": "500000.000000", "unit": "", "event": "topdown-bad-spec", "pcnt-running": 100.00} +{"counter-value": "45000.000000", "unit": "", "event": "cpu/INT_MISC.UOP_DROPPING/", "pcnt-running": 100.00} +""" + +result = parse_perf_stat_json(json_output) + +print("Parsed event values (raw perf names):") +for name, value in sorted(result.event_values.items()): + print(f" {name:40s} = {value:,.0f}") + + +# --- Example 3: Event name normalization --- +section("3. Event Name Normalization (perf → perfmon)") + +print("The PERF_TO_PERFMON mapping:") +for perf_name, perfmon_name in sorted(PERF_TO_PERFMON.items()): + print(f" {perf_name:30s} → {perfmon_name}") + +print("\n\nApplying normalization to parsed values:") +normalized = _normalize_event_values(result.event_values) + +print("\nAfter normalization (all available names):") +for name, value in sorted(normalized.items()): + print(f" {name:45s} = {value:,.0f}") + +print("\nKey insight: both 'topdown-be-bound' AND 'PERF_METRICS.BACKEND_BOUND'") +print("now resolve to the same value. Metric formulas can use either name.") + + +# --- Example 4: parse_auto (auto-detect format) --- +section("4. Auto-detection with parse_auto()") + +print("parse_auto() detects format AND normalizes event names in one call.") +print("It checks if input starts with '{' (JSON) or not (text).\n") + +result = parse_auto(json_output) +print(f"Detected format: JSON") +print(f"Events parsed: {len(result.event_values)}") +print(f"Includes normalized names: {'PERF_METRICS.BACKEND_BOUND' in result.event_values}") + + +# --- Example 5: Interval mode parsing --- +section("5. Parsing interval mode output (-I flag)") + +interval_output = """\ +1.000123456;cycles;5000000;;100.00 +1.000123456;instructions;2500000;;100.00 +1.000123456;cache-misses;50000;;100.00 +2.000234567;cycles;5100000;;100.00 +2.000234567;instructions;2600000;;100.00 +2.000234567;cache-misses;48000;;100.00 +3.000345678;cycles;5200000;;100.00 +3.000345678;instructions;2700000;;100.00 +3.000345678;cache-misses;52000;;100.00 +""" + +intervals = parse_perf_stat_interval(interval_output) + +print(f"Parsed {len(intervals)} intervals:\n") +print(f" {'Interval':<10} {'cycles':>12} {'instructions':>14} {'cache-misses':>14} {'IPC':>6}") +print(f" {'-'*10} {'-'*12} {'-'*14} {'-'*14} {'-'*6}") +for i, interval in enumerate(intervals, 1): + ipc = interval.get("instructions", 0) / interval.get("cycles", 1) + print(f" {i:<10} {interval.get('cycles', 0):>12,.0f} " + f"{interval.get('instructions', 0):>14,.0f} " + f"{interval.get('cache-misses', 0):>14,.0f} " + f"{ipc:>6.2f}") + +print("\nInterval mode is used for phase detection — if IPC varies significantly") +print("across intervals, the workload has multiple phases that need separate analysis.") + + +# --- Example 6: Multiplexing detection --- +section("6. Multiplexing Detection") + +mux_output = """\ +{"counter-value": "5000000.000000", "unit": "", "event": "cycles", "pcnt-running": 100.00} +{"counter-value": "2500000.000000", "unit": "", "event": "instructions", "pcnt-running": 85.50} +{"counter-value": "100000.000000", "unit": "", "event": "cache-misses", "pcnt-running": 42.30} +{"counter-value": "", "unit": "", "event": "branch-misses", "pcnt-running": 0.00} +""" + +result = parse_perf_stat_json(mux_output) + +print("When more events are requested than available hardware counters,") +print("perf time-shares (multiplexes) them. This introduces statistical error.\n") +print("Detection results:") +for issue in result.multiplexing_issues: + print(f" ⚠ {issue.message}") +print(f"\nThreshold: events measured < 90% of time are flagged.") +print(f"Action: reduce event count or split into multiple runs.") diff --git a/skills/tma-drilldown/examples/README.md b/skills/tma-drilldown/examples/README.md new file mode 100644 index 0000000..0ac8385 --- /dev/null +++ b/skills/tma-drilldown/examples/README.md @@ -0,0 +1,507 @@ +# Examples + +Runnable examples demonstrating each aspect of perfmon-skills. All examples work without real hardware. + +## Prerequisites + +```bash +pip install -e . +# Ensure ./perfmon symlink or PERFMON_DATA points to intel/perfmon repo +``` + +## Files + +| Example | What it shows | +|---------|---------------| +| `01_quick_start.sh` | All 5 CLI commands in action | +| `02_tma_drilldown.py` | Full TMA drill-down workflow step-by-step | +| `03_trace_visualization.py` | Decision tracing DAG in all 4 output formats | +| `04_perf_output_parsing.py` | Parsing perf stat output (text + JSON) and event name normalization | + +--- + +## Example 1: Quick Start (`01_quick_start.sh`) + +Shows all CLI subcommands with SPR (Sapphire Rapids) platform data. + +```bash +bash examples/01_quick_start.sh +``` + +
+Output (click to expand) + +``` +=== 1. Event/Metric Search === + +====================================================================== +EVENTS (SPR) — 67 matches +====================================================================== + OFFCORE_REQUESTS_OUTSTANDING.L3_MISS_DEMAND_DATA_RD + For every cycle, increments by the number of demand data read requests pending t + Code: 0x20, UMask: 0x10, Counter: 0,1,2,3, PEBS: 0 + + OFFCORE_REQUESTS.L3_MISS_DEMAND_DATA_RD + Counts demand data read requests that miss the L3 cache. + Code: 0x21, UMask: 0x10, Counter: 0,1,2,3, PEBS: 0 + + L2_RQSTS.DEMAND_DATA_RD_MISS + Demand Data Read miss L2 cache + Code: 0x24, UMask: 0x21, Counter: 0,1,2,3, PEBS: 0 + ... + (67 events matching "cache miss") + +=== 2. TMA Metric Lookup === + +====================================================================== +METRICS (SPR) — 1 matches +====================================================================== + Backend_Bound (L1, TMA) + This category represents fraction of slots where no uops are being delivered due + Unit: percent + Groups: BvOB;TmaL1 + +=== 3. Generate L1 TMA Command === + +# TMA Level 1 (4 nodes) [SPR] +# Events: 6 (GP: 1, Fixed: 0, PerfMetrics: 5) +# Counters available: 12 (GP: 8) + +perf stat -e cpu/INT_MISC.UOP_DROPPING/,topdown-be-bound,topdown-bad-spec,topdown-fe-bound,topdown-retiring,slots sleep 5 + +=== 4. Generate Drill-Down Command === + +# TMA drill-down: Backend_Bound → ['Core_Bound', 'Memory_Bound'] [SPR] +# Events: 5 (GP: 0, Fixed: 0, PerfMetrics: 5) +# Counters available: 12 (GP: 8) + +perf stat -e topdown-be-bound,topdown-bad-spec,topdown-fe-bound,topdown-mem-bound,topdown-retiring -p sleep 5 + +=== 5. Cross-Platform Comparison === + +====================================================================== +ICX → SPR Comparison (metrics) +====================================================================== + Added metrics: 26 + Removed metrics: 8 + Changed metrics: 91 + ... + +=== 6. Guided Investigation === + +====================================================================== +NEW INVESTIGATION SESSION +====================================================================== + Platform: SPR + Strategy: ... + Counter budget: ... + + STEP 1: Run this command and feed the output back: + + perf stat -j -e cpu/INT_MISC.UOP_DROPPING/,topdown-be-bound,... -- sleep 2 +``` + +
+ +--- + +## Example 2: TMA Drill-Down (`02_tma_drilldown.py`) + +Demonstrates the iterative recommendation engine with synthetic perf data. Shows a 3-step investigation from L1 to L3. + +```bash +python examples/02_tma_drilldown.py +``` + +
+Output (click to expand) + +``` +TMA Drill-Down Recommendation Engine — Synthetic Simulation +======================================================================== + +This demo walks through the iterative TMA methodology: + L1: Identify which top-level category is the bottleneck + L2: Drill into that category's children + L3: Continue drilling until we reach a leaf or actionable node + +All data is synthetic — no real hardware or perf tool required. + + +======================================================================== + +STEP 0: Start Investigation +---------------------------------------- +We begin by telling the engine which platform we're analyzing. +It will generate the initial perf stat command for L1 TMA collection. + + Platform: SPR + Strategy: {'smt_active': True, 'use_perf_metrics': True, 'counters': '8 GP + 4 fixed'} + Session dir: /tmp/tma_demo_.../2026-05-19_..._cmd + State: COLLECTING + Command: perf stat -j -e cpu/INT_MISC.UOP_DROPPING/,topdown-be-bound,... + Notes: + - SMT active: using per-thread events. Cross-thread interference may affect L3+ accuracy. + - PERF_METRICS supported: L1/L2 TMA available without multiplexing. + +On a real system, you would now run the perf command above. +Here we simulate its output with synthetic data. + +======================================================================== + +STEP 1: Analyze L1 TMA Results +---------------------------------------- + +Scenario: Our workload is backend-bound (memory/compute limited). +We simulate perf output where: + Backend_Bound = 50% (3M out of 6M slots) + Frontend_Bound = 25% (1.5M slots) + Retiring = 17% (1.02M slots) + Bad_Speculation= 8% (0.48M slots) + +Analysis results: + State: COLLECTING + Step: 1 + Path so far: Backend_Bound + Complete: False + Node values: + Backend_Bound = 50.0% [THRESHOLD PASSED] + Retiring = 17.0% + Frontend_Bound = N/A (missing events) + Bad_Speculation = N/A (missing events) + Next command: perf stat -j -e topdown-be-bound,topdown-bad-spec,... + Next action: Run the command, then feed output to 'recommend analyze' + +======================================================================== + +STEP 2: Drill Into Backend_Bound (L2) +---------------------------------------- + +The engine identified Backend_Bound as the top bottleneck. +Now we drill into its children: Memory_Bound vs Core_Bound. + +Analysis results: + State: COLLECTING + Step: 2 + Path so far: Backend_Bound -> Memory_Bound + Complete: False + Node values: + Memory_Bound = 45.0% + Core_Bound = N/A (missing events) + Next command: perf stat -j -e cpu/CPU_CLK_UNHALTED.THREAD/,... + Next action: Run the command, then feed output to 'recommend analyze' + +======================================================================== + +STEP 3: Drill Into Memory_Bound (L3) +---------------------------------------- + +Analysis results: + State: COLLECTING + Step: 3 + Path so far: Backend_Bound -> Memory_Bound -> DRAM_Bound + Complete: False + Node values: + DRAM_Bound = 16.0% + L3_Bound = 8.0% + L2_Bound = 6.0% + Store_Bound = 4.0% + L1_Bound = N/A (missing events) + Next command: perf stat -j -e cpu/CPU_CLK_UNHALTED.THREAD/,... + Next action: Run the command, then feed output to 'recommend analyze' + +======================================================================== + +SESSION STATUS +---------------------------------------- + + State: COLLECTING + Platform: SPR + Step: 3 + Path: Backend_Bound -> Memory_Bound -> DRAM_Bound + Target: {'pid': None, 'command': './my_workload'} + Findings: + L1: Backend_Bound = 50.0% + L2: Memory_Bound = 45.0% + L3: DRAM_Bound = 16.0% + +======================================================================== + +SUMMARY OF TMA METHODOLOGY +---------------------------------------- + +The Top-down Microarchitecture Analysis (TMA) method works by: + + 1. CLASSIFY: Measure L1 categories to find the dominant bottleneck + (Frontend_Bound, Backend_Bound, Bad_Speculation, Retiring) + + 2. DRILL DOWN: For the top bottleneck, measure its children + (e.g., Backend_Bound -> Memory_Bound vs Core_Bound) + + 3. REPEAT: Continue drilling until reaching a leaf node or + actionable category (e.g., DRAM_Bound, Branch_Mispredicts) + + 4. ACT: Use the LocateWith events (perf record) to find the + exact code locations responsible for the bottleneck +``` + +
+ +--- + +## Example 3: Trace Visualization (`03_trace_visualization.py`) + +Shows the decision tracing DAG — every automated decision recorded with inputs, alternatives, and confidence levels. Outputs in JSON, Mermaid, DOT, and interactive HTML. + +```bash +python examples/03_trace_visualization.py +``` + +
+Output (click to expand) + +``` +======================================================================== + perfmon-skills: Decision Tracing & Visualization Demo +======================================================================== + +This example builds a realistic decision trace representing a TMA +drill-down from L1 metrics down to a DRAM_Bound leaf node, then +renders the trace in all supported output formats. + +Trace contains 7 decision nodes +Root nodes: ['d001'] + +------------------------------------------------------------------------ + FORMAT 1: JSON (first 20 lines) +------------------------------------------------------------------------ + +The JSON format captures the full DAG with all metadata. +Suitable for programmatic analysis or session replay. + + { + "trace_version": "1.0", + "nodes": [ + { + "id": "d001", + "timestamp": "2026-05-19T15:50:57", + "actor": "system", + "operation": "start_investigation", + "inputs": { + "cpu_family": 6, + "model": "0x8F", + "detected": "SPR" + }, + "reasoning": "Detected Intel Sapphire Rapids via /proc/cpuinfo family=6 model=0x8F", + "decision": "Selected SPR platform with 8 GP + 4 fixed counters", + "alternatives": [], + "confidence": 1.0, + ... + ... (207 total lines) + +------------------------------------------------------------------------ + FORMAT 2: Mermaid Flowchart +------------------------------------------------------------------------ + +Mermaid diagrams render in GitHub markdown, Obsidian, and mermaid.live. +Paste this into any Mermaid-compatible viewer to see the DAG. + + graph TD + d001[start_investigation\nSelected SPR platform with 8 GP + 4 fixed counters] + d002[evaluate_l1\nBackend_Bound is the dominant bottleneck at 50%] + d001 -->|All L1 TMA metrics computed from topdown| d002 + d003[select_bottleneck\nChose Backend_Bound for drill-down] + d002 -->|Backend_Bound exceeds threshold (50% > 2| d003 + d004[evaluate_l2\nMemory_Bound=45%, Core_Bound=15% under Backend_Bound] + d003 -->|L2 children of Backend_Bound evaluated; | d004 + d005[select_bottleneck\nChose Memory_Bound for drill-down] + d004 -->|Memory_Bound is 3x higher than Core_Boun| d005 + d006[evaluate_l3\nDRAM_Bound=30% is the leaf bottleneck (no further children)] + d005 -->|DRAM_Bound dominates at 30%; cache level| d006 + d007[generate_guidance\nTuning advice: optimize data locality, consider prefetching, check NUMA placement] + d006 -->|DRAM_Bound leaf reached; generating plat| d007 + +------------------------------------------------------------------------ + FORMAT 3: Graphviz DOT +------------------------------------------------------------------------ + +DOT format for Graphviz. Render with: dot -Tpng trace.dot -o trace.png +Colors indicate actor: blue=system, yellow=human, green=ai + + digraph trace { + rankdir=TB; + node [shape=box, style=rounded]; + "d001" [label="start_investigation\n...\nconf=1.00", fillcolor="lightblue", ...]; + "d002" [label="evaluate_l1\n...\nconf=0.95", fillcolor="lightblue", ...]; + ... + "d007" [label="generate_guidance\n...\nconf=0.85", fillcolor="lightgreen", ...]; + "d001" -> "d002" [label="All L1 TMA metrics computed fr"]; + "d002" -> "d003" [label="Backend_Bound exceeds threshol"]; + ... + } + +------------------------------------------------------------------------ + FORMAT 4: Interactive HTML +------------------------------------------------------------------------ + + Saved interactive HTML to: /tmp/perfmon_trace_example.html + Open in a browser to explore the decision tree interactively. + + Saved Mermaid diagram to: /tmp/perfmon_trace_example.mmd + +======================================================================== + Summary +======================================================================== + +Decision path taken: + start_investigation (SPR) + -> evaluate_l1 (Backend_Bound=50%) + -> select_bottleneck (Backend_Bound) + -> evaluate_l2 (Memory_Bound=45%) + -> select_bottleneck (Memory_Bound) + -> evaluate_l3 (DRAM_Bound=30%) + -> generate_guidance (optimize data locality) + +To enable tracing in your own workflows: + PERFMON_TRACE=1 perfmon-skills recommend start --platform SPR +``` + +
+ +--- + +## Example 4: Perf Output Parsing (`04_perf_output_parsing.py`) + +Shows how perfmon-skills parses perf stat output in all formats (text, JSON, interval) and translates between perf event names and Intel perfmon canonical names. + +```bash +python examples/04_perf_output_parsing.py +``` + +
+Output (click to expand) + +``` +====================================================================== + 1. Parsing perf stat TEXT output +====================================================================== + +Parsed event values: + cache-misses = 12,345,678 + cache-references = 345,678,901 + cycles = 4,521,345,678 + instructions = 2,890,123,456 + +Duration: 2.501 seconds + +Multiplexing issues (2): + cycles: measured only 66.5% of time + branch-misses: not counted + +====================================================================== + 2. Parsing perf stat JSON output (-j flag) +====================================================================== + +Parsed event values (raw perf names): + cpu/INT_MISC.UOP_DROPPING/ = 45,000 + slots = 6,000,000 + topdown-bad-spec = 500,000 + topdown-be-bound = 3,000,000 + topdown-fe-bound = 1,500,000 + topdown-retiring = 1,000,000 + +====================================================================== + 3. Event Name Normalization (perf → perfmon) +====================================================================== + +The PERF_TO_PERFMON mapping: + slots → TOPDOWN.SLOTS + topdown-bad-spec → PERF_METRICS.BAD_SPECULATION + topdown-be-bound → PERF_METRICS.BACKEND_BOUND + topdown-br-mispredict → PERF_METRICS.BRANCH_MISPREDICTS + topdown-fe-bound → PERF_METRICS.FRONTEND_BOUND + topdown-fetch-lat → PERF_METRICS.FETCH_LATENCY + topdown-heavy-ops → PERF_METRICS.HEAVY_OPS + topdown-mem-bound → PERF_METRICS.MEMORY_BOUND + topdown-retiring → PERF_METRICS.RETIRING + +After normalization (all available names): + INT_MISC.UOP_DROPPING = 45,000 + PERF_METRICS.BACKEND_BOUND = 3,000,000 + PERF_METRICS.BAD_SPECULATION = 500,000 + PERF_METRICS.FRONTEND_BOUND = 1,500,000 + PERF_METRICS.RETIRING = 1,000,000 + TOPDOWN.SLOTS = 6,000,000 + cpu/INT_MISC.UOP_DROPPING/ = 45,000 + slots = 6,000,000 + topdown-bad-spec = 500,000 + topdown-be-bound = 3,000,000 + topdown-fe-bound = 1,500,000 + topdown-retiring = 1,000,000 + +Key insight: both 'topdown-be-bound' AND 'PERF_METRICS.BACKEND_BOUND' +now resolve to the same value. Metric formulas can use either name. + +====================================================================== + 4. Auto-detection with parse_auto() +====================================================================== + +parse_auto() detects format AND normalizes event names in one call. +It checks if input starts with '{' (JSON) or not (text). + +Detected format: JSON +Events parsed: 12 +Includes normalized names: True + +====================================================================== + 5. Parsing interval mode output (-I flag) +====================================================================== + +Parsed 3 intervals: + + Interval cycles instructions cache-misses IPC + ---------- ------------ -------------- -------------- ------ + 1 5,000,000 2,500,000 50,000 0.50 + 2 5,100,000 2,600,000 48,000 0.51 + 3 5,200,000 2,700,000 52,000 0.52 + +Interval mode is used for phase detection — if IPC varies significantly +across intervals, the workload has multiple phases that need separate analysis. + +====================================================================== + 6. Multiplexing Detection +====================================================================== + +When more events are requested than available hardware counters, +perf time-shares (multiplexes) them. This introduces statistical error. + +Detection results: + ⚠ instructions: measured only 85.5% of time + ⚠ cache-misses: measured only 42.3% of time + ⚠ branch-misses: not counted + +Threshold: events measured < 90% of time are flagged. +Action: reduce event count or split into multiple runs. +``` + +
+ +--- + +## On Real Hardware (Intel) + +```bash +# Start an actual investigation +perfmon-skills recommend start --cmd "./my_workload" + +# Run the suggested perf command, then: +perf stat -j -e -- ./my_workload 2> perf_out.txt +perfmon-skills recommend analyze --input perf_out.txt + +# Repeat until investigation completes (typically 3-5 steps) + +# View the decision trace +PERFMON_TRACE=1 perfmon-skills recommend start --cmd "./my_workload" +# ... run investigation ... +perfmon-skills trace --last --format html --output trace.html +``` diff --git a/skills/tma-drilldown/perfmon b/skills/tma-drilldown/perfmon new file mode 160000 index 0000000..7ab0b6e --- /dev/null +++ b/skills/tma-drilldown/perfmon @@ -0,0 +1 @@ +Subproject commit 7ab0b6e2c09d3df26671c7875148d6ab6e853775 diff --git a/skills/tma-drilldown/pyproject.toml b/skills/tma-drilldown/pyproject.toml new file mode 100644 index 0000000..f1861bc --- /dev/null +++ b/skills/tma-drilldown/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=68.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "perfmon-skills" +version = "0.1.0" +description = "Performance analysis toolkit built on Intel perfmon data" +requires-python = ">=3.9" +license = {text = "MIT"} +dependencies = [] + +[project.optional-dependencies] +rich = ["rich>=13.0"] +dev = ["pytest>=7.0"] + +[project.scripts] +perfmon-skills = "perfmon_tools.cli.main:main" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/skills/tma-drilldown/references/perf-cmdgen.md b/skills/tma-drilldown/references/perf-cmdgen.md new file mode 100644 index 0000000..1963c05 --- /dev/null +++ b/skills/tma-drilldown/references/perf-cmdgen.md @@ -0,0 +1,33 @@ +--- +name: perf-cmdgen +description: Generate ready-to-run perf stat commands +--- + +# Performance Command Generator + +Generate `perf stat` commands with correct event encoding for the user's platform. + +## Usage + +```bash +perfmon-skills cmdgen --format json [options] +``` + +Options: +- `--platform PLT` — target platform (default: auto-detect) +- `--tma-level N` — generate command for TMA level N +- `--tma-node NAME` — generate command for a specific TMA node's children +- `--metric NAME` — include specific metric(s) (repeatable) +- `--event NAME` — include specific event(s) (repeatable) +- `--duration SEC` — collection duration (default: 5) +- `--pid PID` — target process ID +- `--cmd CMD` — command to profile +- `--json` — add `-j` flag for JSON output from perf +- `--per-core` — add per-core breakdown + +## Interpretation + +- Present the generated command(s) ready to copy-paste +- Explain counter budget: how many events vs available counters +- If multiplexing is needed, explain the confidence impact +- For hybrid platforms, explain the core affinity flags diff --git a/skills/tma-drilldown/references/perf-compare.md b/skills/tma-drilldown/references/perf-compare.md new file mode 100644 index 0000000..724486f --- /dev/null +++ b/skills/tma-drilldown/references/perf-compare.md @@ -0,0 +1,27 @@ +--- +name: perf-compare +description: Compare events and metrics across Intel platform generations +--- + +# Cross-Platform Comparison + +Compare PMU events and TMA metrics between Intel platform generations. + +## Usage + +```bash +perfmon-skills compare PLATFORM1 PLATFORM2 --format json [options] +``` + +Options: +- `--type events|metrics|all` — what to compare +- `--metric NAME` — compare a specific metric +- `--event NAME` — compare a specific event +- `--category CAT` — filter by category + +## Interpretation + +- Summarize key differences: new capabilities, removed events, changed formulas +- For TMA metrics, highlight accuracy improvements or methodology changes +- When comparing adjacent generations (e.g., ICX→SPR), focus on what's new +- For distant generations (e.g., SKL→SPR), provide a migration guide diff --git a/skills/tma-drilldown/references/perf-lookup.md b/skills/tma-drilldown/references/perf-lookup.md new file mode 100644 index 0000000..b8aa03f --- /dev/null +++ b/skills/tma-drilldown/references/perf-lookup.md @@ -0,0 +1,30 @@ +--- +name: perf-lookup +description: Look up Intel PMU events and TMA metrics +--- + +# Performance Event & Metric Lookup + +Use the `perfmon-skills` CLI to search for Intel PMU events and TMA metrics. + +## Usage + +When the user asks about a performance event or metric, run: + +```bash +perfmon-skills lookup "" --format json +``` + +Options: +- `--platform PLT` — target platform (default: auto-detect from CPU) +- `--type events|metrics|all` — filter by type +- `--category CAT` — filter by category (e.g., "TMA", "Cache", "Pipeline") +- `--level N` — TMA level filter (1-6) +- `--cross-arch` — search across all platforms + +## Interpretation + +- Present results conversationally, highlighting the most relevant matches +- For TMA metrics, explain their position in the hierarchy and what they measure +- For events, explain what the event counts and common use cases +- If the user's platform isn't specified, mention which platform the results are for diff --git a/skills/tma-drilldown/references/perf-recommend.md b/skills/tma-drilldown/references/perf-recommend.md new file mode 100644 index 0000000..92f439c --- /dev/null +++ b/skills/tma-drilldown/references/perf-recommend.md @@ -0,0 +1,57 @@ +--- +name: perf-recommend +description: TMA-guided iterative performance investigation +--- + +# Performance Recommendation Engine + +Orchestrate an iterative TMA drill-down investigation to identify performance bottlenecks. + +## Workflow + +### Step 1: Start Investigation + +```bash +perfmon-skills recommend start --format json [--pid PID | --cmd "command"] [--duration SEC] +``` + +This returns the first `perf stat` command to run. + +### Step 2: Collect and Analyze (iterative) + +Run the perf command suggested by the engine, then feed the output back: + +```bash +perf stat ... 2>&1 | perfmon-skills recommend analyze --stdin --format json +``` + +Or save to file: +```bash +perfmon-skills recommend analyze --input perf_output.txt --format json +``` + +The engine will either: +- Identify a deeper bottleneck and suggest the next perf command (continue iterating) +- Reach a leaf node and provide tuning guidance (investigation complete) + +### Step 3: Check Status / Summary + +```bash +perfmon-skills recommend status --format json +perfmon-skills recommend summary --format json +``` + +## Interpretation + +- At each step, explain what the TMA analysis found and why the engine chose to drill into a particular branch +- Flag multiplexing issues if events exceeded counter budget +- When complete, present the full bottleneck path and actionable tuning suggestions +- Mention event coverage gaps if significant domains were unexplored +- Suggest `perf record` sampling commands for code-level localization + +## Important Notes + +- The engine is deterministic — it uses threshold formulas from Intel's TMA methodology +- Each step produces a compact finding (~200 tokens); raw perf data stays on disk +- The investigation typically takes 3-5 iterations (L1 → leaf) +- If the user's workload is non-steady-state, the engine will detect phases diff --git a/skills/tma-drilldown/src/perfmon_tools/__init__.py b/skills/tma-drilldown/src/perfmon_tools/__init__.py new file mode 100644 index 0000000..179fdf8 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/__init__.py @@ -0,0 +1,3 @@ +"""perfmon-skills: Performance analysis toolkit built on Intel perfmon data.""" + +__version__ = "0.1.0" diff --git a/skills/tma-drilldown/src/perfmon_tools/cli/__init__.py b/skills/tma-drilldown/src/perfmon_tools/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skills/tma-drilldown/src/perfmon_tools/cli/cmdgen_cmd.py b/skills/tma-drilldown/src/perfmon_tools/cli/cmdgen_cmd.py new file mode 100644 index 0000000..e411a31 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/cli/cmdgen_cmd.py @@ -0,0 +1,66 @@ +"""CLI for perf command generation.""" + +import argparse +import json + + +def add_parser(subparsers): + parser = subparsers.add_parser("cmdgen", help="Generate perf stat commands") + parser.add_argument("--platform", "-p", help="Platform shortname (default: auto-detect)") + parser.add_argument("--metric", "-m", action="append", help="Metric name (repeatable)") + parser.add_argument("--tma-level", type=int, help="TMA level (1-6)") + parser.add_argument("--tma-node", help="TMA node name (generate drill-down command)") + parser.add_argument("--event", "-e", action="append", help="Specific event (repeatable)") + parser.add_argument("--duration", "-d", type=int, default=5, help="Duration in seconds") + parser.add_argument("--pid", type=int, help="Target PID") + parser.add_argument("--cmd", help="Command to profile") + parser.add_argument("--json", "-j", action="store_true", help="JSON output from perf") + parser.add_argument("--per-core", action="store_true", help="Per-core output") + parser.add_argument("--interval", "-I", type=int, help="Interval in ms") + parser.add_argument("--repeat", "-r", type=int, help="Number of repetitions") + parser.add_argument("--format", "-f", choices=["text", "json"], default="text") + parser.set_defaults(func=run) + + +def run(args): + from ..cmdgen.generate import generate_perf_command + + result = generate_perf_command( + platform=args.platform, + metrics=args.metric, + tma_level=args.tma_level, + tma_node=args.tma_node, + events=args.event, + duration=args.duration, + pid=args.pid, + command=args.cmd, + json_output=args.json, + per_core=args.per_core, + interval_ms=args.interval, + repeat=args.repeat, + ) + + if args.format == "json": + print(json.dumps(result, indent=2)) + return + + print(f"\n# {result['description']} [{result['platform']}]") + + ci = result["counter_info"] + print(f"# Events: {ci['events_count']} " + f"(GP: {ci['programmable_events']}, Fixed: {ci['fixed_events']}, " + f"PerfMetrics: {ci['perf_metrics_events']})") + print(f"# Counters available: {ci['available_counters']} " + f"(GP: {ci['gp_counters']})") + + if ci["needs_multiplexing"]: + ratio = ci["estimated_mux_ratio"] + print(f"# WARNING: Multiplexing needed (ratio: {ratio:.1f}x)") + + for note in result.get("notes", []): + print(f"# NOTE: {note}") + + print() + for cmd in result["commands"]: + print(cmd) + print() diff --git a/skills/tma-drilldown/src/perfmon_tools/cli/compare_cmd.py b/skills/tma-drilldown/src/perfmon_tools/cli/compare_cmd.py new file mode 100644 index 0000000..cc2487d --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/cli/compare_cmd.py @@ -0,0 +1,109 @@ +"""CLI for cross-platform comparison.""" + +import argparse +import json + + +def add_parser(subparsers): + parser = subparsers.add_parser("compare", help="Compare events/metrics between platforms") + parser.add_argument("platform1", help="First platform shortname") + parser.add_argument("platform2", help="Second platform shortname") + parser.add_argument("--type", "-t", choices=["events", "metrics", "all"], default="all") + parser.add_argument("--metric", help="Compare specific metric") + parser.add_argument("--event", help="Compare specific event") + parser.add_argument("--category", "-c", help="Filter by category") + parser.add_argument("--format", "-f", choices=["text", "json"], default="text") + parser.set_defaults(func=run) + + +def run(args): + from ..compare.diff import compare_platforms + + result = compare_platforms( + platform1=args.platform1, + platform2=args.platform2, + compare_type=args.type, + metric_name=args.metric, + event_name=args.event, + category=args.category, + ) + + if args.format == "json": + print(json.dumps(result, indent=2)) + return + + p1 = result["platform1"] + p2 = result["platform2"] + print(f"\n{'='*70}") + print(f"COMPARISON: {p1} → {p2}") + print(f"{'='*70}") + + # Events + ev = result["events"] + if ev["added"] or ev["removed"] or ev["changed"]: + print(f"\n--- EVENTS ---") + if ev["added"]: + print(f"\n Added in {p2} ({len(ev['added'])}):") + for e in ev["added"][:20]: + print(f" + {e['name']}: {e.get('description', '')[:60]}") + if len(ev["added"]) > 20: + print(f" ... and {len(ev['added'])-20} more") + + if ev["removed"]: + print(f"\n Removed in {p2} ({len(ev['removed'])}):") + for e in ev["removed"][:20]: + print(f" - {e['name']}: {e.get('description', '')[:60]}") + if len(ev["removed"]) > 20: + print(f" ... and {len(ev['removed'])-20} more") + + if ev["changed"]: + print(f"\n Changed ({len(ev['changed'])}):") + for e in ev["changed"][:20]: + print(f" ~ {e['name']}:") + for field, change in e["changes"].items(): + if isinstance(change, dict): + print(f" {field}: {change['from']} → {change['to']}") + else: + print(f" {field}: {change}") + + # Metrics + mt = result["metrics"] + if mt["added"] or mt["removed"] or mt["changed"]: + print(f"\n--- METRICS ---") + if mt["added"]: + print(f"\n Added in {p2} ({len(mt['added'])}):") + for m in mt["added"][:20]: + print(f" + {m['name']} (L{m.get('level','?')}, {m.get('category','')})") + if len(mt["added"]) > 20: + print(f" ... and {len(mt['added'])-20} more") + + if mt["removed"]: + print(f"\n Removed in {p2} ({len(mt['removed'])}):") + for m in mt["removed"][:20]: + print(f" - {m['name']} (L{m.get('level','?')}, {m.get('category','')})") + if len(mt["removed"]) > 20: + print(f" ... and {len(mt['removed'])-20} more") + + if mt["changed"]: + print(f"\n Changed ({len(mt['changed'])}):") + for m in mt["changed"][:15]: + print(f" ~ {m['name']} (L{m.get('level','?')}, {m.get('category','')}):") + for field, change in m["changes"].items(): + if isinstance(change, dict): + if field == "formula": + print(f" formula changed") + else: + print(f" {field}: {change.get('from','')} → {change.get('to','')}") + elif isinstance(change, list) and change: + print(f" {field}: {change[:5]}") + if len(mt["changed"]) > 15: + print(f" ... and {len(mt['changed'])-15} more") + + # Summary + total_changes = ( + len(ev["added"]) + len(ev["removed"]) + len(ev["changed"]) + + len(mt["added"]) + len(mt["removed"]) + len(mt["changed"]) + ) + if total_changes == 0: + print("\n No differences found.") + print() diff --git a/skills/tma-drilldown/src/perfmon_tools/cli/lookup_cmd.py b/skills/tma-drilldown/src/perfmon_tools/cli/lookup_cmd.py new file mode 100644 index 0000000..dc77b75 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/cli/lookup_cmd.py @@ -0,0 +1,78 @@ +"""CLI for event/metric lookup.""" + +import argparse +import json +import sys + + +def add_parser(subparsers): + parser = subparsers.add_parser("lookup", help="Search events and metrics") + parser.add_argument("query", help="Search term") + parser.add_argument("--platform", "-p", help="Platform shortname (default: auto-detect)") + parser.add_argument("--type", "-t", choices=["events", "metrics", "all"], default="all") + parser.add_argument("--category", "-c", help="Filter metrics by category") + parser.add_argument("--level", "-l", type=int, help="Filter metrics by TMA level") + parser.add_argument("--cross-arch", action="store_true", help="Search all architectures") + parser.add_argument("--format", "-f", choices=["table", "json", "brief"], default="table") + parser.add_argument("--deprecated", action="store_true", help="Include deprecated events") + parser.set_defaults(func=run) + + +def run(args): + from ..lookup.search import search + + results = search( + query=args.query, + platform=args.platform, + search_type=args.type, + category=args.category, + level=args.level, + cross_arch=args.cross_arch, + include_deprecated=args.deprecated, + ) + + if args.format == "json": + print(json.dumps(results, indent=2)) + return + + platform_str = results.get("platform", "unknown") + + if results["events"]: + print(f"\n{'='*70}") + print(f"EVENTS ({platform_str}) — {len(results['events'])} matches") + print(f"{'='*70}") + if args.format == "brief": + for ev in results["events"]: + print(f" {ev['name']}") + else: + for ev in results["events"]: + dep = " [DEPRECATED]" if ev["deprecated"] else "" + print(f" {ev['name']}{dep}") + print(f" {ev['description'][:80]}") + print(f" Code: {ev['event_code']}, UMask: {ev['umask']}, " + f"Counter: {ev['counter']}, PEBS: {ev['precise']}") + print() + + if results["metrics"]: + print(f"\n{'='*70}") + print(f"METRICS ({platform_str}) — {len(results['metrics'])} matches") + print(f"{'='*70}") + if args.format == "brief": + for m in results["metrics"]: + print(f" {m['name']} (L{m['level']}, {m['category']})") + else: + for m in results["metrics"]: + parent = f" → {m['parent_category']}" if m.get("parent_category") else "" + print(f" {m['name']} (L{m['level']}, {m['category']}{parent})") + print(f" {m['description'][:80]}") + if m.get("unit"): + print(f" Unit: {m['unit']}") + if m.get("metric_group"): + print(f" Groups: {m['metric_group']}") + print() + + total = len(results.get("events", [])) + len(results.get("metrics", [])) + if total == 0: + print(f"No results for '{args.query}' on {platform_str}") + if not args.cross_arch: + print(" Try --cross-arch to search all platforms") diff --git a/skills/tma-drilldown/src/perfmon_tools/cli/main.py b/skills/tma-drilldown/src/perfmon_tools/cli/main.py new file mode 100644 index 0000000..2d53fb8 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/cli/main.py @@ -0,0 +1,31 @@ +"""Main CLI entry point for perfmon-skills.""" + +import argparse +import sys + + +def main(): + parser = argparse.ArgumentParser( + prog="perfmon-skills", + description="Performance analysis toolkit built on Intel perfmon data", + ) + subparsers = parser.add_subparsers(dest="command") + + # Register subcommands + from . import lookup_cmd, cmdgen_cmd, compare_cmd, recommend_cmd, trace_cmd + lookup_cmd.add_parser(subparsers) + cmdgen_cmd.add_parser(subparsers) + compare_cmd.add_parser(subparsers) + recommend_cmd.add_parser(subparsers) + trace_cmd.add_parser(subparsers) + + args = parser.parse_args() + if not hasattr(args, "func"): + parser.print_help() + sys.exit(1) + + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/skills/tma-drilldown/src/perfmon_tools/cli/recommend_cmd.py b/skills/tma-drilldown/src/perfmon_tools/cli/recommend_cmd.py new file mode 100644 index 0000000..a4c486c --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/cli/recommend_cmd.py @@ -0,0 +1,206 @@ +"""CLI for the recommendation engine.""" + +import argparse +import json +import sys + + +def add_parser(subparsers): + parser = subparsers.add_parser("recommend", help="TMA-guided performance investigation") + sub = parser.add_subparsers(dest="action") + + # start + start = sub.add_parser("start", help="Start new investigation session") + start.add_argument("--platform", "-p", help="Platform shortname (default: auto-detect)") + start.add_argument("--pid", type=int, help="Target PID") + start.add_argument("--cmd", help="Command to profile") + start.add_argument("--duration", "-d", type=int, default=5, help="Duration (seconds)") + start.set_defaults(func=run_start) + + # analyze + analyze = sub.add_parser("analyze", help="Analyze perf stat output") + analyze.add_argument("--input", "-i", help="Path to perf stat output file") + analyze.add_argument("--stdin", action="store_true", help="Read from stdin") + analyze.add_argument("--session", help="Session directory path") + analyze.set_defaults(func=run_analyze) + + # status + status = sub.add_parser("status", help="Show current session state") + status.add_argument("--session", help="Session directory path") + status.set_defaults(func=run_status) + + # summary + summary = sub.add_parser("summary", help="Show investigation summary") + summary.add_argument("--session", help="Session directory path") + summary.set_defaults(func=run_summary) + + # General format option + parser.add_argument("--format", "-f", choices=["text", "json"], default="text") + parser.set_defaults(func=lambda args: parser.print_help()) + + +def run_start(args): + from ..recommend.engine import RecommendationEngine + + engine = RecommendationEngine() + result = engine.start( + platform=args.platform, + pid=args.pid, + command=args.cmd, + duration=args.duration, + ) + + fmt = getattr(args, "format", "text") + if fmt == "json": + print(json.dumps(result, indent=2, default=str)) + return + + print(f"\n{'='*70}") + print(f"NEW INVESTIGATION SESSION") + print(f"{'='*70}") + print(f" Platform: {result['platform']}") + print(f" Session: {result['session_dir']}") + print(f" Strategy: {result['strategy']}") + if result.get("notes"): + for note in result["notes"]: + print(f" Note: {note}") + print(f"\n Counter budget: {result['counter_budget']}") + print(f"\n{'='*70}") + print(f" STEP 1: Run this command and feed the output back:") + print(f"{'='*70}") + print(f"\n {result['command']}") + print(f"\n Then run: perfmon-skills recommend analyze --input ") + print(f" Or pipe: 2>&1 | perfmon-skills recommend analyze --stdin") + print() + + +def run_analyze(args): + from ..recommend.engine import RecommendationEngine + + # Read perf output + if args.input: + from pathlib import Path + perf_output = Path(args.input).read_text() + elif args.stdin or not sys.stdin.isatty(): + perf_output = sys.stdin.read() + else: + print("Error: provide --input FILE or --stdin, or pipe perf output") + sys.exit(1) + + engine = RecommendationEngine() + result = engine.analyze( + perf_output=perf_output, + session_dir=args.session, + ) + + fmt = getattr(args, "format", "text") + if fmt == "json": + print(json.dumps(result, indent=2, default=str)) + return + + print(f"\n{'='*70}") + print(f"ANALYSIS — Step {result['step']} [{result['state']}]") + print(f"{'='*70}") + print(f" Path: {' → '.join(result['path']) or '(root)'}") + print() + + # Show results + print(" Node Values:") + for r in result["results"]: + if r["value"] is not None: + marker = " ◀ BOTTLENECK" if r["threshold_passed"] else "" + print(f" {r['name']:30s} {r['value']:6.1f}%{marker}") + + # Multiplexing warnings + if result.get("multiplexing_issues"): + print(f"\n ⚠ Multiplexing issues:") + for m in result["multiplexing_issues"]: + print(f" {m['event']}: measured {m['measured_pct']:.1f}% of time") + + if result["is_complete"]: + print(f"\n{'='*70}") + print(f" INVESTIGATION COMPLETE") + print(f"{'='*70}") + print(f" Bottleneck path: {' → '.join(result['path'])}") + if result.get("guidance"): + g = result["guidance"] + print(f"\n Diagnosis: {g.get('brief', '')}") + print(f"\n Suggestions:") + for s in g.get("suggestions", []): + print(f" - {s}") + if g.get("compiler_suggestion"): + print(f"\n Compiler: {g['compiler_suggestion']}") + if result.get("sampling_suggestion"): + ss = result["sampling_suggestion"] + print(f"\n For code-level localization, run:") + print(f" {ss['command']}") + else: + print(f"\n{'='*70}") + print(f" NEXT STEP: Run this command:") + print(f"{'='*70}") + print(f"\n {result.get('next_command', '(no command generated)')}") + print(f"\n Then: perfmon-skills recommend analyze --input ") + print() + + +def run_status(args): + from ..recommend.engine import RecommendationEngine + + engine = RecommendationEngine() + result = engine.status(session_dir=args.session) + + fmt = getattr(args, "format", "text") + if fmt == "json": + print(json.dumps(result, indent=2)) + return + + print(f"\n State: {result.get('state', 'unknown')}") + print(f" Platform: {result.get('platform', 'unknown')}") + print(f" Step: {result.get('step', 0)}") + print(f" Path: {' → '.join(result.get('path', []))}") + if result.get("findings"): + print(f" Findings:") + for f in result["findings"]: + print(f" L{f['level']}: {f['node']} = {f['value']:.1f}%") + print() + + +def run_summary(args): + from ..recommend.engine import RecommendationEngine + + engine = RecommendationEngine() + result = engine.summary(session_dir=args.session) + + fmt = getattr(args, "format", "text") + if fmt == "json": + print(json.dumps(result, indent=2, default=str)) + return + + if "error" in result: + print(f" {result['error']}") + return + + if result.get("message"): + print(f" {result['message']}") + return + + print(f"\n{'='*70}") + print(f"INVESTIGATION SUMMARY") + print(f"{'='*70}") + path = result.get("bottleneck_path", []) + print(f" Bottleneck: {' → '.join(path)}") + print(f" Final node: {result.get('final_node', '')}") + if result.get("guidance"): + g = result["guidance"] + print(f"\n {g.get('brief', '')}") + for s in g.get("suggestions", []): + print(f" - {s}") + if result.get("coverage_pct") is not None: + print(f"\n Event coverage: {result['coverage_pct']:.1f}%") + if result.get("suggested_expansions"): + print(f"\n Suggested deeper investigation:") + for s in result["suggested_expansions"]: + print(f" {s['node']}: {s['rationale']}") + for ev in s.get("events", [])[:3]: + print(f" - {ev}") + print() diff --git a/skills/tma-drilldown/src/perfmon_tools/cli/trace_cmd.py b/skills/tma-drilldown/src/perfmon_tools/cli/trace_cmd.py new file mode 100644 index 0000000..ff6fab3 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/cli/trace_cmd.py @@ -0,0 +1,90 @@ +"""CLI command for trace visualization.""" + +import argparse +import json +import sys +from pathlib import Path + + +def add_parser(subparsers): + parser = subparsers.add_parser("trace", help="Visualize decision trace from a session") + parser.add_argument( + "--session", metavar="DIR", help="Path to session directory containing trace.json" + ) + parser.add_argument( + "--last", action="store_true", help="Use the most recent session" + ) + parser.add_argument( + "--format", "-f", + choices=["json", "mermaid", "dot", "html"], + default="mermaid", + help="Output format (default: mermaid)", + ) + parser.add_argument( + "--output", "-o", metavar="FILE", help="Write output to file instead of stdout" + ) + parser.set_defaults(func=run_trace) + + +def run_trace(args): + from ..core.tracer import DecisionNode, Trace + from ..recommend.session import Session + + # Resolve session directory + if args.session: + session_dir = Path(args.session) + elif args.last: + base_dir = Path.cwd() / "sessions" + session = Session.find_latest(base_dir) + if session is None: + print("Error: no sessions found", file=sys.stderr) + sys.exit(1) + session_dir = session.dir + else: + print("Error: specify --session DIR or --last", file=sys.stderr) + sys.exit(1) + + # Load trace data + trace_path = session_dir / "trace.json" + if not trace_path.exists(): + print(f"Error: no trace.json found in {session_dir}", file=sys.stderr) + sys.exit(1) + + data = json.loads(trace_path.read_text()) + + # Reconstruct Trace object + trace = Trace() + for node_dict in data.get("nodes", []): + node = DecisionNode( + id=node_dict.get("id", ""), + timestamp=node_dict.get("timestamp", ""), + actor=node_dict.get("actor", "system"), + operation=node_dict.get("operation", ""), + inputs=node_dict.get("inputs", {}), + reasoning=node_dict.get("reasoning", ""), + decision=node_dict.get("decision", ""), + alternatives=node_dict.get("alternatives", []), + confidence=node_dict.get("confidence", 1.0), + parent_id=node_dict.get("parent_id"), + children_ids=node_dict.get("children_ids", []), + metadata=node_dict.get("metadata", {}), + duration_ms=node_dict.get("duration_ms", 0.0), + ) + trace.nodes.append(node) + trace._node_map[node.id] = node + + # Render in requested format + if args.format == "json": + output = trace.to_json() + elif args.format == "mermaid": + output = trace.to_mermaid() + elif args.format == "dot": + output = trace.to_dot() + elif args.format == "html": + output = trace.to_html() + + # Write output + if args.output: + Path(args.output).write_text(output) + else: + print(output) diff --git a/skills/tma-drilldown/src/perfmon_tools/cmdgen/__init__.py b/skills/tma-drilldown/src/perfmon_tools/cmdgen/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skills/tma-drilldown/src/perfmon_tools/cmdgen/generate.py b/skills/tma-drilldown/src/perfmon_tools/cmdgen/generate.py new file mode 100644 index 0000000..49e2fc9 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/cmdgen/generate.py @@ -0,0 +1,286 @@ +"""Generate perf stat/record commands from metrics or TMA levels.""" + +from pathlib import Path +from typing import Optional + +from ..core.platform import ( + PlatformInfo, + _find_perfmon_root, + detect_cpu, + resolve_platform, +) +from ..core.catalog import PlatformCatalog +from ..core.tma_tree import TmaTree + + +def generate_perf_command( + platform: Optional[str] = None, + metrics: Optional[list] = None, + tma_level: Optional[int] = None, + tma_node: Optional[str] = None, + events: Optional[list] = None, + duration: int = 5, + pid: Optional[int] = None, + command: Optional[str] = None, + json_output: bool = False, + per_core: bool = False, + interval_ms: Optional[int] = None, + repeat: Optional[int] = None, +) -> dict: + """Generate perf stat command(s). + + Returns dict with: + - commands: list of perf command strings + - events_used: set of event names + - counter_info: counter budget analysis + - notes: any warnings or suggestions + """ + perfmon_root = _find_perfmon_root() + + # Resolve platform + from ..lookup.search import _resolve_by_shortname + if platform: + plat_info = _resolve_by_shortname(platform, perfmon_root) + else: + cpu = detect_cpu() + plat_info = resolve_platform(cpu, perfmon_root) + + catalog = PlatformCatalog(plat_info, perfmon_root) + tree = TmaTree(catalog) + + # Collect required events + required_events = set() + notes = [] + description = "" + + if events: + required_events.update(events) + description = f"Custom events" + + if metrics: + for metric_name in metrics: + m = catalog.get_metric(metric_name) + if m: + required_events.update(m.event_names_with_modifiers) + else: + notes.append(f"Metric '{metric_name}' not found") + description = f"Metrics: {', '.join(metrics)}" + + if tma_level is not None: + level_nodes = tree.get_nodes_at_level(tma_level) + for node in level_nodes: + required_events.update(node.metric.event_names_with_modifiers) + description = f"TMA Level {tma_level} ({len(level_nodes)} nodes)" + + if tma_node: + node = tree.get_node(tma_node) + if node: + # Get events for this node's children (drill-down) + children = tree.get_children(tma_node) + if children: + for child in children: + required_events.update(child.metric.event_names_with_modifiers) + description = f"TMA drill-down: {tma_node} → {[c.name for c in children]}" + else: + required_events.update(node.metric.event_names_with_modifiers) + description = f"TMA node: {tma_node} (leaf)" + else: + notes.append(f"TMA node '{tma_node}' not found") + + if not required_events: + # Default: TMA Level 1 + for root in tree.roots: + required_events.update(root.metric.event_names_with_modifiers) + description = "TMA Level 1 (default)" + + # Analyze counter budget + counter_info = _analyze_counter_budget(required_events, plat_info, catalog) + + # Build perf command(s) + commands = _build_commands( + required_events, + plat_info, + duration=duration, + pid=pid, + command=command, + json_output=json_output, + per_core=per_core, + interval_ms=interval_ms, + repeat=repeat, + counter_info=counter_info, + ) + + if counter_info["needs_multiplexing"]: + notes.append( + f"Requires {counter_info['events_count']} events but only " + f"{counter_info['available_counters']} counters available. " + f"Multiplexing will reduce accuracy." + ) + + return { + "commands": commands, + "events_used": sorted(required_events), + "counter_info": counter_info, + "notes": notes, + "description": description, + "platform": plat_info.shortname, + } + + +def _analyze_counter_budget(events: set, platform: PlatformInfo, catalog) -> dict: + """Analyze whether events fit in available counters.""" + # Determine available counters based on platform generation + if platform.default_level >= 2: + # ICL+: 8 GP + 4 fixed + perf_metrics + gp_counters = 8 + fixed_counters = 4 + elif platform.default_level == 1: + gp_counters = 8 + fixed_counters = 4 + else: + # Pre-ICL: 4 GP + 3 fixed + gp_counters = 4 + fixed_counters = 3 + + # Count fixed vs programmable events + fixed_events = set() + programmable_events = set() + perf_metrics_events = set() + + for ev_name in events: + base_name = ev_name.split(":")[0] + ev_def = catalog.get_event(base_name) + + if "PERF_METRICS" in ev_name or "TOPDOWN.SLOTS" in ev_name: + perf_metrics_events.add(ev_name) + elif ev_def and ev_def.is_fixed: + fixed_events.add(ev_name) + else: + programmable_events.add(ev_name) + + needs_multiplexing = len(programmable_events) > gp_counters + available = gp_counters + fixed_counters + + return { + "events_count": len(events), + "programmable_events": len(programmable_events), + "fixed_events": len(fixed_events), + "perf_metrics_events": len(perf_metrics_events), + "available_counters": available, + "gp_counters": gp_counters, + "needs_multiplexing": needs_multiplexing, + "estimated_mux_ratio": ( + len(programmable_events) / gp_counters if needs_multiplexing else 1.0 + ), + } + + +def _build_commands( + events: set, + platform: PlatformInfo, + duration: int, + pid: Optional[int], + command: Optional[str], + json_output: bool, + per_core: bool, + interval_ms: Optional[int], + repeat: Optional[int], + counter_info: dict, +) -> list: + """Build perf stat command string(s).""" + # Format event names for perf + event_specs = [] + for ev in sorted(events): + spec = _format_event_spec(ev, platform) + event_specs.append(spec) + + # Build base command + parts = ["perf stat"] + + if json_output: + parts.append("-j") + if per_core: + parts.append("--per-core") + if interval_ms: + parts.append(f"-I {interval_ms}") + if repeat: + parts.append(f"-r {repeat}") + + # Event list + parts.append("-e") + parts.append(",".join(event_specs)) + + # Target + if pid: + parts.append(f"-p {pid}") + if duration: + parts.append(f"sleep {duration}") + elif command: + parts.append("--") + parts.append(command) + else: + parts.append(f"sleep {duration}") + + return [" ".join(parts)] + + +def _format_event_spec(event_name: str, platform: PlatformInfo) -> str: + """Format event name as perf event specifier. + + Handles: + - PERF_METRICS.* → topdown-* perf events + - TOPDOWN.SLOTS:perf_metrics → slots + - Regular events: cpu_core/EVENT.NAME/ or cpu/EVENT.NAME/ + - Events with modifiers: EVENT.NAME:c1:e1 + """ + # PERF_METRICS → perf's built-in topdown events + perf_metrics_map = { + "PERF_METRICS.FRONTEND_BOUND": "topdown-fe-bound", + "PERF_METRICS.BAD_SPECULATION": "topdown-bad-spec", + "PERF_METRICS.BACKEND_BOUND": "topdown-be-bound", + "PERF_METRICS.RETIRING": "topdown-retiring", + "PERF_METRICS.FETCH_LATENCY": "topdown-fetch-lat", + "PERF_METRICS.BRANCH_MISPREDICTS": "topdown-br-mispredict", + "PERF_METRICS.MEMORY_BOUND": "topdown-mem-bound", + "PERF_METRICS.HEAVY_OPS": "topdown-heavy-ops", + } + + if event_name in perf_metrics_map: + return perf_metrics_map[event_name] + + if "TOPDOWN.SLOTS" in event_name: + return "slots" + + # Regular event with possible modifiers + parts = event_name.split(":") + base = parts[0] + modifiers = parts[1:] if len(parts) > 1 else [] + + # Build perf event spec + if platform.is_hybrid: + # For hybrid, specify cpu_core or cpu_atom + prefix = "cpu_core" + else: + prefix = "cpu" + + spec = f"{prefix}/{base}/" + if modifiers: + # Translate modifiers: c1 -> cmask=1, e1 -> edge=1 + for mod in modifiers: + if mod == "perf_metrics": + continue + # Keep modifier as-is for perf + spec = f"{prefix}/{base},{_translate_modifier(mod)}/" + + return spec + + +def _translate_modifier(mod: str) -> str: + """Translate event modifier shorthand to perf format.""" + if mod.startswith("c") and mod[1:].isdigit(): + return f"cmask={mod[1:]}" + if mod.startswith("e") and mod[1:].isdigit(): + return f"edge={mod[1:]}" + if mod.startswith("i") and mod[1:].isdigit(): + return f"inv={mod[1:]}" + return mod diff --git a/skills/tma-drilldown/src/perfmon_tools/compare/__init__.py b/skills/tma-drilldown/src/perfmon_tools/compare/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skills/tma-drilldown/src/perfmon_tools/compare/diff.py b/skills/tma-drilldown/src/perfmon_tools/compare/diff.py new file mode 100644 index 0000000..a81a306 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/compare/diff.py @@ -0,0 +1,180 @@ +"""Cross-platform comparison of events and metrics.""" + +from typing import Optional + +from ..core.platform import _find_perfmon_root +from ..core.catalog import PlatformCatalog +from ..lookup.search import _resolve_by_shortname + + +def compare_platforms( + platform1: str, + platform2: str, + compare_type: str = "all", # "events", "metrics", "all" + metric_name: Optional[str] = None, + event_name: Optional[str] = None, + category: Optional[str] = None, +) -> dict: + """Compare events/metrics between two platforms. + + Returns: + dict with added, removed, changed entries for events and metrics + """ + perfmon_root = _find_perfmon_root() + plat1 = _resolve_by_shortname(platform1, perfmon_root) + plat2 = _resolve_by_shortname(platform2, perfmon_root) + cat1 = PlatformCatalog(plat1, perfmon_root) + cat2 = PlatformCatalog(plat2, perfmon_root) + + result = { + "platform1": platform1, + "platform2": platform2, + "events": {"added": [], "removed": [], "changed": []}, + "metrics": {"added": [], "removed": [], "changed": []}, + } + + # Compare specific metric + if metric_name: + m1 = cat1.get_metric(metric_name) + m2 = cat2.get_metric(metric_name) + if m1 and m2: + diff = _diff_metric(m1, m2) + if diff: + result["metrics"]["changed"].append(diff) + elif m1 and not m2: + result["metrics"]["removed"].append({"name": metric_name}) + elif not m1 and m2: + result["metrics"]["added"].append({"name": metric_name}) + return result + + # Compare specific event + if event_name: + e1 = cat1.get_event(event_name) + e2 = cat2.get_event(event_name) + if e1 and e2: + diff = _diff_event(e1, e2) + if diff: + result["events"]["changed"].append(diff) + elif e1 and not e2: + result["events"]["removed"].append({"name": event_name}) + elif not e1 and e2: + result["events"]["added"].append({"name": event_name}) + return result + + # Full comparison + if compare_type in ("events", "all"): + _compare_events(cat1, cat2, result) + + if compare_type in ("metrics", "all"): + _compare_metrics(cat1, cat2, result, category=category) + + return result + + +def _compare_events(cat1, cat2, result): + """Compare all core events between two catalogs.""" + # Only compare core events (not uncore) + names1 = {e.name for e in cat1.events if not e.deprecated and "uncore" not in str(e.raw.get("Unit", "")).lower()} + names2 = {e.name for e in cat2.events if not e.deprecated and "uncore" not in str(e.raw.get("Unit", "")).lower()} + + # Added in platform2 + for name in sorted(names2 - names1): + ev = cat2.get_event(name) + result["events"]["added"].append({ + "name": name, + "description": ev.brief_description[:80] if ev else "", + }) + + # Removed from platform2 + for name in sorted(names1 - names2): + ev = cat1.get_event(name) + result["events"]["removed"].append({ + "name": name, + "description": ev.brief_description[:80] if ev else "", + }) + + # Changed (same name, different encoding) + for name in sorted(names1 & names2): + e1 = cat1.get_event(name) + e2 = cat2.get_event(name) + if e1 and e2: + diff = _diff_event(e1, e2) + if diff: + result["events"]["changed"].append(diff) + + +def _compare_metrics(cat1, cat2, result, category=None): + """Compare all metrics between two catalogs.""" + metrics1 = {m.name: m for m in cat1.metrics} + metrics2 = {m.name: m for m in cat2.metrics} + + if category: + metrics1 = {k: v for k, v in metrics1.items() if v.category.lower() == category.lower()} + metrics2 = {k: v for k, v in metrics2.items() if v.category.lower() == category.lower()} + + names1 = set(metrics1.keys()) + names2 = set(metrics2.keys()) + + for name in sorted(names2 - names1): + m = metrics2[name] + result["metrics"]["added"].append({ + "name": name, + "level": m.level, + "category": m.category, + "description": m.brief_description[:80], + }) + + for name in sorted(names1 - names2): + m = metrics1[name] + result["metrics"]["removed"].append({ + "name": name, + "level": m.level, + "category": m.category, + "description": m.brief_description[:80], + }) + + for name in sorted(names1 & names2): + diff = _diff_metric(metrics1[name], metrics2[name]) + if diff: + result["metrics"]["changed"].append(diff) + + +def _diff_event(e1, e2) -> Optional[dict]: + """Compare two event definitions.""" + changes = {} + if e1.event_code != e2.event_code: + changes["event_code"] = {"from": e1.event_code, "to": e2.event_code} + if e1.umask != e2.umask: + changes["umask"] = {"from": e1.umask, "to": e2.umask} + if e1.counter != e2.counter: + changes["counter"] = {"from": e1.counter, "to": e2.counter} + if not changes: + return None + return {"name": e1.name, "changes": changes} + + +def _diff_metric(m1, m2) -> Optional[dict]: + """Compare two metric definitions.""" + changes = {} + if m1.formula != m2.formula: + changes["formula"] = {"from": m1.formula, "to": m2.formula} + if m1.base_formula != m2.base_formula: + changes["base_formula"] = {"from": m1.base_formula, "to": m2.base_formula} + + events1 = {e["Name"] for e in m1.events} + events2 = {e["Name"] for e in m2.events} + if events1 != events2: + changes["events_added"] = sorted(events2 - events1) + changes["events_removed"] = sorted(events1 - events2) + + if m1.level != m2.level: + changes["level"] = {"from": m1.level, "to": m2.level} + + if not changes: + return None + return { + "name": m1.name, + "category": m1.category, + "level": m1.level, + "changes": changes, + } diff --git a/skills/tma-drilldown/src/perfmon_tools/core/__init__.py b/skills/tma-drilldown/src/perfmon_tools/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skills/tma-drilldown/src/perfmon_tools/core/catalog.py b/skills/tma-drilldown/src/perfmon_tools/core/catalog.py new file mode 100644 index 0000000..cd3e36d --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/core/catalog.py @@ -0,0 +1,283 @@ +"""Event and metric loading, indexing, and search.""" + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + + +@dataclass +class EventDef: + name: str + event_code: str + umask: str + brief_description: str + public_description: str + counter: str + sample_after_value: str + precise: str + deprecated: bool + platform: str + core_type: str + raw: dict = field(repr=False, default_factory=dict) + + @property + def is_fixed(self) -> bool: + return "fixed" in self.counter.lower() + + @property + def raw_encoding(self) -> str: + return f"event={self.event_code},umask={self.umask}" + + +@dataclass +class MetricDef: + name: str + legacy_name: str + level: int + brief_description: str + unit_of_measure: str + events: list # [{Name, Alias}] + constants: list # [{Name, Alias}] + formula: str + base_formula: str + category: str + parent_category: str + threshold: dict + metric_group: str + locate_with: str + count_domain: str + resolution_levels: str + platform: str + core_type: str + + @property + def is_tma(self) -> bool: + return self.category == "TMA" + + @property + def is_bottleneck(self) -> bool: + return self.name.startswith("Bottleneck_") + + @property + def is_info(self) -> bool: + return self.name.startswith("Info_") + + @property + def is_tree_node(self) -> bool: + return self.is_tma and not self.is_bottleneck and not self.is_info + + @property + def event_names(self) -> set: + """All event names referenced in this metric (without modifiers).""" + return {ev["Name"].split(":")[0] for ev in self.events} + + @property + def event_names_with_modifiers(self) -> set: + """All event names with their modifiers.""" + return {ev["Name"] for ev in self.events} + + +def _load_events_file(path: Path, platform: str, core_type: str) -> list: + """Load a single event JSON file.""" + with open(path) as f: + data = json.load(f) + + events = [] + for raw in data.get("Events", []): + deprecated = ( + raw.get("Deprecated", "0") == "1" + or "deprecated" in raw.get("BriefDescription", "").lower() + ) + events.append( + EventDef( + name=raw.get("EventName", ""), + event_code=raw.get("EventCode", ""), + umask=raw.get("UMask", ""), + brief_description=raw.get("BriefDescription", ""), + public_description=raw.get("PublicDescription", ""), + counter=raw.get("Counter", ""), + sample_after_value=raw.get("SampleAfterValue", ""), + precise=raw.get("Precise", "0"), + deprecated=deprecated, + platform=platform, + core_type=core_type, + raw=raw, + ) + ) + return events + + +def _load_metrics_file(path: Path, platform: str, core_type: str) -> list: + """Load a single metric JSON file.""" + with open(path) as f: + data = json.load(f) + + metrics = [] + for raw in data.get("Metrics", []): + metrics.append( + MetricDef( + name=raw.get("MetricName", ""), + legacy_name=raw.get("LegacyName", ""), + level=raw.get("Level", 0), + brief_description=raw.get("BriefDescription", ""), + unit_of_measure=raw.get("UnitOfMeasure", ""), + events=raw.get("Events", []), + constants=raw.get("Constants", []), + formula=raw.get("Formula", ""), + base_formula=raw.get("BaseFormula", ""), + category=raw.get("Category", ""), + parent_category=raw.get("ParentCategory", ""), + threshold=raw.get("Threshold", {}), + metric_group=raw.get("MetricGroup", ""), + locate_with=raw.get("LocateWith", ""), + count_domain=raw.get("CountDomain", ""), + resolution_levels=raw.get("ResolutionLevels", ""), + platform=platform, + core_type=core_type, + ) + ) + return metrics + + +class PlatformCatalog: + """Loaded event + metric catalog for one platform.""" + + def __init__(self, platform_info, perfmon_root: Optional[Path] = None): + from .platform import _find_perfmon_root + + if perfmon_root is None: + perfmon_root = _find_perfmon_root() + + self.platform = platform_info + self._events = [] + self._metrics = [] + self._event_index = {} # name -> EventDef + self._metric_index = {} # name -> MetricDef + + for core in platform_info.core_types: + core_type = core.core_type or "" + for etype, path in core.event_files.items(): + if path.exists(): + self._events.extend( + _load_events_file(path, platform_info.shortname, core_type) + ) + for path in core.metrics_files: + if path.exists(): + self._metrics.extend( + _load_metrics_file(path, platform_info.shortname, core_type) + ) + + # Build indexes + for ev in self._events: + self._event_index[ev.name] = ev + self._event_index[ev.name.upper()] = ev + + for m in self._metrics: + self._metric_index[m.name] = m + if m.legacy_name: + self._metric_index[m.legacy_name] = m + + @property + def events(self) -> list: + return self._events + + @property + def metrics(self) -> list: + return self._metrics + + @property + def tma_metrics(self) -> list: + return [m for m in self._metrics if m.is_tma] + + @property + def bottleneck_metrics(self) -> list: + return [m for m in self._metrics if m.is_bottleneck] + + @property + def tree_nodes(self) -> list: + return [m for m in self._metrics if m.is_tree_node] + + @property + def info_metrics(self) -> list: + return [m for m in self._metrics if m.is_info] + + def get_event(self, name: str) -> Optional[EventDef]: + return self._event_index.get(name) or self._event_index.get(name.upper()) + + def get_metric(self, name: str) -> Optional[MetricDef]: + return self._metric_index.get(name) + + def get_metrics_by_level(self, level: int) -> list: + return [m for m in self._metrics if m.level == level] + + def get_metrics_by_category(self, category: str) -> list: + return [m for m in self._metrics if m.category.lower() == category.lower()] + + def search_events( + self, query: str, include_deprecated: bool = False + ) -> list: + """Search events by name or description (case-insensitive).""" + query_lower = query.lower() + tokens = query_lower.split() + results = [] + for ev in self._events: + if not include_deprecated and ev.deprecated: + continue + text = f"{ev.name} {ev.brief_description}".lower() + if all(t in text for t in tokens): + results.append(ev) + return results + + def search_metrics( + self, query: str, category: Optional[str] = None + ) -> list: + """Search metrics by name, description, or group (case-insensitive).""" + query_lower = query.lower() + tokens = query_lower.split() + results = [] + for m in self._metrics: + if category and m.category.lower() != category.lower(): + continue + text = f"{m.name} {m.brief_description} {m.metric_group}".lower() + if all(t in text for t in tokens): + results.append(m) + return results + + def get_all_referenced_events(self) -> set: + """All event names referenced by any metric formula or LocateWith.""" + referenced = set() + for m in self._metrics: + for ev in m.events: + referenced.add(ev["Name"].split(":")[0]) + if m.locate_with and m.locate_with != "#NA": + for e in m.locate_with.split(";"): + referenced.add(e.strip()) + return referenced + + def get_unreachable_events(self, include_deprecated: bool = False) -> list: + """Events not referenced by any metric or LocateWith.""" + referenced = self.get_all_referenced_events() + unreachable = [] + for ev in self._events: + if not include_deprecated and ev.deprecated: + continue + if ev.name not in referenced: + unreachable.append(ev) + return unreachable + + def coverage_stats(self) -> dict: + """Event coverage statistics.""" + non_deprecated = [e for e in self._events if not e.deprecated] + referenced = self.get_all_referenced_events() + reached = [e for e in non_deprecated if e.name in referenced] + unreachable = [e for e in non_deprecated if e.name not in referenced] + return { + "total_events": len(non_deprecated), + "reached_events": len(reached), + "unreachable_events": len(unreachable), + "coverage_pct": ( + 100.0 * len(reached) / len(non_deprecated) if non_deprecated else 0 + ), + } diff --git a/skills/tma-drilldown/src/perfmon_tools/core/context_budget.py b/skills/tma-drilldown/src/perfmon_tools/core/context_budget.py new file mode 100644 index 0000000..6567356 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/core/context_budget.py @@ -0,0 +1,120 @@ +"""Context window budget tracking for the recommendation engine. + +Prevents attention loss by tracking token usage across stateful workflows, +enforcing compression, and ensuring decision-relevant data stays within +the LLM's effective attention window. +""" + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class StepUsage: + step: int + raw_size: int # tokens in raw perf output + compressed_size: int # tokens in compact finding + passed_forward: int # tokens actually sent to LLM + + +@dataclass +class ContextBudget: + max_tokens: int = 8192 + current_tokens: int = 0 + per_step_usage: list = field(default_factory=list) + + def estimate_tokens(self, text: str) -> int: + """Approximate token count (~4 chars per token for mixed content).""" + return len(text) // 4 + + def would_exceed(self, new_data: str) -> bool: + """Check if adding new_data exceeds budget.""" + return self.current_tokens + self.estimate_tokens(new_data) > self.max_tokens + + def record_step(self, step: int, raw_text: str, compact_finding: str): + """Record token usage for a completed step.""" + raw_tokens = self.estimate_tokens(raw_text) + compact_tokens = self.estimate_tokens(compact_finding) + self.current_tokens += compact_tokens + self.per_step_usage.append( + StepUsage( + step=step, + raw_size=raw_tokens, + compressed_size=compact_tokens, + passed_forward=compact_tokens, + ) + ) + + @property + def headroom(self) -> int: + return max(0, self.max_tokens - self.current_tokens) + + @property + def compression_ratio(self) -> float: + """Overall compression: how much we reduced raw data.""" + total_raw = sum(s.raw_size for s in self.per_step_usage) + total_compressed = sum(s.compressed_size for s in self.per_step_usage) + if total_raw == 0: + return 1.0 + return total_compressed / total_raw + + def report(self) -> dict: + return { + "max_tokens": self.max_tokens, + "current_tokens": self.current_tokens, + "headroom": self.headroom, + "steps": len(self.per_step_usage), + "compression_ratio": f"{self.compression_ratio:.2%}", + "per_step": [ + { + "step": s.step, + "raw_tokens": s.raw_size, + "compressed_tokens": s.compressed_size, + } + for s in self.per_step_usage + ], + } + + +def prepare_focus_window( + findings: list, + current_step_data: dict, + budget: int = 4096, +) -> dict: + """Prepare data for LLM consumption with attention-aware ordering. + + Strategy: critical data at start and end (not buried in middle). + + Args: + findings: list of compact findings from prior steps + current_step_data: current step's analyzed data (metrics, thresholds) + budget: max tokens for the focus window + + Returns: + dict with 'history' (prior findings) and 'current' (focus data) + """ + # History always fits (compact findings are ~200 tokens each) + history_tokens = sum(len(str(f)) // 4 for f in findings) + + # Current step: prioritize by relevance + current = current_step_data.copy() + if "node_values" in current: + # Sort by value descending (most important first) + sorted_nodes = sorted( + current["node_values"].items(), key=lambda x: x[1], reverse=True + ) + remaining_budget = budget - history_tokens + # Keep top nodes that fit in budget + trimmed = {} + for name, value in sorted_nodes: + entry_tokens = len(f"{name}: {value}") // 4 + if remaining_budget - entry_tokens < 0: + break + trimmed[name] = value + remaining_budget -= entry_tokens + current["node_values"] = trimmed + + return { + "history": findings, # at the start (attention is good here) + "current": current, # at the end (attention is also good here) + } diff --git a/skills/tma-drilldown/src/perfmon_tools/core/formula.py b/skills/tma-drilldown/src/perfmon_tools/core/formula.py new file mode 100644 index 0000000..8988190 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/core/formula.py @@ -0,0 +1,176 @@ +"""Formula expansion and evaluation for perfmon metrics.""" + +import math +import re +from typing import Optional + + +def expand_formula(metric) -> str: + """Replace aliases (a, b, c...) with actual event/constant names in formula.""" + formula = metric.formula + if not formula: + return "" + + # Build alias -> name mapping + alias_map = {} + for ev in metric.events: + alias_map[ev["Alias"]] = ev["Name"] + for const in metric.constants: + alias_map[const["Alias"]] = const["Name"] + + # Sort by alias length descending to avoid partial replacement + # (e.g., "aa" before "a") + sorted_aliases = sorted(alias_map.keys(), key=len, reverse=True) + + # Replace aliases that appear as standalone tokens (word boundaries) + result = formula + for alias in sorted_aliases: + # Match alias as a standalone token (not part of a longer identifier) + pattern = r'\b' + re.escape(alias) + r'\b' + result = re.sub(pattern, alias_map[alias], result) + + return result + + +def extract_events(metric) -> set: + """Return set of hardware event names needed for this metric (without modifiers).""" + return metric.event_names + + +def extract_events_with_modifiers(metric) -> set: + """Return set of event names with modifiers (e.g., UOPS_RETIRED.MS:c1:e1).""" + return metric.event_names_with_modifiers + + +def extract_constants(metric) -> set: + """Return set of constant names needed for this metric.""" + return {c["Name"] for c in metric.constants} + + +def _safe_eval_formula(formula: str, values: dict) -> Optional[float]: + """Evaluate a metric formula with the given event/constant values. + + The formula uses standard arithmetic operators and functions: + min(), max(), d_ratio(), source_count(), has_event(), if/else + """ + if not formula: + return None + + # Replace event/constant names with their values + expr = formula + + # Sort names by length descending to avoid partial replacement + sorted_names = sorted(values.keys(), key=len, reverse=True) + for name in sorted_names: + val = values[name] + pattern = re.escape(name) + expr = re.sub(pattern, str(float(val)), expr) + + # Replace common functions + expr = re.sub(r'\bmin\b', 'min', expr) + expr = re.sub(r'\bmax\b', 'max', expr) + + # Handle d_ratio (safe division) + def _d_ratio_replace(match): + return f"_d_ratio({match.group(1)}, {match.group(2)})" + expr = re.sub(r'd_ratio\s*\(([^,]+),\s*([^)]+)\)', _d_ratio_replace, expr) + + # Handle source_count (returns 1 for counting mode) + expr = re.sub(r'source_count\s*\([^)]*\)', '1', expr) + + # Handle has_event (returns 1 if event value exists, 0 otherwise) + expr = re.sub(r'has_event\s*\([^)]*\)', '1', expr) + + # Handle #NA + expr = expr.replace('#NA', '0') + + # Safe eval environment + safe_globals = { + "__builtins__": {}, + "min": min, + "max": max, + "_d_ratio": lambda a, b: a / b if b != 0 else 0, + "math": math, + } + + try: + result = eval(expr, safe_globals) + if isinstance(result, (int, float)) and not math.isnan(result) and not math.isinf(result): + return float(result) + return None + except (SyntaxError, NameError, TypeError, ZeroDivisionError, ValueError): + return None + + +def evaluate_metric(metric, event_values: dict, constants: dict = None) -> Optional[float]: + """Compute metric value from collected event counts. + + Args: + metric: MetricDef with events, constants, and formula + event_values: dict of event_name -> collected value (float) + constants: dict of constant_name -> value (e.g., SYSTEM_TSC_FREQ) + """ + if constants is None: + constants = {} + + # Build alias -> value mapping + alias_values = {} + for ev in metric.events: + name = ev["Name"] + alias = ev["Alias"] + # Try exact match first, then without modifiers + base_name = name.split(":")[0] + if name in event_values: + alias_values[alias] = event_values[name] + elif base_name in event_values: + alias_values[alias] = event_values[base_name] + else: + return None # Missing required event + + for const in metric.constants: + name = const["Name"] + alias = const["Alias"] + if name in constants: + alias_values[alias] = constants[name] + else: + return None # Missing required constant + + return _safe_eval_formula(metric.formula, alias_values) + + +def evaluate_threshold(metric, metric_values: dict) -> Optional[bool]: + """Evaluate a TMA metric's threshold formula. + + Args: + metric: MetricDef with threshold field + metric_values: dict of metric_legacy_name -> value + + Returns: + True if threshold passes (bottleneck detected), False if not, None if can't evaluate + """ + threshold = metric.threshold + if not threshold: + return None + + formula = threshold.get("Formula", "") + if not formula: + return None + + threshold_metrics = threshold.get("ThresholdMetrics", []) + if not threshold_metrics: + return None + + # Build alias -> value mapping for threshold formula + alias_values = {} + for tm in threshold_metrics: + alias = tm["Alias"] + value_key = tm["Value"] + if value_key in metric_values: + alias_values[alias] = metric_values[value_key] + else: + return None + + result = _safe_eval_formula(formula, alias_values) + if result is None: + return None + return bool(result) diff --git a/skills/tma-drilldown/src/perfmon_tools/core/perf_output.py b/skills/tma-drilldown/src/perfmon_tools/core/perf_output.py new file mode 100644 index 0000000..4cfa19c --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/core/perf_output.py @@ -0,0 +1,249 @@ +"""Parse perf stat output (text and JSON formats).""" + +import json +import re +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class PerfStatResult: + event_values: dict # event_name -> value + multiplexing_issues: list # events with poor coverage + duration_seconds: Optional[float] + raw_text: str + + +@dataclass +class MultiplexingIssue: + event: str + enabled_pct: float # percentage of time event was actually measured + message: str + + +def parse_perf_stat_text(output: str) -> PerfStatResult: + """Parse standard perf stat text output. + + Handles formats like: + 1,234,567 event_name # comment + 1234567 event_name:modifier (66.52%) + event_name + """ + event_values = {} + multiplexing_issues = [] + duration = None + + for line in output.splitlines(): + line = line.strip() + if not line or line.startswith("#") or line.startswith("Performance"): + continue + + # Duration line: "1.234567890 seconds time elapsed" + dur_match = re.match(r'^\s*([\d.]+)\s+seconds\s+time\s+elapsed', line) + if dur_match: + duration = float(dur_match.group(1)) + continue + + # events + not_counted = re.match(r'^\s*\s+(\S+)', line) + if not_counted: + event_name = not_counted.group(1) + multiplexing_issues.append( + MultiplexingIssue(event_name, 0.0, f"{event_name}: not counted") + ) + continue + + # Standard value line: " 1,234,567 event_name ... (XX.XX%)" + match = re.match( + r'^\s*([\d,]+(?:\.\d+)?)\s+(\S+)(?:\s+.*?)?\s*(?:\((\d+\.\d+)%\))?\s*$', + line, + ) + if match: + value_str = match.group(1).replace(",", "") + event_name = match.group(2) + pct_str = match.group(3) + + try: + value = float(value_str) + except ValueError: + continue + + event_values[event_name] = value + + # Check multiplexing percentage + if pct_str: + pct = float(pct_str) + if pct < 90.0: + multiplexing_issues.append( + MultiplexingIssue( + event_name, + pct, + f"{event_name}: measured only {pct:.1f}% of time", + ) + ) + continue + + return PerfStatResult( + event_values=event_values, + multiplexing_issues=multiplexing_issues, + duration_seconds=duration, + raw_text=output, + ) + + +def parse_perf_stat_json(output: str) -> PerfStatResult: + """Parse perf stat JSON output (one JSON object per line). + + Each line is like: + {"counter-value": "1234.000000", "unit": "", "event": "cycles", ...} + or with newer perf: + {"counter-value": "1234", "event": "cycles", "event-runtime": 100, "pcnt-running": 100.00} + """ + event_values = {} + multiplexing_issues = [] + duration = None + + for line in output.splitlines(): + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + + event = obj.get("event", "") + if not event: + continue + + # Handle "counter-value" field + val_str = obj.get("counter-value", "") + if val_str == "" or val_str == "": + multiplexing_issues.append( + MultiplexingIssue(event, 0.0, f"{event}: not counted") + ) + continue + + try: + value = float(val_str) + except (ValueError, TypeError): + continue + + event_values[event] = value + + # Check multiplexing via pcnt-running or enabled/running ratio + pcnt = obj.get("pcnt-running") + if pcnt is not None: + try: + pct = float(pcnt) + if pct < 90.0: + multiplexing_issues.append( + MultiplexingIssue( + event, pct, f"{event}: measured only {pct:.1f}% of time" + ) + ) + except (ValueError, TypeError): + pass + + return PerfStatResult( + event_values=event_values, + multiplexing_issues=multiplexing_issues, + duration_seconds=duration, + raw_text=output, + ) + + +def parse_perf_stat_interval(output: str) -> list: + """Parse perf stat interval output (-I mode). + + Returns list of dicts, one per interval, each mapping event_name -> value. + Interval output format: + 1.000123456;event_name;1234567;;100.00 + or text format with timestamp prefix. + """ + intervals = [] + current_time = None + current_values = {} + + for line in output.splitlines(): + line = line.strip() + if not line: + continue + + # CSV format: timestamp;event;value;unit;percent + parts = line.split(";") + if len(parts) >= 3: + try: + timestamp = float(parts[0]) + value_str = parts[2].replace(",", "") + event_name = parts[1] + + if current_time is not None and abs(timestamp - current_time) > 0.001: + if current_values: + intervals.append(current_values.copy()) + current_values = {} + current_time = timestamp + + if value_str and value_str != "": + current_values[event_name] = float(value_str) + except (ValueError, IndexError): + continue + + # Don't forget the last interval + if current_values: + intervals.append(current_values) + + return intervals + + +# Reverse mapping from perf event names to perfmon canonical names +PERF_TO_PERFMON = { + "topdown-fe-bound": "PERF_METRICS.FRONTEND_BOUND", + "topdown-bad-spec": "PERF_METRICS.BAD_SPECULATION", + "topdown-be-bound": "PERF_METRICS.BACKEND_BOUND", + "topdown-retiring": "PERF_METRICS.RETIRING", + "topdown-fetch-lat": "PERF_METRICS.FETCH_LATENCY", + "topdown-br-mispredict": "PERF_METRICS.BRANCH_MISPREDICTS", + "topdown-mem-bound": "PERF_METRICS.MEMORY_BOUND", + "topdown-heavy-ops": "PERF_METRICS.HEAVY_OPS", + "slots": "TOPDOWN.SLOTS", +} + + +def _normalize_event_values(event_values: dict) -> dict: + """Normalize perf event names to perfmon canonical names. + + Handles: + - topdown-* → PERF_METRICS.* + - cpu/EVENT/ or cpu_core/EVENT/ → EVENT + - Keeps original names alongside normalized ones + """ + normalized = {} + for name, value in event_values.items(): + normalized[name] = value + + # Map perf topdown names + if name in PERF_TO_PERFMON: + normalized[PERF_TO_PERFMON[name]] = value + + # Strip cpu/ wrapper + if name.startswith("cpu/") and name.endswith("/"): + bare = name[4:-1] + normalized[bare] = value + elif name.startswith("cpu_core/") and name.endswith("/"): + bare = name[9:-1] + normalized[bare] = value + + return normalized + + +def parse_auto(output: str) -> PerfStatResult: + """Auto-detect format, parse, and normalize event names.""" + stripped = output.strip() + if stripped.startswith("{"): + result = parse_perf_stat_json(output) + else: + result = parse_perf_stat_text(output) + + result.event_values = _normalize_event_values(result.event_values) + return result diff --git a/skills/tma-drilldown/src/perfmon_tools/core/platform.py b/skills/tma-drilldown/src/perfmon_tools/core/platform.py new file mode 100644 index 0000000..24360c4 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/core/platform.py @@ -0,0 +1,261 @@ +"""CPU detection and platform resolution against perfmon mapfile.""" + +import csv +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + + +@dataclass +class CoreInfo: + core_type: str # "P-core", "E-core", or "" for non-hybrid + role_name: str # "Core", "Atom", "LowPower_Atom", or "" + native_model_id: str # hex mask, e.g. "0x40" + event_files: dict = field(default_factory=dict) # {type: path} e.g. {"core": Path(...)} + metrics_files: list = field(default_factory=list) + + +@dataclass +class PlatformInfo: + shortname: str # e.g. "SPR" + name: str # e.g. "Sapphire Rapids Server" + family_model: str # e.g. "GenuineIntel-6-8F" + version: str # e.g. "V1.39" + is_hybrid: bool + default_level: int # 0-2, TMA level supported natively by PERF_METRICS + core_types: list # list[CoreInfo] + + +@dataclass +class CpuInfo: + vendor: str # e.g. "GenuineIntel" + family: int # decimal, e.g. 6 + model: int # decimal, e.g. 143 (0x8F) + stepping: int + model_name: str + family_model: str # e.g. "GenuineIntel-6-8F" + + +def detect_cpu(cpuinfo_path: str = "/proc/cpuinfo") -> CpuInfo: + """Read /proc/cpuinfo and return structured CPU info.""" + text = Path(cpuinfo_path).read_text() + info = {} + for line in text.splitlines(): + if ":" in line: + key, _, val = line.partition(":") + key = key.strip() + val = val.strip() + if key not in info: + info[key] = val + + vendor = info.get("vendor_id", "") + family = int(info.get("cpu family", "0")) + model = int(info.get("model", "0")) + stepping = int(info.get("stepping", "0")) + model_name = info.get("model name", "") + model_hex = format(model, "X") + family_model = f"{vendor}-{family}-{model_hex}" + + return CpuInfo( + vendor=vendor, + family=family, + model=model, + stepping=stepping, + model_name=model_name, + family_model=family_model, + ) + + +def _find_perfmon_root() -> Path: + """Locate the perfmon data directory. Checks: + 1. PERFMON_DATA env var + 2. ./perfmon/ (symlink or submodule in current repo) + 3. ../perfmon/ (sibling directory) + """ + import os + + env_path = os.environ.get("PERFMON_DATA") + if env_path: + p = Path(env_path) + if p.exists(): + return p + + candidates = [ + Path(__file__).resolve().parents[3] / "perfmon", # src/perfmon_tools/core -> repo/perfmon + Path.cwd() / "perfmon", + Path.cwd().parent / "perfmon", + ] + for p in candidates: + if p.exists() and (p / "mapfile.csv").exists(): + return p + + raise FileNotFoundError( + "Cannot find perfmon data. Set PERFMON_DATA env var or ensure ./perfmon/ exists." + ) + + +def _load_platform_config(perfmon_root: Path) -> dict: + """Load platform_config.json as a dict keyed by ShortName.""" + config_path = perfmon_root / "scripts" / "config" / "platform_config.json" + if not config_path.exists(): + return {} + with open(config_path) as f: + entries = json.load(f) + result = {} + for entry in entries: + short = entry.get("ShortName", "") + if short not in result: + result[short] = entry + return result + + +def _parse_mapfile(perfmon_root: Path) -> dict: + """Parse mapfile.csv into a dict keyed by family-model string. + Returns: {family_model: [{version, filename, event_type, core_type, native_model_id, role_name}]} + """ + mapfile_path = perfmon_root / "mapfile.csv" + entries = {} + with open(mapfile_path, newline="") as f: + reader = csv.reader(f) + header = next(reader) + for row in reader: + if len(row) < 4: + continue + fm = row[0].strip() + entry = { + "family_model": fm, + "version": row[1].strip() if len(row) > 1 else "", + "filename": row[2].strip() if len(row) > 2 else "", + "event_type": row[3].strip() if len(row) > 3 else "", + "core_type": row[4].strip() if len(row) > 4 else "", + "native_model_id": row[5].strip() if len(row) > 5 else "", + "role_name": row[6].strip() if len(row) > 6 else "", + } + if fm not in entries: + entries[fm] = [] + entries[fm].append(entry) + return entries + + +def _match_family_model(target: str, mapfile_entries: dict) -> Optional[str]: + """Find matching family-model key in mapfile, handling regex patterns. + Target format: GenuineIntel-6-8F + Mapfile may have patterns like: GenuineIntel-6-9[7A] + """ + if target in mapfile_entries: + return target + + for key in mapfile_entries: + if key == target: + return key + try: + pattern = "^" + re.escape(key).replace(r"\[", "[").replace(r"\]", "]") + "$" + if re.match(pattern, target): + return key + except re.error: + continue + return None + + +def _derive_shortname(filename: str) -> str: + """Extract platform shortname from file path. e.g. /SPR/events/... -> SPR""" + parts = filename.strip("/").split("/") + if parts: + return parts[0] + return "" + + +def resolve_platform( + cpu: CpuInfo, perfmon_root: Optional[Path] = None +) -> PlatformInfo: + """Map CpuInfo to perfmon platform files.""" + if perfmon_root is None: + perfmon_root = _find_perfmon_root() + + mapfile_entries = _parse_mapfile(perfmon_root) + platform_config = _load_platform_config(perfmon_root) + + matched_key = _match_family_model(cpu.family_model, mapfile_entries) + if matched_key is None: + raise ValueError( + f"CPU {cpu.family_model} ({cpu.model_name}) not found in mapfile.csv" + ) + + rows = mapfile_entries[matched_key] + shortname = _derive_shortname(rows[0]["filename"]) + + # Group by role_name for hybrid detection + roles = {} + for row in rows: + role = row["role_name"] or "" + if role not in roles: + roles[role] = CoreInfo( + core_type=row["core_type"], + role_name=role, + native_model_id=row["native_model_id"], + ) + core_info = roles[role] + + event_type = row["event_type"] + filepath = perfmon_root / row["filename"].lstrip("/") + + if event_type == "metrics": + core_info.metrics_files.append(filepath) + else: + core_info.event_files[event_type] = filepath + + named_roles = {k for k in roles if k != ""} + is_hybrid = len(named_roles) > 1 + core_types = list(roles.values()) + + # Look up platform config metadata + config = platform_config.get(shortname, {}) + name = config.get("Name", shortname) + default_level = config.get("DefaultLevel", 0) + + version = rows[0]["version"] if rows else "" + + return PlatformInfo( + shortname=shortname, + name=name, + family_model=cpu.family_model, + version=version, + is_hybrid=is_hybrid, + default_level=default_level, + core_types=core_types, + ) + + +def list_platforms(perfmon_root: Optional[Path] = None) -> list: + """Enumerate all available platforms from mapfile.""" + if perfmon_root is None: + perfmon_root = _find_perfmon_root() + + mapfile_entries = _parse_mapfile(perfmon_root) + platform_config = _load_platform_config(perfmon_root) + + seen = set() + platforms = [] + for fm, rows in mapfile_entries.items(): + shortname = _derive_shortname(rows[0]["filename"]) + if shortname in seen: + continue + seen.add(shortname) + + config = platform_config.get(shortname, {}) + is_hybrid = any(r["role_name"] for r in rows) + platforms.append( + PlatformInfo( + shortname=shortname, + name=config.get("Name", shortname), + family_model=fm, + version=rows[0].get("version", ""), + is_hybrid=is_hybrid, + default_level=config.get("DefaultLevel", 0), + core_types=[], + ) + ) + + return sorted(platforms, key=lambda p: p.shortname) diff --git a/skills/tma-drilldown/src/perfmon_tools/core/tma_tree.py b/skills/tma-drilldown/src/perfmon_tools/core/tma_tree.py new file mode 100644 index 0000000..08a6f56 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/core/tma_tree.py @@ -0,0 +1,154 @@ +"""TMA hierarchy builder from ParentCategory relationships.""" + +from dataclasses import dataclass, field +from typing import Optional + + +TMA_L1_ROOTS = ["Frontend_Bound", "Bad_Speculation", "Backend_Bound", "Retiring"] + + +@dataclass +class TmaNode: + metric: object # MetricDef + children: list = field(default_factory=list) + parent: Optional["TmaNode"] = None + + @property + def name(self) -> str: + return self.metric.name + + @property + def level(self) -> int: + return self.metric.level + + @property + def is_leaf(self) -> bool: + return len(self.children) == 0 + + @property + def threshold(self) -> dict: + return self.metric.threshold + + @property + def locate_with(self) -> str: + return self.metric.locate_with + + +class TmaTree: + """TMA hierarchy built from metric ParentCategory relationships.""" + + def __init__(self, catalog): + self.roots = [] + self.bottlenecks = catalog.bottleneck_metrics + self.info_metrics = catalog.info_metrics + self._nodes = {} # name -> TmaNode + + tree_metrics = catalog.tree_nodes + + # Create all nodes + for m in tree_metrics: + self._nodes[m.name] = TmaNode(metric=m) + + # Link parent-child + for m in tree_metrics: + node = self._nodes[m.name] + parent_name = m.parent_category + if parent_name and parent_name in self._nodes: + parent_node = self._nodes[parent_name] + parent_node.children.append(node) + node.parent = parent_node + elif m.name in TMA_L1_ROOTS: + self.roots.append(node) + + # Sort roots in canonical order + root_order = {name: i for i, name in enumerate(TMA_L1_ROOTS)} + self.roots.sort(key=lambda n: root_order.get(n.name, 99)) + + # Sort children by level then name + for node in self._nodes.values(): + node.children.sort(key=lambda n: (n.level, n.name)) + + @property + def max_level(self) -> int: + if not self._nodes: + return 0 + return max(n.level for n in self._nodes.values()) + + @property + def node_count(self) -> int: + return len(self._nodes) + + def get_node(self, name: str) -> Optional[TmaNode]: + return self._nodes.get(name) + + def get_children(self, name: str) -> list: + node = self._nodes.get(name) + if node is None: + return [] + return node.children + + def get_nodes_at_level(self, level: int) -> list: + return [n for n in self._nodes.values() if n.level == level] + + def get_path_to_root(self, name: str) -> list: + """Return path from node to root (inclusive), leaf first.""" + node = self._nodes.get(name) + if node is None: + return [] + path = [] + while node is not None: + path.append(node) + node = node.parent + return path + + def get_subtree_events(self, name: str) -> set: + """All events needed to compute a node and all its descendants.""" + node = self._nodes.get(name) + if node is None: + return set() + events = set() + stack = [node] + while stack: + current = stack.pop() + events.update(current.metric.event_names) + stack.extend(current.children) + return events + + def get_level_events(self, level: int) -> set: + """All events needed to compute all nodes at a given level.""" + events = set() + for node in self.get_nodes_at_level(level): + events.update(node.metric.event_names) + return events + + def get_children_events(self, name: str) -> set: + """Events needed to compute direct children of a node.""" + events = set() + for child in self.get_children(name): + events.update(child.metric.event_names) + return events + + def print_tree(self, max_level: Optional[int] = None) -> str: + """Text representation of the TMA tree.""" + lines = [] + + def _walk(node, indent=0): + if max_level and node.level > max_level: + return + prefix = " " * indent + leaf_marker = " [leaf]" if node.is_leaf else "" + lines.append(f"{prefix}{node.name} (L{node.level}){leaf_marker}") + for child in node.children: + _walk(child, indent + 1) + + for root in self.roots: + _walk(root) + return "\n".join(lines) + + def level_summary(self) -> dict: + """Count of nodes per level.""" + summary = {} + for node in self._nodes.values(): + level = node.level + summary[level] = summary.get(level, 0) + 1 + return dict(sorted(summary.items())) diff --git a/skills/tma-drilldown/src/perfmon_tools/core/tracer.py b/skills/tma-drilldown/src/perfmon_tools/core/tracer.py new file mode 100644 index 0000000..e203008 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/core/tracer.py @@ -0,0 +1,291 @@ +"""Decision tracing and observability for perfmon-skills. + +Activated via PERFMON_TRACE=1 environment variable. +When disabled, all tracing calls are no-ops with zero overhead. +""" + +import json +import os +import time +import uuid +from contextlib import contextmanager +from dataclasses import asdict, dataclass, field +from functools import wraps +from pathlib import Path +from typing import Optional + + +TRACE_ENABLED = os.environ.get("PERFMON_TRACE", "0") == "1" + + +@dataclass +class DecisionNode: + id: str = field(default_factory=lambda: str(uuid.uuid4())[:8]) + timestamp: str = "" + actor: str = "system" # "ai", "human", or "system" + operation: str = "" + inputs: dict = field(default_factory=dict) + reasoning: str = "" + decision: str = "" + alternatives: list = field(default_factory=list) # [{option, reason_rejected}] + confidence: float = 1.0 # 0.0-1.0 + parent_id: Optional[str] = None + children_ids: list = field(default_factory=list) + metadata: dict = field(default_factory=dict) + duration_ms: float = 0.0 + + +class _NoopDecision: + """No-op context for when tracing is disabled.""" + + def __setattr__(self, name, value): + pass + + def __getattr__(self, name): + return None + + +class _DecisionContext: + """Context manager for recording a decision.""" + + def __init__(self, tracer: "Trace", operation: str, parent_id: Optional[str]): + self._tracer = tracer + self._node = DecisionNode( + timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"), + operation=operation, + parent_id=parent_id, + ) + self._start = time.time() + + @property + def id(self) -> str: + return self._node.id + + @property + def inputs(self): + return self._node.inputs + + @inputs.setter + def inputs(self, val): + self._node.inputs = val + + @property + def reasoning(self): + return self._node.reasoning + + @reasoning.setter + def reasoning(self, val): + self._node.reasoning = val + + @property + def decision(self): + return self._node.decision + + @decision.setter + def decision(self, val): + self._node.decision = val + + @property + def alternatives(self): + return self._node.alternatives + + @alternatives.setter + def alternatives(self, val): + self._node.alternatives = val + + @property + def confidence(self): + return self._node.confidence + + @confidence.setter + def confidence(self, val): + self._node.confidence = val + + @property + def actor(self): + return self._node.actor + + @actor.setter + def actor(self, val): + self._node.actor = val + + @property + def metadata(self): + return self._node.metadata + + @metadata.setter + def metadata(self, val): + self._node.metadata = val + + def __enter__(self): + return self + + def __exit__(self, *args): + self._node.duration_ms = (time.time() - self._start) * 1000 + self._tracer._add_node(self._node) + + +class Trace: + """Collects decision nodes into a DAG.""" + + def __init__(self): + self.nodes: list = [] + self._node_map: dict = {} + + @property + def root_ids(self) -> list: + return [n.id for n in self.nodes if n.parent_id is None] + + def decision(self, operation: str, parent_id: Optional[str] = None): + """Context manager for recording a decision point.""" + if not TRACE_ENABLED: + return _noop_context() + return _DecisionContext(self, operation, parent_id) + + def _add_node(self, node: DecisionNode): + self.nodes.append(node) + self._node_map[node.id] = node + if node.parent_id and node.parent_id in self._node_map: + self._node_map[node.parent_id].children_ids.append(node.id) + + def to_json(self, indent: int = 2) -> str: + return json.dumps( + { + "trace_version": "1.0", + "nodes": [asdict(n) for n in self.nodes], + "root_ids": self.root_ids, + }, + indent=indent, + ) + + def to_mermaid(self) -> str: + """Render trace as Mermaid flowchart.""" + lines = ["graph TD"] + for node in self.nodes: + label = f"{node.operation}\\n{node.decision}" + shape = "([{}])" if node.actor == "human" else "[{}]" + lines.append(f" {node.id}{shape.format(label)}") + if node.parent_id: + edge_label = node.reasoning[:40] if node.reasoning else "" + if edge_label: + lines.append(f" {node.parent_id} -->|{edge_label}| {node.id}") + else: + lines.append(f" {node.parent_id} --> {node.id}") + return "\n".join(lines) + + def to_dot(self) -> str: + """Render trace as Graphviz DOT.""" + lines = ["digraph trace {", " rankdir=TB;", ' node [shape=box, style=rounded];'] + for node in self.nodes: + label = f"{node.operation}\\n{node.decision}\\nconf={node.confidence:.2f}" + color = "lightblue" if node.actor == "system" else ( + "lightyellow" if node.actor == "human" else "lightgreen" + ) + lines.append( + f' "{node.id}" [label="{label}", fillcolor="{color}", style="filled,rounded"];' + ) + for node in self.nodes: + if node.parent_id: + label = node.reasoning[:30] if node.reasoning else "" + lines.append(f' "{node.parent_id}" -> "{node.id}" [label="{label}"];') + lines.append("}") + return "\n".join(lines) + + def to_html(self) -> str: + """Render as self-contained interactive HTML with DAG visualization.""" + trace_json = self.to_json() + return f""" +Perfmon Trace + +

Decision Trace

+
+""" + + def save(self, path: Path): + """Save trace to session directory.""" + path.write_text(self.to_json()) + + +# Module-level singleton +_tracer: Optional[Trace] = None + + +def get_tracer() -> Trace: + """Get or create the global tracer instance.""" + global _tracer + if _tracer is None: + _tracer = Trace() + return _tracer + + +def reset_tracer(): + """Reset the global tracer (for testing or new sessions).""" + global _tracer + _tracer = Trace() + + +@contextmanager +def _noop_context(): + """No-op context manager when tracing is disabled.""" + yield _NoopDecision() + + +def trace(operation: str): + """Decorator for functions that make decisions. Records inputs/outputs. + + When PERFMON_TRACE is disabled, passes through with zero overhead. + """ + def decorator(func): + if not TRACE_ENABLED: + return func + + @wraps(func) + def wrapper(*args, **kwargs): + tracer = get_tracer() + with tracer.decision(operation) as d: + d.inputs = {"args": str(args)[:200], "kwargs": str(kwargs)[:200]} + result = func(*args, **kwargs) + d.decision = str(result)[:200] + d.confidence = 1.0 + return result + return wrapper + return decorator diff --git a/skills/tma-drilldown/src/perfmon_tools/lookup/__init__.py b/skills/tma-drilldown/src/perfmon_tools/lookup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skills/tma-drilldown/src/perfmon_tools/lookup/search.py b/skills/tma-drilldown/src/perfmon_tools/lookup/search.py new file mode 100644 index 0000000..f7af464 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/lookup/search.py @@ -0,0 +1,147 @@ +"""Event and metric search across platforms.""" + +from pathlib import Path +from typing import Optional + +from ..core.platform import ( + CpuInfo, + PlatformInfo, + _find_perfmon_root, + detect_cpu, + list_platforms, + resolve_platform, +) +from ..core.catalog import PlatformCatalog + + +def search( + query: str, + platform: Optional[str] = None, + search_type: str = "all", # "events", "metrics", "all" + category: Optional[str] = None, + level: Optional[int] = None, + cross_arch: bool = False, + include_deprecated: bool = False, +) -> dict: + """Search events and metrics. + + Args: + query: search term (matches name and description) + platform: platform shortname (auto-detect if None) + search_type: "events", "metrics", or "all" + category: filter metrics by category (e.g., "TMA", "Freq") + level: filter metrics by level + cross_arch: search across all architectures + include_deprecated: include deprecated events + + Returns: + dict with "events" and "metrics" lists + """ + perfmon_root = _find_perfmon_root() + + if cross_arch: + platforms_to_search = _resolve_all_platforms(perfmon_root) + elif platform: + platforms_to_search = [_resolve_by_shortname(platform, perfmon_root)] + else: + try: + cpu = detect_cpu() + platforms_to_search = [resolve_platform(cpu, perfmon_root)] + except (FileNotFoundError, ValueError): + raise ValueError( + "Cannot auto-detect CPU. Specify --platform or ensure /proc/cpuinfo exists." + ) + + results = {"events": [], "metrics": [], "platform": None} + + for plat_info in platforms_to_search: + catalog = PlatformCatalog(plat_info, perfmon_root) + results["platform"] = plat_info.shortname + + if search_type in ("events", "all"): + events = catalog.search_events(query, include_deprecated=include_deprecated) + for ev in events: + results["events"].append({ + "name": ev.name, + "description": ev.brief_description, + "event_code": ev.event_code, + "umask": ev.umask, + "counter": ev.counter, + "precise": ev.precise, + "platform": ev.platform, + "deprecated": ev.deprecated, + }) + + if search_type in ("metrics", "all"): + metrics = catalog.search_metrics(query, category=category) + if level is not None: + metrics = [m for m in metrics if m.level == level] + for m in metrics: + results["metrics"].append({ + "name": m.name, + "level": m.level, + "description": m.brief_description, + "category": m.category, + "unit": m.unit_of_measure, + "metric_group": m.metric_group, + "parent_category": m.parent_category, + "platform": m.platform, + }) + + return results + + +def _resolve_by_shortname(shortname: str, perfmon_root: Path) -> PlatformInfo: + """Resolve platform by shortname.""" + from ..core.platform import _parse_mapfile, _load_platform_config, _derive_shortname, CoreInfo + + mapfile_entries = _parse_mapfile(perfmon_root) + platform_config = _load_platform_config(perfmon_root) + + for fm, rows in mapfile_entries.items(): + sn = _derive_shortname(rows[0]["filename"]) + if sn.upper() == shortname.upper(): + # Reconstruct PlatformInfo + roles = {} + for row in rows: + role = row["role_name"] or "" + if role not in roles: + roles[role] = CoreInfo( + core_type=row["core_type"], + role_name=role, + native_model_id=row["native_model_id"], + ) + core_info = roles[role] + event_type = row["event_type"] + filepath = perfmon_root / row["filename"].lstrip("/") + if event_type == "metrics": + core_info.metrics_files.append(filepath) + else: + core_info.event_files[event_type] = filepath + + config = platform_config.get(sn, {}) + named_roles = {k for k in roles if k != ""} + return PlatformInfo( + shortname=sn, + name=config.get("Name", sn), + family_model=fm, + version=rows[0]["version"], + is_hybrid=len(named_roles) > 1, + default_level=config.get("DefaultLevel", 0), + core_types=list(roles.values()), + ) + + raise ValueError(f"Platform '{shortname}' not found. Use --cross-arch to list all.") + + +def _resolve_all_platforms(perfmon_root: Path) -> list: + """Get PlatformInfo for all platforms (expensive but needed for cross-arch search).""" + all_platforms = list_platforms(perfmon_root) + result = [] + for p in all_platforms: + try: + full = _resolve_by_shortname(p.shortname, perfmon_root) + result.append(full) + except ValueError: + continue + return result diff --git a/skills/tma-drilldown/src/perfmon_tools/recommend/__init__.py b/skills/tma-drilldown/src/perfmon_tools/recommend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skills/tma-drilldown/src/perfmon_tools/recommend/coverage.py b/skills/tma-drilldown/src/perfmon_tools/recommend/coverage.py new file mode 100644 index 0000000..29aabcf --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/recommend/coverage.py @@ -0,0 +1,125 @@ +"""Event coverage tracking and gap-driven investigation suggestions.""" + +from dataclasses import dataclass, field +from typing import Optional + +from ..core.catalog import PlatformCatalog + + +# Domain-to-TMA affinity: which unreachable event domains provide deeper insight +# for each TMA bottleneck node +DOMAIN_AFFINITY = { + "DRAM_Bound": { + "domains": ["OCR.DEMAND_DATA_RD", "OFFCORE_REQUESTS", "MEM_TRANS_RETIRED"], + "rationale": "Memory hierarchy detail: NUMA locality, load latency distribution", + }, + "L1_Bound": { + "domains": ["CYCLE_ACTIVITY.STALLS_L1D", "L1D_PEND_MISS", "LD_BLOCKS"], + "rationale": "L1D stall breakdown: fill buffer pressure, address aliasing", + }, + "L2_Bound": { + "domains": ["L2_RQSTS.ALL_DEMAND", "L2_TRANS"], + "rationale": "L2 request types and writebacks", + }, + "L3_Bound": { + "domains": ["OCR.DEMAND_DATA_RD.L3_HIT.SNOOP", "CORE_SNOOP_RESPONSE"], + "rationale": "Cross-core sharing patterns, snoop responses", + }, + "Core_Bound": { + "domains": ["EXE_ACTIVITY.3_PORTS", "EXE_ACTIVITY.4_PORTS", "UOPS_EXECUTED.CORE_CYCLES_GE"], + "rationale": "Execution port saturation and utilization", + }, + "Fetch_Latency": { + "domains": ["FRONTEND_RETIRED.LATENCY_GE", "IDQ_BUBBLES", "IDQ_UOPS_NOT_DELIVERED"], + "rationale": "Frontend delivery gaps and bubble analysis", + }, + "Branch_Mispredicts": { + "domains": ["BR_MISP_RETIRED.COND", "BR_MISP_RETIRED.INDIRECT", "BR_MISP_RETIRED.NEAR_TAKEN"], + "rationale": "Branch type breakdown: conditional vs indirect vs taken", + }, + "Retiring": { + "domains": ["INT_VEC_RETIRED", "FP_ARITH_DISPATCHED"], + "rationale": "Vectorization quality: actual vector width distribution", + }, + "Store_Bound": { + "domains": ["L2_RQSTS.RFO", "OFFCORE_REQUESTS.DEMAND_RFO"], + "rationale": "Store-to-memory path: RFO hits/misses", + }, + "Machine_Clears": { + "domains": ["MACHINE_CLEARS.SMC", "RTM_RETIRED"], + "rationale": "Clear types: self-modifying code, TSX aborts", + }, +} + + +@dataclass +class CoverageReport: + total_events: int + reached_events: set = field(default_factory=set) + coverage_pct: float = 0.0 + unreached_by_domain: dict = field(default_factory=dict) + suggested_expansions: list = field(default_factory=list) + + +class CoverageTracker: + """Track which events have been touched during an investigation.""" + + def __init__(self, catalog: PlatformCatalog): + self.catalog = catalog + self._reached = set() + # Get non-deprecated core events + self._all_events = { + e.name for e in catalog.events + if not e.deprecated + } + + def record_events(self, events: set): + """Record that these events were collected.""" + self._reached.update(events) + + @property + def coverage_pct(self) -> float: + if not self._all_events: + return 0.0 + return 100.0 * len(self._reached & self._all_events) / len(self._all_events) + + def report(self, current_path: list = None) -> CoverageReport: + """Generate coverage report with gap-driven suggestions.""" + reached = self._reached & self._all_events + unreached = self._all_events - reached + + # Group unreached by prefix + by_domain = {} + for ev in sorted(unreached): + prefix = ev.split(".")[0] + if prefix not in by_domain: + by_domain[prefix] = [] + by_domain[prefix].append(ev) + + # Generate suggestions based on current TMA path + suggestions = [] + if current_path: + for node_name in current_path: + affinity = DOMAIN_AFFINITY.get(node_name) + if affinity: + # Find matching unreached events + matching = [] + for domain_prefix in affinity["domains"]: + for ev in unreached: + if ev.startswith(domain_prefix): + matching.append(ev) + if matching: + suggestions.append({ + "tma_node": node_name, + "rationale": affinity["rationale"], + "events": matching[:10], + "count": len(matching), + }) + + return CoverageReport( + total_events=len(self._all_events), + reached_events=reached, + coverage_pct=self.coverage_pct, + unreached_by_domain=by_domain, + suggested_expansions=suggestions, + ) diff --git a/skills/tma-drilldown/src/perfmon_tools/recommend/engine.py b/skills/tma-drilldown/src/perfmon_tools/recommend/engine.py new file mode 100644 index 0000000..a8c1ca7 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/recommend/engine.py @@ -0,0 +1,347 @@ +"""Recommendation engine — state machine orchestrator. + +Deterministic layer: runs the full TMA drill-down without an LLM. +The LLM layer (Claude Code skill) sits on top and adds interpretation. +""" + +import json +import subprocess +import time +from pathlib import Path +from typing import Optional + +from ..core.catalog import PlatformCatalog +from ..core.context_budget import ContextBudget +from ..core.formula import evaluate_metric +from ..core.perf_output import parse_auto, parse_perf_stat_interval +from ..core.platform import PlatformInfo, _find_perfmon_root, detect_cpu, resolve_platform +from ..core.tma_tree import TmaTree +from ..core.tracer import get_tracer, TRACE_ENABLED +from ..cmdgen.generate import generate_perf_command, _format_event_spec +from .coverage import CoverageTracker +from .guidance import get_guidance, NO_OBSERVABILITY_NODES +from .preflight import create_strategy, detect_steady_state, compute_counter_budget +from .session import Session, SessionState, StepFinding +from .tma_drilldown import TmaDrillDown + + +DEFAULT_SESSIONS_DIR = Path.cwd() / "sessions" + + +class RecommendationEngine: + """Orchestrates iterative TMA drill-down investigation.""" + + def __init__(self, sessions_dir: Optional[Path] = None): + self.sessions_dir = sessions_dir or DEFAULT_SESSIONS_DIR + self.sessions_dir.mkdir(parents=True, exist_ok=True) + self._perfmon_root = _find_perfmon_root() + + def start( + self, + platform: Optional[str] = None, + pid: Optional[int] = None, + command: Optional[str] = None, + duration: int = 5, + ) -> dict: + """Start a new investigation session. + + Returns dict with session info and first perf command to run. + """ + # Resolve platform + from ..lookup.search import _resolve_by_shortname + if platform: + plat_info = _resolve_by_shortname(platform, self._perfmon_root) + else: + cpu = detect_cpu() + plat_info = resolve_platform(cpu, self._perfmon_root) + + # Pre-flight checks + strategy = create_strategy(plat_info) + + # Create session + session = Session.create_new( + self.sessions_dir, plat_info.shortname, pid=pid, + command=command, duration=duration, + ) + session.state.smt_active = strategy.smt_active + session.state.use_perf_metrics = strategy.use_perf_metrics + + # Load catalog and tree + catalog = PlatformCatalog(plat_info, self._perfmon_root) + tree = TmaTree(catalog) + drilldown = TmaDrillDown(tree, catalog) + + # Generate initial command (L1 + Bottlenecks) + initial_events = drilldown.initial_events() + budget = compute_counter_budget(initial_events, plat_info) + + cmd_result = generate_perf_command( + platform=plat_info.shortname, + tma_level=1, + duration=duration, + pid=pid, + command=command, + json_output=True, + ) + + session.state.state = "COLLECTING" + session.save() + + # Trace decision + if TRACE_ENABLED: + with get_tracer().decision("start_investigation") as d: + d.inputs = {"platform": plat_info.shortname, "pid": pid, "command": command} + d.decision = f"Starting L1 collection with {len(initial_events)} events" + d.confidence = 1.0 + + return { + "session_dir": str(session.dir), + "platform": plat_info.shortname, + "strategy": { + "smt_active": strategy.smt_active, + "use_perf_metrics": strategy.use_perf_metrics, + "counters": f"{strategy.programmable_counters} GP + {strategy.fixed_counters} fixed", + }, + "counter_budget": budget, + "command": cmd_result["commands"][0], + "notes": strategy.notes + cmd_result.get("notes", []), + "state": "COLLECTING", + "next_action": "Run the perf command, then feed output to 'recommend analyze'", + } + + def analyze( + self, + perf_output: str, + session_dir: Optional[str] = None, + constants: Optional[dict] = None, + ) -> dict: + """Analyze perf stat output and determine next steps. + + Args: + perf_output: raw perf stat output (text or JSON) + session_dir: path to session directory (default: most recent) + constants: system constants (SYSTEM_TSC_FREQ, etc.) + + Returns: + dict with analysis results, finding, and next step suggestion + """ + if constants is None: + constants = {} + + # Load session + if session_dir: + session = Session(Path(session_dir)) + else: + session = Session.find_latest(self.sessions_dir) + if not session: + raise ValueError("No active session. Run 'recommend start' first.") + + # Load platform catalog and tree + from ..lookup.search import _resolve_by_shortname + plat_info = _resolve_by_shortname(session.state.platform, self._perfmon_root) + catalog = PlatformCatalog(plat_info, self._perfmon_root) + tree = TmaTree(catalog) + drilldown = TmaDrillDown(tree, catalog) + coverage = CoverageTracker(catalog) + + # Parse perf output + parsed = parse_auto(perf_output) + event_values = parsed.event_values + + # Record coverage + coverage.record_events(set(event_values.keys())) + # Also record base names (strip cpu/ prefix) + base_events = set() + for ev in event_values: + base = ev.replace("cpu/", "").replace("cpu_core/", "").rstrip("/") + base_events.add(base) + coverage.record_events(base_events) + + # Determine which nodes to evaluate + current_path = session.state.path + if not current_path: + # First step: evaluate L1 roots + nodes_to_eval = tree.roots + else: + # Drill-down: evaluate children of last node in path + last_node = current_path[-1] + children = tree.get_children(last_node) + if children: + nodes_to_eval = children + else: + nodes_to_eval = [] + + # Evaluate nodes + results = drilldown.evaluate_level(nodes_to_eval, event_values, constants) + + # Get suggestion for next step + suggestion = drilldown.suggest_next(results, current_path[-1] if current_path else None) + + # Save step data + step_dir = session.new_step() + node_values = {r.name: r.value for r in results if r.value is not None} + analysis_data = { + "node_values": node_values, + "threshold_results": [ + {"name": r.name, "value": r.value, "passed": r.threshold_passed} + for r in results + ], + "suggestion": suggestion.__dict__ if suggestion else None, + } + # Convert sets to lists for JSON serialization + analysis_json = json.loads(json.dumps(analysis_data, default=lambda x: list(x) if isinstance(x, set) else x)) + session.save_step_data( + step_dir, + command="(provided by user)", + raw_output=perf_output, + parsed=event_values, + analysis=analysis_json, + ) + + # Create finding + top_result = results[0] if results else None + if top_result and top_result.value is not None: + siblings = {r.name: r.value for r in results[1:] if r.value is not None} + finding = StepFinding( + level=top_result.level, + top_node=top_result.name, + value=top_result.value, + threshold_passed=top_result.threshold_passed or False, + siblings=siblings, + path_so_far=current_path + [top_result.name], + ) + session.add_finding(finding) + + # Determine next state + is_complete = False + next_command = None + guidance_info = None + + if suggestion is None or suggestion.is_leaf: + is_complete = True + session.state.state = "COMPLETE" + # Generate guidance + leaf_node = top_result.name if top_result else current_path[-1] if current_path else "" + guidance_info = get_guidance(leaf_node) + + # Generate summary + coverage_report = coverage.report(session.state.path) + summary = { + "bottleneck_path": session.state.path, + "final_node": leaf_node, + "guidance": guidance_info, + "coverage_pct": coverage_report.coverage_pct, + "suggested_expansions": [ + {"node": s["tma_node"], "rationale": s["rationale"], "events": s["events"][:5]} + for s in coverage_report.suggested_expansions + ], + "locate_with": list(suggestion.locate_with_events) if suggestion else [], + } + session.save_summary(summary) + else: + session.state.state = "COLLECTING" + # Generate next perf command + cmd_result = generate_perf_command( + platform=session.state.platform, + tma_node=top_result.name if top_result else None, + duration=session.state.duration, + pid=session.state.target_pid, + command=session.state.target_command, + json_output=True, + ) + next_command = cmd_result["commands"][0] if cmd_result["commands"] else None + + session.save() + + # Trace + if TRACE_ENABLED and top_result: + with get_tracer().decision("select_bottleneck") as d: + d.inputs = {"node_values": node_values} + d.decision = top_result.name + d.reasoning = ( + f"{top_result.name} = {top_result.value:.1f}%, " + f"threshold {'passed' if top_result.threshold_passed else 'not evaluated'}" + ) + d.confidence = 0.95 if top_result.threshold_passed else 0.7 + d.alternatives = [ + {"option": r.name, "reason_rejected": f"value={r.value:.1f}%"} + for r in results[1:3] + ] + + # Build response + response = { + "session_dir": str(session.dir), + "state": session.state.state, + "step": session.state.current_step, + "path": session.state.path, + "results": [ + {"name": r.name, "value": r.value, "threshold_passed": r.threshold_passed} + for r in results + ], + "multiplexing_issues": [ + {"event": m.event, "measured_pct": m.enabled_pct} + for m in parsed.multiplexing_issues + ], + "is_complete": is_complete, + } + + if next_command: + response["next_command"] = next_command + response["next_action"] = f"Run the command, then feed output to 'recommend analyze'" + if guidance_info: + response["guidance"] = guidance_info + if is_complete and suggestion and suggestion.locate_with_events: + response["sampling_suggestion"] = { + "events": list(suggestion.locate_with_events), + "command": f"perf record -e {','.join(suggestion.locate_with_events)} " + f"-p {session.state.target_pid}" if session.state.target_pid else + f"perf record -e {','.join(suggestion.locate_with_events)} -- " + f"{session.state.target_command or 'sleep 5'}", + } + + return response + + def status(self, session_dir: Optional[str] = None) -> dict: + """Get current session status.""" + if session_dir: + session = Session(Path(session_dir)) + else: + session = Session.find_latest(self.sessions_dir) + if not session: + return {"state": "NO_SESSION", "message": "No active session found."} + + return { + "session_dir": str(session.dir), + "state": session.state.state, + "platform": session.state.platform, + "step": session.state.current_step, + "path": session.state.path, + "findings": [ + {"level": f.level, "node": f.top_node, "value": f.value} + for f in session.state.findings + ], + "target": { + "pid": session.state.target_pid, + "command": session.state.target_command, + }, + } + + def summary(self, session_dir: Optional[str] = None) -> dict: + """Get investigation summary.""" + if session_dir: + session = Session(Path(session_dir)) + else: + session = Session.find_latest(self.sessions_dir) + if not session: + return {"error": "No session found."} + + summary_path = session.dir / "summary.json" + if summary_path.exists(): + return json.loads(summary_path.read_text()) + + # Not complete yet + return { + "state": session.state.state, + "path_so_far": session.state.path, + "message": "Investigation not complete. Continue with 'recommend analyze'.", + } diff --git a/skills/tma-drilldown/src/perfmon_tools/recommend/guidance.py b/skills/tma-drilldown/src/perfmon_tools/recommend/guidance.py new file mode 100644 index 0000000..8aadcc4 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/recommend/guidance.py @@ -0,0 +1,242 @@ +"""Tuning guidance rules keyed by TMA node name.""" + + +GUIDANCE = { + "Frontend_Bound": { + "brief": "Pipeline starved for instructions due to frontend issues", + "suggestions": [ + "Profile with FRONTEND_RETIRED.LATENCY_GE_* for precise fetch stalls", + "Check code layout: hot functions scattered across pages", + "Consider PGO/LTO for better code placement", + ], + "sample_events": ["FRONTEND_RETIRED.LATENCY_GE_4"], + }, + "Fetch_Latency": { + "brief": "Frontend stalled waiting for instructions", + "suggestions": [ + "Check ITLB misses: large code footprint or scattered jump targets", + "Check I-cache misses: code too large for L1I", + "Branch resteers after mispredictions waste frontend cycles", + ], + "sample_events": ["FRONTEND_RETIRED.LATENCY_GE_16", "FRONTEND_RETIRED.LATENCY_GE_8"], + }, + "ICache_Misses": { + "brief": "Instruction cache misses causing frontend stalls", + "suggestions": [ + "Reduce code footprint: eliminate dead code, split hot/cold paths", + "Use PGO to colocate hot functions", + "Consider -Os optimization for code size", + ], + "sample_events": ["FRONTEND_RETIRED.L1I_MISS", "FRONTEND_RETIRED.L2_MISS"], + }, + "ITLB_Misses": { + "brief": "Instruction TLB misses", + "suggestions": [ + "Code footprint exceeds ITLB reach", + "Consider huge pages for code (2MB pages)", + "Reduce number of active code pages via PGO", + ], + "sample_events": ["FRONTEND_RETIRED.ITLB_MISS", "FRONTEND_RETIRED.STLB_MISS"], + }, + "Branch_Resteers": { + "brief": "Frontend resteering after branch events", + "suggestions": [ + "Reduce branch misprediction rate (see Bad_Speculation)", + "Reduce branch density in hot loops", + "Unknown branches (indirect calls) are expensive to resteer", + ], + "sample_events": ["BR_MISP_RETIRED.ALL_BRANCHES"], + }, + "Fetch_Bandwidth": { + "brief": "Frontend delivering suboptimal bandwidth", + "suggestions": [ + "Check DSB (decoded stream buffer) coverage", + "Avoid LCP (length-changing prefixes) in hot code", + "Ensure hot loops fit in DSB (< 64 uops)", + ], + "sample_events": ["FRONTEND_RETIRED.LATENCY_GE_2_BUBBLES_GE_1"], + }, + "Bad_Speculation": { + "brief": "Pipeline slots wasted on incorrect speculation", + "suggestions": [ + "Focus on branch misprediction reduction", + "Check machine clears (memory ordering, SMC)", + ], + "sample_events": [], + "no_further_hw_observability": False, + }, + "Branch_Mispredicts": { + "brief": "Branch misprediction overhead", + "suggestions": [ + "Profile with BR_MISP_RETIRED.ALL_BRANCHES to find hot mispredicts", + "Convert unpredictable branches to branchless (cmov, predication)", + "Consider LLVM HW-PGO: -fprofile-sample-use with branch data (ref: EuroLLVM 2024)", + "Check if indirect calls (vtables) dominate: consider devirtualization", + ], + "sample_events": ["BR_MISP_RETIRED.ALL_BRANCHES"], + "compiler_suggestion": "LLVM HW-PGO can auto-convert mispredicted branches to CMOV (1.8x on benchmarks)", + }, + "Machine_Clears": { + "brief": "Machine clears flushing the pipeline", + "suggestions": [ + "Check MACHINE_CLEARS.SMC: self-modifying code (JIT invalidation)", + "Check memory ordering violations in lock-free code", + "RTM aborts may cause repeated clears", + ], + "sample_events": ["MACHINE_CLEARS.COUNT"], + }, + "Backend_Bound": { + "brief": "Execution backend cannot retire uops fast enough", + "suggestions": [ + "Determine if memory-bound or core-bound via L2 breakdown", + ], + "sample_events": [], + }, + "Memory_Bound": { + "brief": "Stalls due to memory subsystem", + "suggestions": [ + "Determine cache level causing stalls via L3 breakdown", + "Check data locality and access patterns", + ], + "sample_events": [], + }, + "L1_Bound": { + "brief": "L1 data cache causing stalls", + "suggestions": [ + "Check for cache-unfriendly access patterns (strided, random)", + "Consider data layout: struct-of-arrays vs array-of-structs", + "Check fill buffer saturation (L1D_PEND_MISS.FB_FULL)", + "Address aliasing can cause false dependencies (4K aliasing)", + ], + "sample_events": ["MEM_LOAD_RETIRED.L1_MISS", "MEM_LOAD_RETIRED.L1_HIT"], + }, + "L2_Bound": { + "brief": "L2 cache misses causing stalls", + "suggestions": [ + "Working set exceeds L1 but fits in L2", + "Check HW prefetcher effectiveness (L2_RQSTS.ALL_HWPF)", + "Consider SW prefetch for predictable patterns", + ], + "sample_events": ["MEM_LOAD_RETIRED.L2_MISS"], + }, + "L3_Bound": { + "brief": "L3 cache latency causing stalls", + "suggestions": [ + "Working set exceeds L2, check if it fits in L3", + "Cross-core sharing (snoops) adds L3 latency", + "Check for false sharing with perf c2c", + ], + "sample_events": ["MEM_LOAD_RETIRED.L3_MISS", "MEM_LOAD_RETIRED.L3_HIT"], + }, + "DRAM_Bound": { + "brief": "Memory latency/bandwidth from DRAM", + "suggestions": [ + "Check NUMA locality: local vs remote DRAM access ratio", + "Consider memory bandwidth: are you saturating channels?", + "Optimize data placement: numactl --membind or first-touch", + "Profile load latency: MEM_TRANS_RETIRED.LOAD_LATENCY_GT_*", + "Check prefetch effectiveness: are HW prefetches reaching DRAM in time?", + ], + "sample_events": ["MEM_LOAD_RETIRED.L3_MISS"], + }, + "Store_Bound": { + "brief": "Store operations causing stalls", + "suggestions": [ + "Store buffer saturation: too many concurrent stores", + "Check for store-to-load forwarding failures", + "Consider write-combining for streaming stores (NT stores)", + ], + "sample_events": ["MEM_INST_RETIRED.ALL_STORES"], + }, + "Core_Bound": { + "brief": "Execution units or scheduler limiting throughput", + "suggestions": [ + "Check port utilization for imbalanced execution", + "Divider contention (ARITH.DIVIDER_ACTIVE) if arithmetic heavy", + "Consider vectorization to increase throughput", + ], + "sample_events": ["EXE_ACTIVITY.BOUND_ON_LOADS"], + }, + "Divider": { + "brief": "Divider unit contention", + "suggestions": [ + "Replace divisions with multiplications where possible", + "Use shift operations for power-of-2 divisions", + "Consider approximate reciprocal for FP divisions", + ], + "sample_events": ["ARITH.DIVIDER_ACTIVE"], + "no_further_hw_observability": True, + }, + "Ports_Utilization": { + "brief": "Suboptimal execution port utilization", + "suggestions": [ + "Check which ports are saturated vs idle", + "Reorder independent operations to fill ports", + "Vectorize to utilize wider execution units", + ], + "sample_events": ["EXE_ACTIVITY.1_PORTS_UTIL", "EXE_ACTIVITY.2_PORTS_UTIL"], + }, + "Retiring": { + "brief": "Pipeline successfully retiring uops (not a bottleneck)", + "suggestions": [ + "High Retiring is good — but check if uops/instruction is high", + "Microcode assists inflate retirement without useful work", + "Check vectorization: INT_VEC_RETIRED, FP_ARITH_INST_RETIRED", + ], + "sample_events": [], + }, + "Heavy_Operations": { + "brief": "Retiring heavy (multi-uop) operations", + "suggestions": [ + "Microcode sequencer (MS) operations are expensive", + "Check for string operations, CPUID, serializing instructions", + "Reduce use of complex instructions that decode to many uops", + ], + "sample_events": ["UOPS_RETIRED.MS"], + }, + "Microcode_Sequencer": { + "brief": "Microcode sequencer generating many uops", + "suggestions": [ + "Identify MS-heavy instructions (REP MOV, CPUID, etc.)", + "Replace REP MOVSB with optimized memcpy for known sizes", + "Check for FP assists (denormals): set FTZ/DAZ flags", + ], + "sample_events": ["UOPS_RETIRED.MS"], + }, + "Light_Operations": { + "brief": "Efficiently retiring simple operations", + "suggestions": [ + "This is the ideal state — pipeline is efficient", + "Check vectorization breadth (FP_Arith, Int_Operations)", + ], + "sample_events": [], + }, +} + +# Nodes where no PMU event can drill deeper +NO_OBSERVABILITY_NODES = { + "Divider", + "LCP", + "DSB_Switches", + "MS_Switches", + "Non_Fused_Branches", + "Memory_Operations", + "Fused_Instructions", +} + + +def get_guidance(node_name: str) -> dict: + """Get tuning guidance for a TMA node. + + Returns dict with brief, suggestions, sample_events, and flags. + """ + guidance = GUIDANCE.get(node_name, { + "brief": f"TMA node: {node_name}", + "suggestions": ["No specific guidance available for this node"], + "sample_events": [], + }) + + result = dict(guidance) + result["no_hw_observability"] = node_name in NO_OBSERVABILITY_NODES + + return result diff --git a/skills/tma-drilldown/src/perfmon_tools/recommend/preflight.py b/skills/tma-drilldown/src/perfmon_tools/recommend/preflight.py new file mode 100644 index 0000000..3bf79a5 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/recommend/preflight.py @@ -0,0 +1,246 @@ +"""Pre-flight system checks before data collection. + +Detects SMT, steady-state behavior, and counter budget constraints. +""" + +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from ..core.platform import PlatformInfo + + +@dataclass +class PhaseInfo: + phase_id: int + intervals: list # indices of intervals belonging to this phase + runtime_pct: float # percentage of total runtime + tma_l1: dict # {Frontend_Bound: %, Backend_Bound: %, ...} + + +@dataclass +class MeasurementStrategy: + smt_active: bool + use_perf_metrics: bool + programmable_counters: int + fixed_counters: int + interval_mode: bool + interval_ms: int + phase_detected: bool + phases: list = field(default_factory=list) + notes: list = field(default_factory=list) + + +def detect_smt(smt_path: str = "/sys/devices/system/cpu/smt/active") -> bool: + """Detect whether SMT/Hyper-Threading is active.""" + try: + content = Path(smt_path).read_text().strip() + return content == "1" + except (FileNotFoundError, PermissionError): + # Fallback: check /proc/cpuinfo for siblings vs cores + try: + cpuinfo = Path("/proc/cpuinfo").read_text() + siblings = None + cores = None + for line in cpuinfo.splitlines(): + if "siblings" in line and siblings is None: + siblings = int(line.split(":")[1].strip()) + if "cpu cores" in line and cores is None: + cores = int(line.split(":")[1].strip()) + if siblings and cores: + return siblings > cores + except (FileNotFoundError, ValueError): + pass + return False + + +def detect_steady_state(interval_values: list, threshold_cv: float = 0.20) -> tuple: + """Analyze per-interval TMA L1 values for steady-state behavior. + + Args: + interval_values: list of dicts, each mapping metric_name -> value + threshold_cv: coefficient of variation threshold (default 20%) + + Returns: + (is_steady, phases: list[PhaseInfo]) + """ + if len(interval_values) < 3: + return True, [] + + # Extract key metrics across intervals + metric_series = {} + for iv in interval_values: + for name, val in iv.items(): + if name not in metric_series: + metric_series[name] = [] + metric_series[name].append(val) + + # Compute coefficient of variation for each metric + max_cv = 0.0 + for name, values in metric_series.items(): + if len(values) < 3: + continue + mean = sum(values) / len(values) + if mean == 0: + continue + variance = sum((v - mean) ** 2 for v in values) / len(values) + std = variance ** 0.5 + cv = std / mean + max_cv = max(max_cv, cv) + + is_steady = max_cv < threshold_cv + + phases = [] + if not is_steady: + phases = _cluster_intervals(interval_values) + + return is_steady, phases + + +def _cluster_intervals(interval_values: list) -> list: + """Simple k=2 clustering of intervals by TMA L1 profile. + + Uses the dominant metric (highest value) as the clustering key. + """ + if not interval_values: + return [] + + # Determine dominant metric for each interval + dominants = [] + for iv in interval_values: + if iv: + dominant = max(iv.items(), key=lambda x: x[1]) + dominants.append(dominant[0]) + else: + dominants.append("") + + # Group by dominant metric + groups = {} + for i, dom in enumerate(dominants): + if dom not in groups: + groups[dom] = [] + groups[dom].append(i) + + # Convert to PhaseInfo + total = len(interval_values) + phases = [] + for phase_id, (dom, indices) in enumerate( + sorted(groups.items(), key=lambda x: -len(x[1])) + ): + # Compute average TMA L1 for this phase + tma_l1 = {} + for idx in indices: + for name, val in interval_values[idx].items(): + tma_l1[name] = tma_l1.get(name, 0) + val + for name in tma_l1: + tma_l1[name] /= len(indices) + + phases.append( + PhaseInfo( + phase_id=phase_id, + intervals=indices, + runtime_pct=100.0 * len(indices) / total, + tma_l1=tma_l1, + ) + ) + + return phases + + +def compute_counter_budget( + events: set, platform: PlatformInfo +) -> dict: + """Compute whether events fit without multiplexing. + + Returns dict with fit analysis and suggested splits if needed. + """ + if platform.default_level >= 1: + gp_counters = 8 + fixed_counters = 4 + else: + gp_counters = 4 + fixed_counters = 3 + + # Categorize events + perf_metrics = set() + fixed = set() + programmable = set() + + fixed_event_names = { + "INST_RETIRED.ANY", "CPU_CLK_UNHALTED.THREAD", + "CPU_CLK_UNHALTED.REF_TSC", "TOPDOWN.SLOTS", + } + + for ev in events: + base = ev.split(":")[0] + if "PERF_METRICS" in ev or "TOPDOWN.SLOTS:perf_metrics" in ev: + perf_metrics.add(ev) + elif base in fixed_event_names: + fixed.add(ev) + else: + programmable.add(ev) + + needs_split = len(programmable) > gp_counters + mux_ratio = len(programmable) / gp_counters if needs_split else 1.0 + + # Suggest splits if needed + splits = [] + if needs_split: + prog_list = sorted(programmable) + for i in range(0, len(prog_list), gp_counters): + group = set(prog_list[i:i + gp_counters]) + group.update(perf_metrics) + group.update(fixed) + splits.append(group) + else: + splits = [events] + + return { + "fits_single_run": not needs_split, + "programmable_needed": len(programmable), + "gp_available": gp_counters, + "mux_ratio": mux_ratio, + "confidence_pct": min(100.0, 100.0 / mux_ratio) if mux_ratio > 0 else 100.0, + "suggested_splits": splits, + "split_count": len(splits), + } + + +def create_strategy(platform: PlatformInfo) -> MeasurementStrategy: + """Create measurement strategy based on platform and system state.""" + smt = detect_smt() + + use_perf_metrics = platform.default_level >= 1 + if platform.default_level >= 1: + gp = 8 + fixed = 4 + else: + gp = 4 + fixed = 3 + + notes = [] + if smt: + notes.append( + "SMT active: using per-thread events. " + "Cross-thread interference may affect L3+ accuracy." + ) + if use_perf_metrics: + notes.append( + f"PERF_METRICS supported: L1/L2 TMA available without multiplexing." + ) + else: + notes.append( + "Pre-ICL platform: L1 TMA requires TOPDOWN.SLOTS + programmable counters." + ) + + return MeasurementStrategy( + smt_active=smt, + use_perf_metrics=use_perf_metrics, + programmable_counters=gp, + fixed_counters=fixed, + interval_mode=False, + interval_ms=1000, + phase_detected=False, + notes=notes, + ) diff --git a/skills/tma-drilldown/src/perfmon_tools/recommend/session.py b/skills/tma-drilldown/src/perfmon_tools/recommend/session.py new file mode 100644 index 0000000..e16a87e --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/recommend/session.py @@ -0,0 +1,127 @@ +"""Session directory management for investigation state.""" + +import json +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Optional + + +@dataclass +class StepFinding: + level: int + top_node: str + value: float + threshold_passed: bool + siblings: dict # {name: value} + path_so_far: list # path from L1 to current + + +@dataclass +class SessionState: + created: str + platform: str + target_pid: Optional[int] = None + target_command: Optional[str] = None + duration: int = 5 + state: str = "IDLE" # IDLE, STARTED, COLLECTING, ANALYZED, COMPLETE + current_step: int = 0 + path: list = field(default_factory=list) + findings: list = field(default_factory=list) + smt_active: bool = False + phase_detected: bool = False + use_perf_metrics: bool = False + + +class Session: + """Manages investigation session directory and state.""" + + def __init__(self, session_dir: Path): + self.dir = session_dir + self.dir.mkdir(parents=True, exist_ok=True) + self._state_path = self.dir / "session.json" + + if self._state_path.exists(): + self.state = self._load_state() + else: + self.state = SessionState( + created=time.strftime("%Y-%m-%dT%H:%M:%S"), + platform="", + ) + + def _load_state(self) -> SessionState: + data = json.loads(self._state_path.read_text()) + findings = [StepFinding(**f) for f in data.pop("findings", [])] + state = SessionState(**data) + state.findings = findings + return state + + def save(self): + data = { + "created": self.state.created, + "platform": self.state.platform, + "target_pid": self.state.target_pid, + "target_command": self.state.target_command, + "duration": self.state.duration, + "state": self.state.state, + "current_step": self.state.current_step, + "path": self.state.path, + "findings": [asdict(f) for f in self.state.findings], + "smt_active": self.state.smt_active, + "phase_detected": self.state.phase_detected, + "use_perf_metrics": self.state.use_perf_metrics, + } + self._state_path.write_text(json.dumps(data, indent=2)) + + def new_step(self) -> Path: + """Create directory for next step.""" + self.state.current_step += 1 + step_name = f"step_{self.state.current_step:02d}" + if self.state.path: + step_name += f"_{self.state.path[-1].lower()}" + step_dir = self.dir / step_name + step_dir.mkdir(parents=True, exist_ok=True) + return step_dir + + def save_step_data(self, step_dir: Path, command: str, raw_output: str, + parsed: dict, analysis: dict): + """Save all data for a completed step.""" + (step_dir / "command.txt").write_text(command) + (step_dir / "raw_output.txt").write_text(raw_output) + (step_dir / "parsed.json").write_text(json.dumps(parsed, indent=2)) + (step_dir / "analysis.json").write_text(json.dumps(analysis, indent=2)) + + def add_finding(self, finding: StepFinding): + self.state.findings.append(finding) + if finding.top_node: + self.state.path.append(finding.top_node) + + def save_summary(self, summary: dict): + (self.dir / "summary.json").write_text(json.dumps(summary, indent=2)) + + @staticmethod + def create_new(base_dir: Path, platform: str, pid: Optional[int] = None, + command: Optional[str] = None, duration: int = 5) -> "Session": + """Create a new investigation session.""" + timestamp = time.strftime("%Y-%m-%d_%H%M%S") + target = f"pid{pid}" if pid else "cmd" + session_dir = base_dir / f"{timestamp}_{target}" + session = Session(session_dir) + session.state.platform = platform + session.state.target_pid = pid + session.state.target_command = command + session.state.duration = duration + session.state.state = "STARTED" + session.save() + return session + + @staticmethod + def find_latest(base_dir: Path) -> Optional["Session"]: + """Find the most recent session.""" + if not base_dir.exists(): + return None + sessions = sorted(base_dir.iterdir(), reverse=True) + for d in sessions: + if d.is_dir() and (d / "session.json").exists(): + return Session(d) + return None diff --git a/skills/tma-drilldown/src/perfmon_tools/recommend/tma_drilldown.py b/skills/tma-drilldown/src/perfmon_tools/recommend/tma_drilldown.py new file mode 100644 index 0000000..2a8ad17 --- /dev/null +++ b/skills/tma-drilldown/src/perfmon_tools/recommend/tma_drilldown.py @@ -0,0 +1,199 @@ +"""TMA-based iterative drill-down logic.""" + +from dataclasses import dataclass +from typing import Optional + +from ..core.catalog import PlatformCatalog +from ..core.formula import evaluate_metric +from ..core.tma_tree import TmaTree, TmaNode + + +@dataclass +class NodeResult: + name: str + level: int + value: Optional[float] + threshold_passed: Optional[bool] + locate_with: str + + +@dataclass +class DrillDownSuggestion: + target_nodes: list # child node names to investigate + events_needed: set # events for perf stat + perf_command_events: set # events formatted for command + rationale: str + locate_with_events: set # events for perf record sampling + is_leaf: bool + + +class TmaDrillDown: + """Manages TMA drill-down decisions.""" + + def __init__(self, tree: TmaTree, catalog: PlatformCatalog): + self.tree = tree + self.catalog = catalog + + def initial_events(self) -> set: + """Events needed for L1 TMA (4 root nodes) + Bottlenecks View.""" + events = set() + for root in self.tree.roots: + events.update(root.metric.event_names_with_modifiers) + # Also include bottleneck metrics events + for bm in self.tree.bottlenecks: + events.update(bm.event_names_with_modifiers) + return events + + def evaluate_level( + self, nodes: list, event_values: dict, constants: dict = None + ) -> list: + """Evaluate metrics for a set of nodes given collected event values. + + Returns list of NodeResult sorted by value descending. + """ + if constants is None: + constants = {} + + results = [] + metric_values = {} + + for node in nodes: + metric = node.metric + value = evaluate_metric(metric, event_values, constants) + if value is not None: + metric_values[metric.legacy_name] = value + metric_values[metric.name] = value + + # Evaluate thresholds + for node in nodes: + metric = node.metric + value = evaluate_metric(metric, event_values, constants) + + threshold_passed = None + if value is not None and metric.threshold: + threshold_passed = self._evaluate_threshold(metric, metric_values) + + results.append( + NodeResult( + name=node.name, + level=node.level, + value=value, + threshold_passed=threshold_passed, + locate_with=metric.locate_with or "", + ) + ) + + # Sort by value descending (highest bottleneck first) + results.sort(key=lambda r: r.value if r.value is not None else 0, reverse=True) + return results + + def _evaluate_threshold(self, metric, metric_values: dict) -> Optional[bool]: + """Evaluate a metric's threshold formula.""" + threshold = metric.threshold + if not threshold: + return None + + formula = threshold.get("Formula", "") + threshold_metrics = threshold.get("ThresholdMetrics", []) + if not formula or not threshold_metrics: + return None + + # Build alias values from threshold metrics + alias_values = {} + for tm in threshold_metrics: + alias = tm["Alias"] + value_key = tm["Value"] + # Try both name and legacy_name + if value_key in metric_values: + alias_values[alias] = metric_values[value_key] + else: + # Try matching the metric name directly + for key, val in metric_values.items(): + if key in value_key or value_key in key: + alias_values[alias] = val + break + if alias not in alias_values: + return None + + # Evaluate threshold formula + try: + expr = formula + for alias in sorted(alias_values.keys(), key=len, reverse=True): + expr = expr.replace(alias, str(float(alias_values[alias]))) + result = eval(expr, {"__builtins__": {}}) + return bool(result) + except (SyntaxError, NameError, TypeError, ValueError, ZeroDivisionError): + return None + + def suggest_next( + self, results: list, current_node: Optional[str] = None + ) -> Optional[DrillDownSuggestion]: + """Based on evaluation results, suggest which subtree to explore next. + + Args: + results: NodeResult list from evaluate_level + current_node: name of current node (whose children we evaluated) + + Returns: + DrillDownSuggestion or None if no further drill-down needed + """ + # Find top node that passes threshold + top = None + for r in results: + if r.threshold_passed and r.value is not None: + top = r + break + + # If no threshold passes, take the highest value anyway (if significant) + if top is None: + for r in results: + if r.value is not None and r.value > 5.0: # > 5% is worth investigating + top = r + break + + if top is None: + return None + + # Get children of the top node + node = self.tree.get_node(top.name) + if node is None or node.is_leaf: + # Leaf reached + locate_events = set() + if top.locate_with and top.locate_with != "#NA": + locate_events = {e.strip() for e in top.locate_with.split(";")} + return DrillDownSuggestion( + target_nodes=[top.name], + events_needed=set(), + perf_command_events=set(), + rationale=f"Leaf node reached: {top.name} = {top.value:.1f}%", + locate_with_events=locate_events, + is_leaf=True, + ) + + # Get events for children + children = node.children + children_events = set() + for child in children: + children_events.update(child.metric.event_names_with_modifiers) + + # Collect locate_with events for the top node + locate_events = set() + if top.locate_with and top.locate_with != "#NA": + locate_events = {e.strip() for e in top.locate_with.split(";")} + + others = [r for r in results if r.name != top.name and r.value is not None] + others_str = ", ".join(f"{r.name}={r.value:.1f}%" for r in others[:3]) + rationale = ( + f"{top.name} = {top.value:.1f}% " + f"(threshold {'passed' if top.threshold_passed else 'highest value'}). " + f"Others: {others_str}" + ) + + return DrillDownSuggestion( + target_nodes=[c.name for c in children], + events_needed=children_events, + perf_command_events=children_events, + rationale=rationale, + locate_with_events=locate_events, + is_leaf=False, + ) diff --git a/skills/tma-drilldown/tests/__init__.py b/skills/tma-drilldown/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skills/tma-drilldown/tests/conftest.py b/skills/tma-drilldown/tests/conftest.py new file mode 100644 index 0000000..2495280 --- /dev/null +++ b/skills/tma-drilldown/tests/conftest.py @@ -0,0 +1,7 @@ +"""Shared test fixtures.""" + +import sys +from pathlib import Path + +# Add src to path for imports +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) diff --git a/skills/tma-drilldown/tests/test_context_budget.py b/skills/tma-drilldown/tests/test_context_budget.py new file mode 100644 index 0000000..e4bcd03 --- /dev/null +++ b/skills/tma-drilldown/tests/test_context_budget.py @@ -0,0 +1,34 @@ +"""Tests for context budget tracking.""" + +from perfmon_tools.core.context_budget import ContextBudget + + +class TestContextBudget: + def test_estimate_tokens(self): + budget = ContextBudget() + # ~4 chars per token, integer division + assert budget.estimate_tokens("hello world") == 2 # 11 chars // 4 = 2 + + def test_would_exceed(self): + budget = ContextBudget(max_tokens=100) + # Small text should fit + assert not budget.would_exceed("short text") + # Very long text should exceed + long_text = "x" * 500 # 500//4 = 125 tokens > 100 + assert budget.would_exceed(long_text) + + def test_record_step(self): + budget = ContextBudget(max_tokens=8192) + budget.record_step(1, raw_text="x" * 5000, compact_finding="y" * 200) + report = budget.report() + assert report["steps"] == 1 + assert report["current_tokens"] == 50 # 200 // 4 + + def test_report(self): + budget = ContextBudget(max_tokens=8192) + budget.record_step(1, raw_text="x" * 8000, compact_finding="y" * 200) + budget.record_step(2, raw_text="x" * 6000, compact_finding="y" * 180) + report = budget.report() + assert report["steps"] == 2 + assert report["current_tokens"] == 50 + 45 # 200//4 + 180//4 + assert report["headroom"] > 0 diff --git a/skills/tma-drilldown/tests/test_perf_output.py b/skills/tma-drilldown/tests/test_perf_output.py new file mode 100644 index 0000000..1efb186 --- /dev/null +++ b/skills/tma-drilldown/tests/test_perf_output.py @@ -0,0 +1,129 @@ +"""Tests for perf stat output parsing.""" + +import pytest +from perfmon_tools.core.perf_output import ( + parse_perf_stat_text, + parse_perf_stat_json, + parse_perf_stat_interval, + parse_auto, + _normalize_event_values, + PERF_TO_PERFMON, +) + + +SAMPLE_TEXT_OUTPUT = """\ + Performance counter stats for 'sleep 1': + + 1,234,567,890 cycles (66.52%) + 456,789,012 instructions # 0.37 insn per cycle + 12,345,678 cache-misses + branch-misses + + 1.001234567 seconds time elapsed +""" + +SAMPLE_JSON_OUTPUT = """\ +{"counter-value": "1234567890.000000", "unit": "", "event": "cycles", "pcnt-running": 66.52} +{"counter-value": "456789012.000000", "unit": "", "event": "instructions", "pcnt-running": 100.00} +{"counter-value": "", "unit": "", "event": "branch-misses", "pcnt-running": 0.00} +""" + +SAMPLE_INTERVAL_OUTPUT = """\ +1.000123456;cycles;5000000;;100.00 +1.000123456;instructions;2500000;;100.00 +2.000234567;cycles;5100000;;100.00 +2.000234567;instructions;2600000;;100.00 +""" + + +class TestParseText: + def test_basic_values(self): + result = parse_perf_stat_text(SAMPLE_TEXT_OUTPUT) + assert result.event_values["cycles"] == 1234567890.0 + assert result.event_values["instructions"] == 456789012.0 + assert result.event_values["cache-misses"] == 12345678.0 + + def test_duration(self): + result = parse_perf_stat_text(SAMPLE_TEXT_OUTPUT) + assert result.duration_seconds == pytest.approx(1.001234567) + + def test_multiplexing_detection(self): + result = parse_perf_stat_text(SAMPLE_TEXT_OUTPUT) + # cycles at 66.52% should be flagged + mux_events = [m.event for m in result.multiplexing_issues] + assert "cycles" in mux_events + assert "branch-misses" in mux_events # + + def test_not_counted(self): + result = parse_perf_stat_text(SAMPLE_TEXT_OUTPUT) + assert "branch-misses" not in result.event_values + + def test_raw_text_preserved(self): + result = parse_perf_stat_text(SAMPLE_TEXT_OUTPUT) + assert result.raw_text == SAMPLE_TEXT_OUTPUT + + +class TestParseJson: + def test_basic_values(self): + result = parse_perf_stat_json(SAMPLE_JSON_OUTPUT) + assert result.event_values["cycles"] == 1234567890.0 + assert result.event_values["instructions"] == 456789012.0 + + def test_not_counted(self): + result = parse_perf_stat_json(SAMPLE_JSON_OUTPUT) + assert "branch-misses" not in result.event_values + mux_events = [m.event for m in result.multiplexing_issues] + assert "branch-misses" in mux_events + + def test_multiplexing_detection(self): + result = parse_perf_stat_json(SAMPLE_JSON_OUTPUT) + mux_events = [m.event for m in result.multiplexing_issues] + assert "cycles" in mux_events # 66.52% < 90% + + +class TestParseInterval: + def test_two_intervals(self): + intervals = parse_perf_stat_interval(SAMPLE_INTERVAL_OUTPUT) + assert len(intervals) == 2 + assert intervals[0]["cycles"] == 5000000.0 + assert intervals[0]["instructions"] == 2500000.0 + assert intervals[1]["cycles"] == 5100000.0 + + +class TestNormalize: + def test_perf_to_perfmon_mapping(self): + values = {"topdown-fe-bound": 25.0, "topdown-be-bound": 40.0, "slots": 1000000} + normalized = _normalize_event_values(values) + assert normalized["PERF_METRICS.FRONTEND_BOUND"] == 25.0 + assert normalized["PERF_METRICS.BACKEND_BOUND"] == 40.0 + assert normalized["TOPDOWN.SLOTS"] == 1000000 + + def test_cpu_wrapper_strip(self): + values = {"cpu/INST_RETIRED.ANY/": 500} + normalized = _normalize_event_values(values) + assert normalized["INST_RETIRED.ANY"] == 500 + + def test_cpu_core_wrapper_strip(self): + values = {"cpu_core/INST_RETIRED.ANY/": 500} + normalized = _normalize_event_values(values) + assert normalized["INST_RETIRED.ANY"] == 500 + + def test_original_preserved(self): + values = {"topdown-fe-bound": 25.0} + normalized = _normalize_event_values(values) + assert "topdown-fe-bound" in normalized + + +class TestParseAuto: + def test_detects_json(self): + result = parse_auto(SAMPLE_JSON_OUTPUT) + assert result.event_values["cycles"] == 1234567890.0 + + def test_detects_text(self): + result = parse_auto(SAMPLE_TEXT_OUTPUT) + assert result.event_values["cycles"] == 1234567890.0 + + def test_normalizes_names(self): + topdown_json = '{"counter-value": "25.0", "unit": "", "event": "topdown-fe-bound", "pcnt-running": 100.00}\n' + result = parse_auto(topdown_json) + assert "PERF_METRICS.FRONTEND_BOUND" in result.event_values diff --git a/skills/tma-drilldown/tests/test_tracer.py b/skills/tma-drilldown/tests/test_tracer.py new file mode 100644 index 0000000..359ae1a --- /dev/null +++ b/skills/tma-drilldown/tests/test_tracer.py @@ -0,0 +1,84 @@ +"""Tests for decision tracing.""" + +import json +import os +from unittest.mock import patch + +from perfmon_tools.core.tracer import ( + DecisionNode, + Trace, + _DecisionContext, + get_tracer, + reset_tracer, +) + + +class TestTrace: + def setup_method(self): + self.trace = Trace() + + def test_add_node(self): + node = DecisionNode(operation="test_op", decision="chose_A") + self.trace._add_node(node) + assert len(self.trace.nodes) == 1 + assert self.trace.nodes[0].operation == "test_op" + + def test_root_ids(self): + root = DecisionNode(operation="root") + child = DecisionNode(operation="child", parent_id=root.id) + self.trace._add_node(root) + self.trace._add_node(child) + assert root.id in self.trace.root_ids + assert child.id not in self.trace.root_ids + + def test_parent_child_linkage(self): + root = DecisionNode(operation="root") + child = DecisionNode(operation="child", parent_id=root.id) + self.trace._add_node(root) + self.trace._add_node(child) + assert child.id in root.children_ids + + def test_to_json(self): + node = DecisionNode(operation="test", decision="result") + self.trace._add_node(node) + output = json.loads(self.trace.to_json()) + assert output["trace_version"] == "1.0" + assert len(output["nodes"]) == 1 + assert output["nodes"][0]["operation"] == "test" + + def test_to_mermaid(self): + node = DecisionNode(operation="analyze", decision="Backend_Bound") + self.trace._add_node(node) + mermaid = self.trace.to_mermaid() + assert "graph TD" in mermaid + assert "analyze" in mermaid + assert "Backend_Bound" in mermaid + + def test_to_dot(self): + node = DecisionNode(operation="select", decision="Memory_Bound", confidence=0.95) + self.trace._add_node(node) + dot = self.trace.to_dot() + assert "digraph trace" in dot + assert "Memory_Bound" in dot + assert "0.95" in dot + + def test_to_html(self): + node = DecisionNode(operation="drill", decision="DRAM_Bound") + self.trace._add_node(node) + html = self.trace.to_html() + assert "" in html + assert "DRAM_Bound" in html + + +class TestDecisionContext: + def test_context_manager(self): + trace = Trace() + ctx = _DecisionContext(trace, "test_decision", None) + with ctx as d: + d.inputs = {"key": "value"} + d.decision = "chose_X" + d.confidence = 0.8 + assert len(trace.nodes) == 1 + assert trace.nodes[0].decision == "chose_X" + assert trace.nodes[0].confidence == 0.8 + assert trace.nodes[0].duration_ms >= 0