Team Ai
Apppublic

lablab-ai-amd-developer-hackathon/gpu-goblin

sourceHugging Facemitupdated 5mo agoView on Hugging Face
0likes
profile_parser.py694 linesDownload Raw Back to runner
1"""profile_parser — turn goblin_runner.sh artefacts into RunMetrics.2 3Reads three files written by goblin_runner.sh (architecture.md §5):4 5    <out_dir>/trace.csv          rocprofv3 kernel trace6    <out_dir>/torch_profile.json torch.profiler chrome trace7    <out_dir>/amd_smi.csv        amd-smi telemetry, ~200 ms cadence8 9and produces one `RunMetrics` with a populated `WasteBudget`.10 11Each waste-budget bucket is computed with a deliberately simple, documented12heuristic — these are best-effort signals for the agent, NOT measured13ground-truth. See the docstring on `_waste_budget()` for the per-bucket14formula and its known failure modes.15 16This module is import-tolerant on machines without the rocprofv3 stack —17it only reads files. Missing or unparseable files degrade individual18metrics to zero and append a warning to RunMetrics.warnings rather than19raising. LiveRunner ultimately decides what to do with parse failures.20"""21 22from __future__ import annotations23 24import csv25import json26import logging27import re28from dataclasses import dataclass29from pathlib import Path30from typing import Any31 32from agent.schemas import KernelEntry, RunMetrics, WasteBudget, WorkloadConfig33 34_LOG = logging.getLogger(__name__)35 36 37# MI300X has 192 GB HBM3. We treat sustained <70% utilisation as headroom.38_HBM_TOTAL_GB = 192.039_HBM_HEALTHY_TARGET = 0.7040 41# Kernel-name patterns used by the heuristics.42#43# We use `(?<![A-Za-z0-9])` / `(?![A-Za-z0-9])` instead of `\b` so that44# underscores act as token separators — kernel names like45# `rccl_AllReduce` and `hipBLASLt_generic_gemm` should match.46_BOUND_L = r"(?<![A-Za-z0-9])"47_BOUND_R = r"(?![A-Za-z0-9])"48_RCCL_PATTERN = re.compile(49    _BOUND_L + r"(rccl|nccl|all[_-]?reduce|broadcast|reduce[_-]?scatter)" + _BOUND_R, re.I50)51_GEMM_PATTERN = re.compile(_BOUND_L + r"(gemm|matmul|hgemm|sgemm|hipblaslt)" + _BOUND_R, re.I)52_GENERIC_GEMM_PATTERN = re.compile(53    _BOUND_L + r"(generic|fallback|naive|reference)" + _BOUND_R, re.I54)55_FP16_PATTERN = re.compile(_BOUND_L + r"(fp16|half|f16)" + _BOUND_R, re.I)56_BF16_PATTERN = re.compile(_BOUND_L + r"(bf16|bfloat16)" + _BOUND_R, re.I)57 58 59# ---------------------------------------------------------------------------60# Public entry point61# ---------------------------------------------------------------------------62 63 64def parse(65    out_dir: Path,66    config: WorkloadConfig | None = None,67    steps: int = 10,68) -> RunMetrics:69    """Build a `RunMetrics` from goblin_runner.sh artefacts in `out_dir`.70 71    `config` is used by some heuristics (e.g. precision_path skips the72    estimate when the config is already bf16). It can be None at the cost73    of a more conservative waste-budget estimate.74 75    Always returns a RunMetrics object — individual metrics degrade to76    zero with a warning rather than raising, because LiveRunner ultimately77    decides whether parse failures should trigger fallback.78    """79    warnings: list[str] = []80 81    kernels = _read_kernels(out_dir / "trace.csv", warnings)82    torch_summary = _read_torch_profile(out_dir / "torch_profile.json", warnings)83    smi = _read_amd_smi(out_dir / "amd_smi.csv", warnings)84 85    top_kernels = _top_kernels(kernels, top_n=5)86    gpu_util_pct = smi.gpu_util_pct if smi.gpu_util_pct is not None else _gpu_util_from_kernels(87        kernels, torch_summary88    )89    # Write the resolved gpu_util back into smi so _waste_budget sees the90    # same number RunMetrics reports. Without this, a build where amd-smi's91    # `usage` column returns N/A leaves smi.gpu_util_pct=None — and92    # _waste_budget reads it as 0%, which forces host_gap to consume the93    # full step time and collapses kernel_shape / precision_path to zero94    # (both multiplied by gpu_util).95    smi.gpu_util_pct = gpu_util_pct96 97    waste_budget = _waste_budget(98        kernels=kernels,99        torch_summary=torch_summary,100        smi=smi,101        config=config,102    )103 104    attention_kernel_loaded = _detect_attention_kernel(kernels)105 106    return RunMetrics(107        steps=steps,108        tokens_per_sec=torch_summary.tokens_per_sec or 0.0,109        mfu_pct=torch_summary.mfu_pct or 0.0,110        hbm_peak_gb=smi.hbm_peak_gb or 0.0,111        hbm_avg_gb=smi.hbm_avg_gb or 0.0,112        gpu_util_pct=gpu_util_pct,113        top_kernels=top_kernels,114        attention_kernel_loaded=attention_kernel_loaded,115        waste_budget=waste_budget,116        warnings=warnings,117        rocm_version=smi.rocm_version or "unknown",118        pytorch_version=torch_summary.pytorch_version or "unknown",119        runner_kind="live",120    )121 122 123# ---------------------------------------------------------------------------124# Internal data carriers125# ---------------------------------------------------------------------------126 127 128@dataclass129class _Kernel:130    name: str131    duration_ns: int132    """Kernel duration in nanoseconds."""133 134    is_collective: bool = False135    is_gemm: bool = False136    is_generic_gemm: bool = False137 138 139@dataclass140class _TorchSummary:141    tokens_per_sec: float | None = None142    mfu_pct: float | None = None143    pytorch_version: str | None = None144    step_time_seconds: float | None = None145    """Average wall-clock seconds per training step (used by waste budget)."""146 147    host_busy_fraction: float | None = None148    """Fraction of step time the host (CPU) spent doing non-launch work.149    Heuristic: `cpu_self_time / step_time` reported by torch.profiler."""150 151 152@dataclass153class _SmiSummary:154    hbm_peak_gb: float | None = None155    hbm_avg_gb: float | None = None156    gpu_util_pct: float | None = None157    rocm_version: str | None = None158 159 160# ---------------------------------------------------------------------------161# trace.csv (rocprofv3) → list[_Kernel]162# ---------------------------------------------------------------------------163 164 165def _read_kernels(path: Path, warnings: list[str]) -> list[_Kernel]:166    """Parse rocprofv3 kernel-trace CSV into a list of `_Kernel` records.167 168    rocprofv3 column names vary slightly by version. We look up by header and169    accept the common aliases; if the file is missing or unparseable we170    return an empty list and append a warning so the caller can decide.171    """172    if not path.exists():173        warnings.append(f"profile_parser: kernel trace not found at {path}")174        return []175 176    try:177        with path.open(newline="") as f:178            reader = csv.DictReader(f)179            if reader.fieldnames is None:180                warnings.append(f"profile_parser: empty kernel trace at {path}")181                return []182            name_col = _pick_column(reader.fieldnames, ["KernelName", "Kernel_Name", "kernel_name", "Name"])183            start_col = _pick_column(reader.fieldnames, ["BeginNs", "start_ns", "BeginNS", "Start"])184            end_col = _pick_column(reader.fieldnames, ["EndNs", "end_ns", "EndNS", "End"])185            duration_col = _pick_column(reader.fieldnames, ["DurationNs", "duration_ns", "Duration"])186 187            kernels: list[_Kernel] = []188            for row in reader:189                name = (row.get(name_col) or "").strip() if name_col else ""190                if not name:191                    continue192                duration = _row_duration_ns(row, duration_col, start_col, end_col)193                if duration <= 0:194                    continue195                kernels.append(196                    _Kernel(197                        name=name,198                        duration_ns=duration,199                        is_collective=bool(_RCCL_PATTERN.search(name)),200                        is_gemm=bool(_GEMM_PATTERN.search(name)),201                        is_generic_gemm=bool(202                            _GEMM_PATTERN.search(name) and _GENERIC_GEMM_PATTERN.search(name)203                        ),204                    )205                )206            return kernels207    except (OSError, csv.Error) as exc:208        warnings.append(f"profile_parser: failed to read kernel trace ({exc})")209        return []210 211 212def _pick_column(fieldnames: list[str], candidates: list[str]) -> str | None:213    """Pick the first matching column name, with three fallback tiers:214        1. Exact match.215        2. Case-insensitive exact match.216        3. Substring match (case-insensitive) — every token of any candidate217           must appear somewhere in the field name. Tolerates the column-name218           drift between rocprofv3 / amd-smi versions (e.g. `VRAM_USED` vs219           `vram_used_mb` vs `VRAM USED MB`).220    """221    for c in candidates:222        if c in fieldnames:223            return c224    lower = {f.lower(): f for f in fieldnames}225    for c in candidates:226        if c.lower() in lower:227            return lower[c.lower()]228    # Substring tier: split each candidate on _/space, require all tokens229    # appear in the (lowercased) field name. Avoids matching too eagerly230    # by requiring every token of the candidate.231    for c in candidates:232        tokens = [t for t in c.lower().replace("_", " ").split() if t]233        if not tokens:234            continue235        for fname in fieldnames:236            fl = fname.lower()237            if all(t in fl for t in tokens):238                return fname239    return None240 241 242def _row_duration_ns(243    row: dict[str, str], duration_col: str | None, start_col: str | None, end_col: str | None244) -> int:245    if duration_col and row.get(duration_col):246        try:247            return int(float(row[duration_col]))248        except ValueError:249            return 0250    if start_col and end_col and row.get(start_col) and row.get(end_col):251        try:252            return int(float(row[end_col])) - int(float(row[start_col]))253        except ValueError:254            return 0255    return 0256 257 258def _top_kernels(kernels: list[_Kernel], top_n: int) -> list[KernelEntry]:259    if not kernels:260        return []261    total = sum(k.duration_ns for k in kernels) or 1262    by_name: dict[str, int] = {}263    for k in kernels:264        by_name[k.name] = by_name.get(k.name, 0) + k.duration_ns265    ranked = sorted(by_name.items(), key=lambda kv: kv[1], reverse=True)[:top_n]266    return [KernelEntry(name=name, pct_time=ns / total * 100.0) for name, ns in ranked]267 268 269def _detect_attention_kernel(kernels: list[_Kernel]) -> str:270    for k in kernels:271        n = k.name.lower()272        if "flash_attn" in n and "rocm" in n:273            return "flash_rocm"274        if "flash" in n and "attn" in n:275            return "flash"276        if "scaled_dot_product_attention" in n:277            return "sdpa"278    if kernels:279        return "eager"  # nothing flash-shaped; default conservative label280    return "unknown"281 282 283def _gpu_util_from_kernels(kernels: list[_Kernel], torch_summary: _TorchSummary) -> float:284    """Fallback GPU util when amd-smi is missing.285 286    util ≈ sum(kernel duration) / total wall-clock step time.287    """288    if not kernels or torch_summary.step_time_seconds in (None, 0):289        return 0.0290    total_kernel_ns = sum(k.duration_ns for k in kernels)291    wall_ns = torch_summary.step_time_seconds * 1e9292    if wall_ns <= 0:293        return 0.0294    return min(100.0, total_kernel_ns / wall_ns * 100.0)295 296 297# ---------------------------------------------------------------------------298# torch_profile.json (torch.profiler chrome trace) → _TorchSummary299# ---------------------------------------------------------------------------300 301 302def _read_torch_profile(path: Path, warnings: list[str]) -> _TorchSummary:303    """Pull tokens/sec, MFU, and step timing from a torch.profiler artefact.304 305    The user script (workloads/train_qwen_lora.py in Phase 3) is306    responsible for embedding `tokens_per_sec`, `mfu_pct`, `pytorch_version`307    and `step_time_seconds` in the trace as `metadata` events. If those are308    missing, we estimate `step_time_seconds` from the total trace duration.309    """310    summary = _TorchSummary()311    if not path.exists():312        warnings.append(f"profile_parser: torch profile not found at {path}")313        return summary314    try:315        data = json.loads(path.read_text())316    except (OSError, json.JSONDecodeError) as exc:317        warnings.append(f"profile_parser: failed to read torch profile ({exc})")318        return summary319 320    # torch.profiler chrome trace is `{"traceEvents": [...], "metadata": {...}}`321    metadata = data.get("metadata") if isinstance(data, dict) else None322    if isinstance(metadata, dict):323        summary.tokens_per_sec = _coerce_float(metadata.get("tokens_per_sec"))324        summary.mfu_pct = _coerce_float(metadata.get("mfu_pct"))325        summary.pytorch_version = metadata.get("pytorch_version") or metadata.get("torch_version")326        summary.step_time_seconds = _coerce_float(metadata.get("step_time_seconds"))327        summary.host_busy_fraction = _coerce_float(metadata.get("host_busy_fraction"))328 329    events = data.get("traceEvents") if isinstance(data, dict) else None330    if isinstance(events, list):331        if summary.step_time_seconds is None:332            summary.step_time_seconds = _step_time_from_events(events)333        if summary.host_busy_fraction is None:334            summary.host_busy_fraction = _host_busy_from_events(events)335 336    return summary337 338 339def _coerce_float(v: Any) -> float | None:340    if v is None:341        return None342    try:343        return float(v)344    except (TypeError, ValueError):345        return None346 347 348def _step_time_from_events(events: list[dict]) -> float | None:349    """Estimate per-step wall-clock seconds from chrome-trace duration events.350 351    Looks for `name == "ProfilerStep#*"` complete events; falls back to the352    overall trace span if those aren't present.353    """354    durations: list[float] = []355    overall_start: float | None = None356    overall_end: float | None = None357    for ev in events:358        if not isinstance(ev, dict):359            continue360        name = ev.get("name", "")361        ts = ev.get("ts")362        dur = ev.get("dur")363        if isinstance(name, str) and name.startswith("ProfilerStep") and isinstance(dur, (int, float)):364            durations.append(float(dur) / 1e6)  # us → seconds365        if isinstance(ts, (int, float)) and isinstance(dur, (int, float)):366            start = float(ts)367            end = start + float(dur)368            overall_start = start if overall_start is None else min(overall_start, start)369            overall_end = end if overall_end is None else max(overall_end, end)370    if durations:371        return sum(durations) / len(durations)372    if overall_start is not None and overall_end is not None and overall_end > overall_start:373        return (overall_end - overall_start) / 1e6374    return None375 376 377def _host_busy_from_events(events: list[dict]) -> float | None:378    """Heuristic: cpu_op event time / total event span.379 380    Used by the data_wait waste bucket to disambiguate "GPU idle because the381    host is busy preparing the next batch" from "GPU idle because nothing382    is running anywhere".383    """384    cpu_op_us = 0.0385    span_min: float | None = None386    span_max: float | None = None387    for ev in events:388        if not isinstance(ev, dict):389            continue390        cat = ev.get("cat", "")391        ts = ev.get("ts")392        dur = ev.get("dur")393        if not (isinstance(ts, (int, float)) and isinstance(dur, (int, float))):394            continue395        ts_f = float(ts)396        dur_f = float(dur)397        end = ts_f + dur_f398        span_min = ts_f if span_min is None else min(span_min, ts_f)399        span_max = end if span_max is None else max(span_max, end)400        if isinstance(cat, str) and "cpu_op" in cat.lower():401            cpu_op_us += dur_f402    if span_min is None or span_max is None or span_max <= span_min:403        return None404    span = span_max - span_min405    if span <= 0:406        return None407    return min(1.0, cpu_op_us / span)408 409 410# ---------------------------------------------------------------------------411# amd_smi.csv → _SmiSummary412# ---------------------------------------------------------------------------413 414 415def _read_amd_smi(path: Path, warnings: list[str]) -> _SmiSummary:416    """Aggregate amd-smi polling output into HBM peak/avg + GPU util."""417    summary = _SmiSummary()418    if not path.exists():419        warnings.append(f"profile_parser: amd-smi telemetry not found at {path}")420        return summary421 422    try:423        raw = path.read_text()424    except OSError as exc:425        warnings.append(f"profile_parser: failed to read amd-smi telemetry ({exc})")426        return summary427 428    csv_text = _strip_amd_smi_preamble(raw)429    if csv_text is None:430        warnings.append(f"profile_parser: no parseable header in amd-smi telemetry at {path}")431        return summary432 433    try:434        import io as _io435 436        reader = csv.DictReader(_io.StringIO(csv_text))437        if reader.fieldnames is None:438            warnings.append(f"profile_parser: empty amd-smi telemetry at {path}")439            return summary440        hbm_col = _pick_column(441            reader.fieldnames,442            [443                # `amd-smi metric --mem-usage --csv` (ROCm 7.x) emits444                # "used_vram" in MB. Older `amd-smi monitor --vram-usage`445                # emits "VRAM_USED" / "vram_used_mb" depending on minor446                # version.447                "used_vram",448                "USED_VRAM",449                "VRAM_USED_MB",450                "vram_used_mb",451                "VRAM_USED",452                "VRAM_USED_GB",453                "vram_used",454                "VRAM Used",455                # Older rocm 6.x naming456                "MEM_USED",457                "mem_used",458            ],459        )460        util_col = _pick_column(461            reader.fieldnames,462            [463                # `amd-smi metric --usage --csv` (ROCm 7.x) emits a single464                # consolidated "usage" column. Some builds report N/A here465                # — _coerce_float drops those silently and we fall back to466                # the kernel-trace gpu_util estimate downstream.467                "usage",468                "USAGE",469                # `amd-smi monitor --gfx` (ROCm 7.x) → "gfx_util"470                "GFX_UTIL",471                "gfx_util",472                "GFX_UTILIZATION",473                "gfx_utilization",474                # rocm 6.x and older475                "GFX_ACTIVITY",476                "gfx_activity",477                "GPU_USE",478                "GFX %",479                "Util",480            ],481        )482        rocm_col = _pick_column(reader.fieldnames, ["ROCM_VERSION", "rocm_version"])483 484        hbm_samples: list[float] = []485        util_samples: list[float] = []486        for row in reader:487            if hbm_col:488                hbm_gb = _hbm_to_gb(row.get(hbm_col), hbm_col)489                if hbm_gb is not None:490                    hbm_samples.append(hbm_gb)491            if util_col:492                util = _coerce_float(row.get(util_col))493                if util is not None:494                    util_samples.append(min(100.0, util))495            if rocm_col and summary.rocm_version is None:496                val = (row.get(rocm_col) or "").strip()497                if val:498                    summary.rocm_version = val499        if hbm_samples:500            summary.hbm_peak_gb = max(hbm_samples)501            summary.hbm_avg_gb = sum(hbm_samples) / len(hbm_samples)502        if util_samples:503            summary.gpu_util_pct = sum(util_samples) / len(util_samples)504        return summary505    except csv.Error as exc:506        warnings.append(f"profile_parser: failed to parse amd-smi telemetry ({exc})")507        return summary508 509 510def _strip_amd_smi_preamble(raw: str) -> str | None:511    """Drop everything before the first real CSV header and dedupe repeated512    header lines — both noise produced by `amd-smi <subcmd> --watch`.513 514    --watch prints a "'CTRL' + 'C' to stop watching output:" banner once at515    the top, then re-emits the CSV header on every iteration. csv.DictReader516    naively reads the banner as fieldnames and treats every subsequent517    header as a misshapen data row. Pre-strip both before handing it off.518 519    Returns a CSV string ready for DictReader, or None if no header line520    is recognisable.521    """522    lines = raw.splitlines()523    header_idx: int | None = None524    for i, line in enumerate(lines):525        if "," not in line:526            continue527        lower = line.lower()528        # Recognised tokens come from the columns amd-smi metric / monitor529        # actually emit. Match conservatively — banners or other noise can530        # contain commas too.531        if any(tok in lower for tok in ("vram", "gfx_", "timestamp,", "gpu_use", "gpu,")):532            header_idx = i533            break534    if header_idx is None:535        return None536 537    header = lines[header_idx]538    data = [539        line540        for line in lines[header_idx + 1 :]541        if line.strip() and line != header542    ]543    return header + "\n" + "\n".join(data) + ("\n" if data else "")544 545 546def _hbm_to_gb(raw: str | None, column_name: str | None = None) -> float | None:547    """amd-smi sometimes reports VRAM in MB, sometimes in GB.548 549    First check the column name — `*vram*` / `*_mb` columns are MB-typed550    in every amd-smi build we've seen; `*_gb` is GB. Without a column-name551    hint, fall back to a value heuristic. The old "v > 1024 ⇒ MB" heuristic552    misclassified small idle samples (e.g. 285 MB at GPU idle) as GB and553    inflated the peak across the run, which then forced memory_headroom to554    zero downstream.555    """556    if not raw:557        return None558    try:559        v = float(str(raw).strip().replace("MB", "").replace("GB", "").replace(",", ""))560    except ValueError:561        return None562    if column_name:563        lower = column_name.lower()564        if "_mb" in lower or lower.endswith("mb"):565            return v / 1024.0566        if "_gb" in lower or lower.endswith("gb"):567            return v568        if "vram" in lower or "mem" in lower:569            # amd-smi metric / monitor default to MB for the bare570            # `used_vram` / `mem_used` columns on every ROCm 6.x+ build.571            return v / 1024.0572    # No column hint — fall back to value heuristic.573    if v > 1024.0:574        return v / 1024.0575    return v576 577 578# ---------------------------------------------------------------------------579# Waste-budget heuristics580# ---------------------------------------------------------------------------581 582 583def _waste_budget(584    *,585    kernels: list[_Kernel],586    torch_summary: _TorchSummary,587    smi: _SmiSummary,588    config: WorkloadConfig | None,589) -> WasteBudget:590    """Decompose step time into the seven WasteBudget buckets (architecture.md §3).591 592    These are HEURISTICS, not measurements. Each bucket is in seconds-per-step593    so they can be summed against `step_time_seconds`. If we can't observe a594    bucket we set it to 0 — `evidence_coverage` in `propose_patch` then595    discounts confidence accordingly.596 597    Per-bucket logic:598 599      data_wait600        Fraction of step time where GPU util was below 30% AND the host was601        busy (host_busy_fraction > 0.5). Maps to dataloader / H2D copy stalls.602 603      precision_path604        If the user is already on bf16/fp8 we skip this bucket (recovery is 0).605        Otherwise we estimate from kernel names: time spent in fp16-tagged606        GEMMs is the recoverable surface; bf16-tagged kernels are not.607 608      kernel_shape609        Fraction of total GEMM kernel time spent on kernels whose names610        match `generic|fallback|naive|reference` — i.e. hipBLASLt/MIOpen611        couldn't pick a tuned tile size and fell back to a slow path.612 613      host_gap614        Time the GPU was idle while the host was NOT busy either — pure615        launch latency / eager-mode kernel gaps. We approximate as616        `(1 - gpu_util) * (1 - host_busy)` × step_time.617 618      comm_excess619        Sum of all collective-kernel duration (anything matching the rccl620        pattern). Treated as 100% recoverable elsewhere — the rule decides621        recovery_fraction.622 623      memory_headroom624        `(192 - hbm_peak) / 192 × small_constant`. Only counts the headroom625        that exceeded a healthy 70% target — running at 60% of HBM costs us626        roughly 0.07 of step time worth of optimisation surface.627 628      useful_gpu629        Whatever step time is left. Will be roughly the busy GPU time minus630        comm_excess and the kernel-shape penalty.631    """632    step_t = torch_summary.step_time_seconds or 0.0633    gpu_util = (smi.gpu_util_pct or 0.0) / 100.0  # 0..1634    host_busy = torch_summary.host_busy_fraction or 0.0635    if step_t <= 0:636        return WasteBudget()637 638    # data_wait: GPU under-utilised AND host busy → dataloader bottleneck.639    if gpu_util < 0.30 and host_busy > 0.5:640        data_wait = step_t * (1.0 - gpu_util) * host_busy641    else:642        data_wait = 0.0643 644    # host_gap: GPU idle while host idle too → launch latency / kernel gaps.645    host_gap = step_t * (1.0 - gpu_util) * (1.0 - host_busy)646 647    # comm_excess: total time in collective kernels (in seconds).648    comm_excess_ns = sum(k.duration_ns for k in kernels if k.is_collective)649    comm_excess = comm_excess_ns / 1e9650 651    # kernel_shape: fraction of GEMM time spent on un-tuned/generic kernels.652    gemm_total_ns = sum(k.duration_ns for k in kernels if k.is_gemm) or 1653    generic_gemm_ns = sum(k.duration_ns for k in kernels if k.is_generic_gemm)654    kernel_shape = (generic_gemm_ns / gemm_total_ns) * step_t * gpu_util  # cap at "real" GPU time655 656    # precision_path: only meaningful if config is fp16/fp32. Estimate from657    # kernel name tags. If config is bf16+ we leave as 0 — already optimal.658    precision_path = 0.0659    if config is None or config.precision in {"fp16", "fp32"}:660        fp16_ns = sum(k.duration_ns for k in kernels if _FP16_PATTERN.search(k.name))661        bf16_ns = sum(k.duration_ns for k in kernels if _BF16_PATTERN.search(k.name))662        denom = fp16_ns + bf16_ns663        if denom > 0:664            # Fraction of compute still on fp16. On MI300X bf16 is faster +665            # more numerically stable, so this fraction is the recoverable666            # precision_path surface.667            precision_path = (fp16_ns / denom) * step_t * gpu_util * 0.10668 669    # memory_headroom: headroom past the 70% healthy target × small constant.670    memory_headroom = 0.0671    hbm_peak = smi.hbm_peak_gb672    if hbm_peak is not None and hbm_peak > 0:673        utilisation = hbm_peak / _HBM_TOTAL_GB674        if utilisation < _HBM_HEALTHY_TARGET:675            slack = (_HBM_HEALTHY_TARGET - utilisation) / _HBM_HEALTHY_TARGET676            # Small constant: HBM slack is real but only enables a fraction of677            # potential gain (you still need a larger batch to use it). 0.05678            # of step time per "unit of slack" is a conservative anchor.679            memory_headroom = slack * step_t * 0.05680 681    # useful_gpu: everything else. Clamp to >= 0.682    spent = data_wait + host_gap + comm_excess + kernel_shape + precision_path + memory_headroom683    useful_gpu = max(0.0, step_t - spent)684 685    return WasteBudget(686        useful_gpu=useful_gpu,687        data_wait=data_wait,688        host_gap=host_gap,689        comm_excess=comm_excess,690        memory_headroom=memory_headroom,691        precision_path=precision_path,692        kernel_shape=kernel_shape,693    )694