Skip to content

Instantly share code, notes, and snippets.

@mlukasze
Created August 28, 2026 07:21
Show Gist options
  • Select an option

  • Save mlukasze/43e1a3a6d5bec86b1de6a4e9f400ca88 to your computer and use it in GitHub Desktop.

Select an option

Save mlukasze/43e1a3a6d5bec86b1de6a4e9f400ca88 to your computer and use it in GitHub Desktop.
GPU Perf Analysis: Bonsai-27B/Qwen3.6-27B INT8 vs INT4 on Arc Pro B60 (Xe2) — omega#66

GPU Performance Analysis: INT8 vs INT4 on Xe2 (Arc Pro B60) for Bonsai-27B / Qwen3.6-27B

Ticket: openvinotoolkit/omega#66 Reported symptom: INT8 weight-compressed inference of Qwen/Qwen3.6-27B (base model for prism-ml/Bonsai-27B-gguf) on GPU.1 (Intel Arc Pro B60, discrete, Xe2/"Battlemage") is ~75x slower (~3.35 s/token) than INT4 on the same device (~44.75 ms/token), despite both being functionally correct (WWB similarity 0.962 for INT8, 0.911 for INT4, both ≥ 0.9 threshold). CPU and GPU.0 (integrated Xe-LPG) show no such anomaly.

Bottom line up front: the dominant root cause is GPU memory (VRAM) capacity exhaustion, not a computational/kernel inefficiency. The INT8 IR for this model is ~26 GB of weights; GPU.1 has ~24.4 GB of physical VRAM (confirmed via clinfo: CL_DEVICE_GLOBAL_MEM_SIZE = 24,385,683,456 bytes ≈ 22.7 GiB). The model does not fit in device memory at INT8, forcing the GPU driver into a memory‑oversubscription/eviction regime that is functionally transparent (hence "correct" output) but catastrophically slow (hence the ~75x latency cliff). INT4 (~15 GB) fits comfortably with ~9 GB of headroom for KV-cache and activations, and is fast. This is corroborated by:

  1. The ticket's own data (FP16 = 51 GB fails outright on GPU.1 with CL_OUT_OF_RESOURCES; INT8 = 26 GB "succeeds" but is anomalously slow; INT4 = 15 GB is fast).
  2. An independent, controlled reproduction built in this investigation (below), which triggers the identical CL_OUT_OF_RESOURCES error once a purely-synthetic model's total weight footprint crosses the same ~24 GB physical ceiling.
  3. OpenVINO GPU-plugin source code that explicitly documents this exact failure mode (src/plugins/intel_gpu/src/runtime/engine.cpp), including a code comment that performance "might drop due to memory swap" when device memory is exceeded.

A secondary, smaller, independently-confirmed effect also exists and is documented below: a real, reproducible, Xe2-specific FC-kernel dequantization/activation-dynamic-quantization slowdown of ~1.7–2.7x for INT8 vs INT4 that scales with hidden dimension. This is real and worth addressing, but it is roughly two orders of magnitude too small to explain a 75x gap on its own — it is a compounding factor, not the primary cause.

Prototype delivered: beyond the report, a narrowly-scoped, safe diagnostic fix has been implemented, built, unit-tested, and validated end-to-end on real hardware (§7), including through a from-source build of OpenVINO GenAI running real generation on the real proxy model (§7.4) — a proactive warning that fires when a model's weights approach or exceed a discrete GPU's physical VRAM, replacing today's silence (or cryptic CL_OUT_OF_RESOURCES) with an actionable message pointing at the real cause.


1. Architecture & Environment

  • Model: Qwen/Qwen3.6-27B (qwen3_5 architecture family), the disclosed base model behind prism-ml/Bonsai-27B-gguf. Dense (not MoE), hybrid linear/full attention: hidden_size=5120, intermediate_size=17408, num_hidden_layers=64, full_attention_interval=4 (3 GatedDeltaNet/linear-attention layers per 1 full-attention layer), linear_num_value_heads=48, linear_key_head_dim=128, plus a 1-layer MTP head. Confirmed via the model's public config.json.
  • Proxy model used for hands-on reproduction: Qwen/Qwen3.5-0.8B — same architecture family/op set (confirmed identical GatedDeltaNet kernel selection, same dynamic-quantization transformation logic), 24 layers, small enough to run natively in this environment. Exported via optimum-cli export openvino to both INT8 (1.1 GB) and INT4 (866 MB) IRs.
  • Hardware: Intel Core Ultra 9 285K; GPU.0 = integrated Xe-LPG iGPU (arch v12.70.4, shares system RAM, 188 GB); GPU.1 = discrete Intel Arc Pro B60 (Xe2/"Battlemage", arch v20.1.0, PCI 8086:e211, driver xe on Linux 6.17, NEO userspace driver 26.9.37435) — 24,385,683,456 bytes (≈22.7 GiB, ≈24.4 GB) of dedicated VRAM, confirmed via clinfo (CL_DEVICE_GLOBAL_MEM_SIZE and CL_DEVICE_MAX_MEM_ALLOC_SIZE, the latter being equal to the former — a single allocation may span the whole device memory, i.e. there is no separate "single-buffer" ceiling below the total VRAM).
  • Software: stock openvino/openvino-genai 2026.3.1 release wheels for end-to-end benchmarking; a from-source, -DENABLE_DEBUG_CAPS=ON custom build (openvino 2026.5.0) used exclusively for OV_VERBOSE transformation-decision tracing (not used for performance-sensitive timing, since debug-caps builds carry extra instrumentation overhead).

2. Methodology

Because the real 27B model cannot be run end-to-end in this environment in reasonable time (51 GB FP16 baseline; export/compression/compile times of tens of minutes each; and the target failure only manifests on genuinely large models), the investigation combined:

  1. Source-code archaeology of the OpenVINO GPU plugin's dynamic-quantization and memory management code paths, including full git-blame/PR-history tracing of the relevant transformations.
  2. Real, small-scale end-to-end reproduction using the Qwen/Qwen3.5-0.8B proxy model (same architecture family) across CPU / GPU.0 / GPU.1, INT8 vs INT4, using the official openvino.genai llm_bench tool and VLMPipeline directly.
  3. Runtime ground-truth via OV_VERBOSE=4 transformation-decision tracing on a custom debug-caps OpenVINO build, to directly observe which dynamic-quantization group size, kernel implementation, and code path each precision/model combination actually selects (rather than inferring it from source code alone).
  4. Isolated, controlled micro-benchmarks of a single FullyConnected layer at Qwen3.6-27B's exact real dimensions (hidden=5120, intermediate=17408), sweeping size and precision configuration to isolate individual variables (weight bit-width, activation dynamic-quantization group size, symmetric-vs-asymmetric quantization).
  5. A purpose-built synthetic "deep MLP stack" model (repeated gate/up/down SwiGLU-style blocks at the model's real hidden/intermediate dimensions) sized so that its total compressed-weight footprint crosses the GPU's physical VRAM budget at INT8 while staying safely under it at INT4 — used to directly test the VRAM-capacity hypothesis without needing the real, full 27B model.

All benchmarks were run on an otherwise-idle system; results were cross-checked against system load (uptime) to rule out contention artifacts (see §5.4).


3. Primary Root Cause: GPU Memory Capacity Exhaustion

3.1 The numbers

Precision Weight file size (ticket data) Fits in 24.4 GB VRAM? Observed behavior (ticket)
FP16 51 GB No (2.1x over) Hard failure: CL_OUT_OF_RESOURCES
INT8 26 GB No (1.6 GB over) "Succeeds" but ~75x slower (~3.35 s/token)
INT4 (group-size 128) 15 GB Yes (~9 GB headroom) Fast (~44.75 ms/token)

This table alone — taken directly from the ticket's own Step-1/Step-3 progress comments — is the single strongest piece of evidence: the only precision that is both "large enough to be tight against the VRAM ceiling" and "anomalously slow" is INT8; the only one that hard-fails outright is the one that is far over budget (FP16); the only one that is fast is the one with comfortable headroom (INT4). The pattern lines up exactly with a capacity-driven effect, not a precision-specific kernel bug.

3.2 Independent reproduction (this investigation)

To confirm this is a genuine capacity effect and not a coincidence of the specific model, a synthetic model was constructed: repeated SwiGLU-style MLP blocks (gate/up/down MatMuls) at the exact real hidden_size=5120 / intermediate_size=17408 dimensions, stacked to a chosen layer count, then weight-compressed with NNCF exactly as the real model is (INT8_ASYM / INT4_ASYM, group-size 128) and compiled for GPU.1.

Layers Weight footprint (INT8) Weight footprint (INT4) INT8 result INT4 result
60 16.04 GB (fits) 8.02 GB (fits) 39.09 ms/inference 19.85 ms/inference (ratio 1.97x — matches isolated-kernel baseline, §4)
88 23.53 GB (fits, ~0.9 GB headroom) 11.77 GB (fits) 57.23 ms/inference (perfectly linear vs the 60-layer point) not needed (already established linear)
96 25.67 GB (exceeds 24.4 GB by ~1.3 GB) 12.83 GB (fits) Hard crash: onednn_verbose,...,error,ocl,errcode -5,CL_OUT_OF_RESOURCES,src/gpu/intel/ocl/kernel.cpp:240 not applicable (int8-only test)
110 29.41 GB (exceeds by ~5 GB) 14.71 GB (fits, ~9.7 GB headroom) Hard crash, same CL_OUT_OF_RESOURCES 36.30 ms/inference — perfectly linear with the 60-layer point (110/60 × 19.85 = 36.4 ms, measured 36.30 ms)

Key observations:

  • Below the VRAM ceiling, INT8 and INT4 both scale perfectly linearly with layer count (i.e., with total weight bytes moved) — there is no hidden slowdown building up gradually.
  • The exact same CL_OUT_OF_RESOURCES OpenCL error code that the ticket's own Step-3 validation observed for the (grossly oversized) FP16 case appears in this from-scratch, independently-built reproduction, at a model size within ~1 GB of the real INT8 model's documented 26 GB footprint.
  • INT4 never crashes at any tested size (up to 14.7 GB), and scales linearly throughout.

3.3 Why "hard crash" here but "slow-but-correct" for the real 27B pipeline?

This investigation's synthetic model crashes outright once VRAM is exceeded, while the real Qwen3.6-27B INT8 pipeline reportedly runs (just ~75x slower). Both are legitimate manifestations of the same underlying resource-exhaustion phenomenon; the difference in failure mode is explained by GPU-plugin source code:

// src/plugins/intel_gpu/src/runtime/engine.cpp — engine::check_allocatable()
auto used_mem = get_used_device_memory(allocation_type::usm_device)
              + get_used_device_memory(allocation_type::usm_host);
auto exceed_available_mem_size = (layout.bytes_count() + used_mem > get_max_memory_size());
...
#ifdef __unix__
    // Prevent from being killed by Ooo Killer of Linux
    OPENVINO_ASSERT(!exceed_available_mem_size, "[GPU] Exceeded max size of memory allocation: ...");
#else
    if (exceed_available_mem_size) {
        GPU_DEBUG_COUT << "[Warning] [GPU] Exceeded max size of memory allocation: ...";
        GPU_DEBUG_COUT << "Please note that performance might drop due to memory swap." << std::endl;
    }
#endif
  • The book-keeping budget checked here (get_max_memory_size()) is actually host RAM + device VRAM for a discrete GPU (188 GB + 24.4 GB ≈ 212 GB on this system) — a coarse guard against truly impossible requests, not a precise VRAM check, so it does not explain the crash by itself at ~26 GB.
  • The crash observed in this investigation (CL_OUT_OF_RESOURCES from src/gpu/intel/ocl/kernel.cpp:240, i.e. at kernel-execution time, not at the book-keeping check above) comes from a lower level: the actual OpenCL/oneDNN runtime failing to satisfy a real device-memory (usm_device) request once physical VRAM is genuinely full. This is sensitive to exact allocation pattern/fragmentation (number and order of constants, whether the OS/driver has other consumers, etc.), which plausibly explains why the real pipeline's specific allocation pattern (a full GenAI stateful model with KV-cache, vs. this investigation's simpler stack of raw constants) can behave differently at a similar total size — sometimes it still fails allocation outright, sometimes the driver instead falls back to page migration / host-visible USM memory and keeps functioning (validated by the ticket's own comment: "Process is confirmed healthy… steady forward progress… ~100-200x slower than every other device/precision combination" — i.e. it is not hung, it is genuinely computing, just extremely slowly, exactly consistent with constant eviction/re-fetch of weights over PCIe rather than local VRAM bandwidth).
  • Crucially, on non-Linux systems the code above only ever prints a debug-only warning (GPU_DEBUG_COUT, which compiles to a complete no-op — if (0) ... — in any build without -DENABLE_DEBUG_CAPS=ON, i.e. every standard release build users actually run). This means in a real production/release build, there is currently no user-visible diagnostic at all for this condition on the "graceful but catastrophically slow" path — the user just observes an inexplicable, undocumented 75x slowdown. See the proposed fix in §6.

3.4 Why this wasn't visible in the 0.8B proxy or in isolated kernel tests

  • Qwen3.5-0.8B INT8 is only ~1.1 GB — nowhere near a 24.4 GB ceiling, so no VRAM pressure exists at that scale; end-to-end re-benchmarking (5 runs each, idle system) showed a stable ~1.05x INT8-vs-INT4 ratio, not the reported 75x. This is fully consistent with (not contradictory to) the VRAM hypothesis: the effect is scale-gated.
  • A single isolated FullyConnected layer, even at the model's real 5120×17408 dimensions, is only ~89 MB of weights — orders of magnitude below any VRAM ceiling, so isolated micro-benchmarks (§4) never trigger this effect either. They correctly measure a different, smaller, always-present kernel-level cost.

4. Secondary, Confirmed Finding: Xe2 FC-Kernel Dequantization Scaling (~1.7–2.7x)

Independently of the VRAM effect, a real, reproducible, Xe2-specific performance gap between INT8 and INT4 FullyConnected execution was isolated and confirmed. This will still matter once the VRAM issue is addressed (e.g. on a larger-memory GPU, or for smaller models of this family), so it is documented and should be tracked, but it is not the explanation for the 75x figure.

4.1 What was ruled out first

Two earlier hypotheses were tested and falsified using OV_VERBOSE=4 ground-truth tracing on both the real INT8 and INT4 Qwen3.5-0.8B IRs:

  • DynamicQuantizeFullyConnected GS128 hybrid-attention workaround (PR #35961, "[GPU] Robust INT8 dynamic quantization handling for hybrid linear-attn (GatedDeltaNet) models") is not a differential cause: verbose logs show both INT8 and INT4 in the real hybrid model get forced to identical group_sizes: 1,1,128 (97 DynamicQuantize ops each). This WA exists because "hybrid linear-attention blocks... produce SSM-gated outputs with a wide dynamic range... per-token INT8 dynamic activation quantization... can cause severe output collapse" (from the PR description) — it is an accuracy fix, empirically validated on Qwen3.5/3.6 dense and MoE models, and it applies uniformly to both precisions in a hybrid model, so it cannot explain an INT8-vs-INT4 differential.
  • GatedDeltaNet kernel selection is identical for both precisions: verbose logs confirm the exact same ocl::gated_delta_net::ref_ kernel and kernel hash for INT8 and INT4 (18 ops, matching num_hidden_layers − num_full_attention_layers). Note: this kernel has no optimized implementation at all — only a reference implementation exists — a legitimate, separate performance-improvement opportunity (it accounted for ~29% of decode step time in PERF_COUNT profiling) but again equally for both precisions.

A related, separate, and important discovery: use_gs128_for_int8_per_token (transformations_pipeline.cpp) forces group_size=128 for any INT8-weight FullyConnected on any Xe2+ device, unconditionally — not gated on whether the model is a hybrid-attention model. This predates the hybrid-attention WA (PR #32493, "enable gs128 by default for int8 models in xe2+ platforms... It is not enabled for other cases because of performance concern"). Because it is unconditional, it means every INT8 model — not just hybrid-attention ones — pays the GS128 grouping cost on Xe2, whereas INT4 only pays it in hybrid-attention models. This was investigated as a possible explanation for the FC-level delta below (§4.2) but was ruled out as the primary driver of the scaling effect (§4.3): forcing INT4 to the same group_size=128 explicitly (DYNAMIC_QUANTIZATION_GROUP_SIZE=128) reproduces virtually the same ratio curve as leaving INT4 at its default per-token grouping, proving the grouping granularity itself is nearly free — the actual cost is elsewhere (weight bit-width/dequantization), as detailed next.

4.2 Data: apples-to-apples FC scaling, matched grouping, matched quantization symmetry

Because the real model's INT4 weights are actually INT4_ASYM (confirmed via direct IR inspection: .../weight tensors are uint4_t and 100% of them have a paired /zero_point constant, also uint4_t) — not INT4_SYM as an earlier draft of this benchmark mistakenly used — the final, representative comparison below uses INT8_ASYM vs INT4_ASYM, both explicitly forced to the same group_size=128 activation dynamic-quantization granularity (matching what the real hybrid model does for both precisions):

hidden intermediate INT8 median (ms) INT4 median (ms) Ratio
1024 3584 0.0935 0.0857 1.09x
2048 7168 0.1017 0.0931 1.09x
4096 14336 0.2234 0.1506 1.48x
5120 17408 0.3131 0.1884 1.66x ← real model's exact dims
8192 28672 0.7510 0.3702 2.03x
12288 32768 1.2450 0.5770 2.16x
16384 43008 2.4612 0.9733 2.53x

(GPU.1, PERF_COUNT node-level timing corroborates the same trend at the FC-node level, peaking at ~2.7x.)

At the model's actual real dimensions (hidden=5120, intermediate=17408), the isolated FC effect is only ~1.7x — not 75x. The ratio grows with size and appears to plateau around 2.5–2.7x at the largest tested dimension (16384); it does not explode.

4.3 Interpretation

  • Since matching the activation-quantization group size does not remove the gap, and using the representative asymmetric (zero-point) quantization for both precisions does not meaningfully change it either (INT4_SYM vs INT4_ASYM gave nearly identical ratios), the remaining, isolated cause is the weight bit-width itself: INT8 weight storage is 2x that of INT4, meaning 2x the bytes must be streamed from device memory per FC invocation. The observed ratio (up to ~2.5-2.7x, somewhat above the "naive" 2x memory-bandwidth expectation) suggests the FC/dequantization GEMM kernel on Xe2 is not perfectly bandwidth-proportional — plausibly extra per-element unpack/dequant instruction overhead, or GEMM tiling/occupancy that favors the smaller (INT4) weight footprint at large sizes (better cache/register reuse). This was not traced further into the OpenCL kernel source itself due to time constraints, and is flagged as a good target for focused kernel-level profiling (e.g. with cliloader/kernel-level GPU timing) as a follow-up.
  • This effect is real, Xe2-specific (gated by device_info.supports_immad && use_onednn, i.e. does not apply to GPU.0 — confirmed separately: GPU.0 uses a completely different fully_connected_gpu_bf_tiled kernel path with no oneDNN/dynamic-quantization at all, showing an expected ~1.55x ratio consistent with plain 2x-bandwidth minus reuse benefits), and worth incremental optimization — but it is two orders of magnitude short of explaining the reported 75x, and should not be conflated with the VRAM-capacity finding in §3.

5. Hypotheses Tested and Ruled Out

For completeness/audit trail, the following were investigated and explicitly falsified before arriving at the VRAM-capacity conclusion:

  1. GS128 hybrid-attention accuracy WA (PR #35961) as a differential cause — falsified; applies identically to both precisions in a real hybrid model (§4.1).
  2. GatedDeltaNet kernel selection differing by precision — falsified; identical kernel/hash for both precisions (§4.1).
  3. Dynamic-quantization grouping granularity (GS128 vs per-token) as the FC-scaling cause — falsified; forcing matched grouping reproduces the same ratio curve (§4.1/4.2).
  4. Symmetric vs. asymmetric weight quantization as the FC-scaling cause — falsified; INT4_SYM vs. representative INT4_ASYM give effectively the same ratios (§4.2).
  5. PERF_COUNT-based op-level profiling as a methodology — found to be misleading: it forces serialization that erased a previously-observed wall-clock gap and showed nearly identical INT8/INT4 op-level breakdowns regardless of ground truth; OV_VERBOSE transformation-decision tracing was far more informative for root-causing than runtime op-counters.
  6. CPU contention / thermal / driver kernel-cache cold-start artifacts — investigated at length (synthetic yes-loop CPU stress, real concurrent cc1plus/ninja compile load, deliberately cleared NEO GPU kernel cache) as an explanation for an earlier, non-reproducible 0.8B-scale measurement; none reproduced any anomaly. This confirms environmental noise was not a factor in the (separately explained, VRAM-driven) real 27B anomaly, and that the originally-recorded 0.8B-scale "3.84x" data point was most likely a one-off measurement artifact unrelated to the true (VRAM-driven) 27B phenomenon — the two are unrelated findings from different scales.
  7. ATTENTION_BACKEND (PagedAttention vs SDPA) choice — affects absolute latency substantially (5 ms vs 103 ms/token in one comparison) but affects INT8 and INT4 equally; not a differentiator.

6. Recommendations

6.1 Immediate / user-facing (no code change)

On a ~24 GB-class GPU, INT4 (or lower) is the only practical precision for a ~27B dense model. INT8 is expected to be unusable-at-acceptable-speed by design, not by bug, on this class of hardware — this should be explicitly documented for the Bonsai-27B / Qwen3.6-27B enablement (e.g., in the model card / notebook produced by this enablement pipeline), so users do not attempt INT8-on-24GB-GPU expecting good performance. Deployment on a GPU with ≥32 GB VRAM (headroom for KV-cache/activations beyond the ~26 GB weights) would be expected to remove the effect entirely — this is a hardware-sizing recommendation, not a code fix.

6.2 Proposed narrow, safe code improvement: proactive VRAM-pressure diagnostic

The core problem today is silence: neither Linux (hard, somewhat generic assert) nor Windows (release-mode no-op due to GPU_DEBUG_COUT) surfaces a clear, actionable message in a normal release build explaining why a model is about to be extremely slow or fail.

This has been implemented and validated as a prototype (not just proposed) — see §7 for the full implementation, test, and end-to-end validation results. In summary, it adds an always-on (not gated behind ENABLE_DEBUG_CAPS), best-effort diagnostic at compile time that:

  • Sums the total constant/weight byte count for the compiled network (a lightweight, private pre-pass over data nodes in program::processing_order, deliberately independent of get_estimated_device_mem_usage(), which is wired only into the unrelated auto-batch-sizing feature and not invoked on the normal single-request LATENCY-hint compile path).
  • Compares it against the device's actual physical memory (device_info.max_global_mem_size, not the inflated host+device get_max_memory_size() figure used for the Linux OOM-killer guard).
  • If the total is within a configurable margin of (default 90%), or exceeds, that figure, logs a clear, user-visible warning via std::cerr, e.g.:

    [GPU] [WARNING] Model weights (~23.92 GiB) are close to or exceed device 'Intel(R) Arc(TM) Pro B60 Graphics' physical memory capacity (~22.71 GiB). This can force the driver into a memory-oversubscription fallback that remains functionally correct but may be dramatically slower than expected (up to ~75x observed in practice for a similarly-sized model). Consider a smaller or more compressed model (e.g. INT4 instead of INT8), or a device with more memory. Background: openvinotoolkit/omega#66.

This is purely additive/diagnostic (no numerical, allocation, or kernel behavior changes), touches no other model's runtime performance, and directly turns a mysterious 75x slowdown (or a cryptic CL_OUT_OF_RESOURCES/could not execute a primitive exception) into an explained, actionable one. It is implemented directly against src/plugins/intel_gpu/ (see §7 for exact files/diff), placed alongside the existing get_estimated_device_mem_usage() machinery in .../graph/program.cpp and .../include/intel_gpu/graph/program.hpp, and covered by a new unit test suite. It is offered as a starting point for a properly-scoped, reviewed upstream PR — the GPU plugin team should still weigh in on the exact logging channel/verbosity conventions and default margin before merging, but the detection logic itself is implemented, tested, and validated end-to-end (§7).

6.3 Longer-term, larger-scope (out of scope for this task, flagged for the GPU plugin team)

  • Weight streaming / layer-wise paging for models whose weights exceed device memory (keep only N layers resident, prefetch the next while computing the current one) — a substantial architecture investment, common in other inference stacks for oversized models, but well beyond a "narrow, safe fix."
  • Kernel-level investigation of the ~1.7–2.7x Xe2 FC dequantization scaling (§4) via cliloader/kernel-timing tools, to see whether the INT8 dequant/GEMM kernel can be brought closer to the theoretical ~2x bandwidth ratio at large sizes.
  • An optimized (non-reference) GatedDeltaNet kernel — currently only ref_ exists, consuming ~29% of decode time for both precisions; a fused/optimized implementation would benefit this whole model family regardless of the VRAM finding.

7. Prototype status — IMPLEMENTED AND VALIDATED

Per the task's explicit framing, a working code fix is optional/best-effort and secondary to this report. Given the primary root cause (§3) is a genuine hardware-capacity constraint without a safe, narrowly-scoped performance fix (the model must either be smaller, more compressed, or the device must have more memory — none of which are code changes to propose safely within this task's scope), the actionable prototype pursued is the proactive VRAM-pressure diagnostic warning from §6.2. Unlike a performance fix, this is safe, narrowly scoped, beneficial to any model/user hitting this class of problem, and fully testable — and it has been implemented, built, and validated end-to-end.

7.1 Implementation

Three files changed/added directly against the openvino source tree (full unified diff saved at repro/vram_pressure_diagnostic.patch):

File Change
src/plugins/intel_gpu/include/intel_gpu/graph/program.hpp Declares two new program methods: static bool is_device_memory_pressure_risk(uint64_t total_weights_bytes, uint64_t device_max_global_mem_bytes, double margin_ratio = 0.9) (pure, side-effect-free, unit-testable without a device) and void check_device_memory_pressure() (computes real weight bytes + logs).
src/plugins/intel_gpu/src/graph/program.cpp Implements both methods. check_device_memory_pressure() early-returns for integrated GPUs (dev_type != discrete_gpu — no separate VRAM tier to exceed), otherwise sums bytes_count() over all non-shape-infer data (constant) nodes in processing_order and compares against device_info.max_global_mem_size. Wired into program::build_program() immediately after the existing transfer_memory_to_device() call (i.e. only for real, non-internal program builds — internal/sub-graph builds are unaffected, matching the existing gating pattern for weight transfer).
src/plugins/intel_gpu/tests/unit/test_cases/test_device_memory_pressure_warning.cpp (new) 9 unit tests: 8 pure-logic tests of is_device_memory_pressure_risk() covering fits-comfortably, exceeds-capacity, default/custom margin behavior, and zero-input edge cases (no GPU device needed); 1 integration smoke test that builds a real (tiny) program end-to-end via program::build_program() on the actual test device and asserts it does not throw.

The change is ~63 lines of new production code + ~112 lines of new test code; zero existing lines modified (only two call-sites/declarations added). No other function, model, or code path is touched.

7.2 Build & test verification

  • Full incremental rebuild of the affected static library succeeded cleanly: ninja openvino_intel_gpu_graph (797 targets, no new warnings/errors introduced).
  • New unit tests: all 9/9 pass, including the real-device smoke test:
    [==========] 9 tests from test_device_memory_pressure_warning ran. (160 ms total)
    [  PASSED  ] 9 tests.
    
  • Regression check, pre-existing related test: test_device_mem_usage_estimation.* (3/3 pass, unchanged behavior).
  • Broader regression sample (chosen for direct relevance to build_program() and to the FC/weight-compression code paths central to this investigation): network_test.*, basic.*, basic_memory_dependencies.*, swiglu_gpu_test.* (15/15 pass) and the full fully_connected_gpu_tests suite (137/137 pass, grep-confirmed zero spurious warnings emitted for any of these — none of their models approach the discrete GPU's capacity threshold).

7.3 End-to-end validation (real Python API, real hardware, both directions)

The Python wheel was rebuilt from the same modified source tree (ninja pyopenvino ie_wheel) and reinstalled into venv_debug, then exercised on the real GPU.1 (Arc Pro B60, discrete) in three scenarios:

Scenario Weights vs. capacity Result
Real 0.8B proxy model (models/qwen3.5-0.8b-int8, ~1.1 GB) ~4.8% of 22.71 GiB Compiles and runs correctly in 1.3s. No warning printed — confirms zero false positives for models nowhere near the risk zone.
Synthetic 88-layer stack, INT8 (23.53 GB ≈ 21.92 GiB) ~96.5% of capacity Warning correctly fires: "Model weights (~21.9239 GiB) are close to or exceed device ... physical memory capacity (~22.7109 GiB)." Inference still completes and runs with stable, linear-scaling latency (57ms/iter) — matching §3.2's original finding that this configuration is "near-limit but still functional." The diagnostic proactively flags real risk before any failure occurs.
Synthetic 96-layer stack, INT8 (25.67 GB ≈ 23.92 GiB) ~105% of capacity (genuinely exceeds) Warning correctly fires during compilation: "Model weights (~23.917 GiB) are close to or exceed device ... physical memory capacity (~22.7109 GiB)." Immediately afterward, the same underlying CL_OUT_OF_RESOURCES (src/gpu/intel/ocl/kernel.cpp:240) failure from §3.2 still occurs at req.infer() — but now the user sees the actionable, root-caused warning before the cryptic low-level driver exception, rather than only the raw RuntimeError: ... could not execute a primitive with no explanation.

This confirms the diagnostic behaves correctly in all three directions: silent when safe, a proactive warning when near the edge (including cases that end up working, consistent with the conservative 90% margin being an early-warning threshold rather than a strict failure predictor), and a proactive warning immediately ahead of an actual hard failure — directly addressing the "no user-visible warning exists today" gap identified in §3 and §6.2.

7.4 Whole-stack validation: OpenVINO GenAI (built from source against the modified core)

Per the task's request to validate beyond the same performance-analysis tooling used for the initial evaluation, openvino.genai (already forked/cloned as mlukasze/openvino.genai) was built from source against this modified OpenVINO core, to prove the diagnostic is exercised correctly by a real, production-shaped inference pipeline rather than only by raw ov::Core/Python-API calls or unit tests:

  • Configured with CMake pointed directly at the modified core's build tree (-DOpenVINO_DIR=.../openvino/build), initialized the missing openvino_tokenizers submodule, built with Ninja (ninja py_openvino_genai, 335/335 targets, succeeded cleanly).

  • Loaded the resulting py_openvino_genai module (built against, and linked to, libopenvino.so from this session's modified core) via PYTHONPATH/LD_LIBRARY_PATH, alongside the matching rebuilt openvino Python wheel.

  • Ran ov_genai.VLMPipeline(...) (the correct pipeline class for this VLM-family proxy model, which exports separate text/vision embedding sub-models alongside the language model) on GPU.1 for both the INT8 and INT4 exports of the 0.8B proxy model, generating real text:

    Model Prompt Generated output
    qwen3.5-0.8b-int8 "The capital of France is" "The capital of France is Paris. It is the largest city in France and the seat of"
    qwen3.5-0.8b-int4 "The capital of France is" "The capital of France is Paris. It is also the largest city in Europe."

    Both are factually correct, coherent completions — confirming the modified OpenVINO core produces no functional/correctness regression through the full GenAI pipeline (tokenizer → embeddings → language model → detokenizer, all compiled through the same build_program() path that now includes the new diagnostic check). Neither run printed a spurious warning (both models are ~4.8%/~2.9% of device capacity), confirming the diagnostic remains silent and non-intrusive for real, well-provisioned GenAI workloads, not just synthetic ones.

OVMS was scoped out of hands-on validation (a deliberate, reasoned decision, not an omission): OVMS is fundamentally a serving wrapper around the same ov::Core::compile_model()program::build_program() call chain already exercised and validated at three independent levels above (C++ unit test, raw Python API, and now full GenAI VLMPipeline). Building it from source would mean standing up its separate Bazel-based build system for a change that (a) touches no public API surface OVMS or any consumer depends on, and (b) would exercise the exact same internal code path already proven correct — a disproportionate time cost for near-zero incremental risk coverage given the change's narrow scope. If desired, a follow-up could still run the model through an OVMS Docker image built against a packaged wheel from repro/vram_pressure_diagnostic.patch applied to a stock OpenVINO checkout.

7.5 Scope and remaining upstream work

This prototype is offered as a starting point for a properly-scoped, reviewed PR, not a final upstream-ready patch. Before merging into openvinotoolkit/openvino, the GPU plugin team should weigh in on:

  • Preferred logging channel/verbosity conventions (currently a direct std::cerr write, clearly marked in code comments as a prototype choice — a real PR should likely route through whatever the plugin's standard user-facing warning mechanism is, if one exists, or establish one).
  • Whether the default 90% margin is the right balance between early warning and false-positive risk across the full range of supported discrete GPUs and workloads (this investigation only validated it against one device, Arc Pro B60/24GB-class).
  • Whether a one-time-per-process warning (vs. current unconditional per-build_program()-call) is preferred for pipelines that compile many sub-networks.

No changes to optimum-intel were required or made, consistent with the task's constraints.


8. Evidence artifacts

  • repro/fc_bench.py, repro/fc_bench_scaling.py, repro/fc_bench_matched_grouping.py, repro/fc_bench_matched_grouping_asym.py — isolated FC-kernel scaling benchmarks (§4).
  • repro/vram_oversubscription_test.py — synthetic deep-MLP-stack VRAM-capacity reproduction (§3.2), also reused for prototype validation (§7.3).
  • repro/vram_pressure_diagnostic.patch — full unified diff of the implemented and validated proactive VRAM-pressure diagnostic prototype (§6.2, §7).
  • repro/prototype_validation/ — validation logs and scripts for the prototype: real-model no-warning compile log, 88-/96-layer synthetic VRAM-pressure warning logs (§7.3), and the full GenAI VLMPipeline end-to-end script + logs for both INT8/INT4 (§7.4).
  • repro/llm_bench.py, repro/llm_bench_backend.py — real end-to-end VLMPipeline benchmarks on the 0.8B proxy model (§3.4, §5).
  • /tmp/int8_verbose4.log, /tmp/int4_verbose4.logOV_VERBOSE=4 ground-truth transformation-decision traces for the real proxy model (§4.1).
  • models/qwen3.5-0.8b-int8/, models/qwen3.5-0.8b-int4/ — exported, verified-correct OpenVINO IR for the proxy model.

9. Limitations

  • The real Qwen/Qwen3.6-27B model was not run end-to-end in this environment (infeasible in the available time/disk/compute budget); all 27B-scale conclusions are derived from (a) the ticket's own previously-collected data on the real model, and (b) an independent synthetic reproduction at the model's exact real dimensions and representative quantization modes, cross-validated against GPU-plugin source code. This is a strong but indirect evidence chain; direct confirmation on the real model (e.g. instrumenting an actual run with GPU memory-usage telemetry, if tooling becomes available) would further strengthen it.
  • No GPU frequency/VRAM-usage telemetry tool (intel_gpu_top, xpu-smi) was available/ installable in this environment (no root access), so live VRAM occupancy during the failing synthetic run could not be directly observed; the conclusion rests on file-size arithmetic, device capability queries (clinfo), and the matching CL_OUT_OF_RESOURCES error signature rather than a live memory-usage graph.
  • The exact micro-mechanism that lets the real 27B pipeline degrade gracefully (slow) rather than hard-fail (as this investigation's simpler synthetic model does) was not traced to a specific line of driver or runtime code; §3.3 gives a well-evidenced but not 100%-pinpointed explanation for this difference in failure mode (both are the same root cause — VRAM exhaustion — manifesting differently based on allocation pattern specifics).
diff --git a/src/plugins/intel_gpu/include/intel_gpu/graph/program.hpp b/src/plugins/intel_gpu/include/intel_gpu/graph/program.hpp
index ac09b4e6c7..732c945f91 100644
--- a/src/plugins/intel_gpu/include/intel_gpu/graph/program.hpp
+++ b/src/plugins/intel_gpu/include/intel_gpu/graph/program.hpp
@@ -280,6 +280,33 @@ public:
// returns {-1, -1} if it failed to estimate by allocating given batch size
std::pair<int64_t/*const alloc*/, int64_t/*general alloc*/> get_estimated_device_mem_usage();
+ // Returns true if `total_weights_bytes` (the total size of constants/weights that need to be
+ // device-resident) is within `margin_ratio` of, or exceeds, `device_max_global_mem_bytes` (the
+ // device's *physical* memory capacity, i.e. device_info::max_global_mem_size -- NOT
+ // engine::get_max_memory_size(), which also folds in host RAM and is only meant as a last-resort
+ // guard against being killed by the Linux OOM-killer, not a VRAM-capacity check).
+ //
+ // Once a model's resident weights approach or exceed physical device memory, the device/driver is
+ // forced into a memory-oversubscription regime (host-visible fallback allocations, eviction/paging)
+ // that remains functionally correct but can be catastrophically slower (up to ~75x observed in
+ // practice for a hybrid-attention LLM whose INT8 weights slightly exceeded a 24GB GPU's VRAM, while
+ // the same model's INT4 weights fit comfortably and ran at expected speed) -- see
+ // openvinotoolkit/omega#66 for the investigation that motivated this diagnostic.
+ //
+ // This is a pure, side-effect-free helper (no device/engine access) so it can be exercised directly
+ // by unit tests without requiring a real GPU device.
+ static bool is_device_memory_pressure_risk(uint64_t total_weights_bytes,
+ uint64_t device_max_global_mem_bytes,
+ double margin_ratio = 0.9);
+
+ // Computes the total size of constants/weights in this program and, if it is within margin_ratio of
+ // or exceeds the device's physical memory capacity, logs a clear, user-visible warning (independent
+ // of ENABLE_DEBUG_CAPS / OV_VERBOSE, since this class of failure otherwise produces either a hard,
+ // hard-to-diagnose driver error, or -- on platforms where the driver falls back to a slow oversubscribed
+ // path -- no diagnostic output at all in a release build). Purely informational: never alters
+ // allocation, execution, or numerical behavior.
+ void check_device_memory_pressure();
+
using ImplementationsCache = cldnn::LruCacheThreadSafe<kernel_impl_params, std::shared_ptr<primitive_impl>, kernel_impl_params::Hasher>;
ImplementationsCache& get_implementations_cache() const { return *_impls_cache; }
diff --git a/src/plugins/intel_gpu/src/graph/program.cpp b/src/plugins/intel_gpu/src/graph/program.cpp
index 9c7650872a..4feb826f32 100644
--- a/src/plugins/intel_gpu/src/graph/program.cpp
+++ b/src/plugins/intel_gpu/src/graph/program.cpp
@@ -520,6 +520,7 @@ void program::build_program(bool is_internal) {
prim_info = get_current_stage_info();
if (get_engine().get_device_info().has_separate_cache)
transfer_memory_to_device();
+ check_device_memory_pressure();
}
}
@@ -777,6 +778,41 @@ void program::transfer_memory_to_device() {
}
}
+bool program::is_device_memory_pressure_risk(uint64_t total_weights_bytes,
+ uint64_t device_max_global_mem_bytes,
+ double margin_ratio) {
+ if (device_max_global_mem_bytes == 0)
+ return false;
+ return static_cast<double>(total_weights_bytes) >= margin_ratio * static_cast<double>(device_max_global_mem_bytes);
+}
+
+void program::check_device_memory_pressure() {
+ // Integrated GPUs share system RAM directly -- there is no separate, smaller VRAM tier to
+ // exceed, so this class of oversubscription risk is specific to discrete GPUs.
+ if (get_engine().get_device_info().dev_type != device_type::discrete_gpu)
+ return;
+
+ uint64_t total_weights_bytes = 0;
+ for (const auto& node : processing_order) {
+ if (node->is_type<data>() && !node->is_shape_infer_dep()) {
+ total_weights_bytes += node->get_output_layout().bytes_count();
+ }
+ }
+
+ const uint64_t device_max_global_mem_bytes = get_engine().get_device_info().max_global_mem_size;
+ if (is_device_memory_pressure_risk(total_weights_bytes, device_max_global_mem_bytes)) {
+ const double weights_gib = total_weights_bytes / (1024.0 * 1024.0 * 1024.0);
+ const double device_gib = device_max_global_mem_bytes / (1024.0 * 1024.0 * 1024.0);
+ std::cerr << "[GPU] [WARNING] Model weights (~" << weights_gib << " GiB) are close to or exceed "
+ << "device '" << get_engine().get_device_info().dev_name << "' physical memory capacity (~"
+ << device_gib << " GiB). This can force the driver into a memory-oversubscription "
+ << "fallback that remains functionally correct but may be dramatically slower than "
+ << "expected (up to ~75x observed in practice for a similarly-sized model). Consider a "
+ << "smaller or more compressed model (e.g. INT4 instead of INT8), or a device with more "
+ << "memory. Background: openvinotoolkit/omega#66." << std::endl;
+ }
+}
+
program::nodes_ordering& program::get_processing_order() { return processing_order; }
const program::nodes_ordering& program::get_processing_order() const { return processing_order; }
diff --git a/src/plugins/intel_gpu/tests/unit/test_cases/test_device_memory_pressure_warning.cpp b/src/plugins/intel_gpu/tests/unit/test_cases/test_device_memory_pressure_warning.cpp
new file mode 100644
index 0000000000..9d09230a70
--- /dev/null
+++ b/src/plugins/intel_gpu/tests/unit/test_cases/test_device_memory_pressure_warning.cpp
@@ -0,0 +1,112 @@
+// Copyright (C) 2018-2026 Intel Corporation
+// SPDX-License-Identifier: Apache-2.0
+//
+
+// Unit tests for program::is_device_memory_pressure_risk() / program::check_device_memory_pressure().
+//
+// Motivation (openvinotoolkit/omega#66): a hybrid-attention LLM whose INT8 weights (~26 GiB)
+// slightly exceeded a discrete GPU's physical VRAM (~24.4 GiB) ran ~75x slower than the same
+// model in INT4 (~15 GiB, comfortably under budget), while remaining functionally correct.
+// The GPU plugin gave no user-visible diagnostic for this class of failure in release builds:
+// the only existing check (engine::check_allocatable()) compares against host RAM + VRAM
+// combined (a coarse OOM-killer guard, irrelevant at this scale) and its Windows-only
+// "proceeding anyway" log line compiles to a complete no-op without ENABLE_DEBUG_CAPS.
+//
+// These tests cover the small, pure decision function in isolation (no device required), plus
+// a smoke test that invoking the check via a normal program build neither throws nor otherwise
+// disrupts compilation, regardless of whether the (synthetic, tiny) test topology trips the
+// warning.
+
+#include <cstddef>
+
+#include "test_utils.h"
+#include <intel_gpu/primitives/permute.hpp>
+#include <intel_gpu/primitives/eltwise.hpp>
+
+using namespace cldnn;
+using namespace tests;
+
+class test_device_memory_pressure_warning: public ::testing::Test {};
+
+// -------------------------------------------------------------------------------------------
+// Pure logic tests for program::is_device_memory_pressure_risk() -- no GPU device required.
+// -------------------------------------------------------------------------------------------
+
+TEST_F(test_device_memory_pressure_warning, fits_comfortably_is_not_a_risk) {
+ // 15 GiB of weights on a 24.4 GiB device (matches the real INT4 Bonsai-27B scenario): not a risk.
+ const uint64_t weights_15gib = 15ULL * 1024 * 1024 * 1024;
+ const uint64_t device_24_4gib = 24385683456ULL; // real GPU.1 VRAM size from clinfo
+ ASSERT_FALSE(program::is_device_memory_pressure_risk(weights_15gib, device_24_4gib));
+}
+
+TEST_F(test_device_memory_pressure_warning, exceeding_capacity_is_a_risk) {
+ // 26 GiB of weights on a 24.4 GiB device (matches the real INT8 Bonsai-27B scenario): is a risk.
+ const uint64_t weights_26gib = 26ULL * 1024 * 1024 * 1024;
+ const uint64_t device_24_4gib = 24385683456ULL;
+ ASSERT_TRUE(program::is_device_memory_pressure_risk(weights_26gib, device_24_4gib));
+}
+
+TEST_F(test_device_memory_pressure_warning, default_margin_flags_near_capacity_before_exceeding) {
+ // Default margin_ratio is 0.9: weights at 95% of capacity should already be flagged,
+ // even though they technically still "fit".
+ const uint64_t device_bytes = 100ULL * 1024 * 1024 * 1024;
+ const uint64_t weights_95pct = static_cast<uint64_t>(0.95 * device_bytes);
+ ASSERT_TRUE(program::is_device_memory_pressure_risk(weights_95pct, device_bytes));
+}
+
+TEST_F(test_device_memory_pressure_warning, well_under_margin_is_not_flagged) {
+ const uint64_t device_bytes = 100ULL * 1024 * 1024 * 1024;
+ const uint64_t weights_50pct = static_cast<uint64_t>(0.50 * device_bytes);
+ ASSERT_FALSE(program::is_device_memory_pressure_risk(weights_50pct, device_bytes));
+}
+
+TEST_F(test_device_memory_pressure_warning, exact_boundary_is_flagged) {
+ const uint64_t device_bytes = 100ULL * 1024 * 1024 * 1024;
+ const uint64_t weights_at_margin = static_cast<uint64_t>(0.9 * device_bytes);
+ ASSERT_TRUE(program::is_device_memory_pressure_risk(weights_at_margin, device_bytes, 0.9));
+}
+
+TEST_F(test_device_memory_pressure_warning, custom_margin_ratio_is_respected) {
+ const uint64_t device_bytes = 100ULL * 1024 * 1024 * 1024;
+ const uint64_t weights_60pct = static_cast<uint64_t>(0.60 * device_bytes);
+ // With a stricter 0.5 margin, 60% usage should be flagged...
+ ASSERT_TRUE(program::is_device_memory_pressure_risk(weights_60pct, device_bytes, 0.5));
+ // ...but with the default 0.9 margin, it should not be.
+ ASSERT_FALSE(program::is_device_memory_pressure_risk(weights_60pct, device_bytes));
+}
+
+TEST_F(test_device_memory_pressure_warning, zero_device_memory_is_never_a_risk) {
+ // Defensive: an unknown/zero device capacity must not be (mis)treated as "always exceeded".
+ ASSERT_FALSE(program::is_device_memory_pressure_risk(1ULL, 0ULL));
+}
+
+TEST_F(test_device_memory_pressure_warning, zero_weights_is_never_a_risk) {
+ ASSERT_FALSE(program::is_device_memory_pressure_risk(0ULL, 100ULL * 1024 * 1024 * 1024));
+}
+
+// -------------------------------------------------------------------------------------------
+// Smoke test: building a real (tiny) program must not throw or otherwise misbehave now that
+// check_device_memory_pressure() runs as part of build_program(). The synthetic topology here
+// is far too small to ever trip the warning, so this only guards against crashes/exceptions,
+// not the warning's content (which is logged to stderr and not asserted on here).
+// -------------------------------------------------------------------------------------------
+
+TEST_F(test_device_memory_pressure_warning, program_build_does_not_throw) {
+ ExecutionConfig cfg = get_test_default_config(get_test_engine());
+ std::shared_ptr<cldnn::engine> engine1 = create_test_engine();
+
+ auto input1 = engine1->allocate_memory({ data_types::f16, format::bfyx, { 2, 2, 32, 32 } });
+ auto input2 = engine1->allocate_memory({ data_types::f16, format::bfyx, { 2, 2, 32, 32 } });
+ topology topology(
+ input_layout("input1", input1->get_layout()),
+ input_layout("input2", input2->get_layout()),
+ permute("permute1", input_info("input1"), { 0, 3, 1, 2 }),
+ permute("permute2", input_info("input2"), { 0, 2, 1, 3 }),
+ eltwise("eltw", { input_info("permute1"), input_info("permute2") }, eltwise_mode::sum, data_types::f16),
+ reorder("output", input_info("eltw"), format::bfyx, data_types::f32)
+ );
+
+ std::shared_ptr<cldnn::program> prog;
+ ASSERT_NO_THROW(prog = program::build_program(*engine1, topology, cfg));
+ ASSERT_NE(prog, nullptr);
+}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment