[rocprofiler-compute] improve config management system (#2359)

Tento commit je obsažen v:
xuchen-amd
2026-01-14 13:20:27 -05:00
odevzdal GitHub
rodič d7ff927690
revize 71b9ea6ba0
25 změnil soubory, kde provedl 1407 přidání a 20261 odebrání
+1 -1
Zobrazit soubor
@@ -23,7 +23,7 @@ repos:
hooks:
- id: hash-check
name: Hash consistency check
entry: bash -lc 'cd projects/rocprofiler-compute && python3 tools/config_management/hash_checker.py'
entry: bash -lc 'cd projects/rocprofiler-compute && python3 src/utils/hash_checker.py'
language: system
pass_filenames: false
stages: [pre-commit]
@@ -1,4 +1,3 @@
# AUTOGENERATED FILE. Only edit for testing purposes, not for development. Generated from utils/unified_sets.yaml. Generated by utils/split_config.py
sets:
- title: Compute Throughput Utilization
set_option: compute_thruput_util
@@ -1,4 +1,3 @@
# AUTOGENERATED FILE. Only edit for testing purposes, not for development. Generated from utils/unified_sets.yaml. Generated by utils/split_config.py
sets:
- title: Compute Throughput Utilization
set_option: compute_thruput_util
@@ -1,4 +1,3 @@
# AUTOGENERATED FILE. Only edit for testing purposes, not for development. Generated from utils/unified_sets.yaml. Generated by utils/split_config.py
sets:
- title: Compute Throughput Utilization
set_option: compute_thruput_util
@@ -1,4 +1,3 @@
# AUTOGENERATED FILE. Only edit for testing purposes, not for development. Generated from utils/unified_sets.yaml. Generated by utils/split_config.py
sets:
- title: Compute Throughput Utilization
set_option: compute_thruput_util
@@ -1,4 +1,3 @@
# AUTOGENERATED FILE. Only edit for testing purposes, not for development. Generated from utils/unified_sets.yaml. Generated by utils/split_config.py
sets:
- title: Compute Throughput Utilization
set_option: compute_thruput_util
@@ -1,4 +1,3 @@
# AUTOGENERATED FILE. Only edit for testing purposes, not for development. Generated from utils/unified_sets.yaml. Generated by utils/split_config.py
sets:
- title: Compute Throughput Utilization
set_option: compute_thruput_util
@@ -43,27 +43,16 @@ from pathlib import Path
import yaml
try:
from . import hash_manager # type: ignore
except Exception:
import importlib.util
PROJECT_ROOT = Path(__file__).resolve().parents[2] # rocprofiler-compute/
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
_HERE = Path(__file__).resolve().parent
_SPEC = importlib.util.spec_from_file_location(
"hash_manager", str(_HERE / "hash_manager.py")
)
hash_manager = importlib.util.module_from_spec(_SPEC) # type: ignore[assignment]
assert _SPEC and _SPEC.loader is not None
_SPEC.loader.exec_module(hash_manager) # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
from tools.config_management import hash_manager # noqa: E402
# Subproject root: .../projects/rocprofiler-compute
SUBROOT = Path(__file__).resolve().parents[2]
CONFIGS_ROOT: Path = SUBROOT / "src" / "rocprof_compute_soc" / "analysis_configs"
HASH_FILE: Path = SUBROOT / "tools" / "config_management" / ".config_hashes.json"
CONFIGS_ROOT: Path = PROJECT_ROOT / "src" / "rocprof_compute_soc" / "analysis_configs"
HASH_FILE: Path = PROJECT_ROOT / "src" / "utils" / ".config_hashes.json"
TEMPLATE_FILE: Path = (
SUBROOT / "tools" / "config_management" / "analysis_config_template.yaml"
PROJECT_ROOT / "tools" / "config_management" / "gfx9_config_template.yaml"
)
@@ -73,7 +62,7 @@ TEMPLATE_FILE: Path = (
def _latest_arch(template_file: Path) -> str:
if not template_file.is_file():
return ""
with open(template_file, "r", encoding="utf-8") as f:
with open(template_file, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return str(data.get("latest_arch") or "")
+82 -16
Zobrazit soubor
@@ -24,25 +24,91 @@
##############################################################################
import hashlib
import json
from pathlib import Path
import pytest
import yaml
PROJECT_ROOT = Path(__file__).resolve().parents[1]
HASH_DB = PROJECT_ROOT / "src/utils/.config_hashes.json"
ANALYSIS_CONFIGS = PROJECT_ROOT / "src/rocprof_compute_soc/analysis_configs"
@pytest.mark.skip(
reason=(
"TODO: Skip this test until we use "
"tools/config_management/.config.hashes.json for testing"
def md5(path: Path) -> str:
return hashlib.md5(path.read_bytes()).hexdigest()
def test_config_hashes_match_files() -> None:
assert HASH_DB.exists(), f"Missing hash DB: {HASH_DB}"
assert ANALYSIS_CONFIGS.exists(), (
f"Missing analysis configs dir: {ANALYSIS_CONFIGS}"
)
)
def test_modification_time():
# Ensure hash map consistency
hash_path = Path("tools/autogen_hash.yaml")
with open(hash_path) as f:
hash_map = yaml.safe_load(f)
for file, hash in hash_map.items():
file_hash = hashlib.sha256(Path(file).read_bytes()).hexdigest()
assert file_hash == hash, (
f"Hash mismatch for {file}: expected {hash}, got {file_hash}"
)
with HASH_DB.open() as f:
data = json.load(f)
assert "archs" in data, "Hash DB missing 'archs' key"
assert isinstance(data["archs"], dict)
failures = []
for arch, arch_data in data["archs"].items():
arch_dir = ANALYSIS_CONFIGS / arch
if not arch_dir.exists():
failures.append(f"Arch directory missing: {arch_dir}")
continue
# -------------------------
# Panel YAMLs
# -------------------------
files = arch_data.get("files", {})
if not isinstance(files, dict):
failures.append(f"'files' for {arch} is not a dict")
continue
for rel_path, expected_hash in files.items():
panel_path = arch_dir / rel_path
if not panel_path.exists():
failures.append(f"Missing panel file: {panel_path}")
continue
actual_hash = md5(panel_path)
if actual_hash != expected_hash:
failures.append(
f"[{arch}] Panel hash mismatch: {panel_path}\n"
f" expected: {expected_hash}\n"
f" actual: {actual_hash}"
)
# -------------------------
# Delta YAML (if any)
# -------------------------
delta_hash = arch_data.get("delta_hash")
if delta_hash is not None:
delta_dir = arch_dir / "config_delta"
if not delta_dir.exists():
failures.append(f"[{arch}] Missing config_delta directory")
continue
# Exactly one *_diff.yaml should exist
delta_files = list(delta_dir.glob("*_diff.yaml"))
if len(delta_files) != 1:
failures.append(
f"[{arch}] Expected exactly one delta file, found "
f"{len(delta_files)} in {delta_dir}"
)
continue
delta_path = delta_files[0]
actual_delta_hash = md5(delta_path)
if actual_delta_hash != delta_hash:
failures.append(
f"[{arch}] Delta hash mismatch: {delta_path}\n"
f" expected: {delta_hash}\n"
f" actual: {actual_delta_hash}"
)
if failures:
pytest.fail("Hash consistency failures:\n\n" + "\n".join(failures))
-2
Zobrazit soubor
@@ -1,2 +0,0 @@
# AUTOGENERATED FILE. Only edit for testing purposes, not for development. Generated from tools/unified_config.yaml. Generated by tools/split_config.py
{}
+251 -475
Zobrazit soubor
@@ -1,500 +1,276 @@
# Architecture Configuration Workflow
# ROCProfiler-Compute Configuration Management
This document explains the master workflow system for managing architecture-specific metric configurations.
This directory contains the authoritative configuration-management system for ROCProfiler-Compute analysis configurations.
## Overview
It is designed to guarantee:
The workflow system manages changes to architecture configurations located in `src/rocprof_compute_soc/analysis_configs/gfx<arch>/`. It handles:
- **Structural correctness** across GPU architectures
- **Deterministic deltas** relative to a single latest architecture
- **Byte-level immutability** enforced via hashes
- **Safe promotion** of a new latest architecture with rollback
- **CI enforcement** of all invariants
- **Metric changes** (additions, deletions, modifications)
- **Metric description changes** (plain text + RST documentation)
- **New architecture additions**
- **Template updates**
- **Config delta generation** for version control
## Files Overview
### Core Scripts
1. **`master_config_workflow_script.py`** - Main orchestrator script
2. **`hash_manager.py`** - Tracks file changes via MD5 hashes
3. **`metric_description_manager.py`** - Syncs metric descriptions across files
4. **`config_workflow.yaml`** - Configuration file
5. **`parse_config_template.py`** - Parses base config template from latest arch
6. **`generate_config_deltas.py`** - Generates config deltas between two archs
7. **`apply_config_deltas.py`** - Applies config deltas to genearte new arch configs
8. **`verify_against_config_template.py`** - Validates configs against template
## Quick Start
### Initial Setup (not needed following first commit)
1. Create the hash database:
```bash
python hash_manager.py --compute-all src/rocprof_compute_soc/analysis_configs
```
2. Ensure `analysis_config_template.yaml` has metadata:
```yaml
latest_arch: gfx950
panels:
- file: top_stats.yaml
panel_id: 0
...
```
### Making Changes
Simply run the master workflow after making any changes:
All workflows are orchestrated by a single sequential driver script:
```bash
python master_config_workflow_script.py
tools/config_management/master_config_workflow_script.py
```
## Repository Layout
```bash
rocprofiler-compute/
├── src/rocprof_compute_soc/
│ └── analysis_configs/
│ ├── gfx908/
│ │ ├── 0000_top_stats.yaml
│ │ └── config_delta/
│ │ └── <latest_arch>_diff.yaml
│ ├── gfx90a/
│ ├── gfx940/
│ ├── gfx950/ # latest_arch
│ └── gfx9_config_template.yaml # single source of truth
├── src/util/
│ ├── hash_checker.py
│ ├── .config_hashes.json
└── tools/config_management/
├── master_config_workflow_script.py
├── parse_config_template.py
├── verify_against_config_template.py
├── generate_config_deltas.py
├── apply_config_deltas.py
├── hash_manager.py
├── TESTING.md
└── README.md
```
## Core Concepts
### Latest Architecture
- Exactly one architecture is considered *latest*
- Defined in:
```bash
src/rocprof_compute_soc/analysis_configs/gfx9_config_template.yaml
```
### Panel YAMLs
- Live under:
```bash
analysis_configs/<arch>/*.yaml
```
- Must conform strictly to the template schema
- Are edited in-place using ruamel.yaml round-trip mode
### Delta YAMLs
- Represent differences from latest → older architecture
- Live under:
```bash
analysis_configs/<older_arch>/config_delta/
```
- Exactly one delta file per arch
- Always named:
```bash
<latest_arch>_diff.yaml
```
### Hash Database
- Stored at:
```bash
src/utils/.config_hashes.json
```
- Records:
- md5 hashes of panel YAMLs per arch
- md5 hash of the delta YAML (or null for latest)
- Machine-generated only
- Enforced in CI and pytest
## Architecture Diagram (End-to-End Flow)
```pqsql
┌──────────────────────────┐
│ analysis_configs/ │
│ gfx9_config_template │
└───────────┬──────────────┘
┌───────────────────────────────┐
│ verify_against_config_template│
│ (structural validation) │
└───────────┬───────────────────┘
┌───────────────────┴───────────────────┐
│ │
▼ ▼
┌────────────────────┐ ┌──────────────────────┐
│ edit-existing mode │ │ promotion mode │
│ (local dev only) │ │ (authoritative path) │
└─────────┬──────────┘ └──────────┬───────────┘
│ │
▼ ▼
┌────────────────────┐ ┌─────────────────────────────┐
│ generate / apply │ │ parse_config_template.py │
│ deltas manually │ │ (update latest_arch) │
└────────────────────┘ └──────────┬──────────────────┘
┌──────────────────────────────────┐
│ generate_config_deltas.py │
│ latest → all older arches │
│ (<latest>_diff.yaml only) │
└──────────┬───────────────────────┘
┌──────────────────────────────────┐
│ verify_against_config_template │
│ (post-promotion validation) │
└──────────┬───────────────────────┘
┌──────────────────────────────────┐
│ hash_manager.py --compute-all │
│ (new steady state) │
└──────────┬───────────────────────┘
┌──────────────────────────────────┐
│ hash_checker.py │
│ (semantic consistency) │
└──────────────────────────────────┘
```
## Contributor Quick Start
> [!NOTE]
> **Required Python Dependency**
> This configuration management system requires the `ruamel.yaml` Python package.
> It is used to safely modify YAML files while preserving comments, ordering,
> and formatting. The workflow scripts will not function correctly without it.
>
> Install it via:
> ```bash
> pip install ruamel.yaml
> ```
### 1. Validate the current state
Before making **any** config changes:
```bash
python tools/config_management/master_config_workflow_script.py --validate-only
```
This must pass.
### 2. Editing an existing architecture (most common)
Edit panel YAMLs **directly** under:
```bash
src/rocprof_compute_soc/analysis_configs/<arch>/
```
Rules:
- Preserve structure
- Preserve ordering
- Use multiline `>-` formatting for metric descriptions
- Do **not** regenerate entire files
After editing:
```bash
python tools/config_management/master_config_workflow_script.py --validate-only
```
### 3. Generating or applying deltas (advanced / optional)
For local experimentation only:
```bash
python tools/config_management/master_config_workflow_script.py --edit-existing
```
This mode:
- never updates the template
- never updates hashes
- always re-validates after application
### 4. Promoting a new latest architecture (rare, gated)
Promotion changes **global invariants** and must use the master script:
```bash
python tools/config_management/master_config_workflow_script.py --promote <latest_arch>
```
The script will:
- Detect what changed
- Prompt you for confirmation
- Apply changes
- Validate results
- Update all necessary files
### Dry Run Mode
1. Update `latest_arch` in the template
2. Regenerate deltas for all older arches
3. Remove stale delta files
4. Re-validate everything
5. Rebuild the hash database
6. Verify semantic consistency
To see what would happen without making changes:
If anything fails:
- all changes are rolled back
- no partial state remains
### 5. Hash checks (fast local / CI)
```bash
python master_config_workflow_script.py --dry-run
python tools/config_management/master_config_workflow_script.py --hash-only
```
## Usage Scenarios
### Scenario A: Add Metrics to Latest Arch (gfx950)
**Method 1: Direct Edit**
1. Edit `src/rocprof_compute_soc/analysis_configs/gfx950/0700_wavefront.yaml`
2. Add your metric to the appropriate table
3. Add description to `metrics_description` section
4. Run: `python master_config_workflow_script.py`
5. Answer prompts
**Method 2: Using Delta**
1. Create `src/rocprof_compute_soc/analysis_configs/gfx950/config_delta/gfx955_diff.yaml`:
```yaml
Addition:
- Panel Config:
id: 700
title: Wavefront
metric_tables:
- metric_table:
id: 701
title: Wavefront Launch Stats
metrics:
- New Metric:
avg: AVG(something)
unit: Units
metric_descriptions:
New Metric:
plain: Description text
rst: >- # Optional
Description with :ref:`RST markup <link>`
Deletion:
[]
Modification:
[]
```
2. Run: `python master_config_workflow_script.py`
**What Happens:**
- Changes applied to gfx950
- Template updated
- Deltas regenerated for all previous archs (gfx940, gfx941, etc.)
- Metric descriptions synced to:
- `tools/per_arch_metric_definitions/gfx950_metrics_description.yaml`
- `docs/data/metrics_description.yaml`
- All archs validated
- Hashes updated
### Scenario B: Modify Metrics in Older Arch (gfx940)
**Method 1: Direct Edit**
1. Edit `src/rocprof_compute_soc/analysis_configs/gfx940/0700_wavefront.yaml`
2. Make your changes
3. Run: `python master_config_workflow_script.py`
**Method 2: Using Delta**
1. Create `src/rocprof_compute_soc/analysis_configs/gfx940/config_delta/gfx950_diff.yaml`
2. Run: `python master_config_workflow_script.py`
**What Happens:**
- Changes applied to gfx940 only
- Validated against template (must still match structure)
- Metric descriptions synced to `tools/per_arch_metric_definitions/gfx940_metrics_description.yaml`
- Hashes updated for gfx940 only
### Scenario C: Add New Architecture (gfx955)
**Method 1: Create Directory with YAMLs**
1. Create `src/rocprof_compute_soc/analysis_configs/gfx955/`
2. Copy/create YAML files
3. Run: `python master_config_workflow_script.py`
4. Confirm this is the new latest arch
**Method 2: Using Delta from Latest**
1. Create delta showing differences from gfx950
2. Place in `src/rocprof_compute_soc/analysis_configs/gfx955/config_delta/gfx955_diff.yaml`
3. Run: `python master_config_workflow_script.py`
4. Confirm this is the new latest arch
**What Happens:**
- gfx955 becomes new latest arch
- Template updated with gfx955 as source
- Deltas generated: gfx955 → gfx950, gfx955 → gfx940, etc.
- All archs validated
- Metric descriptions synced
- Hashes updated
### Scenario D: Update Metric Descriptions Only
1. Edit description in config YAML:
```yaml
metrics_description:
Grid Size: "Updated description text"
```
2. Run: `python master_config_workflow_script.py`
**What Happens:**
- Same workflow as metric changes
- Plain text stored in config YAMLs
- RST version generated and stored in docs/tools files
## Delta YAML Structure
### Complete Example
```yaml
Addition:
- Panel Config:
id: 1100
title: Compute Units - Compute Pipeline
metric_tables:
- metric_table:
id: 1103
title: Arithmetic Operations
metrics:
- F8 OPs:
avg: AVG(((512 * SQ_INSTS_VALU_MFMA_MOPS_F8) / $denom))
min: MIN(((512 * SQ_INSTS_VALU_MFMA_MOPS_F8) / $denom))
max: MAX(((512 * SQ_INSTS_VALU_MFMA_MOPS_F8) / $denom))
unit: (OPs + $normUnit)
metric_descriptions:
F8 OPs:
plain: Number of 8-bit floating point operations
rst: |-
Number of 8-bit floating point operations per :ref:`normalization unit <normalization-units>`"
Deletion:
- Panel Config:
id: 1100
title: Compute Units - Compute Pipeline
metric_tables:
- metric_table:
id: 1103
title: Arithmetic Operations
metrics:
- Old Metric:
avg: AVG(something)
metric_descriptions:
Old Metric:
plain: "Old description"
Modification:
- Panel Config:
id: 1100
title: Compute Units - Compute Pipeline
metric_tables:
- metric_table:
id: 1103
title: Arithmetic Operations
metrics:
- Existing Metric:
avg: AVG(new_formula) # Changed field only
metric_descriptions:
Existing Metric:
plain: Updated description
rst: >-
Updated description with **RST**"
```
### Rules for Deltas
1. **Must have all three sections**: Addition, Deletion, Modification (can be empty lists)
2. **Metric descriptions**:
- `plain` field is required
- `rst` field is optional (defaults to copy of plain)
3. **Delta filename**: Must be `<target_arch>_diff.yaml`
4. **Location**: `src/rocprof_compute_soc/analysis_configs/gfx<arch>/config_delta/`
## Standalone Tool Usage
### Hash Manager
or:
```bash
# Compute hashes for all archs
python hash_manager.py --compute-all src/rocprof_compute_soc/analysis_configs
# Detect changes
python hash_manager.py --detect-changes src/rocprof_compute_soc/analysis_configs
# Update hashes for specific arch
python hash_manager.py --update gfx950 src/rocprof_compute_soc/analysis_configs
python tools/config_management/master_config_workflow_script.py --ci
```
### Metric Description Manager
This runs semantic hash validation only.
## Automated Testing
### Pytest Hash Integrity Test
Located at:
```bash
# Sync descriptions for specific arch
python metric_description_manager.py --sync-arch gfx950 src/rocprof_compute_soc/analysis_configs --latest-arch gfx950
# Sync all archs
python metric_description_manager.py --sync-all src/rocprof_compute_soc/analysis_configs --latest-arch gfx950
# Validate descriptions
python metric_description_manager.py --validate gfx950 src/rocprof_compute_soc/analysis_configs
tests/test_autogen_config.py
```
### Parse Config Template
This test:
- parses `.config_hashes.json`
- verifies **byte-for-byte** integrity of:
- panel YAMLs
- delta YAMLs
- fails on:
- missing files
- changed content
- stale hash DB
Semantic correctness is enforced separately by `hash_checker.py`.
## Contributor Rules (Strict)
- Do **not** edit `.config_hashes.json` manually
- Do **not** create multiple delta files per arch
- Do **not** rename delta files arbitrarily
- Do **not** regenerate full YAMLs unnecessarily
- Use in-place edits (ruamel round-trip)
- Use the master script for promotions
- Expect CI to reject inconsistent states
## Summary
This system guarantees:
- A **single source of truth** for latest architecture
- Deterministic, reviewable deltas
- Stable diffs for Git review
- Hash-backed immutability
- Safe, transactional promotions
- CI-enforced correctness
All correctness flows through:
```bash
# Generate template with metadata
python parse_config_template.py src/rocprof_compute_soc/analysis_configs/gfx950 \
tools/config_management/analysis_config_template.yaml \
--latest-arch gfx950
```
### Generate Delta
```bash
# Generate delta from current arch to previous arch
python generate_config_deltas.py \
src/rocprof_compute_soc/analysis_configs/gfx950 \
src/rocprof_compute_soc/analysis_configs/gfx940
```
### Apply Delta
```bash
# Apply delta to base arch
python apply_config_deltas.py \
src/rocprof_compute_soc/analysis_configs/gfx940 \
src/rocprof_compute_soc/analysis_configs/gfx940/config_delta/gfx950_diff.yaml \
output_dir
```
### Verify Against Template
```bash
# Validate all archs
python verify_against_config_template.py \
src/rocprof_compute_soc/analysis_configs \
tools/config_management/analysis_config_template.yaml
```
## File Structure
```
.
├── src/rocprof_compute_soc/analysis_configs/
│ ├── gfx940/
│ │ ├── 0700_wavefront.yaml # Config with plain descriptions
│ │ └── config_delta/
│ │ └── gfx950_diff.yaml # Delta to apply changes
│ ├── gfx941/
│ └── gfx950/ # Latest arch
│ ├── 0700_wavefront.yaml
│ └── config_delta/
│ └── gfx950_diff.yaml # Optional delta for modifications
├── tools/
│ ├── config_management/
│ │ ├── .config_hashes.json # Hash database (auto-generated)
│ │ ├── analysis_config_template.yaml # Template with metadata
│ │ ├── hash_manager.py
│ │ ├── metric_description_manager.py
│ │ ├── parse_config_template.py
│ │ ├── generate_config_deltas.py
│ │ ├── apply_config_deltas.py
│ │ ├── verify_against_config_template.py
│ │ ├── master_config_workflow_script.py
│ │ └── config_workflow.yaml
│ │
│ └── per_arch_metric_definitions/
│ ├── gfx940_metrics_description.yaml # RST only
│ ├── gfx941_metrics_description.yaml
│ └── gfx950_metrics_description.yaml
├── docs/data/
│ └── metrics_description.yaml # RST only, latest arch only
└── .backups/ # Auto-generated backups
└── 20250115_143022/ # Timestamped backup
```
## Configuration
Edit `config_workflow.yaml` to customize paths and behavior:
```yaml
paths:
template: tools/config_management/analysis_config_template.yaml
configs_root: src/rocprof_compute_soc/analysis_configs
backups: .backups
hashes: tools/config_management/.config_hashes.json
per_arch_metrics: tools/per_arch_metric_definitions
docs_metrics: docs/data/metrics_description.yaml
validation:
strict_mode: true # Fail on warnings
verify_after_changes: true # Validate after operations
behavior:
require_confirmation: true # Prompt before changes
```
## Error Handling
### Validation Failures
If validation fails:
1. All changes are automatically reverted
2. Backup is restored
3. Detailed error report is printed
4. Fix the issue and run again
### Hash Mismatches
If hashes are out of sync:
```bash
# Recompute all hashes
python hash_manager.py --compute-all src/rocprof_compute_soc/analysis_configs
```
### Description Validation Errors
Common issues:
- **Missing descriptions**: Warning only (won't fail)
- **Invalid RST syntax**: Error (will fail and revert)
- **Missing plain text**: Error (plain is required)
## Best Practices
1. **Always use master_config_workflow_script.py** - Don't run individual scripts manually unless debugging
2. **Test with --dry-run first** - See what will happen before committing
3. **Use deltas for complex changes** - Easier to review and version control
4. **Keep descriptions updated** - Plain text in configs, RST in docs
5. **One change at a time** - If multiple archs need updates, do them sequentially
6. **Check validation output** - Review warnings even if they don't fail
## Troubleshooting
### "No changes detected"
- Check that files were actually modified
- Ensure you're in the correct directory
- Verify hash database exists: `tools/config_management/.config_hashes.json`
### "Validation failed"
- Review the error output carefully
- Check that new metrics match template structure
- Ensure panel IDs are correct
- Verify data source ordering
### "Failed to sync metric descriptions"
- Check RST syntax in descriptions
- Ensure all metrics have descriptions
- Verify section_panel_map includes your table ID
### Changes not detected after manual edit
```bash
# Force recompute hashes
python hash_manager.py --compute-all src/rocprof_compute_soc/analysis_configs
# Then run workflow
python master_config_workflow_script.py
```
## Development Notes
### Adding New Architecture Support
When adding a completely new architecture line:
1. Ensure table IDs are in `metric_description_manager.py`'s `SECTION_PANEL_MAP`
2. Follow existing naming conventions (gfxXXX)
3. Create complete YAML set (don't start with partial configs)
### Modifying the Workflow
If you need to modify the workflow behavior:
1. Edit `config_workflow.yaml` for path/behavior changes
2. Edit `master_config_workflow_script.py` for workflow logic changes
3. Test with `--dry-run` extensively
4. Update this README
# Pre-commit: Hash Consistency Check
We ship a lightweight pre-commit hook that catches inconsistent hash updates across config YAMLs and deltas.
## What it enforces (per arch)
* Latest panels changed → latest delta must change (if there are older archs).
* Latest delta changed → latest panels must change or a new arch must be added.
* Older arch panels changed → that archs delta must change.
* Older arch delta changed → either latest panels or that archs panels must have changed.
## Setup
Install and enable pre-commit:
```bash
pip install pre-commit
pre-commit install
```
Our .pre-commit-config.yaml includes a local hook that runs the checker.
```yaml
- repo: local
hooks:
- id: hash-check
name: Hash consistency check
entry: bash -lc 'cd projects/rocprofiler-compute && python3 tools/config_management/hash_checker.py'
language: system
pass_filenames: false
stages: [pre-commit]
```
## Run manually
```bash
# from super-repo root
pre-commit run --all-files
# or directly in the subproject
cd projects/rocprofiler-compute
python3 tools/config_management/hash_checker.py
master_config_workflow_script.py
```
@@ -36,24 +36,14 @@ import sys
from pathlib import Path
from typing import Any, Optional, Union
try:
from . import utils as cm_utils
except Exception:
repo_root = Path(__file__).resolve().parents[1]
if str(repo_root) not in sys.path:
sys.path.insert(0, str(repo_root))
try:
import config_management.utils as cm_utils # type: ignore
except Exception:
import utils as cm_utils # type: ignore
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
AUTOGEN_TEXT = (
"# AUTOGENERATED FILE. Only edit for testing purposes, not for development. "
"Generated by tools/config_management/apply_config_deltas.py\n"
)
from config_management import utils_ruamel as cm_utils # noqa: E402
def find_table_in_config(config: dict, table_id: Any) -> Optional[dict]:
def find_table(config: dict, table_id: Any) -> Optional[dict]:
"""Find and return the table with given id, or None."""
for item in config.get("Panel Config", {}).get("data source", []):
table = item.get("metric_table")
@@ -72,8 +62,8 @@ def add_table(config: dict, metric_table: dict) -> None:
def add_metrics(config: dict, table_id: Any, metrics: list[dict]) -> None:
"""Add metrics to existing table."""
table = find_table_in_config(config, table_id)
if not table:
table = find_table(config, table_id)
if table is None:
print(f"WARNING: Table {table_id} not found for metric addition")
return
@@ -90,7 +80,7 @@ def delete_table(config: dict, table_id: Any) -> None:
for idx, item in enumerate(list(data_source)):
table = item.get("metric_table")
if isinstance(table, dict) and table.get("id") == table_id:
data_source.pop(idx)
del data_source[idx]
print(f"Deleted table: {table_id}")
return
print(f"WARNING: Table {table_id} not found for deletion")
@@ -98,8 +88,8 @@ def delete_table(config: dict, table_id: Any) -> None:
def delete_metrics(config: dict, table_id: Any, metrics: list[dict]) -> None:
"""Remove specific metrics from table."""
table = find_table_in_config(config, table_id)
if not table or "metric" not in table:
table = find_table(config, table_id)
if table is None or "metric" not in table:
print(f"WARNING: Table {table_id} not found or has no metrics")
return
@@ -112,8 +102,8 @@ def delete_metrics(config: dict, table_id: Any, metrics: list[dict]) -> None:
def modify_metrics(config: dict, table_id: Any, metrics: list[dict]) -> None:
"""Modify specific fields in existing metrics."""
table = find_table_in_config(config, table_id)
if not table or "metric" not in table:
table = find_table(config, table_id)
if table is None or "metric" not in table:
print(f"WARNING: Table {table_id} not found or has no metrics")
return
@@ -129,19 +119,17 @@ def modify_metrics(config: dict, table_id: Any, metrics: list[dict]) -> None:
def add_descriptions(config: dict, descriptions: dict) -> None:
"""Add metric descriptions to config."""
pc = config.setdefault("Panel Config", {})
pc.setdefault("metrics_description", {})
md = pc["metrics_description"]
md = config["Panel Config"].setdefault("metrics_description", {})
for metric_name, desc_data in descriptions.items():
value = desc_data if isinstance(desc_data, dict) else desc_data
md[metric_name] = value
md[metric_name] = dict(desc_data) if isinstance(desc_data, dict) else desc_data
print(f"Added description: {metric_name}")
def delete_descriptions(config: dict, descriptions: dict) -> None:
"""Remove metric descriptions from config."""
md = config.get("Panel Config", {}).get("metrics_description", {})
md = config["Panel Config"].setdefault("metrics_description", {})
for metric_name in descriptions.keys():
if metric_name in md:
del md[metric_name]
@@ -150,21 +138,25 @@ def delete_descriptions(config: dict, descriptions: dict) -> None:
def modify_descriptions(config: dict, descriptions: dict) -> None:
"""Modify metric descriptions in config."""
pc = config.setdefault("Panel Config", {})
pc.setdefault("metrics_description", {})
md = pc["metrics_description"]
md = config["Panel Config"].setdefault("metrics_description", {})
for metric_name, desc_data in descriptions.items():
value = desc_data if isinstance(desc_data, dict) else desc_data
md[metric_name] = value
if isinstance(desc_data, dict):
new_dict = {}
for k, v in desc_data.items():
new_dict[k] = v
md[metric_name] = new_dict
else:
md[metric_name] = desc_data
print(f"Added description: {metric_name}")
def apply_changes(config: dict, changes: list[dict], category: str) -> None:
"""Apply delta changes to configuration."""
for change in changes:
for mt_wrapper in change.get("metric_tables", []):
mt = mt_wrapper.get("metric_table", mt_wrapper)
mt = change.get("metric_table")
if mt:
table_id = mt.get("id")
if category == "Addition":
@@ -199,7 +191,7 @@ def apply_delta(
output_dir: Union[str, Path],
) -> None:
"""Apply delta YAML to all files in base directory."""
delta = cm_utils.load_yaml(delta_file)
delta = cm_utils.load_yaml(delta_file, round_trip=True)
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
@@ -214,7 +206,7 @@ def apply_delta(
base_path = Path(base_dir)
for yaml_file in base_path.glob("*.yaml"):
config = cm_utils.load_yaml(yaml_file)
config = cm_utils.load_yaml(yaml_file, round_trip=True)
panel_id = config.get("Panel Config", {}).get("id")
if panel_id in changes_by_panel:
@@ -226,7 +218,8 @@ def apply_delta(
config, changes_by_panel[panel_id][category], category
)
cm_utils.save_yaml(config, output_path / yaml_file.name, AUTOGEN_TEXT)
cm_utils.strip_existing_header(config)
cm_utils.save_yaml(config, output_path / yaml_file.name)
print(f"Saved: {yaml_file.name}")
else:
shutil.copy(yaml_file, output_path / yaml_file.name)
@@ -11,7 +11,7 @@ paths:
backups: .backups
# Hash database file
hashes: tools/config_management/.config_hashes.json
hashes: src/utils/.config_hashes.json
# Per-arch metric definitions output
per_arch_metrics: tools/per_arch_metric_definitions
@@ -34,326 +34,265 @@ from __future__ import annotations
import sys
from pathlib import Path
from typing import Any, Optional
try:
from . import utils as cm_utils
except Exception:
repo_root = Path(__file__).resolve().parents[1]
if str(repo_root) not in sys.path:
sys.path.insert(0, str(repo_root))
try:
import config_management.utils as cm_utils # type: ignore
except Exception:
import utils as cm_utils # type: ignore
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
AUTOGEN_TEXT = (
"# AUTOGENERATED FILE. Only edit for testing purposes, not for development. "
"Generated by tools/config_management/generate_config_deltas.py\n"
)
from config_management import utils_ruamel as cm_utils # noqa: E402
from ruamel.yaml.comments import CommentedMap # noqa: E402
def get_metric_tables(data: dict) -> list[dict]:
"""Extract all metric tables from data source."""
tables: list[dict] = []
for item in data.get("Panel Config", {}).get("data source", []):
mt = item.get("metric_table")
if isinstance(mt, dict):
tables.append(mt)
return tables
def load_yaml_roundtrip(path: Path) -> Any:
return cm_utils.load_yaml(path, round_trip=True)
def get_metric_descriptions(data: dict) -> dict:
"""Extract metric descriptions from panel config."""
return data.get("Panel Config", {}).get("metrics_description", {}) or {}
def diff_metric_fields(base_fields, new_fields) -> Optional[CommentedMap]:
out = CommentedMap()
for key in new_fields:
if key not in base_fields or base_fields[key] != new_fields[key]:
# Preserve the original value with comments
out[key] = new_fields[key]
return out if out else None
def compare_metrics(
prev_metrics: dict, curr_metrics: dict
def descriptions_equal(base_desc, new_desc) -> bool:
"""Check if two descriptions are equal by comparing their string representation."""
return str(base_desc) == str(new_desc)
def diff_metric_table(
base_table, new_table
) -> tuple[list[dict], list[dict], list[dict]]:
"""Compare metrics and return (additions, deletions, modifications)."""
prev_keys = set(prev_metrics.keys())
curr_keys = set(curr_metrics.keys())
additions = [{name: curr_metrics[name]} for name in sorted(curr_keys - prev_keys)]
deletions = [{name: prev_metrics[name]} for name in sorted(prev_keys - curr_keys)]
modifications: list[dict] = []
for name in sorted(prev_keys & curr_keys):
if prev_metrics[name] != curr_metrics[name]:
all_fields = set(prev_metrics[name].keys()) | set(curr_metrics[name].keys())
modified_fields = {
field: curr_metrics[name].get(field)
for field in all_fields
if prev_metrics[name].get(field) != curr_metrics[name].get(field)
}
if modified_fields:
modifications.append({name: modified_fields})
return additions, deletions, modifications
def compare_descriptions(
prev_descriptions: dict, curr_descriptions: dict
) -> tuple[dict, dict, dict]:
"""
Compare metric descriptions and return (additions, deletions, modifications).
Values are dicts with 'plain' and 'rst'.
Returns (additions, modifications, deletions) tuple.
"""
prev_keys = set(prev_descriptions.keys())
curr_keys = set(curr_descriptions.keys())
addition_metrics: list[dict] = []
modification_metrics: list[dict] = []
deletion_metrics: list[dict] = []
additions: dict = {}
deletions: dict = {}
modifications: dict = {}
base_metrics = base_table.get("metric", {})
new_metrics = new_table.get("metric", {})
for name in sorted(curr_keys - prev_keys):
desc = curr_descriptions[name]
additions[name] = (
desc if isinstance(desc, dict) else {"plain": desc, "rst": desc}
)
# Metrics deleted
for metric in base_metrics:
if metric not in new_metrics:
deletion_metrics.append({metric: None})
for name in sorted(prev_keys - curr_keys):
desc = prev_descriptions[name]
deletions[name] = (
desc if isinstance(desc, dict) else {"plain": desc, "rst": desc}
)
# Metrics added or modified
for metric in new_metrics:
if metric not in base_metrics:
# Entire metric is new - preserve original with comments
addition_metrics.append({metric: new_metrics[metric]})
else:
# Field-level diff
changes = diff_metric_fields(base_metrics[metric], new_metrics[metric])
if changes:
modification_metrics.append({metric: changes})
for name in sorted(prev_keys & curr_keys):
prev_desc = prev_descriptions[name]
curr_desc = curr_descriptions[name]
prev_plain = (
prev_desc if isinstance(prev_desc, str) else prev_desc.get("plain", "")
)
curr_plain = (
curr_desc if isinstance(curr_desc, str) else curr_desc.get("plain", "")
)
prev_rst = (
prev_desc
if isinstance(prev_desc, str)
else prev_desc.get("rst", prev_plain)
)
curr_rst = (
curr_desc
if isinstance(curr_desc, str)
else curr_desc.get("rst", curr_plain)
)
if prev_plain != curr_plain or prev_rst != curr_rst:
modifications[name] = {"plain": curr_plain, "rst": curr_rst}
return additions, deletions, modifications
return addition_metrics, modification_metrics, deletion_metrics
def compare_tables(
prev_tables: list[dict], curr_tables: list[dict]
) -> tuple[list[dict], list[dict], list[dict]]:
"""Compare tables and return (additions, deletions, modifications)."""
prev_dict = {t["id"]: t for t in prev_tables}
curr_dict = {t["id"]: t for t in curr_tables}
def diff_descriptions(
base_md, new_md
) -> tuple[Optional[CommentedMap], Optional[CommentedMap], Optional[CommentedMap]]:
"""
Returns (additions, modifications, deletions) tuple.
"""
additions = CommentedMap()
modifications = CommentedMap()
deletions = CommentedMap()
prev_ids = set(prev_dict.keys())
curr_ids = set(curr_dict.keys())
# Deletions
for key in base_md:
if key not in new_md:
deletions[key] = None
additions: list[dict] = []
deletions: list[dict] = []
modifications: list[dict] = []
# Additions and modifications
for key in new_md:
if key not in base_md:
# New description - preserve original node
additions[key] = new_md[key]
else:
# Check if modified
if not descriptions_equal(base_md[key], new_md[key]):
# Preserve original node to maintain style
modifications[key] = new_md[key]
additions.extend(curr_dict[tid] for tid in sorted(curr_ids - prev_ids))
deletions.extend(prev_dict[tid] for tid in sorted(prev_ids - curr_ids))
return (
additions if additions else None,
modifications if modifications else None,
deletions if deletions else None,
)
for tid in sorted(prev_ids & curr_ids):
prev_metrics = prev_dict[tid].get("metric", {}) or {}
curr_metrics = curr_dict[tid].get("metric", {}) or {}
metric_adds, metric_dels, metric_mods = compare_metrics(
prev_metrics, curr_metrics
)
def extract_metric_tables(data_sources) -> list[Any]:
out = []
for ds in data_sources:
if "metric_table" in ds:
mt = ds["metric_table"]
table_id = mt.get("id")
if table_id is not None:
out.append((table_id, mt))
return out
if metric_adds:
additions.append({
"id": tid,
"title": curr_dict[tid].get("title"),
"metrics": metric_adds,
})
if metric_dels:
deletions.append({
"id": tid,
"title": prev_dict[tid].get("title"),
"metrics": metric_dels,
})
if metric_mods:
modifications.append({
"id": tid,
"title": curr_dict[tid].get("title"),
"metrics": metric_mods,
def diff_panel(base_config, new_config) -> Optional[dict[str, list[Any]]]:
"""
Produce delta for a single panel.
Returns dicts under keys:
'Addition', 'Deletion', 'Modification'
or None if no diffs.
"""
out = {"Addition": [], "Deletion": [], "Modification": []}
panel_id = base_config["Panel Config"]["id"]
# Table-level diffs
base_tables = extract_metric_tables(
base_config["Panel Config"].get("data source", [])
)
new_tables = extract_metric_tables(
new_config["Panel Config"].get("data source", [])
)
# Indexing by table ID to preserve order
base_by_id = {tid: table for (tid, table) in base_tables}
new_by_id = {tid: table for (tid, table) in new_tables}
# Table deletions
for tid in base_by_id:
if tid not in new_by_id:
out["Deletion"].append({
"Panel Config": {"id": panel_id},
"metric_tables": [{"metric_table": {"id": tid}}],
})
return additions, deletions, modifications
def format_metric_fields(metric_data: dict) -> list[str]:
"""Format metric fields as YAML lines."""
lines: list[str] = []
for field_name, field_value in metric_data.items():
if isinstance(field_value, str) and (
"\n" in field_value or len(field_value) > 80
):
lines.append(f" {field_name}: |")
lines.extend(
f" {line}" for line in field_value.split("\n")
# Table additions + modifications
for tid in new_by_id:
if tid not in base_by_id:
# Entire table is added - preserve original
out["Addition"].append({
"Panel Config": {"id": panel_id},
"metric_tables": [{"metric_table": new_by_id[tid]}],
})
else:
# Check metric-level diffs
additions, modifications, deletions = diff_metric_table(
base_by_id[tid], new_by_id[tid]
)
else:
lines.append(f" {field_name}: {field_value}")
return lines
if deletions:
out["Deletion"].append({
"Panel Config": {"id": panel_id},
"metric_table": {"id": tid, "metrics": deletions},
})
if additions:
out["Addition"].append({
"Panel Config": {"id": panel_id},
"metric_table": {"id": tid, "metrics": additions},
})
if modifications:
out["Modification"].append({
"Panel Config": {"id": panel_id},
"metric_table": {"id": tid, "metrics": modifications},
})
# Description diffs
base_md = base_config["Panel Config"].get("metrics_description", {})
new_md = new_config["Panel Config"].get("metrics_description", {})
desc_additions, desc_modifications, desc_deletions = diff_descriptions(
base_md, new_md
)
if desc_deletions:
out["Deletion"].append({
"Panel Config": {"id": panel_id},
"metric_descriptions": desc_deletions,
})
if desc_additions:
out["Addition"].append({
"Panel Config": {"id": panel_id},
"metric_descriptions": desc_additions,
})
if desc_modifications:
out["Modification"].append({
"Panel Config": {"id": panel_id},
"metric_descriptions": desc_modifications,
})
# Clean empties
if not out["Addition"]:
del out["Addition"]
if not out["Deletion"]:
del out["Deletion"]
if not out["Modification"]:
del out["Modification"]
return out if out else None
def format_description_fields(desc_data: dict) -> list[str]:
"""Format description fields as YAML lines."""
lines: list[str] = []
for field_name, field_value in desc_data.items():
if isinstance(field_value, str) and (
"\n" in field_value or len(field_value) > 80
):
lines.append(f" {field_name}: |")
lines.extend(f" {line}" for line in field_value.split("\n"))
else:
lines.append(f" {field_name}: {field_value}")
return lines
def generate_arch_delta(base_dir: Path, new_dir: Path) -> CommentedMap:
"""
Compare all YAML files panel-by-panel.
"""
out = CommentedMap()
out["Addition"] = []
out["Deletion"] = []
out["Modification"] = []
def format_output(combined_diff: dict) -> str:
"""Format the diff dictionary into a YAML string."""
lines: list[str] = []
for category in ("Addition", "Deletion", "Modification"):
lines.append(f"{category}:")
if not combined_diff.get(category):
lines.append(" []")
lines.append("")
base_files = sorted(base_dir.glob("*.yaml"))
for base_file in base_files:
new_file = new_dir / base_file.name
if not new_file.exists():
continue
for panel_item in combined_diff[category]:
pc = panel_item["panel_config"]
lines.extend([
" - Panel Config:",
f" id: {pc['id']}",
f" title: {pc['title']}",
])
base_config = load_yaml_roundtrip(base_file)
new_config = load_yaml_roundtrip(new_file)
if panel_item.get("metric_tables"):
lines.append(" metric_tables:")
for mt in panel_item["metric_tables"]:
lines.extend([
" - metric_table:",
f" id: {mt['id']}",
f" title: {mt['title']}",
" metrics:",
])
metrics_to_format = mt.get("metrics") or [
{name: data} for name, data in (mt.get("metric") or {}).items()
]
for metric in metrics_to_format:
for metric_name, metric_data in metric.items():
lines.append(f" - {metric_name}:")
lines.extend(format_metric_fields(metric_data))
diff = diff_panel(base_config, new_config)
if not diff:
continue
if panel_item.get("metric_descriptions"):
lines.append(" metric_descriptions:")
for metric_name, desc_data in panel_item["metric_descriptions"].items():
lines.append(f" {metric_name}:")
lines.extend(format_description_fields(desc_data))
if "Addition" in diff:
out["Addition"].extend(diff["Addition"])
if "Deletion" in diff:
out["Deletion"].extend(diff["Deletion"])
if "Modification" in diff:
out["Modification"].extend(diff["Modification"])
lines.append("")
return "\n".join(lines)
# Strip empty categories
if not out["Addition"]:
del out["Addition"]
if not out["Deletion"]:
del out["Deletion"]
if not out["Modification"]:
del out["Modification"]
return out
def main() -> None:
if len(sys.argv) != 3:
print("Usage: python generate_config_deltas.py <curr_arch_dir> <prev_arch_dir>")
sys.exit(1)
curr_arch_dir = Path(sys.argv[1])
prev_arch_dir = Path(sys.argv[2])
if not curr_arch_dir.is_dir() or not prev_arch_dir.is_dir():
print("Error: Both arguments must be directories")
sys.exit(1)
curr_files = {f.name for f in curr_arch_dir.glob("*.yaml")}
prev_files = {f.name for f in prev_arch_dir.glob("*.yaml")}
common_files = curr_files & prev_files
if not common_files:
print("Error: No common YAML files found")
sys.exit(1)
print(f"Comparing {len(common_files)} files...")
combined_diff = {"Addition": [], "Deletion": [], "Modification": []}
for filename in sorted(common_files):
curr_data = cm_utils.load_yaml(curr_arch_dir / filename)
prev_data = cm_utils.load_yaml(prev_arch_dir / filename)
curr_pc = curr_data.get("Panel Config", {}) or {}
prev_pc = prev_data.get("Panel Config", {}) or {}
curr_tables = get_metric_tables(curr_data)
prev_tables = get_metric_tables(prev_data)
curr_descriptions = get_metric_descriptions(curr_data)
prev_descriptions = get_metric_descriptions(prev_data)
table_adds, table_dels, table_mods = compare_tables(prev_tables, curr_tables)
desc_adds, desc_dels, desc_mods = compare_descriptions(
prev_descriptions, curr_descriptions
if len(sys.argv) != 4:
print(
"Usage: python generate_config_deltas.py <base_arch_dir> <new_arch_dir> <output_delta_yaml>" # noqa: E501
)
sys.exit(1)
if table_adds or desc_adds:
entry = {
"panel_config": {"id": curr_pc.get("id"), "title": curr_pc.get("title")}
}
if table_adds:
entry["metric_tables"] = table_adds
if desc_adds:
entry["metric_descriptions"] = desc_adds
combined_diff["Addition"].append(entry)
base_dir = Path(sys.argv[1])
new_dir = Path(sys.argv[2])
out_file = Path(sys.argv[3])
if table_dels or desc_dels:
entry = {
"panel_config": {"id": prev_pc.get("id"), "title": prev_pc.get("title")}
}
if table_dels:
entry["metric_tables"] = table_dels
if desc_dels:
entry["metric_descriptions"] = desc_dels
combined_diff["Deletion"].append(entry)
delta = generate_arch_delta(base_dir, new_dir)
if table_mods or desc_mods:
entry = {
"panel_config": {"id": curr_pc.get("id"), "title": curr_pc.get("title")}
}
if table_mods:
entry["metric_tables"] = table_mods
if desc_mods:
entry["metric_descriptions"] = desc_mods
combined_diff["Modification"].append(entry)
output = AUTOGEN_TEXT + format_output(combined_diff)
print("\n" + "=" * 80)
print("COMBINED DIFF OUTPUT:")
print("=" * 80)
print(output)
output_dir = prev_arch_dir / "config_delta"
output_dir.mkdir(exist_ok=True)
output_file = output_dir / f"{curr_arch_dir.name}_diff.yaml"
with open(output_file, "w") as f:
f.write(output)
print(f"\nDiff written to: {output_file}")
cm_utils.save_yaml(delta, out_file)
print(f"Delta generated at: {out_file}")
if __name__ == "__main__":
+1 -1
Zobrazit soubor
@@ -43,7 +43,7 @@ import sys
from pathlib import Path
from typing import Optional
DEFAULT_HASH_DB = "tools/config_management/.config_hashes.json"
DEFAULT_HASH_DB = "src/utils/.config_hashes.json"
def compute_file_hash(filepath: Path) -> str:
Rozdílový obsah nebyl zobrazen, protože je příliš veliký Načíst rozdílové porovnání
@@ -43,21 +43,11 @@ from typing import Union
import yaml
try:
from . import utils as cm_utils
except Exception:
repo_root = Path(__file__).resolve().parents[1]
if str(repo_root) not in sys.path:
sys.path.insert(0, str(repo_root))
try:
import config_management.utils as cm_utils # type: ignore
except Exception:
import utils as cm_utils # type: ignore
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
AUTOGEN_TEXT = (
"# AUTOGENERATED FILE. Only edit for testing purposes, not for development. "
"Generated by tools/config_management/metric_description_manager.py\n"
)
from config_management import utils_ruamel as cm_utils # noqa: E402
# Section to panel ID mapping for organizing descriptions
SECTION_PANEL_MAP: dict[str, int] = {
@@ -274,7 +264,7 @@ def update_per_arch_metrics_file(
entry["unit"] = desc_data["unit"]
rst_descriptions[section][metric_name] = entry
cm_utils.save_yaml(rst_descriptions, output_path, AUTOGEN_TEXT)
cm_utils.save_yaml(rst_descriptions, output_path)
print(f"Updated: {output_path}")
@@ -303,7 +293,7 @@ def update_docs_metrics_file(
docs_path.parent.mkdir(parents=True, exist_ok=True)
cm_utils.save_yaml(existing, docs_path, AUTOGEN_TEXT)
cm_utils.save_yaml(existing, docs_path)
return True
@@ -1,8 +1,49 @@
#!/usr/bin/env python3
##############################################################################
# MIT License
#
# Copyright (c) 2025 Advanced Micro Devices, Inc. All Rights Reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
##############################################################################
"""
Parse panel configuration based on YAML files for an architecture.
Usage:
python parse_config_template.py <dir_path> [output_file.yaml] [--latest-arch ARCH]
parse_config_template.py
Parse panel configuration based on YAML files for an architecture and, optionally,
generate a lightweight template describing panel IDs, titles, aliases, and
data-source ordering.
Usage
-----
Generate a template from an architecture directory:
python tools/config_management/parse_config_template.py \
analysis_configs/gfx950 \
analysis_configs/config_template.yaml \
--latest-arch gfx950
Inspect an architecture (no template written):
python tools/config_management/parse_config_template.py \
analysis_configs/gfx950
"""
from __future__ import annotations
@@ -12,45 +53,93 @@ import sys
from pathlib import Path
from typing import Any, Optional
try:
from . import utils as cm_utils
except Exception:
repo_root = Path(__file__).resolve().parents[1]
if str(repo_root) not in sys.path:
sys.path.insert(0, str(repo_root))
try:
import config_management.utils as cm_utils # type: ignore
except Exception:
import utils as cm_utils # type: ignore
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
AUTOGEN_TEXT = (
"# AUTOGENERATED FILE. Only edit for testing purposes, not for development. "
"Generated by tools/config_management/parse_config_template.py\n"
)
from config_management import utils_ruamel as cm_utils # noqa: E402
def parse_panel_config(yaml_file: Path) -> Optional[dict]:
"""Parse a single YAML file and extract panel and data source info."""
def normalize_panel_id(panel_id: Optional[int]) -> Optional[int]:
"""Normalize panel ID by dividing by 100 if needed."""
if panel_id is None:
return None
return panel_id // 100 if panel_id >= 100 else panel_id
def normalize_table_id(table_id: Optional[int]) -> Optional[int]:
"""Normalize table ID using modulo 100."""
if table_id is None:
return None
return table_id % 100
def parse_panel_config(yaml_file: Path) -> Optional[dict[str, Any]]:
"""
Parse a single panel YAML file and extract template-relevant info.
Returns a dict with:
- file: panel filename (without leading numeric prefix)
- panel_id: normalized panel id (id // 100 when >= 100)
- panel_title: Panel Config.title
- panel_alias: Panel Config.alias (optional)
- data_sources: ordered list of
{type: <key>, id: <normalized table id>, title: <title>}
or None if the file does not contain a valid Panel Config or fails basic checks.
"""
data = cm_utils.load_yaml(yaml_file)
panel_config = data.get("Panel Config")
if not isinstance(panel_config, dict):
print(f"WARNING: {yaml_file} has no valid 'Panel Config' mapping, skipping.")
return None
# Enforce presence of core panel-level keys
missing_keys: list[str] = []
for key in ("id", "title", "data source", "metrics_description"):
if key not in panel_config:
missing_keys.append(key)
if missing_keys:
missing_str = ", ".join(missing_keys)
print(
f"ERROR: {yaml_file} is missing required Panel Config keys: {missing_str}"
)
return None
filename = (
yaml_file.name.split("_", 1)[1] if "_" in yaml_file.name else yaml_file.name
)
panel_id = panel_config.get("id")
if panel_id and panel_id >= 100:
panel_id = panel_id // 100
raw_panel_id = panel_config.get("id")
if not isinstance(raw_panel_id, int):
print(
f"ERROR: {yaml_file} has non-integer or missing Panel Config.id "
f"({raw_panel_id!r})"
)
return None
data_sources = []
for ds in panel_config.get("data source", []):
panel_id = normalize_panel_id(raw_panel_id)
# Extract and normalize data sources
data_sources: list[dict[str, Any]] = []
ds_list = panel_config.get("data source", [])
if not isinstance(ds_list, list):
print(
f"ERROR: {yaml_file} has non-list 'data source' field "
f"({type(ds_list).__name__})"
)
return None
for ds in ds_list:
if not isinstance(ds, dict):
print(f"WARNING: {yaml_file} has non-dict data source entry: {ds!r}")
continue
for key, value in ds.items():
if isinstance(value, dict) and "id" in value and "title" in value:
norm_id = normalize_table_id(value["id"])
data_sources.append({
"type": key,
"id": value["id"] % 100,
"id": norm_id,
"title": value["title"],
})
@@ -63,15 +152,65 @@ def parse_panel_config(yaml_file: Path) -> Optional[dict]:
}
def build_template_from_directory(
directory: Path,
existing_panels_by_id: Optional[dict[int, dict]],
) -> list[dict]:
panels: list[dict] = []
errors = 0
for yaml_file in sorted(directory.glob("*.yaml")):
info = parse_panel_config(yaml_file)
if info is None:
errors += 1
continue
panel_id = info.get("panel_id")
if (
existing_panels_by_id
and panel_id is not None
and panel_id in existing_panels_by_id
):
old_panel = existing_panels_by_id[panel_id]
# Preserve panel_alias unless explicitly set by panel YAML
if info.get("panel_alias") is None and "panel_alias" in old_panel:
info["panel_alias"] = old_panel["panel_alias"]
panels.append(info)
# Deterministic ordering for stable templates
panels.sort(key=lambda p: (p["panel_id"], p["file"]))
if errors:
print(
f"\nEncountered {errors} panel file(s) with structural errors "
"while building template."
)
return panels
def main() -> None:
parser = argparse.ArgumentParser(
description="Parse panel configuration from YAML files"
description=(
"Parse panel YAML files for an architecture and optionally generate "
"a config_template-style YAML describing panel IDs and data sources."
)
)
parser.add_argument("directory", help="Directory containing panel YAML files")
parser.add_argument(
"output",
nargs="?",
help="Output YAML file (optional). If omitted, only a summary is printed.",
)
parser.add_argument("directory", help="Directory containing YAML files")
parser.add_argument("output", nargs="?", help="Output YAML file (optional)")
parser.add_argument(
"--latest-arch",
help="Specify this architecture as latest (adds metadata to output)",
help=(
"Specify this architecture as latest (adds 'latest_arch' metadata "
"to the generated template). Only used when an output file is given."
),
)
args = parser.parse_args()
@@ -80,33 +219,45 @@ def main() -> None:
print(f"Error: '{args.directory}' is not a valid directory")
sys.exit(1)
results = []
for yaml_file in sorted(directory.glob("*.yaml")):
parsed = parse_panel_config(yaml_file)
if parsed:
results.append(parsed)
existing_template = None
if args.output and Path(args.output).exists():
existing_template = cm_utils.load_yaml(Path(args.output))
if not results:
print("No valid panel configurations found.")
existing_panels_by_id = {}
if existing_template:
for p in existing_template.get("panels", []):
pid = p.get("panel_id")
if pid is not None:
existing_panels_by_id[pid] = p
panels = build_template_from_directory(
directory,
existing_panels_by_id=existing_panels_by_id if args.output else None,
)
if not panels:
print("No valid panel YAML files found; nothing to do.")
sys.exit(1)
for panel in results:
print(f"\n{'=' * 80}")
print(f"File: {panel['file']}")
# Always show a human-readable summary.
print(f"Found {len(panels)} panel(s) in {directory}:")
for panel in panels:
print(f"\nFile: {panel['file']}")
print(f"Panel ID: {panel['panel_id']}")
print(f"Panel Title: {panel['panel_title']}")
if panel.get("panel_alias"):
if panel["panel_alias"]:
print(f"Panel Alias: {panel['panel_alias']}")
print(f"\nData Sources ({len(panel['data_sources'])}):")
for ds in panel["data_sources"]:
print(f" - {ds['type']}: {ds['id']} - {ds['title']}")
# Optionally write a template YAML.
if args.output:
output_data: Any = results
output_data: Any = {"panels": panels}
if args.latest_arch:
output_data = {"latest_arch": args.latest_arch, "panels": results}
cm_utils.save_yaml(output_data, args.output, AUTOGEN_TEXT)
print(f"\nResults saved to: {args.output}")
output_data = {"latest_arch": args.latest_arch, "panels": panels}
cm_utils.save_yaml(output_data, Path(args.output))
print(f"\nTemplate saved to: {args.output}")
if __name__ == "__main__":
-52
Zobrazit soubor
@@ -1,52 +0,0 @@
##############################################################################
# MIT License
#
# Copyright (c) 2025 Advanced Micro Devices, Inc. All Rights Reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
##############################################################################
from pathlib import Path
from typing import Optional, Union
import yaml
def str_representer(dumper, data):
if "\n" in data:
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style=">")
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
yaml.add_representer(str, str_representer)
def load_yaml(filepath: Union[str, Path]) -> dict:
with open(filepath) as f:
return yaml.safe_load(f) or {}
def save_yaml(
data: dict, filepath: Union[str, Path], header: Optional[str] = None
) -> None:
with open(filepath, "w") as f:
if header:
f.write(header)
yaml.dump(data, f, sort_keys=False)
@@ -0,0 +1,92 @@
##############################################################################
# MIT License
#
# Copyright (c) 2025 Advanced Micro Devices, Inc. All Rights Reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
##############################################################################
from pathlib import Path
from typing import Any, Union
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
# --- Round-trip YAML (for writing) ---
RT_YAML = YAML(typ="rt")
RT_YAML.preserve_quotes = True
RT_YAML.width = 4096 # prevent unwanted line wrapping
RT_YAML.indent(mapping=2, sequence=2, offset=0)
RT_YAML.explicit_start = False
RT_YAML.explicit_end = False
# --- Read-only YAML (safe loader) ---
RO_YAML = YAML(typ="safe")
RO_YAML.width = 4096
def load_yaml(
filepath: Union[str, Path],
*,
round_trip: bool = False,
) -> Any:
path = Path(filepath)
if not path.exists():
raise FileNotFoundError(f"YAML file not found: {path}")
yaml = RT_YAML if round_trip else RO_YAML
with open(path, "r", encoding="utf-8") as f:
return yaml.load(f) or CommentedMap()
def save_yaml(data: Any, filepath: Union[str, Path]) -> None:
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
RT_YAML.dump(data, f)
def strip_existing_header(yaml_data) -> None:
ca = getattr(yaml_data, "ca", None)
if not ca or not hasattr(ca, "comment") or ca.comment is None:
return
original = ca.comment
cleaned = []
for block in original:
if block is None:
cleaned.append(None)
continue
new_block = [token for token in block if "AUTOGENERATED" not in token.value]
if not new_block:
cleaned.append(None)
else:
cleaned.append(new_block)
if len(cleaned) < 2:
cleaned.extend([None] * (2 - len(cleaned)))
ca.comment = cleaned
@@ -23,155 +23,318 @@
# THE SOFTWARE.
##############################################################################
"""
Validate panel YAML files against base template ordering.
Checks that panel configs match expected structure, IDs, titles, and data source order.
verify_against_config_template.py
Validate per-architecture panel YAMLs against a shared config template.
- Validate structure + ordering only.
- Treat any deviation as an error.
- Collect all errors and report at end.
Template format (generated by parse_config_template.py):
latest_arch: gfx### (optional)
panels:
- file: <filename without numeric prefix>
panel_id: <normalized panel id>
panel_title: <title>
panel_alias: <optional>
data_sources:
- type: metric_table|raw_csv_table|...
id: <normalized table id>
title: <title>
Usage:
python verify_against_config_template.py <analysis_configs_dir> <template_yaml>
python verify_against_config_template.py <analysis_configs_dir> <template_yaml>
"""
from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from typing import Any, Optional
import yaml
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from config_management import utils_ruamel as cm_utils # noqa: E402
REQUIRED_PANEL_KEYS = ("id", "title", "data source", "metrics_description")
OPTIONAL_PANEL_KEYS = ("alias",)
DEFAULT_ALLOWED_PANEL_KEYS = set(REQUIRED_PANEL_KEYS) | set(OPTIONAL_PANEL_KEYS)
def normalize_panel_id(panel_id: int) -> int:
"""Normalize panel ID by dividing by 100."""
return panel_id // 100 if panel_id and panel_id >= 100 else panel_id
return panel_id // 100 if panel_id >= 100 else panel_id
def normalize_table_id(table_id: int) -> Optional[int]:
"""Normalize table ID using modulo 100."""
return table_id % 100 if table_id else None
def normalize_table_id(table_id: int) -> int:
return table_id % 100
def load_template(template_file: Path) -> dict[int, dict]:
"""Load template and create lookup by normalized panel ID."""
with open(template_file) as f:
data = yaml.safe_load(f) or {}
panels = data.get("panels", [])
lookup: dict[int, dict] = {}
for panel in panels:
pid = normalize_panel_id(panel["panel_id"])
lookup[pid] = {
"panel_title": panel["panel_title"],
"panel_alias": panel.get("panel_alias"),
"data_sources": [
{"type": ds["type"], "id": ds["id"], "title": ds["title"]}
for ds in panel.get("data_sources", [])
],
}
return lookup
@dataclass(frozen=True)
class TemplateDataSource:
type: str
id: int
title: str
def extract_panel_info(yaml_file: Path) -> Optional[dict]:
"""Extract panel config info from YAML file."""
with open(yaml_file) as f:
data = yaml.safe_load(f) or {}
if "Panel Config" not in data:
return None
panel_config = data["Panel Config"]
data_sources = []
for ds in panel_config.get("data source", []):
for key, value in ds.items():
if isinstance(value, dict) and "id" in value and "title" in value:
data_sources.append({
"type": key,
"id": normalize_table_id(value["id"]),
"title": value["title"],
})
return {
"panel_id": normalize_panel_id(panel_config.get("id")),
"panel_title": panel_config.get("title"),
"data_sources": data_sources,
}
@dataclass(frozen=True)
class TemplatePanel:
file: str
panel_id: int
panel_title: str
panel_alias: Any
data_sources: tuple[TemplateDataSource, ...]
def validate_panel(
yaml_file: Path, panel_info: dict, template: dict[int, dict], stats: dict
) -> None:
"""Validate a single panel YAML against template."""
panel_id = panel_info["panel_id"]
file_path = f"{yaml_file.parent.name}/{yaml_file.name}"
def _as_str(v: Any) -> str:
return "" if v is None else str(v)
if panel_id not in template:
print(f"WARNING [{file_path}]: Panel ID {panel_id} not found in template")
stats["warnings"] += 1
return
expected = template[panel_id]
def load_template(
template_file: Path,
) -> tuple[list[TemplatePanel], dict[int, TemplatePanel]]:
data = cm_utils.load_yaml(template_file) or {}
panels_raw = data.get("panels", [])
if not isinstance(panels_raw, list):
raise ValueError("Template YAML must contain a top-level 'panels' list")
panels: list[TemplatePanel] = []
by_id: dict[int, TemplatePanel] = {}
for idx, p in enumerate(panels_raw):
if not isinstance(p, dict):
raise ValueError(f"Template panels[{idx}] must be a mapping")
if "panel_id" not in p or "panel_title" not in p:
raise ValueError(
f"Template panels[{idx}] missing 'panel_id' or 'panel_title'"
)
pid_raw = p.get("panel_id")
if not isinstance(pid_raw, int):
raise ValueError(
f"Template panels[{idx}].panel_id must be int, got {pid_raw!r}"
)
pid = normalize_panel_id(pid_raw)
ds_list = p.get("data_sources", []) or []
if not isinstance(ds_list, list):
raise ValueError(f"Template panels[{idx}].data_sources must be list")
ds_out: list[TemplateDataSource] = []
for j, ds in enumerate(ds_list):
if not isinstance(ds, dict):
raise ValueError(
f"Template panels[{idx}].data_sources[{j}] must be mapping"
)
for k in ("type", "id", "title"):
if k not in ds:
raise ValueError(
f"Template panels[{idx}].data_sources[{j}] missing '{k}'"
)
ds_id = ds["id"]
if not isinstance(ds_id, int):
raise ValueError(
f"Template panels[{idx}].data_sources[{j}].id must be int, "
f"got {ds_id!r}"
)
ds_out.append(
TemplateDataSource(
type=_as_str(ds["type"]),
id=normalize_table_id(ds_id),
title=_as_str(ds["title"]),
)
)
panel = TemplatePanel(
file=_as_str(p.get("file", "")),
panel_id=pid,
panel_title=_as_str(p.get("panel_title")),
panel_alias=p.get("panel_alias"),
data_sources=tuple(ds_out),
)
if pid in by_id:
raise ValueError(f"Duplicate panel_id {pid} in template")
panels.append(panel)
by_id[pid] = panel
return panels, by_id
def extract_panel_info(
yaml_file: Path,
) -> tuple[Optional[int], dict[str, Any], list[dict[str, Any]]]:
"""Return (panel_id, panel_config, extracted_data_sources)."""
data = cm_utils.load_yaml(yaml_file) or {}
panel_config = data.get("Panel Config")
if not isinstance(panel_config, dict):
return None, {}, []
pid_raw = panel_config.get("id")
pid = normalize_panel_id(pid_raw) if isinstance(pid_raw, int) else None
ds_extracted: list[dict[str, Any]] = []
ds_list = panel_config.get("data source", [])
if isinstance(ds_list, list):
for item in ds_list:
if not isinstance(item, dict):
continue
for ds_type, value in item.items():
if (
isinstance(value, dict)
and isinstance(value.get("id"), int)
and "title" in value
):
ds_extracted.append({
"type": str(ds_type),
"id": normalize_table_id(value["id"]),
"title": _as_str(value.get("title")),
})
return pid, panel_config, ds_extracted
def validate_arch(
arch_dir: Path,
template_panels: list[TemplatePanel],
template_by_id: dict[int, TemplatePanel],
allowed_panel_keys: set[str],
) -> list[str]:
"""Validate one architecture directory. Returns list of errors."""
errors: list[str] = []
warnings: list[str] = []
if panel_info["panel_title"] != expected["panel_title"]:
errors.append(
f"Panel title mismatch: expected '{expected['panel_title']}', "
f"got '{panel_info['panel_title']}'"
)
panel_files = sorted(arch_dir.glob("*.yaml"))
actual_by_id: dict[int, Path] = {}
actual_order: list[int] = []
if len(panel_info["data_sources"]) != len(expected["data_sources"]):
errors.append(
f"Data source count mismatch: expected {len(expected['data_sources'])}, "
f"got {len(panel_info['data_sources'])}"
)
for f in panel_files:
pid, panel_config, ds_actual = extract_panel_info(f)
rel = f"{arch_dir.name}/{f.name}"
for i, actual_ds in enumerate(panel_info["data_sources"]):
matching_idx = next(
(
j
for j, exp_ds in enumerate(expected["data_sources"])
if actual_ds["id"] == exp_ds["id"]
and actual_ds["title"] == exp_ds["title"]
and actual_ds["type"] == exp_ds["type"]
),
None,
)
if matching_idx is None:
if pid is None:
errors.append(f"ERROR [{rel}]: Missing or non-integer Panel Config.id")
continue
# required keys
missing = [k for k in REQUIRED_PANEL_KEYS if k not in panel_config]
if missing:
errors.append(
f"Data source {i + 1}: No matching entry in template for "
f"{actual_ds['type']} id={actual_ds['id']} title='{actual_ds['title']}'"
)
elif matching_idx != i:
warnings.append(
f"Data source {i + 1}: Order mismatch - appears at position {i + 1} "
f"but expected at position {matching_idx + 1}"
f"ERROR [{rel}]: Missing required Panel Config keys: "
f"{', '.join(missing)}"
)
if errors:
print(f"ERROR [{file_path}]:")
for error in errors:
print(f" - {error}")
stats["errors"] += len(errors)
stats["failed_files"] += 1
elif warnings:
print(f"WARNING [{file_path}]:")
for warning in warnings:
print(f" - {warning}")
stats["warnings"] += len(warnings)
stats["passed_files"] += 1
else:
print(f"PASS [{file_path}]")
stats["passed_files"] += 1
# prohibited keys (unknown keys)
for k in panel_config.keys():
if k not in allowed_panel_keys:
errors.append(
f"ERROR [{rel}]: Prohibited/unknown Panel Config key '{k}' "
f"(allowed: {sorted(allowed_panel_keys)})"
)
# panel must exist in template
if pid not in template_by_id:
errors.append(f"ERROR [{rel}]: Panel ID {pid} not found in template")
else:
expected = template_by_id[pid]
actual_title = _as_str(panel_config.get("title"))
if actual_title != expected.panel_title:
errors.append(
f"ERROR [{rel}]: Panel title mismatch for id {pid}: "
f"expected '{expected.panel_title}', got '{actual_title}'"
)
# data sources must match count + order strictly
if len(ds_actual) != len(expected.data_sources):
errors.append(
f"ERROR [{rel}]: Data source count mismatch for panel "
f"{pid}: expected {len(expected.data_sources)}, "
f"got {len(ds_actual)}"
)
for i, exp_ds in enumerate(expected.data_sources):
if i >= len(ds_actual):
break
act = ds_actual[i]
if (
act["type"] != exp_ds.type
or act["id"] != exp_ds.id
or act["title"] != exp_ds.title
):
errors.append(
f"ERROR [{rel}]: Data source #{i + 1} mismatch "
f"for panel {pid}: expected {exp_ds.type} id={exp_ds.id} "
f"title='{exp_ds.title}', got {act['type']} "
f"id={act['id']} title='{act['title']}'"
)
# duplicates
if pid in actual_by_id:
errors.append(
f"ERROR [{rel}]: Duplicate panel id {pid} "
f"(also in {arch_dir.name}/{actual_by_id[pid].name})"
)
else:
actual_by_id[pid] = f
actual_order.append(pid)
# missing / extra panels
expected_ids = [p.panel_id for p in template_panels]
actual_ids = set(actual_by_id.keys())
expected_set = set(expected_ids)
for pid in expected_ids:
if pid not in actual_ids:
errors.append(
f"ERROR [{arch_dir.name}]: Missing panel id {pid} required by template"
)
for pid in sorted(actual_ids - expected_set):
errors.append(
f"ERROR [{arch_dir.name}/{actual_by_id[pid].name}]: "
f"Extra panel id {pid} not present in template"
)
# panel ordering (based on file sorting)
expected_order = [pid for pid in expected_ids if pid in actual_ids]
if actual_order and expected_order and actual_order != expected_order:
for i, (a, e) in enumerate(zip(actual_order, expected_order)):
if a != e:
errors.append(
f"ERROR [{arch_dir.name}]: Panel file order mismatch at position "
f"{i + 1}: expected panel id {e}, got {a} "
"(files must follow template order)"
)
break
return errors
def main() -> None:
if len(sys.argv) != 3:
print(
"Usage: python verify_against_config_template.py "
"<analysis_configs_dir> <template_yaml>"
)
sys.exit(1)
parser = argparse.ArgumentParser(
description="Validate per-arch panel YAMLs against a shared config template."
)
parser.add_argument(
"analysis_configs_dir", help="Directory containing architecture subdirs"
)
parser.add_argument("template_yaml", help="Template YAML (config_template.yaml)")
parser.add_argument(
"--allow-panel-key",
action="append",
default=[],
help="Allow an additional key under 'Panel Config' (repeatable)",
)
args = parser.parse_args()
configs_dir = Path(sys.argv[1])
template_file = Path(sys.argv[2])
configs_dir = Path(args.analysis_configs_dir)
template_file = Path(args.template_yaml)
if not configs_dir.is_dir():
print(f"Error: {configs_dir} is not a directory")
@@ -180,45 +343,40 @@ def main() -> None:
print(f"Error: {template_file} is not a file")
sys.exit(1)
template_panels, template_by_id = load_template(template_file)
allowed_panel_keys = set(DEFAULT_ALLOWED_PANEL_KEYS) | set(args.allow_panel_key)
print(f"Loading template from {template_file}")
template = load_template(template_file)
print(f"Template loaded: {len(template)} panels\n")
print(f"Template loaded: {len(template_panels)} panels\n")
stats = {
"total_files": 0,
"passed_files": 0,
"failed_files": 0,
"errors": 0,
"warnings": 0,
}
all_errors: list[str] = []
total_arches = 0
for arch_dir in sorted(configs_dir.iterdir()):
if not arch_dir.is_dir():
continue
total_arches += 1
print(f"{'=' * 80}\nValidating architecture: {arch_dir.name}\n{'=' * 80}")
for yaml_file in sorted(arch_dir.glob("*.yaml")):
stats["total_files"] += 1
panel_info = extract_panel_info(yaml_file)
if panel_info:
validate_panel(yaml_file, panel_info, template, stats)
else:
print(f"ERROR [{arch_dir.name}/{yaml_file.name}]: Invalid panel config")
stats["errors"] += 1
stats["failed_files"] += 1
arch_errors = validate_arch(
arch_dir=arch_dir,
template_panels=template_panels,
template_by_id=template_by_id,
allowed_panel_keys=allowed_panel_keys,
)
if arch_errors:
for e in arch_errors:
print(e)
all_errors.extend(arch_errors)
else:
print(f"PASS [{arch_dir.name}]: All panel YAMLs match template")
print()
print(f"{'=' * 80}\nVALIDATION SUMMARY\n{'=' * 80}")
print(f"Total files checked: {stats['total_files']}")
print(f"Passed: {stats['passed_files']}")
print(f"Failed: {stats['failed_files']}")
print(f"Total errors: {stats['errors']}")
print(f"Total warnings: {stats['warnings']}")
print(f"Architectures checked: {total_arches}")
print(f"Total errors: {len(all_errors)}")
if stats["failed_files"] > 0:
if all_errors:
print("\nValidation FAILED")
sys.exit(1)
elif stats["warnings"] > 0:
print("\nValidation PASSED with warnings")
else:
print("\nValidation PASSED")
-307
Zobrazit soubor
@@ -1,307 +0,0 @@
##############################################################################
# MIT License
#
# Copyright (c) 2025 Advanced Micro Devices, Inc. All Rights Reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
##############################################################################
# NOTES
#
# Read tools/unified_config.yaml and split it into per gfx architecture per panel
# config files. WARNING: This script will overwrite existing files under per gfx
# architecture folders under src/rocprof_compute_soc/analysis_configs.
#
# Read tools/unified_config.yaml and split it into metric tables per documentation
# section.
# WARNING: This script will overwrite existing docs/data/metrics_description.yaml.
import copy
import hashlib
import re
from pathlib import Path
import yaml
# Get root directory of the project
ROOT_DIR = Path(__file__).parent.parent
SOURCE_DIR = ROOT_DIR / "tools"
TARGET_DIR = ROOT_DIR / "src" / "rocprof_compute_soc" / "analysis_configs"
SETS_TARGET_DIR = ROOT_DIR / "src" / "rocprof_compute_soc" / "profile_configs" / "sets"
DOC_TARGET_DIR = ROOT_DIR / "docs" / "data"
AUTOGEN_TEXT = (
"# AUTOGENERATED FILE. Only edit for testing purposes, not for development. "
"Generated from tools/unified_config.yaml. Generated by tools/split_config.py\n"
)
HASH_FILE = ROOT_DIR / "tools" / "autogen_hash.yaml"
HASH_FILE_MAP = {}
GFX_VERSIONS = ["gfx908", "gfx90a", "gfx940", "gfx941", "gfx942", "gfx950"]
METRIC_ID_TO_NAME_MAP = {gfx_version: {} for gfx_version in GFX_VERSIONS}
def str_representer(dumper, data):
if "\n" in data:
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
yaml.add_representer(str, str_representer)
def get_autogen_text(config_file="tools/unified_config.yaml"):
return (
f"# AUTOGENERATED FILE. Only edit for testing purposes, "
f"not for development. Generated from {config_file}. "
f"Generated by tools/split_config.py\n"
)
def update_analysis_config():
global METRIC_ID_TO_NAME_MAP
# Read the unified config file
with open(SOURCE_DIR / "unified_config.yaml") as file:
unified_config = yaml.safe_load(file)
# Create per panel config file
for panel_config in unified_config["panels"]:
new_panel_config = {"Panel Config": {}}
new_panel_config["Panel Config"]["id"] = panel_config["id"]
new_panel_config["Panel Config"]["title"] = panel_config["title"]
panel_id_int = panel_config["id"]
# Convert int into str with 4 digits
panel_id = str(panel_config["id"]).zfill(4)
# Replace parentehsis, hyphen, slash and space with underscore
# Remove duplicate underscore
# Convert to lower case
panel_title = re.sub(r"[()\-/ ]+", "_", panel_config["title"])
panel_title = "_".join(filter(None, panel_title.split("_")))
panel_title = panel_title.lower()
for gfx_version in GFX_VERSIONS:
# Create per gfx architecture folder
gfx_dir = TARGET_DIR / gfx_version
# Create directory if it doesn't exist
if not gfx_dir.exists():
gfx_dir.mkdir()
print(f"Created directory: {gfx_dir}")
# Collect metrics for this gfx_version
gfx_metrics = []
# Select metrics from current gfx arch
new_panel_config["Panel Config"]["data source"] = []
for data_source_index, data_source_config in enumerate(
panel_config["data source"]
):
data_source_config = copy.deepcopy(data_source_config)
if "metric_table" in data_source_config:
data_source_config["metric_table"]["metric"] = data_source_config[
"metric_table"
]["metric"][gfx_version]
# Collect metric names for this gfx version (preserve order)
for metric_name in data_source_config["metric_table"][
"metric"
].keys():
if metric_name not in gfx_metrics:
gfx_metrics.append(metric_name)
build_metric_id_mapping(
panel_id_int,
data_source_index,
data_source_config["metric_table"]["metric"],
gfx_version,
)
new_panel_config["Panel Config"]["data source"].append(
data_source_config
)
# Only include metric descriptions for metrics that exist in this gfx
new_panel_config["Panel Config"]["metrics_description"] = {
key: value["plain"].strip()
for key, value in panel_config.get("metrics_description", {}).items()
if key in gfx_metrics
}
# Write panel config to file
filename = TARGET_DIR / gfx_version / f"{panel_id}_{panel_title}.yaml"
with open(filename, "w") as file:
file.write(get_autogen_text())
yaml.dump(new_panel_config, file, sort_keys=False)
print(f"File write: {filename}")
# Calculate hash of filename
HASH_FILE_MAP[str(filename.relative_to(ROOT_DIR))] = hashlib.sha256(
filename.read_bytes()
).hexdigest()
def build_metric_id_mapping(panel_id, data_source_index, metrics, gfx_version):
# Build metric id to metric name mapping
global METRIC_ID_TO_NAME_MAP
for metric_index, metric_name in enumerate(metrics.keys()):
metric_id = f"{panel_id // 100}.{data_source_index + 1}.{metric_index}"
METRIC_ID_TO_NAME_MAP[gfx_version][str(metric_id)] = metric_name
def update_sets_config():
# Create directory if it doesn't exist
if not SETS_TARGET_DIR.exists():
SETS_TARGET_DIR.mkdir()
print(f"Created directory: {SETS_TARGET_DIR}")
# Read the unified config file
with open(SOURCE_DIR / "unified_sets.yaml") as file:
unified_sets = yaml.safe_load(file)
# Create per gfx version file
for gfx_version in GFX_VERSIONS:
new_sets = {"sets": []}
for sets in unified_sets["sets"]:
# Create new set object for each set
current_set = {
"title": sets["title"],
"set_option": sets["set_option"],
"description": sets["description"],
"metric": [],
}
for metric_id in sets["metric"][gfx_version]:
current_set["metric"].append({
metric_id: METRIC_ID_TO_NAME_MAP[gfx_version][str(metric_id)]
})
new_sets["sets"].append(current_set)
# Write gfx version sets to file
filename = SETS_TARGET_DIR / f"{gfx_version}_sets.yaml"
with open(filename, "w") as file:
file.write(get_autogen_text("tools/unified_sets.yaml"))
yaml.dump(new_sets, file, sort_keys=False)
print(f"File write: {filename}")
# Calculate hash of filename
HASH_FILE_MAP[str(filename.relative_to(ROOT_DIR))] = hashlib.sha256(
filename.read_bytes()
).hexdigest()
def update_documentation():
# Documentation sections
section_panel_map = {
"Wavefront launch stats": 701,
"Wavefront runtime stats": 702,
"Overall instruction mix": 1001,
"VALU arithmetic instruction mix": 1002,
"MFMA instruction mix": 1004,
"Compute Speed-of-Light": 1101,
"Pipeline statistics": 1102,
"Arithmetic operations": 1103,
"LDS Speed-of-Light": 1201,
"LDS Statistics": 1202,
"vL1D Speed-of-Light": 1601,
"Busy / stall metrics": 1501,
"Instruction counts": 1502,
"Spill / stack metrics": 1503,
"L1 Unified Translation Cache (UTCL1)": 1605,
"vL1D cache stall metrics": 1602,
"vL1D cache access metrics": 1603,
"Vector L1 data-return path or Texture Data (TD)": 1504,
"L2 Speed-of-Light": 1701,
"L2 cache accesses": 1703,
"L2-Fabric interface metrics": 1702,
"L2 - Fabric interface detailed metrics": 1706,
"L2 - Fabric Interface stalls": 1705,
"Scalar L1D Speed-of-Light": 1401,
"Scalar L1D cache accesses": 1402,
"Scalar L1D Cache - L2 Interface": 1403,
"L1I Speed-of-Light": 1301,
"L1I cache accesses": 1302,
"L1I <-> L2 interface": 1303,
"Workgroup manager utilizations": 601,
"Workgroup Manager - Resource Allocation": 602,
"Command processor fetcher (CPF)": 501,
"Command processor packet processor (CPC)": 502,
"System Speed-of-Light": 201,
}
# Read the unified config file
with open(SOURCE_DIR / "unified_config.yaml") as file:
unified_config = yaml.safe_load(file)
panel_metric_map = {}
for panel_config in unified_config["panels"]:
for data_source in panel_config["data source"]:
if "metric_table" in data_source:
metrics_info = {}
# Metric names from data source
metric_names = {
metric
for _, gfx_data in data_source["metric_table"]["metric"].items()
for metric in gfx_data
}
# Select metrics with descriptions available
metric_names = metric_names.intersection(
panel_config["metrics_description"].keys()
)
# Add metrics info
for metric_name in sorted(list(metric_names)):
metrics_info[metric_name] = {
"rst": panel_config["metrics_description"][metric_name][
"rst"
].strip(),
"unit": panel_config["metrics_description"][metric_name][
"unit"
],
}
panel_metric_map[data_source["metric_table"]["id"]] = metrics_info
# Merge panel_metric_map with section_panel_map
section_metric_map = {}
for section, panel_id in section_panel_map.items():
if panel_id in panel_metric_map:
section_metric_map[section] = panel_metric_map[panel_id]
# Write documentation metrics description file
filename = DOC_TARGET_DIR / "metrics_description.yaml"
with open(filename, "w") as file:
file.write(get_autogen_text())
yaml.dump(section_metric_map, file, sort_keys=False)
print(f"File write: {filename}")
# Calculate hash of filename
HASH_FILE_MAP[str(filename.relative_to(ROOT_DIR))] = hashlib.sha256(
filename.read_bytes()
).hexdigest()
def update_hash():
# Write hash file
with open(HASH_FILE, "w") as file:
file.write(get_autogen_text())
yaml.dump(HASH_FILE_MAP, file, sort_keys=False)
print(f"File write: {HASH_FILE}")
if __name__ == "__main__":
update_analysis_config()
update_sets_config()
update_documentation()
update_hash()
Rozdílový obsah nebyl zobrazen, protože je příliš veliký Načíst rozdílové porovnání
-176
Zobrazit soubor
@@ -1,176 +0,0 @@
---
# Pre-defined sets containing a collection of relevant metrics that can be collected in a single pass.
# To profile customized set(s), append to this yaml file.
sets:
- title: Compute Throughput Utilization
set_option: compute_thruput_util
description: Placeholder
metric:
gfx908:
- 11.2.2
- 11.2.3
gfx90a:
- 11.2.3
- 11.2.4
- 11.2.5
- 11.2.6
gfx940:
- 11.2.2
- 11.2.3
- 11.2.4
- 11.2.5
gfx941:
- 11.2.2
- 11.2.3
- 11.2.4
- 11.2.5
gfx942:
- 11.2.2
- 11.2.3
- 11.2.4
- 11.2.5
gfx950:
- 11.2.2
- 11.2.3
- 11.2.5
- 11.2.6
- title: Compute Throughput FLOPS
set_option: compute_thruput_flops
description: Placeholder
metric:
gfx908:
- 2.1.2
- 2.1.3
- 2.1.4
- 2.1.5
- 2.1.6
gfx90a:
- 2.1.2
- 2.1.3
- 2.1.4
- 2.1.5
- 2.1.6
gfx940:
- 2.1.2
- 2.1.3
- 2.1.4
- 2.1.5
- 2.1.6
- 2.1.7
gfx941:
- 2.1.2
- 2.1.3
- 2.1.4
- 2.1.5
- 2.1.6
- 2.1.7
gfx942:
- 2.1.2
- 2.1.3
- 2.1.4
- 2.1.5
- 2.1.6
- 2.1.7
gfx950:
- 2.1.2
- 2.1.3
- 2.1.4
- 2.1.5
- 2.1.6
- 2.1.8
- title: Memory Throughput
set_option: mem_thruput
description: Placeholder
metric:
gfx908:
- 2.1.16
- 2.1.17
- 16.1.2
- 17.1.0
gfx90a:
- 2.1.16
- 2.1.17
- 16.1.2
- 17.1.0
gfx940:
- 2.1.17
- 2.1.18
- 16.1.2
- 17.1.0
gfx941:
- 2.1.17
- 2.1.18
- 16.1.2
- 17.1.0
gfx942:
- 2.1.17
- 2.1.18
- 16.1.2
- 17.1.0
gfx950:
- 2.1.18
- 2.1.19
- 16.1.2
- 17.1.0
- title: Launch Stats
set_option: launch_stats
description: Placeholder
metric:
gfx908:
- 7.1.0
- 7.1.1
- 7.1.2
- 7.1.5
- 7.1.6
- 7.1.7
- 7.1.8
- 7.1.9
gfx90a:
- 7.1.0
- 7.1.1
- 7.1.2
- 7.1.5
- 7.1.6
- 7.1.7
- 7.1.8
- 7.1.9
gfx940:
- 7.1.0
- 7.1.1
- 7.1.2
- 7.1.5
- 7.1.6
- 7.1.7
- 7.1.8
- 7.1.9
gfx941:
- 7.1.0
- 7.1.1
- 7.1.2
- 7.1.5
- 7.1.6
- 7.1.7
- 7.1.8
- 7.1.9
gfx942:
- 7.1.0
- 7.1.1
- 7.1.2
- 7.1.5
- 7.1.6
- 7.1.7
- 7.1.8
- 7.1.9
gfx950:
- 7.1.0
- 7.1.1
- 7.1.2
- 7.1.5
- 7.1.6
- 7.1.7
- 7.1.8
- 7.1.9