Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 194 additions & 30 deletions src/winml/modelkit/commands/perf.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast

import click
import numpy as np
Expand All @@ -34,6 +34,7 @@

if TYPE_CHECKING:
from ..models.winml.base import WinMLPreTrainedModel
from ..models.winml.composite_model import WinMLCompositeModel
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
from ..session.stats import PerfStats

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -290,34 +291,110 @@ class PerfBenchmark:
def __init__(self, config: BenchmarkConfig) -> None:
"""Initialize benchmark with configuration."""
self.config = config
self._model: WinMLPreTrainedModel | None = None
self._model: WinMLPreTrainedModel | WinMLCompositeModel | None = None
self._inputs: dict[str, np.ndarray] | None = None

def run(self) -> BenchmarkResult:
@property
def _is_composite(self) -> bool:
"""Composite models orchestrate multiple sub-sessions (e.g. CLIP/SigLIP).

Uses a concrete ``isinstance(..., WinMLCompositeModel)`` check rather
than duck-typing on ``sub_models`` so a future single-session model
carrying an unrelated ``sub_models`` attribute can't be misrouted. The
import is function-local because ``composite_model`` pulls in torch: a
module-level runtime import would blow the ``winml perf --help`` import
budget (see tests/cli/test_import_time.py). A function-local import
runs only when this property is read — i.e. after a model is loaded, by
which point torch is already imported — and never at module load.
"""
from ..models.winml.composite_model import WinMLCompositeModel

return isinstance(self._model, WinMLCompositeModel)

@property
def _sub_models(self) -> dict[str, WinMLPreTrainedModel]:
Comment thread
xieofxie marked this conversation as resolved.
"""Sub-models of a composite model (only valid when ``_is_composite``)."""
from ..models.winml.composite_model import WinMLCompositeModel

assert isinstance(self._model, WinMLCompositeModel)
return self._model.sub_models

@property
def _single(self) -> WinMLPreTrainedModel:
"""The model under benchmark, narrowed to a single-session model.

Only valid for non-composite models: composites dispatch to
``_run_sub_models``, which benchmarks each sub-model through a child
``PerfBenchmark`` whose ``_model`` is itself single-session. Exposes
``io_config`` / ``device`` / ``ep_name`` / ``task`` directly (the
session caches ``io_config``), so callers read ``self._single.*``
rather than going through per-attribute wrappers.
"""
assert self._model is not None
return cast("WinMLPreTrainedModel", self._model)

def run(self) -> BenchmarkResult | dict[str, BenchmarkResult]:
"""Execute full benchmark pipeline.

Returns:
BenchmarkResult with timing statistics
A single ``BenchmarkResult`` for single-session models, or a
``{sub_model_name: BenchmarkResult}`` mapping for composite models
(e.g. CLIP/SigLIP dual-encoders). Composite models have no single
ORT session, so each sub-model is benchmarked individually rather
than timing the aggregate ``forward()`` pass.
"""
# [1] Load model
logger.info("Loading model: %s", self.config.model_id)
self._load_model()
assert self._model is not None

if self._is_composite:
return self._run_sub_models()
return self._run_single()

def _run_sub_models(self) -> dict[str, BenchmarkResult]:
"""Benchmark each sub-model of a composite individually.

Each sub-model is itself a single-session ``WinMLAutoModel``, so it is
benchmarked through the standard single-model pipeline by spawning a
child ``PerfBenchmark`` with the already-loaded sub-model. Results are
keyed by sub-model name for per-component reporting.
"""
results: dict[str, BenchmarkResult] = {}
for name, sub in self._sub_models.items():
logger.info("Benchmarking sub-model '%s'", name)
Console(stderr=True).print(f"\n[bold]Sub-model:[/bold] {name}")
child = PerfBenchmark(self.config)
child._model = sub
try:
results[name] = child._run_single()
except Exception as exc:
logger.error("Benchmarking sub-model '%s' failed", name)
raise RuntimeError(f"Sub-model '{name}' failed: {exc}") from exc
return results
Comment thread
xieofxie marked this conversation as resolved.

def _run_single(self) -> BenchmarkResult:
"""Benchmark the loaded single-session model.

Returns:
BenchmarkResult with timing statistics
"""
assert self._model is not None

# [2] Generate inputs
logger.info("Generating benchmark inputs")
self._generate_inputs()

# Compile session early so model.device is resolved for display
self._model._session.compile()
self._single._session.compile()

# Print model info before benchmark starts
_print_model_info(
self._model.io_config,
task=self._model.task or self.config.task,
self._single.io_config,
task=self._single.task or self.config.task,
req_device=self.config.device,
act_device=self._model.device,
ep_name=self._model.ep_name,
act_device=self._single.device,
ep_name=self._single.ep_name,
)

# [3] Run benchmark
Expand Down Expand Up @@ -389,10 +466,8 @@ def _load_model(self) -> None:

def _generate_inputs(self) -> None:
"""Generate random inputs based on model io_config."""
assert self._model is not None
io_config = self._model.io_config
self._inputs = generate_random_inputs(
io_config=io_config,
io_config=self._single.io_config,
batch_size=self.config.batch_size,
)

Expand All @@ -404,11 +479,10 @@ def _run_benchmark(self) -> PerfStats:

def _run_benchmark_simple(self) -> PerfStats:
"""Execute benchmark without live monitoring."""
assert self._model is not None
assert self._inputs is not None
session = self._model._session
total_iterations = self.config.warmup + self.config.iterations

session = self._single._session
with session.perf(warmup=self.config.warmup) as stats:
_run_simple_loop(session, self._inputs, total_iterations)

Expand All @@ -426,9 +500,7 @@ def _run_benchmark_monitored(self) -> PerfStats:
from ..session.monitor.hw_monitor import HWMonitor
from ..session.monitor.vitisai_monitor import VitisAIMonitor

assert self._model is not None
assert self._inputs is not None
session = self._model._session
total_iterations = self.config.warmup + self.config.iterations

if not HWMonitor.is_available():
Expand All @@ -442,23 +514,25 @@ def _run_benchmark_monitored(self) -> PerfStats:
# GPU when --device gpu is specified, NPU when --device npu, etc.
# ep_name lets the monitor resolve the exact LUID via ORT's autoEP
# metadata so we follow the adapter the session actually binds to.
monitor_device = self._model.device or self.config.device or "auto"
ep_name = self._single.ep_name
monitor_device = self._single.device or self.config.device or "auto"
hw_monitor = HWMonitor(
poll_interval_ms=_HW_POLL_INTERVAL_MS,
device=monitor_device,
ep_name=session.ep_name,
ep_name=ep_name,
)

# EP-specific proof-of-execution monitor.
# When QNN/OpenVINO monitors become real, add entries here.
_ep_monitors: dict[EPName, Any] = {"VitisAIExecutionProvider": VitisAIMonitor}
monitor_cls = _ep_monitors.get(session.ep_name) if session.ep_name else None
monitor_cls = _ep_monitors.get(ep_name) if ep_name else None
ep_monitor: Any
if monitor_cls and monitor_cls.is_available():
ep_monitor = monitor_cls()
else:
ep_monitor = NullEPMonitor()

session = self._single._session
with (
session.perf(warmup=self.config.warmup) as stats,
hw_monitor as hw,
Expand All @@ -485,8 +559,7 @@ def _run_benchmark_monitored(self) -> PerfStats:

def _collect_results(self, stats: PerfStats) -> BenchmarkResult:
"""Collect benchmark results from PerfStats."""
assert self._model is not None
io_config = self._model.io_config
io_config = self._single.io_config

# Calculate throughput
mean_latency_sec = stats.mean_ms / 1000.0
Expand Down Expand Up @@ -525,10 +598,10 @@ def _collect_results(self, stats: PerfStats) -> BenchmarkResult:
samples_per_sec=samples_per_sec,
batches_per_sec=batches_per_sec,
# Actual values (resolved after build + compile)
actual_device=self._model.device,
actual_task=self._model.task or self.config.task or "auto-detected",
actual_ep=self._model.ep_name,
running_model_path=str(self._model.running_model_path),
actual_device=self._single.device,
actual_task=self._single.task or self.config.task or "auto-detected",
actual_ep=self._single.ep_name,
running_model_path=str(self._single.running_model_path),
# Hardware monitor metrics (only present when --monitor is used)
hw_monitor=getattr(self, "_hw_metrics", None),
)
Expand All @@ -548,6 +621,7 @@ def _perf_modules(
warmup: int,
batch_size: int,
no_quantize: bool,
no_compile: bool,
output: Path | None,
verbose: bool,
console: Console,
Expand All @@ -570,7 +644,8 @@ def _perf_modules(
iterations: Number of benchmark iterations.
warmup: Number of warmup iterations.
batch_size: Batch size for input generation.
no_quantize: If True, skip quantization and compilation.
no_quantize: If True, skip quantization during the per-module build.
no_compile: If True, skip the build's compile stage for each module.
output: Output JSON path, or None for auto-generated path.
verbose: If True, log exceptions at DEBUG level.
console: Rich console for output.
Expand Down Expand Up @@ -664,9 +739,11 @@ def _perf_modules(

submodule = parent_model.get_submodule(module_path)

# Skip quant/compile for faster iteration when requested
# Skip quant/compile for faster iteration when requested. Quantization
# and compilation are independent toggles (mirrors the single-model path).
if no_quantize:
cfg.quant = None
if no_compile:
cfg.compile = None

with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir:
Expand Down Expand Up @@ -901,6 +978,74 @@ def write_json_report(result: BenchmarkResult, output_path: Path) -> None:
json.dump(result.to_dict(), f, indent=2)


def _composite_report_dict(
results: dict[str, BenchmarkResult],
*,
model_id: str,
task: str | None,
) -> dict[str, Any]:
"""Build the combined JSON report for a composite model's sub-models."""
return {
"model_id": model_id,
"task": task,
"component_count": len(results),
"components": {name: result.to_dict() for name, result in results.items()},
}


def report_composite_results(
results: dict[str, BenchmarkResult],
*,
console: Console,
json_mode: bool,
output_path: Path,
model_id: str,
task: str | None,
) -> None:
"""Display and persist per-sub-model results for a composite model.

Composite models (e.g. CLIP/SigLIP dual-encoders) have no single ORT
session; each sub-model is benchmarked individually (like ``--module``)
and reported as its own summary row rather than timing the aggregate
``forward()`` pass. The combined JSON nests each sub-model's full
``BenchmarkResult.to_dict()`` under ``components``.
"""
combined = _composite_report_dict(results, model_id=model_id, task=task)

if json_mode:
click.echo(json.dumps(combined, indent=2))
else:
table = Table(title="Per-Sub-Model Perf", show_header=True)
table.add_column("Sub-Model", style="cyan")
table.add_column("Task")
table.add_column("Device")
table.add_column("Mean (ms)", justify="right")
table.add_column("P90 (ms)", justify="right")
table.add_column("Min (ms)", justify="right")
table.add_column("Max (ms)", justify="right")
for name, result in results.items():
device_str = _device_string(
result.config.device, result.actual_device, result.actual_ep
)
table.add_row(
name,
result.actual_task,
device_str,
f"{result.mean_ms:.2f}",
f"{result.p90_ms:.2f}",
f"{result.min_ms:.2f}",
f"{result.max_ms:.2f}",
)
console.print()
console.print(table)
console.print()

output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
with output_path.open("w", encoding="utf-8") as f:
json.dump(combined, f, indent=2)


def generate_output_path(model_id: str, *, module_class: str | None = None) -> Path:
r"""Generate default output path under the user's cache directory.

Expand Down Expand Up @@ -1242,6 +1387,7 @@ def perf(
warmup=warmup,
batch_size=batch_size,
no_quantize=not quantize,
no_compile=no_compile,
output=output,
verbose=bool(verbose),
console=console,
Expand Down Expand Up @@ -1323,6 +1469,26 @@ def perf(
benchmark = PerfBenchmark(config)
result = benchmark.run()

# Composite models (e.g. CLIP/SigLIP dual-encoders) have no single ORT
# session; each sub-model is benchmarked individually and reported as
# its own row (like --module), not as one aggregate forward() timing.
if isinstance(result, dict):
if op_tracing:
console.print(
"[yellow]Warning:[/yellow] --op-tracing is not supported for "
"composite models and will be skipped."
)
report_composite_results(
result,
console=console,
json_mode=json_mode,
output_path=output,
model_id=hf_model,
task=task,
)
console.print(f"[green]Results saved to:[/green] {output}")
return
Comment thread
xieofxie marked this conversation as resolved.

# Display results
if json_mode:
click.echo(json.dumps(result.to_dict(), indent=2))
Expand Down Expand Up @@ -1354,9 +1520,7 @@ def perf(
# For HF models the ONNX is built internally by PerfBenchmark.
try:
onnx_for_trace = (
model_path
if is_onnx
else (benchmark._model._onnx_path if benchmark._model else None)
model_path if is_onnx else getattr(benchmark._model, "_onnx_path", None)
)
if onnx_for_trace is None:
raise AttributeError("benchmark._model not initialized")
Expand Down
2 changes: 2 additions & 0 deletions src/winml/modelkit/models/auto.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ def from_onnx(
use_cache=use_cache,
force_rebuild=force_rebuild,
skip_build=skip_build,
no_compile=no_compile,
session_options=session_options,
**kwargs,
)
Expand Down Expand Up @@ -365,6 +366,7 @@ def from_pretrained(
config=config,
cache_dir=cache_dir,
allow_unsupported_nodes=allow_unsupported_nodes,
no_compile=no_compile,
**kwargs,
)

Expand Down
Loading
Loading