Files
TheRock/build_tools/analyze_build_times.py
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

501 lines
16 KiB
Python

#!/usr/bin/env python3
# Copyright Advanced Micro Devices, Inc.
# SPDX-License-Identifier: MIT
"""Analyze Ninja build times and generate HTML report.
This script parses the .ninja_log file from a build directory and generates
an HTML report showing build times for each ROCm component and dependency.
Usage:
python analyze_build_times.py --build-dir <path> [--output <path>]
Arguments:
--build-dir Path to the build directory containing .ninja_log (required)
--output Path to output HTML file (optional)
Default: <build-dir>/logs/build_observability.html
Examples:
# Generate report with default output path
python analyze_build_times.py --build-dir /path/to/build
# Generate report with custom output path
python analyze_build_times.py --build-dir /path/to/build --output report.html
CI Usage:
In CI, this script is called automatically after build completion.
The --build-dir is set to the CI build directory, and --output is optional.
"""
import argparse
import os
import re
import sys
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional
# =============================================================================
# Configuration
# =============================================================================
# Build name -> Display name mapping
NAME_MAPPING = {
"clr": "core-hip",
"ocl-clr": "core-ocl",
"ROCR-Runtime": "core-runtime",
"blas": "rocBLAS",
"prim": "rocPRIM",
"fft": "rocFFT",
"rand": "rocRAND",
"miopen": "MIOpen",
"hipdnn": "hipDNN",
"composable-kernel": "composable_kernel",
"support": "mxDataGenerator",
"host-suite-sparse": "SuiteSparse",
"rocwmma": "rocWMMA",
"miopenprovider": "miopenprovider",
"hipblasltprovider": "hipblasltprovider",
}
# Top-level directories for ROCm components
ROCM_COMPONENT_DIRS = {
"base",
"compiler",
"core",
"comm-libs",
"dctools",
"profiler",
"ml-libs",
"media-libs",
"storage-libs",
}
# Regex to parse artifact filenames: <project>_<variant>[_suffix].tar.xz
# - Group 1: project name (e.g., "rocBLAS", "MIOpen")
# - Group 2: variant type (dbg=debug, dev=development, doc=documentation,
# lib=library, run=runtime, test=test)
# - Group 3: optional suffix (e.g., "_gfx90a")
# Example: "rocBLAS_lib_gfx90a.tar.xz" -> ("rocBLAS", "lib", "_gfx90a")
ARTIFACT_REGEX = re.compile(r"(.+)_(dbg|dev|doc|lib|run|test)(_.+)?")
# Phase detection rules: (suffix/pattern, phase_name)
PHASE_RULES = [
(lambda p: p.endswith("/stamp/configure.stamp"), "Configure"),
(lambda p: p.endswith("/stamp/build.stamp"), "Build"),
(lambda p: p.endswith("/stamp/stage.stamp"), "Install"),
(lambda p: p.startswith("artifacts/") and p.endswith(".tar.xz"), "Package"),
(lambda p: "download" in p and "stamp" in p, "Download"),
(lambda p: "update" in p and "stamp" in p, "Update"),
]
# Category labels for grouping projects in the report
CATEGORY_ROCM = "ROCm Component"
CATEGORY_DEP = "Dependency"
# =============================================================================
# Data Classes
# =============================================================================
@dataclass
class Task:
start: int
end: int
output: str
@property
def duration(self) -> int:
return self.end - self.start
# =============================================================================
# Parsing Functions
# =============================================================================
def parse_ninja_log(log_path: Path) -> List[Task]:
"""Parse .ninja_log and return list of Task objects."""
tasks = []
try:
with open(log_path, "r") as f:
f.readline() # Skip header
for line in f:
parts = line.strip().split("\t")
if len(parts) >= 5:
tasks.append(
Task(start=int(parts[0]), end=int(parts[1]), output=parts[3])
)
except FileNotFoundError:
print(f"Error: Log file {log_path} not found.")
sys.exit(1)
return tasks
def get_phase(output_path: str) -> Optional[str]:
"""Detect build phase from output path."""
for check, phase in PHASE_RULES:
if check(output_path):
return phase
return None
def extract_name_from_artifact(filename: str) -> Optional[str]:
"""Extract project name from artifact filename."""
base = filename.replace(".tar.xz", "")
match = ARTIFACT_REGEX.match(base)
name = match.group(1) if match else base
return None if name in ("base", "sysdeps") else name
def parse_output_path(
output_path: str,
) -> tuple[Optional[str], Optional[str], Optional[str]]:
"""Extract (name, category, phase) from output path."""
phase = get_phase(output_path)
if not phase:
return None, None, None
parts = output_path.split("/")
top_dir = parts[0] if parts else ""
# Artifact files - return category=None to inherit from other paths
if output_path.startswith("artifacts/"):
name = extract_name_from_artifact(parts[1])
if not name:
return None, None, None
# Don't determine category here; let analyze_tasks inherit it from
# the project's other paths (e.g., third-party/ or component dirs)
return NAME_MAPPING.get(name, name), None, phase
# Third-party dependencies
if top_dir == "third-party":
if len(parts) > 3 and parts[1] == "sysdeps" and parts[2] in ("linux", "common"):
name = parts[3]
elif len(parts) > 1:
name = parts[1]
else:
return None, None, None
if name == "sysdeps":
return None, None, None
return NAME_MAPPING.get(name, name), CATEGORY_DEP, phase
# ROCm components in standard directories
if top_dir in ROCM_COMPONENT_DIRS:
name = parts[1] if len(parts) > 1 else None
if not name:
return None, None, None
return NAME_MAPPING.get(name, name), CATEGORY_ROCM, phase
# rocm-libraries / rocm-systems
if top_dir in ("rocm-libraries", "rocm-systems"):
if len(parts) > 2 and parts[1] == "projects":
name = parts[2]
return NAME_MAPPING.get(name, name), CATEGORY_ROCM, phase
return None, None, None
# math-libs (special structure)
if top_dir == "math-libs" and len(parts) > 1:
if parts[1] == "BLAS":
name = parts[2] if len(parts) > 2 else None
elif parts[1] == "support" and len(parts) > 2:
name = parts[2]
else:
name = parts[1]
if not name:
return None, None, None
return NAME_MAPPING.get(name, name), CATEGORY_ROCM, phase
return None, None, None
# =============================================================================
# Analysis
# =============================================================================
def load_comp_summary(build_dir: Path) -> str:
"""Load comp-summary.html body content if available."""
path = build_dir / "logs" / "therock-build-prof" / "comp-summary.html"
if not path.exists():
return ""
content = path.read_text()
match = re.search(r"<body>(.*)</body>", content, re.DOTALL)
if not match:
return ""
body = match.group(1).strip()
# Remove table border attribute to match template style
body = re.sub(r"<table[^>]*border=['\"]?\d['\"]?[^>]*>", "<table>", body)
# Remove original h1 title to avoid duplication
body = re.sub(r"<h1>.*?</h1>", "", body, flags=re.DOTALL)
# Remove horizontal rule
body = re.sub(r"<hr\s*/?>", "", body)
return body
def analyze_tasks(
tasks: List[Task], build_dir: Path
) -> Dict[str, Dict[str, Dict[str, int]]]:
"""Aggregate task durations by category/name/phase.
Uses two-phase processing:
1. First pass: Collect project categories from non-artifact paths
2. Second pass: Process all tasks, with artifacts inheriting categories
"""
projects: Dict[str, Dict[str, Dict[str, int]]] = defaultdict(
lambda: defaultdict(lambda: defaultdict(int))
)
seen = set()
build_prefix = str(build_dir.resolve())
# Single pass: process non-artifact tasks immediately, defer artifact tasks
project_categories: Dict[str, str] = {} # {name: category}
deferred_artifacts: List[tuple] = [] # [(name, phase, duration)]
for task in tasks:
# Normalize path (strip build prefix)
output = task.output
if output.startswith(build_prefix):
output = output[len(build_prefix) :].lstrip("/")
# Dedup
key = (output, task.start, task.end)
if key in seen:
continue
seen.add(key)
name, category, phase = parse_output_path(output)
if not name:
continue
if category is None:
# Artifact path: defer processing until all categories are collected
deferred_artifacts.append((name, phase, task.duration))
else:
# Non-artifact path: process immediately and record category
if name not in project_categories:
project_categories[name] = category
projects[category][name][phase] += task.duration
# Process deferred artifacts using collected categories
for name, phase, duration in deferred_artifacts:
category = project_categories.get(name, CATEGORY_ROCM)
projects[category][name][phase] += duration
return projects
# =============================================================================
# Report Generation
# =============================================================================
def get_system_info() -> Dict[str, str]:
"""Get build server system information."""
info = {"cpu_model": "Unknown", "cpu_cores": "Unknown", "memory_gb": "Unknown"}
# Get CPU model from /proc/cpuinfo
try:
with open("/proc/cpuinfo", "r") as f:
for line in f:
if line.startswith("model name"):
info["cpu_model"] = line.split(":")[1].strip()
break
except (FileNotFoundError, IOError):
pass
# Get CPU cores
try:
info["cpu_cores"] = str(os.cpu_count() or "Unknown")
except Exception:
pass
# Get memory from /proc/meminfo
try:
with open("/proc/meminfo", "r") as f:
for line in f:
if line.startswith("MemTotal"):
kb = int(line.split()[1])
info["memory_gb"] = f"{kb / 1024 / 1024:.1f}"
break
except (FileNotFoundError, IOError, ValueError):
pass
return info
def calculate_wall_time(tasks: List[Task]) -> int:
"""Calculate wall clock time from earliest start to latest end."""
if not tasks:
return 0
min_start = min(task.start for task in tasks)
max_end = max(task.end for task in tasks)
return max_end - min_start
def format_time_human(ms: int) -> str:
"""Format milliseconds to human readable time (e.g., 1h 23.5m or 45.32 min)."""
minutes = ms / 60000
hours = int(minutes // 60)
remaining_minutes = minutes % 60
if hours > 0:
return f"{hours}h {remaining_minutes:.1f}m"
return f"{minutes:.2f} min"
def generate_system_info_html(tasks: List[Task]) -> str:
"""Generate HTML for build information section."""
info = get_system_info()
wall_time_str = format_time_human(calculate_wall_time(tasks))
return f"""<div class="system-info">
<h3>Build Information</h3>
<p>CPU: <span>{info['cpu_model']}</span></p>
<p>CPU Cores: <span>{info['cpu_cores']}</span></p>
<p>Memory: <span>{info['memory_gb']} GB</span></p>
<p>Build Duration: <span>{wall_time_str}</span></p>
</div>
"""
def format_duration(ms: int) -> str:
"""Convert milliseconds to formatted minutes string."""
return "-" if ms == 0 else f"{ms / 60000:.2f}"
def build_table_rows(
data: Dict[str, Dict[str, int]], phase_columns: List[str]
) -> List[tuple]:
"""Build sorted table rows from project phase data."""
rows = []
for name, phases in data.items():
total = sum(phases.values())
cols = [format_duration(phases.get(p, 0)) for p in phase_columns]
rows.append((name, cols, format_duration(total), total))
rows.sort(key=lambda x: x[3], reverse=True)
return [(r[0], r[1], r[2]) for r in rows]
def generate_html_table(title: str, headers: List[str], rows: List[tuple]) -> str:
"""Generate HTML table with title, headers, and row data."""
if not rows:
return ""
lines = [
f"<h2>{title}</h2>",
"<table>",
"<thead><tr>",
"".join(f"<th>{h}</th>" for h in headers),
"</tr></thead>",
"<tbody>",
]
for name, cols, total in rows:
cells = (
f"<td>{name}</td>"
+ "".join(f"<td>{v}</td>" for v in cols)
+ f'<td class="total-col">{total}</td>'
)
lines.append(f"<tr>{cells}</tr>")
lines.extend(["</tbody>", "</table>"])
return "\n".join(lines) + "\n"
def generate_report(
projects: Dict, tasks: List[Task], output_file: Path, build_dir: Path
):
"""Generate HTML report from analyzed project data."""
# ROCm Components table
rocm_data = projects.get(CATEGORY_ROCM, {})
rocm_rows = build_table_rows(
rocm_data, ["Configure", "Build", "Install", "Package"]
)
rocm_html = generate_html_table(
"ROCm Components Build Time",
[
"Sub-Project",
"Configure (min)",
"Build (min)",
"Install (min)",
"Package (min)",
"Total (min)",
],
rocm_rows,
)
# Dependencies table (combine Download + Update into single column)
dep_data = {}
for name, phases in projects.get(CATEGORY_DEP, {}).items():
dep_data[name] = {
"Download": phases.get("Download", 0) + phases.get("Update", 0),
"Configure": phases.get("Configure", 0),
"Build": phases.get("Build", 0),
"Install": phases.get("Install", 0),
}
dep_rows = build_table_rows(dep_data, ["Download", "Configure", "Build", "Install"])
dep_html = generate_html_table(
"ROCm Dependency Build Time",
[
"Sub-Project",
"Download (min)",
"Configure (min)",
"Build (min)",
"Install (min)",
"Total (min)",
],
dep_rows,
)
# Generate build info (system info + build times)
system_html = generate_system_info_html(tasks)
# Load template and generate output
template_path = Path(__file__).resolve().parent / "report_build_time_template.html"
comp_summary_html = load_comp_summary(build_dir)
try:
template = template_path.read_text()
html = (
template.replace("{{SYSTEM_INFO}}", system_html)
.replace("{{ROCM_TABLE}}", rocm_html)
.replace("{{DEP_TABLE}}", dep_html)
.replace("{{COMP_SUMMARY}}", comp_summary_html)
)
output_file.write_text(html)
print(f"HTML report generated at: {output_file}")
except FileNotFoundError:
print(f"Error: Template file not found at {template_path}")
except Exception as e:
print(f"Error generating report: {e}")
# =============================================================================
# Main
# =============================================================================
def main():
parser = argparse.ArgumentParser(description="Analyze Ninja build times")
parser.add_argument(
"--build-dir", type=Path, required=True, help="Path to build directory"
)
parser.add_argument("--output", type=Path, help="Path to output HTML file")
args = parser.parse_args()
ninja_log = args.build_dir / ".ninja_log"
if not ninja_log.exists():
print(f"Error: {ninja_log} not found.")
sys.exit(1)
tasks = parse_ninja_log(ninja_log)
projects = analyze_tasks(tasks, args.build_dir)
output_file = args.output or args.build_dir / "logs" / "build_observability.html"
output_file.parent.mkdir(parents=True, exist_ok=True)
generate_report(projects, tasks, output_file, args.build_dir)
if __name__ == "__main__":
main()