Skip to content

Commit 4ef0d67

Browse files
xieofxiehualxie
authored andcommitted
fix(perf): poll the right adapter under --device gpu/--monitor (#445) (#467)
Fixes #445 — `winml perf --monitor --device gpu` now shows GPU utilization instead of silently falling back to NPU. ## Summary - **Adapter routing.** `PdhPoller` / `HWMonitor` now accept `device=` (`npu` | `gpu` | `cpu` | `auto`) and `ep_name=`. Per-device queries go through `build_npu_query` (Compute engine) or `build_gpu_query` (3D engine). - **ORT-first LUID resolution.** Added `resolve_adapter_luid(device_kind, ep_name=None)` in `sysinfo/pdh_adapters.py`. It walks `ort.get_ep_devices()`, filters by EP name + device type, reads `OrtHardwareDevice.metadata["LUID"]`, and converts the decimal LUID to the PDH `0xHHHHHHHH_0xHHHHHHHH` form via `(v >> 32, v & 0xFFFFFFFF)`. ORT-resolved LUIDs are sanity-checked against `enumerate_adapters()` — if PDH doesn't enumerate the LUID (some drivers register the adapter only for autoEP), we skip it and fall through to the PDH-fingerprint helpers (`discover_npu_luid`, `discover_gpu_luid`) instead of raising later in `build_adapter_query`. Same fallback applies when ORT isn't installed, when `get_ep_devices()` raises, when no matching ep_device exists, when LUID metadata is missing, or when an individual ep_device entry is malformed (each is wrapped in `try/except` so one bad entry can't break resolution). - **Graceful build-query degradation.** `PdhPoller.start()` wraps `build_npu_query` / `build_gpu_query` in a local `try/except ValueError`. If a resolved LUID still proves unusable, it clears `device_kind` / `adapter_luid` and degrades to CPU/RAM-only — CPU/RAM counters still register and the benchmark continues. - **`perf.py` plumbing.** `_run_benchmark_monitored` (HF), `_run_onnx_benchmark` (ONNX direct), and `_perf_modules` (per-module) all pass the resolved device + normalized EP name into `HWMonitor`. `--monitor` help text updated to *"Show live hardware utilization chart for the benchmarked device (NPU, GPU, or CPU)"*. - **`HWMonitor.to_dict()` schema.** Always emits an `npu` block (zeros when monitoring GPU — back-compat with existing consumers/tests). When `device_kind == "gpu"` a parallel `gpu` block is added. New top-level keys `device_kind` and `adapter_luid`. - **Live chart labels follow what's actually polled.** Centralised `adapter_label(device_kind)` helper in `hw_monitor.py`. `LiveMonitorDisplay` accepts `device_kind=` (threaded through from `_run_monitored_loop` as `hw.device_kind`); `HWLiveDisplay` derives the label dynamically from `self._hw.device_kind`. So `auto` resolving to GPU now labels "GPU", and CPU-only mode no longer falsely says "NPU". - **CPU-only display drops the adapter row entirely.** When no adapter is polled (`--device cpu` or `auto` resolving to nothing), `LiveMonitorDisplay` / `HWLiveDisplay` skip the green plot, drop the swatch from the chart legend, and collapse the status row to `CPU: ... | Sys Mem: ...` (no adapter cell, no Device Mem cell). The post-benchmark `display_console_report` in `perf.py` likewise prints CPU/RAM only — no zeroed `NPU: 0.0% avg, 0.0% peak`. ## Verification - Targeted suite: `tests/unit/session/test_ep_monitor.py` (84 passed, +15 new) plus `test_perf_cli.py` and `test_perf_module.py` (26 passed). - Full unit suite green: 4665 passed, 73 skipped, 2 xfailed. - `ruff check` clean on every modified file. - Smoke-checked on a Snapdragon X laptop after `winml.register_execution_providers(ort=True)`: ``` GPU (no ep_name) -> 0x00000000_0x000121CF # Adreno X1-85 via DML and QNN GPU (ep=DML) -> 0x00000000_0x000121CF GPU (ep=QNN) -> 0x00000000_0x000121CF NPU (ep=QNN) -> 0x00000000_0x00012C89 # authoritative, not just fingerprinted NPU (no ep_name) -> 0x00000000_0x00012C89 ``` --------- Co-authored-by: hualxie <hualxie@microsoft.com>
1 parent 8aceb27 commit 4ef0d67

8 files changed

Lines changed: 1150 additions & 143 deletions

File tree

src/winml/modelkit/commands/_live_chart.py

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
from rich.console import Console
1616
from rich.panel import Panel
1717

18+
from ..session.monitor.hw_monitor import adapter_label
19+
1820

1921
# Moving window size for the x-axis (seconds)
2022
_CHART_WINDOW_SECONDS = 10.0
@@ -38,11 +40,24 @@ def __init__(
3840
chart_width: int = 80,
3941
chart_height: int = 15,
4042
poll_interval_ms: int = 100,
43+
device_kind: str | None = None,
4144
) -> None:
4245
self._total = total_iterations
4346
self._warmup = warmup
4447
self._model_id = model_id
4548
self._device = device
49+
# `device_kind` is the value HWMonitor resolved at start() — pass it
50+
# in when you want the legend to reflect what's actually polled (e.g.
51+
# "auto" that resolved to GPU). Falls back to the requested string
52+
# when the caller doesn't know the resolved kind yet.
53+
if device_kind is None:
54+
requested = (device or "").lower()
55+
device_kind = requested if requested in ("npu", "gpu") else None
56+
# When no adapter is polled (CPU-only / auto resolved to nothing),
57+
# hide the adapter line + status cell entirely instead of drawing
58+
# a flat zero series labelled "Adapter".
59+
self._show_adapter = device_kind is not None
60+
self._adapter_label = adapter_label(device_kind)
4661
self._chart_width = chart_width
4762
self._chart_height = chart_height
4863
self._poll_interval_s = poll_interval_ms / 1000.0
@@ -111,21 +126,31 @@ def _render_chart(
111126
"""Render utilization chart as a Rich renderable.
112127
113128
Uses plotext with AnsiDecoder for flicker-free Rich Live integration.
114-
Plots NPU (green) and CPU (cyan) with distinct colors.
129+
Plots adapter (NPU/GPU, green) and CPU (cyan) with distinct colors.
115130
X-axis is a moving window of the last N seconds.
116131
Y-axis has fixed ticks: 0, 20, 40, 60, 80, 100.
117132
"""
133+
adapter = self._adapter_label
134+
show_adapter = self._show_adapter
118135
try:
119136
import plotext as plt
120137
except ImportError:
121138
from rich.text import Text
122139

140+
# CPU-only fallback: drop the adapter line entirely.
141+
if not show_adapter:
142+
if cpu_samples:
143+
current = cpu_samples[-1]
144+
bar_len = min(50, max(0, int(current / 2)))
145+
bar = "#" * bar_len + "." * (50 - bar_len)
146+
return Text(f" CPU: [{bar}] {current:.1f}%")
147+
return Text(" CPU: [waiting for data...]")
123148
if util_samples:
124149
current = util_samples[-1]
125150
bar_len = min(50, max(0, int(current / 2)))
126151
bar = "#" * bar_len + "." * (50 - bar_len)
127-
return Text(f" NPU: [{bar}] {current:.1f}%")
128-
return Text(" NPU: [waiting for data...]")
152+
return Text(f" {adapter}: [{bar}] {current:.1f}%")
153+
return Text(f" {adapter}: [waiting for data...]")
129154

130155
plt.clf()
131156
plt.theme("clear")
@@ -134,20 +159,20 @@ def _render_chart(
134159
window_samples = int(_CHART_WINDOW_SECONDS / self._poll_interval_s)
135160
total_npu = len(util_samples) if util_samples else 0
136161

137-
# Absolute elapsed time for each sample in the window
138-
# e.g., at 15s elapsed with 10s window: x-axis shows 5.0 -> 15.0
139-
npu_window = util_samples[-window_samples:] if util_samples else [0]
140-
window_start_idx = max(0, total_npu - len(npu_window))
141-
npu_times = [(window_start_idx + i) * self._poll_interval_s for i in range(len(npu_window))]
142-
143-
# Plot NPU in green (no label -- legend is in the title)
144-
plt.plot(npu_times, npu_window, marker="braille", color="green")
162+
# Plot the adapter line only when an adapter is actually being polled.
163+
if show_adapter:
164+
npu_window = util_samples[-window_samples:] if util_samples else [0]
165+
window_start_idx = max(0, total_npu - len(npu_window))
166+
npu_times = [
167+
(window_start_idx + i) * self._poll_interval_s for i in range(len(npu_window))
168+
]
169+
plt.plot(npu_times, npu_window, marker="braille", color="green")
145170

146-
# Plot CPU in cyan (distinct from NPU)
171+
# Plot CPU in cyan (distinct from adapter)
147172
has_cpu = False
173+
total_cpu = len(cpu_samples) if cpu_samples else 0
148174
if cpu_samples:
149175
has_cpu = True
150-
total_cpu = len(cpu_samples)
151176
cpu_window = cpu_samples[-window_samples:]
152177
cpu_start_idx = max(0, total_cpu - len(cpu_window))
153178
cpu_times = [
@@ -162,8 +187,10 @@ def _render_chart(
162187
plt.ylim(0, 100)
163188
plt.yticks([0.0, 20.0, 40.0, 60.0, 80.0, 100.0])
164189

165-
# X-axis: absolute elapsed time, sliding window
166-
elapsed = total_npu * self._poll_interval_s
190+
# X-axis: absolute elapsed time, sliding window. Use whichever series
191+
# we have to anchor the timeline so a CPU-only chart still scrolls.
192+
sample_count = total_npu if show_adapter else total_cpu
193+
elapsed = sample_count * self._poll_interval_s
167194
x_min = max(0.0, elapsed - _CHART_WINDOW_SECONDS)
168195
x_max = max(elapsed, _CHART_WINDOW_SECONDS)
169196
plt.xlim(x_min, x_max)
@@ -174,13 +201,16 @@ def _render_chart(
174201
from rich.console import Group
175202
from rich.text import Text
176203

177-
# Rich-colored title line with legend swatches
178-
if has_cpu:
204+
# Rich-colored title line with legend swatches.
205+
if show_adapter and has_cpu:
179206
title = Text.from_markup(
180-
" Utilization ([green]\u2588\u2588[/green] NPU % [cyan]\u2588\u2588[/cyan] CPU %)"
207+
f" Utilization ([green]\u2588\u2588[/green] {adapter} % "
208+
f"[cyan]\u2588\u2588[/cyan] CPU %)"
181209
)
210+
elif show_adapter:
211+
title = Text.from_markup(f" Utilization ([green]\u2588\u2588[/green] {adapter} %)")
182212
else:
183-
title = Text.from_markup(" Utilization ([green]\u2588\u2588[/green] NPU %)")
213+
title = Text.from_markup(" Utilization ([cyan]\u2588\u2588[/cyan] CPU %)")
184214

185215
ansi_output = plt.build()
186216
chart_lines = [Text.from_ansi(line) for line in ansi_output.splitlines()]
@@ -219,12 +249,17 @@ def _render_status(
219249
pct_cell = f"{bar} {pct:.0%}"
220250
row1 = f" {pct_cell:<30}| {progress} | Device: {self._device}"
221251

222-
# Row 2: Hardware (pad each cell to fixed width, spaces before divider)
223-
npu_cell = f"NPU: {mean_util:.1f}% avg ({current_util:.1f}% now)"
252+
# Row 2: Hardware (pad each cell to fixed width, spaces before divider).
253+
# CPU-only mode drops the adapter cell + device-memory cell since we
254+
# have no live values to populate them with.
224255
cpu_cell = f"CPU: {cpu_pct:.1f}%"
225256
ram_cell = f"Sys Mem: {ram_mb:.0f} MB"
226-
mem_cell = f"Device Mem: {memory_local_mb:.0f}/{memory_shared_mb:.0f} MB (local/shared)"
227-
row2 = f" {npu_cell:<30}| {cpu_cell:<12}| {ram_cell} | {mem_cell}"
257+
if self._show_adapter:
258+
adapter_cell = f"{self._adapter_label}: {mean_util:.1f}% avg ({current_util:.1f}% now)"
259+
mem_cell = f"Device Mem: {memory_local_mb:.0f}/{memory_shared_mb:.0f} MB (local/shared)"
260+
row2 = f" {adapter_cell:<30}| {cpu_cell:<12}| {ram_cell} | {mem_cell}"
261+
else:
262+
row2 = f" {cpu_cell:<12}| {ram_cell}"
228263

229264
# Row 3: Inference (pad each cell to fixed width, spaces before divider)
230265
lat_cell = f"Latency: {latency_ms:.2f} ms"

src/winml/modelkit/commands/perf.py

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,19 @@ def _run_benchmark_monitored(self) -> PerfStats:
403403
)
404404
return self._run_benchmark_simple()
405405

406-
hw_monitor = HWMonitor(poll_interval_ms=_HW_POLL_INTERVAL_MS)
406+
# Track the device actually being benchmarked so the monitor polls
407+
# GPU when --device gpu is specified, NPU when --device npu, etc.
408+
# ep_name lets the monitor resolve the exact LUID via ORT's autoEP
409+
# metadata so we follow the adapter the session actually binds to.
410+
from ..utils.constants import normalize_ep_name
411+
412+
monitor_device = self._model.device or self.config.device or "auto"
413+
ep_name = normalize_ep_name(self.config.ep) if self.config.ep else None
414+
hw_monitor = HWMonitor(
415+
poll_interval_ms=_HW_POLL_INTERVAL_MS,
416+
device=monitor_device,
417+
ep_name=ep_name,
418+
)
407419

408420
# EP-specific proof-of-execution monitor.
409421
# When QNN/OpenVINO monitors become real, add entries here.
@@ -428,7 +440,7 @@ def _run_benchmark_monitored(self) -> PerfStats:
428440
total_iterations=total_iterations,
429441
warmup=self.config.warmup,
430442
model_id=self.config.model_id,
431-
device=self.config.device,
443+
device=monitor_device,
432444
)
433445

434446
# Store hardware metrics
@@ -624,9 +636,14 @@ def _perf_modules(
624636

625637
if monitor:
626638
from ..session.monitor.hw_monitor import HWMonitor
639+
from ..utils.constants import normalize_ep_name
627640

628641
if HWMonitor.is_available():
629-
hw_ctx = HWMonitor(poll_interval_ms=_HW_POLL_INTERVAL_MS)
642+
hw_ctx = HWMonitor(
643+
poll_interval_ms=_HW_POLL_INTERVAL_MS,
644+
device=resolved_device,
645+
ep_name=normalize_ep_name(ep) if ep else None,
646+
)
630647

631648
if hw_ctx:
632649
with session.perf(warmup=warmup) as stats, hw_ctx as hw:
@@ -778,20 +795,28 @@ def display_console_report(result: BenchmarkResult, console: Console) -> None:
778795
if result.hw_monitor:
779796
console.print()
780797
console.print("[bold]Hardware (during benchmark)[/bold]")
781-
npu = result.hw_monitor.get("npu", {})
782798
cpu = result.hw_monitor.get("cpu", {})
783799
ram = result.hw_monitor.get("ram", {})
784800
dev_mem = result.hw_monitor.get("device_memory", {})
785-
console.print(
786-
f" NPU: {npu.get('mean_pct', 0):.1f}% avg, "
787-
f"{npu.get('peak_pct', 0):.1f}% peak | "
788-
f"CPU: {cpu.get('mean_pct', 0):.1f}% avg"
789-
)
790-
console.print(
791-
f" Sys Mem: {ram.get('used_mb', 0):.0f} MB | "
792-
f"Device Mem: {dev_mem.get('local_peak_mb', 0):.0f}/"
793-
f"{dev_mem.get('shared_peak_mb', 0):.0f} MB (local/shared)"
794-
)
801+
# to_dict() emits both "npu" (always) and "gpu" (when monitoring GPU).
802+
# device_kind is None when CPU/RAM-only — drop the adapter line entirely
803+
# rather than printing zeroed "NPU: 0.0% avg".
804+
device_kind = result.hw_monitor.get("device_kind")
805+
if device_kind in ("npu", "gpu"):
806+
adapter = result.hw_monitor.get(device_kind, {})
807+
console.print(
808+
f" {device_kind.upper()}: {adapter.get('mean_pct', 0):.1f}% avg, "
809+
f"{adapter.get('peak_pct', 0):.1f}% peak | "
810+
f"CPU: {cpu.get('mean_pct', 0):.1f}% avg"
811+
)
812+
console.print(
813+
f" Sys Mem: {ram.get('used_mb', 0):.0f} MB | "
814+
f"Device Mem: {dev_mem.get('local_peak_mb', 0):.0f}/"
815+
f"{dev_mem.get('shared_peak_mb', 0):.0f} MB (local/shared)"
816+
)
817+
else:
818+
console.print(f" CPU: {cpu.get('mean_pct', 0):.1f}% avg")
819+
console.print(f" Sys Mem: {ram.get('used_mb', 0):.0f} MB")
795820

796821
console.print()
797822

@@ -884,6 +909,7 @@ def _run_monitored_loop(
884909
warmup=warmup,
885910
model_id=model_id,
886911
device=device,
912+
device_kind=getattr(hw, "device_kind", None),
887913
)
888914
with display:
889915
for i in range(total_iterations):
@@ -959,9 +985,14 @@ def _run_onnx_benchmark(
959985
# Determine if hardware monitoring is available
960986
if config.monitor:
961987
from ..session.monitor.hw_monitor import HWMonitor
988+
from ..utils.constants import normalize_ep_name
962989

963990
if HWMonitor.is_available():
964-
hw_ctx = HWMonitor(poll_interval_ms=_HW_POLL_INTERVAL_MS)
991+
hw_ctx = HWMonitor(
992+
poll_interval_ms=_HW_POLL_INTERVAL_MS,
993+
device=session.device or device,
994+
ep_name=normalize_ep_name(config.ep) if config.ep else None,
995+
)
965996
else:
966997
Console(stderr=True).print(
967998
"[yellow]Warning:[/yellow] HWMonitor unavailable. "
@@ -1132,7 +1163,7 @@ def _run_onnx_benchmark(
11321163
"--monitor",
11331164
is_flag=True,
11341165
default=False,
1135-
help="Show live NPU utilization chart during benchmark",
1166+
help="Show live hardware utilization chart for the benchmarked device (NPU, GPU, or CPU)",
11361167
)
11371168
@click.option(
11381169
"--op-tracing",

0 commit comments

Comments
 (0)