Skip to content

Commit bd7fb42

Browse files
Measure model loading memory and prepare GPU baseline counters (#1429)
Model construction can allocate memory before the existing perf baseline, while missing GPU process counters leave the pre-model delta unavailable. This ports the loading-memory and GPU-baseline changes from gim-home/winml-cli-mirror PRs #10 and #11 onto current main. - Add versioned load-only memory measurements from runtime/device readiness through model compilation, excluding benchmark inputs and inference. Keep sampled process peaks and OS lifetime high-water diagnostics separate. Preserve null counters and signed deltas. - Prepare absent counters with a model-free D3D12 device on the explicitly selected GPU, retain it through final sampling, and release it on success or failure. Connect preparation to the new process tracker used by the combined port. - Keep automatic Runtime outputs for older projections and explicit output requests for newer ones. Include the initializer-shape regression test; its implementation fix is already upstream. Compatibility: the outer report remains schema version 2 and `load_memory.version` is 1. Classic `memory_measurement.schema_version` is 3 because its structure and legacy-named baseline/delta boundaries change; documentation describes migration and limitations. Existing upstream CGIR changes are retained. Validation on Windows / Python 3.11: - Affected perf CLI/GenAI/lifecycle, all session unit tests, and initializer-shape regression: **1,449 passed, 6 existing skips**. - Final focused memory/GPU integration rerun: **34 passed**; Runtime compatibility suite: **40 passed**. - Ruff across `src/` and `tests/`, changed-file formatting, license headers, and `git diff --check` passed. - Mypy: **no issues in 464 source files**. Review follow-up: distinguish configured polling delay from observed mean/max sampling intervals, including GPU-query overhead. Add native factory/adapter/device failure-injection tests and idempotent COM cleanup checks. The updated memory/GPU suite passes all 40 tests. A fresh-process native smoke test on Qualcomm Adreno X1-85 successfully created the baseline device on the selected LUID, discovered valid PDH counters, recorded final observations, and released the retained device. This validates baseline preparation/cleanup; full model hardware benchmarks from the source PRs were not rerun. --------- Co-authored-by: Qiong Wu <120818070+qiowu_microsoft@users.noreply.github.com>
1 parent 4b83a5f commit bd7fb42

13 files changed

Lines changed: 1535 additions & 203 deletions

File tree

docs/commands/perf.md

Lines changed: 109 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,81 @@ Both runtime reports include `schema_version: 2` and a `benchmark_info.runtime`
5353

5454
When `--memory` is enabled, both `winml-ort` and `ort-genai` reports use the same `memory` field names for shared concepts: RSS baseline, after-compile/load, after-inference, peak, model-load delta, inference/generation delta, and total delta; VRAM local/shared baseline, after-compile/load, after-inference, peak, model-load delta, inference/generation delta, and total delta.
5555

56+
### Classic memory lifecycle
57+
58+
#### Model loading from a user's perspective
59+
60+
`load_memory` is the load-only view (block `version: 1`). It answers how much
61+
additional memory was observed while this artifact became ready in this
62+
runtime, and how much remained at readiness. Runtime/EP setup precedes its
63+
baseline; its endpoint is after session compilation but before allocating
64+
benchmark inputs, warmup or inference. If the selected path requires online
65+
compilation, that temporary cost is included because the user must get through
66+
it to load the model. A precompiled artifact is a different configuration.
67+
68+
Each RSS/local/shared block records baseline, absolute peak, absolute ready
69+
value, `peak_extra_mb` and `ready_extra_mb`, plus the peak method and availability.
70+
RSS uses the Windows high-water when a **new** process high-water occurs inside
71+
the load window; otherwise only observed load samples/endpoints can be used.
72+
GPU peaks are sampled. No inference peak or larger pre-existing process peak
73+
is substituted into this view. Unknown baselines produce null increments.
74+
75+
This is an **observed load footprint**, not a certified minimum device-memory
76+
capacity: RSS is resident memory, allocator caches can remain, sampling may
77+
miss short GPU allocations, and unified RAM/GPU counters may overlap. Report
78+
the load peak and ready increment separately; do not reduce this to parameter
79+
file size or sum local/shared GPU counters. Already-loaded composite children
80+
cannot provide this window and return an explicit unavailable status.
81+
82+
The enclosing perf document still uses schema version 2; the optional
83+
`load_memory` block is explicitly versioned. Consumers must support nullable
84+
memory values and use the recorded scope rather than comparing older deltas
85+
as if they covered the same interval.
86+
87+
For `winml-ort` and `winml-runtime`, RSS baseline is captured **before model
88+
loading**, including eager session construction and Runtime pipeline creation.
89+
GPU baseline is captured after resolving the bound EP/adapter but before
90+
constructing the model. Adapter discovery must not read lazy model properties.
91+
The added `*_after_load_mb` checkpoint separates loading from the explicit
92+
compile step. Input generation follows compilation/readiness. `*_after_compile_mb` and `*_after_inference_mb`
93+
retain their existing names.
94+
95+
`memory_measurement` records PID, adapter LUID, lifecycle scope, units and
96+
per-checkpoint missing reasons. Fields with the legacy `_mb` suffix use MiB
97+
(bytes / 1,048,576). Deltas are signed process changes, not model-only allocation;
98+
negative values can reflect released buffers or working-set changes. Library
99+
imports, build caches and input preparation can contribute to the measured span.
100+
101+
`process_memory` separately records sampled RSS/local/shared peaks, actual
102+
sample counts, interval and duration over loading, compilation and inference.
103+
These are sampled peaks, not an exact continuous maximum. The configured
104+
polling delay is not a sampling frequency: `configured_poll_delay_sec` records
105+
the wait after each observation. `observed_mean_interval_sec` and
106+
`observed_max_interval_sec` record elapsed time between completed observations
107+
(null with fewer than two observations). GPU discovery/retries also delay RSS
108+
sampling; these intervals include that overhead and scheduling delays. The legacy
109+
`*_checkpoint_peak_mb` remains the maximum of the three original checkpoints,
110+
and is `null` if any required checkpoint is unavailable.
111+
112+
On Windows, `process_memory.os_rss_peak_before_mb` and
113+
`os_rss_peak_after_mb` also record the OS working-set high-water marks. These
114+
catch short allocations missed by polling, including native code holding the
115+
Python GIL. They cover the **process lifetime**, cannot be reset at baseline,
116+
and do not replace the benchmark-scoped sampled peak. On unsupported systems
117+
they are `null` with an explicit reason.
118+
119+
Missing GPU counters produce `null`, not zero; deltas with missing endpoints
120+
also remain `null`. A GPU process-memory instance may not exist before model
121+
allocation, so valid later absolute readings do not establish a zero baseline.
122+
Local/shared counters can overlap on unified-memory devices and must not be
123+
summed. GenAI's shared GPU-memory consumer also preserves unavailable values.
124+
125+
Composite components are already loaded when measured. Their explicit scope
126+
is `already_loaded_component_through_inference`; their values include the
127+
shared process and do not claim an independent component-load footprint.
128+
`--no-memory` disables this collector. Background sampling adds overhead and
129+
is separate from the native inference timer.
130+
56131
With `--runtime ort-genai`, `winml perf` benchmarks the onnxruntime-genai decoder pipeline rather than a single `session.run()`. The JSON report uses a phase-based schema: `load` contains startup spans, `requests` contains one warmup or timed generation sample per request, `aggregate` summarizes timed requests only, `memory` contains optional RAM/VRAM deltas, and `hw_monitor` contains optional monitor output. The optional `memory` and `hw_monitor` top-level names match the classic `winml-ort` perf report; GenAI keeps `load`/`requests`/`aggregate` instead of classic `latency_ms`/`throughput` because generation has distinct prompt, first-token, and decode phases.
57132

58133
For model-ID auto-builds, the selected EP/device must be supported by the model's
@@ -79,34 +154,19 @@ target validation.
79154

80155
### Memory measurement contract
81156

82-
With --memory, the legacy baseline stays **after the model factory, before
83-
input generation and explicit session.compile()**, as on main. Existing
84-
baseline/load/inference/total delta fields and the maximum-of-three checkpoint
85-
peak keep that boundary. Eager model loading before this baseline is excluded.
86-
87-
Single-model runs also take an earlier, separately named before_model_load
88-
snapshot after device resolution and before the model factory. For each of
89-
rss, vram_local and vram_shared, additive fields are:
90-
91-
- *_before_model_load_mb: earlier absolute snapshot.
92-
- *_model_factory_delta_mb: legacy baseline minus earlier snapshot.
93-
- *_total_from_before_model_load_delta_mb: inference end minus earlier snapshot.
157+
With `--memory`, classic and Runtime runs use the lifecycle boundaries described
158+
above: process RSS starts before model construction, while the load-only window
159+
starts after runtime/device setup and ends before benchmark input allocation.
160+
Preloaded composite components cannot claim a load baseline; their `load_memory`
161+
block is unavailable and their process scope explicitly starts after loading.
94162

95-
The new total includes model factory/build and input/session overhead. It is
96-
not weights-only memory or a continuous peak. Preloaded composite components
97-
have no observation before loading: all added fields are null with an explicit
98-
reason. They must not inherit the parent's aggregate baseline.
99-
100-
Legacy *_mb fields use MiB. memory_measurement schema_version 2 retains the
101-
legacy baseline definition and adds the earlier boundary definition, PID,
102-
process creation time, selected LUID and timestamped byte/status/source records.
103-
Private commit is separate from RSS. The legacy checkpoint peak excludes the
104-
new earlier snapshot, even if that snapshot is larger. Signed deltas can be negative.
105-
106-
Unavailable readings and dependent deltas are null, never zero. If the earlier
107-
GPU process instance is absent, only metrics needing that point are unavailable;
108-
a valid legacy baseline and its deltas remain usable. CPU GPU memory is
109-
not_applicable. Performance success does not certify memory.
163+
The outer result remains `schema_version: 2`; `load_memory.version` is 1.
164+
Classic `memory_measurement.schema_version` is now 3 (replacing the version 2
165+
byte-checkpoint structure). It describes PID, selected LUID, lifecycle scope and missing
166+
counter reasons. The old after-factory baseline and its additive
167+
`*_before_model_load_mb` fields are superseded by these explicit blocks.
168+
Signed deltas can be negative. Missing readings and dependent deltas stay null.
169+
Performance success does not certify memory requirements.
110170

111171
GPU memory uses main's effective EP-device binding, including --device-luid and
112172
provider selectors resolved through the advertised device options. Unresolved
@@ -121,6 +181,28 @@ not proof of zero; enumeration errors and invalid readings are separate states.
121181
The CLI cannot certify that a process has never used the GPU merely from an
122182
absent PDH instance, so it never fabricates a zero baseline from that condition.
123183

184+
For a Windows GPU target with a resolved LUID, if process memory instances are
185+
absent before model loading, the CLI creates a model-free D3D12 device on that
186+
exact adapter and retries counter discovery for up to two seconds. It creates
187+
no model, buffers or command queue and submits no GPU work. The device is kept
188+
alive through the final checkpoint and released on success or failure, so its
189+
setup footprint is present in both endpoints rather than appearing as model
190+
allocation. Load-only RAM is captured after this preparation too; process RSS
191+
still starts before generic runtime setup. Already valid counters,
192+
CPU/NPU targets, unresolved adapters and preloaded components do not create this
193+
device. Initialization errors never prevent the model's own provider from running.
194+
195+
This is a prepared-device measurement, not cold GPU initialization. The
196+
process delta includes model/provider/input allocations and is
197+
not a minimum VRAM requirement. The load-only delta excludes inputs and inference.
198+
Legacy-named baseline and delta fields use the new boundaries documented above;
199+
they must not be compared directly with version 2 memory measurements. The optional
200+
memory_measurement.gpu_baseline_preparation block records method, timing,
201+
initial/prepared observations, selected adapter and failures. If the subsequent
202+
checkpoint remains unavailable, dependent deltas remain null. A later valid
203+
checkpoint never replaces a missing pre-model baseline. Historical results
204+
cannot be repaired without recollection.
205+
124206
The hardware monitor refreshes PID/LUID memory instances every 200 ms, including
125207
when monitoring starts before model load. Counter registration is deduplicated,
126208
failed registrations retry, and missing/disappeared instances stay unknown.

src/winml/modelkit/commands/_perf_genai.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,14 +187,14 @@ def _round_stats(values: dict[str, float]) -> dict[str, float]:
187187

188188
def _get_rss_mb() -> float:
189189
"""Return current process RSS in MB."""
190-
from ..session.monitor.memory_tracker import get_rss_mb
190+
from ..session.monitor import get_rss_mb
191191

192192
return get_rss_mb()
193193

194194

195195
def _get_vram_mb(adapter_luid: str | None) -> tuple[float | None, float | None]:
196196
"""Return current process device-memory usage as local/shared MB."""
197-
from ..session.monitor.memory_tracker import get_vram_mb
197+
from ..session.monitor import get_vram_mb
198198

199199
return get_vram_mb(adapter_luid)
200200

0 commit comments

Comments
 (0)