← Files AMDARCHIVED FILE

staging/apu-memory-tuner/scripts/detect_platform.py

16.9 KB · Sep 30, 2026 · 23:13 UTC

↓ Download file

#!/usr/bin/env -S uv run --quiet
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""Detect whether this machine is a tunable AMD APU and report support level.

This is the first script the `apu-memory-tuner` skill runs. It is read-only.
It answers a single question: "can this skill help you on this machine?"

It prints a human-readable summary and, with `--json`, emits a structured
record the agent can parse to drive the next steps.

Exit codes:
  0 = supported; the rest of the skill can proceed.
  2 = not an AMD APU, or APU generation we don't have tuning recipes for.
  3 = AMD APU detected, but a hard prerequisite is missing (e.g. Linux
      kernel too old). The agent should surface the reason and stop.

The detection is intentionally best-effort. Failing to read one source does
not abort the script; it just leaves the corresponding field as `None` /
`"unknown"`. The agent should treat empty fields as "needs user
confirmation", not "broken".
"""

from __future__ import annotations

import argparse
import json
import os
import platform
import re
import shutil
import subprocess
import sys
from dataclasses import asdict, dataclass, field
from pathlib import Path

# Mapping from LLVM gfx target to the marketing-friendly generation bucket
# the rest of the skill keys off. RDNA3.5 (gfx115x) is the only generation
# the source ROCm doc describes shared-memory tuning for, so it is the only
# generation marked "supported" here. Older APUs still expose the same TTM
# knob but without an officially supported tuning recipe.
GFX_TO_GENERATION: dict[str, str] = {
    "gfx1150": "rdna35",
    "gfx1151": "rdna35",
    "gfx1152": "rdna35",
    "gfx1103": "rdna3",
    "gfx1102": "rdna3",
    "gfx1100": "rdna3",
    "gfx1036": "rdna2",
    "gfx1035": "rdna2",
    "gfx1034": "rdna2",
    "gfx1033": "rdna2",
}

# Kernel version floors for the Linux gate. The authoritative, up-to-date
# matrix (per distribution + per ROCm release) lives at:
#   https://rocm.docs.amd.com/en/latest/how-to/system-optimization/rdna3-5.html
# Always cross-check that page before bumping these constants. Strix Halo
# (gfx1151) requires the KFD fixes referenced there; without them queue
# creation and memory checks misbehave.
LINUX_KERNEL_MIN_MAINLINE = (6, 18, 4)
LINUX_KERNEL_MIN_UBUNTU_HWE = (6, 17, 0)
LINUX_KERNEL_MIN_UBUNTU_OEM = (6, 14, 0)


@dataclass
class Detection:
    os_family: str = "unknown"          # linux | windows | other
    os_version: str = ""
    cpu_vendor: str = "unknown"
    cpu_model: str = ""
    gpu_name: str = ""
    gfx_target: str = ""                # e.g. gfx1151
    generation: str = "unknown"         # rdna35 | rdna3 | rdna2 | older | not-amd-apu | unknown
    is_apu: bool | None = None
    total_ram_gb: float | None = None
    kernel_version: str = ""            # Linux only
    kernel_supported: bool | None = None  # Linux only
    amd_ttm_present: bool | None = None   # Linux only
    adrenalin_version: str = ""           # Windows only
    supported: bool = False
    reasons: list[str] = field(default_factory=list)
    notes: list[str] = field(default_factory=list)


def _run(cmd: list[str], timeout: float = 5.0) -> tuple[int, str, str]:
    """Run a command; return (exit, stdout, stderr). Never raises."""
    try:
        r = subprocess.run(
            cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
            text=True, timeout=timeout, check=False,
        )
        return r.returncode, r.stdout or "", r.stderr or ""
    except (FileNotFoundError, subprocess.SubprocessError, OSError):
        return 127, "", ""


def _read_text(path: str) -> str:
    try:
        return Path(path).read_text(encoding="utf-8", errors="replace")
    except OSError:
        return ""


def _total_ram_gb_linux() -> float | None:
    txt = _read_text("/proc/meminfo")
    m = re.search(r"^MemTotal:\s+(\d+)\s+kB", txt, re.MULTILINE)
    if not m:
        return None
    return round(int(m.group(1)) / (1024 * 1024), 2)


def _total_ram_gb_windows() -> float | None:
    # `wmic` is deprecated but still ships everywhere; PowerShell CIM is the
    # modern path. Try CIM first, fall back to wmic, then GlobalMemoryStatus
    # via ctypes as a last resort.
    rc, out, _ = _run([
        "powershell", "-NoProfile", "-Command",
        "(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory",
    ], timeout=8)
    if rc == 0 and out.strip().isdigit():
        return round(int(out.strip()) / (1024 ** 3), 2)

    rc, out, _ = _run(["wmic", "ComputerSystem", "get", "TotalPhysicalMemory"])
    if rc == 0:
        for line in out.splitlines():
            line = line.strip()
            if line.isdigit():
                return round(int(line) / (1024 ** 3), 2)

    try:
        import ctypes

        class MEMORYSTATUSEX(ctypes.Structure):
            _fields_ = [
                ("dwLength", ctypes.c_ulong),
                ("dwMemoryLoad", ctypes.c_ulong),
                ("ullTotalPhys", ctypes.c_ulonglong),
                ("ullAvailPhys", ctypes.c_ulonglong),
                ("ullTotalPageFile", ctypes.c_ulonglong),
                ("ullAvailPageFile", ctypes.c_ulonglong),
                ("ullTotalVirtual", ctypes.c_ulonglong),
                ("ullAvailVirtual", ctypes.c_ulonglong),
                ("sullAvailExtendedVirtual", ctypes.c_ulonglong),
            ]

        stat = MEMORYSTATUSEX()
        stat.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
        if ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat)):
            return round(stat.ullTotalPhys / (1024 ** 3), 2)
    except (OSError, AttributeError, ImportError):
        pass
    return None


def _cpu_info_linux() -> tuple[str, str]:
    """Return (vendor, model) from /proc/cpuinfo."""
    txt = _read_text("/proc/cpuinfo")
    vendor = ""
    model = ""
    for line in txt.splitlines():
        if not vendor and line.startswith("vendor_id"):
            vendor = line.split(":", 1)[1].strip()
        elif not model and line.startswith("model name"):
            model = line.split(":", 1)[1].strip()
        if vendor and model:
            break
    vendor_short = "amd" if "AMD" in vendor else ("intel" if "Intel" in vendor else vendor.lower())
    return vendor_short, model


def _cpu_info_windows() -> tuple[str, str]:
    rc, out, _ = _run([
        "powershell", "-NoProfile", "-Command",
        "(Get-CimInstance Win32_Processor | Select-Object -First 1).Name",
    ], timeout=8)
    name = out.strip() if rc == 0 else ""
    vendor = "amd" if "AMD" in name else ("intel" if "Intel" in name else "unknown")
    return vendor, name


def _gfx_target_from_rocminfo() -> tuple[str, str]:
    """Return (gfx_target, gpu_name) from rocminfo if available.

    rocminfo prints one block per agent, and WITHIN a block the ``Name:`` and
    ``Marketing Name:`` lines appear *before* the ``Device Type:`` line. So we
    can't wait until we've seen ``Device Type: GPU`` to start capturing -- by
    then those lines are already behind us. Instead we buffer the current
    block's candidate gfx target + marketing name and commit them the moment we
    confirm the block is a GPU. A new ``Agent N`` header resets the buffer.
    """
    if shutil.which("rocminfo") is None:
        return "", ""
    rc, out, _ = _run(["rocminfo"], timeout=10)
    if rc != 0:
        return "", ""
    cand_gfx = ""
    cand_name = ""
    for line in out.splitlines():
        s = line.strip()
        if s.startswith("Agent ") and s[len("Agent "):].strip().isdigit():
            cand_gfx = ""
            cand_name = ""
        elif s.startswith("Name:"):
            val = s.split(":", 1)[1].strip()
            # The agent's own Name is the bare gfx target (e.g. "gfx1151").
            # ISA sub-blocks report "amdgcn-amd-amdhsa--gfx1151" -- skip those
            # via the startswith("gfx") guard.
            if val.startswith("gfx") and not cand_gfx:
                cand_gfx = val
        elif s.startswith("Marketing Name:") and not cand_name:
            cand_name = s.split(":", 1)[1].strip()
        elif s.startswith("Device Type:") and "GPU" in s and cand_gfx:
            return cand_gfx, cand_name
    return "", ""


def _gfx_target_from_sysfs() -> tuple[str, str]:
    """Best-effort fallback when rocminfo isn't installed.

    The amdgpu driver exposes the LLVM target string at
    /sys/class/drm/card*/device/llvm_gfx_target on recent kernels.
    """
    try:
        for card in sorted(Path("/sys/class/drm").glob("card[0-9]*")):
            target_path = card / "device" / "llvm_gfx_target"
            if target_path.exists():
                target = target_path.read_text().strip()
                name_path = card / "device" / "product_name"
                name = name_path.read_text().strip() if name_path.exists() else ""
                if target.startswith("gfx"):
                    return target, name
    except OSError:
        pass
    return "", ""


def _gpu_info_windows() -> tuple[str, str]:
    """Return (gfx_target_guess, gpu_name) for the AMD adapter on Windows.

    Windows does not expose the LLVM gfx target the way the Linux amdgpu
    driver does; we approximate by mapping the marketing name to a generation
    bucket. The mapping is intentionally conservative; when in doubt we
    return an empty gfx target and let the user confirm.
    """
    rc, out, _ = _run([
        "powershell", "-NoProfile", "-Command",
        "Get-CimInstance Win32_VideoController | Where-Object { $_.Name -like '*AMD*' -or $_.Name -like '*Radeon*' } | Select-Object -ExpandProperty Name",
    ], timeout=8)
    if rc != 0 or not out.strip():
        return "", ""
    name = out.strip().splitlines()[0].strip()
    lname = name.lower()
    # Heuristic mapping for known APU marketing names. Any miss falls through
    # to "unknown gfx target", which downstream code treats as "ask the user".
    if "ryzen ai max" in lname or "strix halo" in lname:
        return "gfx1151", name
    if "radeon 880m" in lname or "radeon 890m" in lname or "strix" in lname:
        return "gfx1150", name
    if "radeon 780m" in lname or "radeon 760m" in lname or "phoenix" in lname or "hawk point" in lname:
        return "gfx1103", name
    return "", name


def _kernel_version_linux() -> str:
    return platform.release()


def _parse_kernel_tuple(v: str) -> tuple[int, int, int]:
    """Extract (major, minor, patch) from a Linux kernel release string.

    Examples:
      "6.17.0-19-generic"   -> (6, 17, 0)
      "6.18.4"              -> (6, 18, 4)
      "6.14.0-1018-oem"     -> (6, 14, 0)
    """
    m = re.match(r"^(\d+)\.(\d+)\.(\d+)", v)
    if not m:
        return (0, 0, 0)
    return (int(m.group(1)), int(m.group(2)), int(m.group(3)))


def _kernel_supported(release: str) -> bool:
    """Apply the version matrix from the source ROCm doc.

    We accept the kernel if it meets ANY of:
      - Mainline >= 6.18.4
      - Ubuntu HWE >= 6.17.0 (HWE kernels carry the backport)
      - Ubuntu OEM >= 6.14.0 (OEM kernels carry the backport)
    """
    tup = _parse_kernel_tuple(release)
    if tup >= LINUX_KERNEL_MIN_MAINLINE:
        return True
    if "generic" in release and tup >= LINUX_KERNEL_MIN_UBUNTU_HWE:
        return True
    if "oem" in release and tup >= LINUX_KERNEL_MIN_UBUNTU_OEM:
        return True
    return False


def _adrenalin_version_windows() -> str:
    """Best-effort probe of the AMD Adrenalin driver version from the registry.

    Adrenalin writes its version under HKLM\\SOFTWARE\\AMD\\CN\\<...>; the
    exact key drifts across releases, so we shell out to PowerShell rather
    than encode a registry path that will rot.
    """
    rc, out, _ = _run([
        "powershell", "-NoProfile", "-Command",
        "(Get-CimInstance Win32_VideoController | Where-Object { $_.Name -like '*AMD*' -or $_.Name -like '*Radeon*' } | Select-Object -First 1).DriverVersion",
    ], timeout=8)
    return out.strip() if rc == 0 else ""


def detect() -> Detection:
    d = Detection()
    sysname = platform.system().lower()
    d.os_version = platform.platform()

    if sysname == "linux":
        d.os_family = "linux"
        d.cpu_vendor, d.cpu_model = _cpu_info_linux()
        d.total_ram_gb = _total_ram_gb_linux()
        d.kernel_version = _kernel_version_linux()
        d.kernel_supported = _kernel_supported(d.kernel_version)
        d.amd_ttm_present = shutil.which("amd-ttm") is not None
        gfx, gpu = _gfx_target_from_rocminfo()
        if not gfx:
            gfx, gpu = _gfx_target_from_sysfs()
        d.gfx_target, d.gpu_name = gfx, gpu
    elif sysname == "windows":
        d.os_family = "windows"
        d.cpu_vendor, d.cpu_model = _cpu_info_windows()
        d.total_ram_gb = _total_ram_gb_windows()
        d.gfx_target, d.gpu_name = _gpu_info_windows()
        d.adrenalin_version = _adrenalin_version_windows()
    else:
        d.os_family = "other"
        d.reasons.append(
            f"Unsupported OS family: {sysname}. This skill targets Linux and Windows."
        )
        return d

    # Generation classification
    if d.gfx_target in GFX_TO_GENERATION:
        d.generation = GFX_TO_GENERATION[d.gfx_target]
    elif d.gfx_target.startswith("gfx10"):
        d.generation = "older"
    elif d.cpu_vendor != "amd":
        d.generation = "not-amd-apu"
    else:
        d.generation = "unknown"

    # An APU has the GPU and CPU on the same package. We can't read that
    # directly without DMI; use a proxy: AMD CPU + integrated Radeon string,
    # or a known APU gfx target.
    if d.generation in {"rdna35", "rdna3", "rdna2"}:
        d.is_apu = True
    elif "radeon" in d.gpu_name.lower() and d.cpu_vendor == "amd":
        d.is_apu = True
    else:
        d.is_apu = False

    # Decide overall supported flag and surface reasons.
    if d.cpu_vendor != "amd":
        d.reasons.append("CPU is not AMD; this skill only tunes AMD APUs.")
    elif not d.is_apu:
        d.reasons.append(
            "No AMD APU detected. This skill is APU-only (it does not tune "
            "discrete Radeon GPUs)."
        )
    elif d.generation == "rdna35":
        d.supported = True
    elif d.generation in {"rdna3", "rdna2"}:
        d.supported = True
        d.notes.append(
            f"Detected {d.generation.upper()} APU. Tuning will work, but the "
            "official AMD recipe targets RDNA3.5; recommended GTT/VRAM splits "
            "may be conservative."
        )
    else:
        d.reasons.append(
            f"AMD GPU detected ({d.gpu_name or 'unknown'}) but the generation "
            "could not be classified. Run `rocminfo` (Linux) or share the "
            "Adapter name (Windows) so the skill can confirm."
        )

    if d.os_family == "linux" and d.supported and d.kernel_supported is False:
        d.supported = False
        d.reasons.append(
            f"Linux kernel {d.kernel_version} is below the minimum required "
            f"for RDNA3.5 ({'.'.join(map(str, LINUX_KERNEL_MIN_MAINLINE))} "
            f"mainline, {'.'.join(map(str, LINUX_KERNEL_MIN_UBUNTU_HWE))} "
            "Ubuntu HWE, or "
            f"{'.'.join(map(str, LINUX_KERNEL_MIN_UBUNTU_OEM))} Ubuntu OEM). "
            "Upgrade the kernel before tuning."
        )

    if d.os_family == "linux" and d.supported and d.amd_ttm_present is False:
        d.notes.append(
            "`amd-ttm` is not on PATH. Apply step will install it on demand "
            "via `pipx install amd-debug-tools`."
        )

    return d


def _print_human(d: Detection) -> None:
    print("APU memory tuner -- platform detection")
    print("-" * 40)
    print(f"OS:                {d.os_family} ({d.os_version})")
    print(f"CPU:               {d.cpu_model or 'unknown'} (vendor: {d.cpu_vendor})")
    print(f"GPU:               {d.gpu_name or 'unknown'}")
    print(f"GFX target:        {d.gfx_target or 'unknown'}")
    print(f"Generation:        {d.generation}")
    print(f"APU:               {d.is_apu}")
    print(f"Total RAM:         {d.total_ram_gb if d.total_ram_gb is not None else 'unknown'} GB")
    if d.os_family == "linux":
        print(f"Kernel:            {d.kernel_version} (supported: {d.kernel_supported})")
        print(f"amd-ttm on PATH:   {d.amd_ttm_present}")
    if d.os_family == "windows":
        print(f"Adrenalin driver:  {d.adrenalin_version or 'unknown'}")
    print()
    print(f"Supported by skill: {'YES' if d.supported else 'NO'}")
    for reason in d.reasons:
        print(f"  - {reason}")
    for note in d.notes:
        print(f"  note: {note}")


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--json",
        action="store_true",
        help="Emit machine-readable JSON instead of the human summary.",
    )
    args = parser.parse_args(argv)

    d = detect()

    if args.json:
        print(json.dumps(asdict(d), indent=2))
    else:
        _print_human(d)

    if not d.supported:
        # Distinguish "wrong hardware" (exit 2) from "right hardware, missing
        # prereq" (exit 3) so the agent can pick the right next step.
        if any("kernel" in r.lower() for r in d.reasons):
            return 3
        return 2
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 059a4cf5896900dd4b6eda5d02ff9e36eea78082b781efa57157bf266e2e80ea