OpenVINO exporter: let OpenVINO fuse attention, RoPE, the KV cache and compressed matmuls (#50311)
* OpenVINO exporter: repeat GQA heads with `repeat_kv` so OpenVINO fuses its KV-cache attention
The exporter swapped `repeat_kv` for `repeat_interleave`, and its SDPA patch repeated
K/V the same way. `repeat_interleave` lowers to a `Gather` between the stateful KV
cache and the attention, which OpenVINO's CPU plugin can't fuse: every attention ran
decomposed into MatMul/Softmax and copied the cache each step. Keep transformers'
`repeat_kv` (unsqueeze, expand, reshape) in both places.
The frozen KV length the `repeat_kv` patch worked around no longer reproduces on
OpenVINO 2026.4: Qwen2.5-0.5B and Qwen3-0.6B decode a left-padded batch of two, 48
tokens past the prompt, token-for-token like eager.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OpenVINO exporter: drop the SDPA patch's fully-masked-row zeroing
The patch zeroed the attention output of rows that mask every key, with a select on a
mask-derived condition after each SDPA. OpenVINO's CPU plugin miscomputes that pattern:
EmbeddingGemma2's fp32 export returned NaN (layer 2 onwards) with no row fully masked.
Without it the fp32 export matches eager (token cosine 1.0000), and a fully masked row
gets the uniform average its dtype-minimum mask describes, as transformers' eager
attention gives it. Generated tokens are unaffected: Qwen2.5-0.5B and Qwen3-0.6B still
decode a left-padded batch of two like eager.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OpenVINO exporter: trace bf16/fp16 models as f32 graphs
OpenVINO's CPU plugin fuses its kernels on f32-typed graphs and picks the inference
precision itself (bf16 on CPUs with AMX or AVX-512 BF16). A bf16-typed graph misses
some of them, notably the compressed-weight matmuls of an NNCF int8/int4 graph:
every weight was decompressed on each call. Trace and convert in f32, then give each
parameter and buffer, and each config, its own dtype back. With an f32 inference hint
the f32 graph matches eager f32 on the same weights token for token.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OpenVINO exporter: give KV-cache variables the update's dims instead of reshaping the update
A batch-1 sample specializes the batch of the cache concat to 1 while its variable stays batch-dynamic, so
`_pin_state_update_shapes` put a Reshape between the concat and its `Assign`. That Reshape keeps the CPU
plugin from fusing the cache into its stateful SDPA. The variable now takes the dims the update fixes, and a
pinning Reshape is only added where the variable fixes a dim the update leaves dynamic.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OpenVINO exporter: fold the frontend's marked scalar casts so RoPE fuses
The PyTorch frontend translates `aten.neg` as a multiply by a `Convert`ed -1, with the Convert marked
DisableConstantFolding and the multiply marked as a dequantization node. Until those marks are dropped,
which only happens on serialization, the CPU plugin's RoPE fusion doesn't match `rotate_half`. Fold such scalar
Converts into constants and drop the marks on their consumers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OpenVINO exporter: store a 16-bit model's weights as 16-bit constants plus a Convert
After the f32 trace, restore bf16/fp16 weights as 16-bit constants followed by a Convert to f32. The CPU plugin
decompresses this pattern inside its FullyConnected, and the IR keeps the checkpoint's exact values at their
original size.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Test that OpenVINO fuses an exported decoder's attention, RoPE, KV cache and compressed matmuls
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OpenVINO exporter: give the 16-bit model helpers their own section
Moves the f32 trace and the 16-bit weight restore into a "16-bit models" section and folds the scalar
casts inside `_convert_to_openvino`, so `export()` reads as on main. `_pin_state_update_shapes` handles
the variable refinement inline.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Move the OpenVINO fusion tests to test_openvino.py and run them on every decoder family
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OpenVINO exporter: keep linear and embedding weights 16-bit during the f32 trace, as optimum-intel does
Upcasting every weight in place doubled the model's memory during export (5.4 GiB peak for a 1.1 GiB
bf16 Qwen3-0.6B, against 1.6 GiB on main) and needed a pass restoring 16-bit constants afterwards. Like
OV's `__make_16bit_traceable`, linear and embedding weights now stay 16-bit behind zero-cost f32
placeholders, and patched `nn.Linear` / `nn.Embedding` forwards cast them to f32 inside the graph: they
land as a 16-bit constant and a `Convert` directly, with no copy and no restore pass. The embedding casts
before the lookup, so a tied `lm_head` stays a compressed-weight matmul after NNCF quantization.
`aten.neg` is traced as a multiply by -1 instead of folding the frontend's marked scalar casts after
conversion, which gives OV's RoPE fusion the same pattern.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Mark the OpenVINO fusion tests slow, like the other export tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OpenVINO exporter: trace MoE experts as a tiled block so OpenVINO fuses them into GatherMatmuls
Every `@use_experts_implementation` experts module is traced with a forward that runs every expert on every
token and weights the outputs by the dense routing weights, the block OpenVINO's CPU plugin rewrites into
`GatherMatmul`s that only compute the routed experts (`ConvertTiledMoeBlockTo{2,3}GatherMatmuls`). Its
expert weights stay 16-bit through the f32 trace, like linear weights: the pass only matches 16-bit weights
with an in-graph `Convert`, and upcasting them doubled a 16-bit MoE model's expert memory during export.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Declare each OpenVINO fusion test model's expected fused kernels in MODELS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Drop the OpenVINO fusion test models' config overrides: only Phi-3 needs a pad token inside the tiny vocabulary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Build the OpenVINO fusion test models from each model's own tester
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OpenVINO exporter: give sliding-window cache layers a full cache so their attention fuses
A `DynamicSlidingWindowLayer` slices its cache to the window on every update, which puts a `Slice` between
the KV-cache concat and its state and keeps OpenVINO's CPU plugin from fusing the cache into its attention
(Mistral, Gemma3's sliding layers). Under the OpenVINO trace these layers take `DynamicLayer`'s update and
mask sizes: the sliding mask still applies the window on absolute positions, and their cache grows with the
sequence. Mistral and Gemma3 join the fusion tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OpenVINO exporter: support OpenVINO 2026.5, which no longer decomposes exported programs
OpenVINO 2026.5 converts exported programs without `run_decompositions` and removes
`openvino.frontend.pytorch.torchdynamo.export_decompositions`, whose `ops_to_not_decompose`
the exporter imports at module level, so the exporter failed to import. The import is now
optional: with OpenVINO < 2026.5 the exporter decomposes with OpenVINO's table as before, and
with 2026.5 it converts the exported program as is, like OpenVINO's own frontend.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OpenVINO exporter: enter the 16-bit model patch with the other export patches
`_traced_in_float32` becomes the public `patch_16bit_model`, entered in the same `with` as
`apply_patches("openvino")` and `patch_model_outputs`, with the trace and the conversion inside.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Exporters: give sliding-window cache layers a full cache on every backend
A `DynamicSlidingWindowLayer` sizes its mask from a Python token counter and slices its cache to the
window, so a trace fixes both at the sample's length and the exported decode fails past the window: the
ONNX decode of a tiny Mistral with a 4-token window errors on its first step. Moving the full-cache patch
from the OpenVINO patches to the shared dynamo ones fixes that for every backend (16 tokens past the
window equal to eager with ONNX Runtime), and OpenVINO still gets the cache shape it fuses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename the OpenVINO fusion tests to tests/exporters/test_fusions.py
One file for every backend's fusion tests, one class each, the way
test_export.py and test_quantization.py hold every backend.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Describe tests/exporters/test_fusions.py as every backend's fusion tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Share the fusion tests' model table across backends
`TESTERS` and `_model` are the same in the ONNX Runtime fusion tests, so both
backends' classes run on the same tiny models; OpenVINO's expected kernels move
to `OPENVINO_FUSIONS` / `OPENVINO_MOE_FUSIONS`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Cache: let sliding-window layers of a DynamicCache keep every token
`DynamicCache(full_sliding_layers=True)` builds sliding-window and chunked
attention layers as full ones: the attention mask still applies the window, so
outputs are unchanged, and the cache grows with the sequence. Some runtimes
need the cache update to feed their state as is to fuse it into attention
(OpenVINO, like optimum-intel's exports).
`generate` passes `GenerationConfig.cache_config` to the `DynamicCache` it
builds, as the field documents ("arguments used in the key-value cache
class"), so `GenerationConfig(cache_config={"full_sliding_layers": True})`
sets it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OpenVINO: keep sliding-window caches full through the generation config
The `"dynamo"` patch that gave every `DynamicSlidingWindowLayer` a
`DynamicLayer`'s methods during the trace is gone: it changed every backend's
exports, and nothing in the generation config said so, while the ONNX,
ExecuTorch and torch.export runtimes rebuild the cache from it.
`OpenVINOExporter.export_for_generation` now captures a stateful export with
`GenerationConfig(cache_config={"full_sliding_layers": True})` when the
generation config sets no `cache_config`: OpenVINO's CPU plugin only fuses a KV
cache into its attention when the update feeds the state as is, and a stateful
model keeps its cache inside, so nothing rebuilds it at runtime.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Revert "OpenVINO: keep sliding-window caches full through the generation config"
This reverts commit f2ca057867216fcc325152cd3e95df327ac7b2c7.
* Revert "Cache: let sliding-window layers of a DynamicCache keep every token"
This reverts commit 18ba237cf591184826d8a93d4c8d61bd1fac658d.
* Note that the sliding-window export patch goes with #49542
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OpenVINO exporter: address review
- `is_openvino_greater_or_equal` gates the 2026.5 decomposition change instead of an ImportError.
- `patch_16bit_model`: loops, a shorter docstring, `_16bit_parameter` (it returns biases too).
- `_configs` walks the pretrained models only.
- `_tiled_experts_forward` follows `batched_mm_experts_forward`'s layout and names, with a `projection` helper.
- `_pin_state_update_shapes`: one early exit per case.
- The sliding-window patch's docstring is shorter.
- The fusion tests build their models in the dtype (`from_config(dtype=...)`), not cast after, so float32 buffers stay float32.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Docs: OpenVINO 16-bit exports, the full sliding-window cache, and OpenVINO 2026.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: free the OpenVINO fusion tests' memory with MemoryCleanupMixin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Docs: keep the OpenVINO notes to one line each
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Docs: drop the OpenVINO 16-bit note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: read each OpenVINO output under the name the export gave it
Fused RoPE merges the replaced nodes' tensor names (`add_4:0`, numeric ids) onto the key-cache outputs of partial-rotary models (gpt_neox, glm4_moe, bamba, ...): the first non-numeric name was no longer the declared one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OpenVINO exporter: convert `torch.mm(..., out_dtype=...)`
OV has no conversion rule for `aten.mm.dtype` (Kolibri1's router on CUDA): rewrite it as an `mm` on its inputs
cast to `out_dtype`, as the ONNX exporter translates it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* OpenVINO exporter: keep a zero-width cache state static and growing
Mistral Large 4's NoPE layers cache a rotary part of width 0. The state init spec used `0` both for the growing
dim and for such a static zero-size dim, so the width went dynamic and `ReadValue` rejected the variable; the
growing dim is now marked `"grow"`. `_fix_empty_cat` also dropped the decode cat of two empty caches, so the
state never grew: it now keeps a cat whose operands are both empty with the same rank.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: skip sliding-window caches in export comparisons, exactness for TimesFM and PP-DocLayoutV4
Exports keep a full cache on sliding-window layers (the sliding mask still applies the window) while eager keeps
only the window, so their cache outputs aren't compared. TimesFM's left-padding patches attend to no key and
PP-DocLayoutV4's tiny config ties every `topk` score, so neither has a defined value to compare on OpenVINO.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>