Skip to content

Commit 1ad4160

Browse files
titaiwangmsCopilot
andauthored
Enable static-cache + Flash-attention path (runtime-gated, ready for ORT #28958) (#364)
## What mobius `main` already emits the correct maskless `is_causal=1` + `nonpad_kv_seqlen` + `TensorScatter` static-cache decoder graph. This PR adds the runtime **enablement, verification, and CI** for that path on the CUDA Flash-attention kernel — it is **not** graph surgery. Nothing in the emitted graph is reverted or rewritten. The path becomes runnable once an ONNX Runtime build containing [microsoft/onnxruntime#28958](microsoft/onnxruntime#28958) is installed (Flash eligibility widening for the bottom-right-causal errata [onnx/onnx#8068](onnx/onnx#8068)). ## Issues - Closes #329 — this PR fully delivers the static-cache numerical-parity CI coverage that #329 asks for (`tests/static_cache_parity_test.py`: static vs dynamic vs HuggingFace, chunked-prefill zero-guard, V convex-hull valid-row invariant, exact-triangle `nonpad == q_seq`). - Part of #345 — this PR delivers the code/probe/CI half of #345 ("emit maskless `is_causal=1` + `nonpad_kv_seqlen` static-cache graph once upstream lands"). The ORT pin bump + node-count rebaseline + ONNX 1.22 pin + skill docs remain deferred until an official PyPI ORT release with microsoft/onnxruntime#28958, so #345 stays open (intentionally `Part of`, not `Closes`). ## Changes - **(a) Conditional opset 24→23 lowering** in `_builder.py` via `_graph_requires_opset24` (a **recursive** subgraph scan) + `_apply_opset_lowering`, so graphs carrying `TensorScatter` or the `Attention` `nonpad_kv_seqlen` input correctly **stay at opset 24**. Flag-gated, default off. (`_builder_test.py` exercises the real branch.) - **(b) Canonical capability probe** `src/mobius/_testing/ort_capabilities.py` — `supports_static_cache_flash()` is a **functional, fail-closed-but-loud runtime probe** (not a version-string check). It builds a minimal `TensorScatter` + maskless `Attention` graph and runs it on CUDA. A **known-answer value check** closes a latent CPU-fallback fail-open: because ORT implicitly appends the CPU EP, a CUDA build that declines the node would silently run on CPU with wrong (top-left) values; the probe's deterministic reference (`2.0`) rejects that → `NEEDS_FIX` → `False`. A structured `_ProbeOutcome` enum distinguishes the expected pre-#28958 reject from unexpected probe errors (logged with `exc_info`). - **(c) Static-cache parity test (#329)** `tests/static_cache_parity_test.py` — static vs dynamic vs HuggingFace, chunked-prefill zero-guard, V convex-hull valid-row invariant, exact-triangle `nonpad == q_seq`. - **(d) e2e CUDA Flash-dispatch test** `tests/static_cache_flash_e2e_test.py` — asserts the ONNX-domain `Attention` actually routes to **Flash** (via VERBOSE dispatch capture), gated on SM ≥ 8.0 (`_flash_capable_gpu`) and `onnxruntime_QUICK_BUILD`. - **(e) Probe consolidation** — deleted `tests/_static_cache_support.py`; one canonical probe module, no shim. ## Gating All new GPU tests **skip automatically** unless the installed ORT can actually run the path (probe-gated). CI stays green today and **flips green automatically** once an official ORT release containing microsoft/onnxruntime#28958 is installed — **zero code change needed** to enable. ## Verified - **5/5** static-cache tests pass on an A100 (SM 8.0) with a post-#28958 ORT (full targeted suite: 14 passed including builder tests). - **Fail-closed on pre-#28958 confirmed two ways**: (1) source — the CUDA kernel guard `causal_cross_no_past = is_causal && (q_seq != total_seq) && (past == 0)` in `Attention<T>::ComputeInternal` (`llm/attention.cc`) raises `NOT_IMPLEMENTED` for the `S_q=1` decode shape (no fast-path bypass); (2) empirically — a real `onnxruntime-gpu==1.27.0` isolated venv raises `NotImplemented` → `supports_static_cache_flash() == False`. ## Explicitly out of scope (intentionally held, separate follow-ups) - ORT dependency pin bump + node-count rebaseline + ONNX 1.22 pin + skill docs — parked until an official PyPI ORT release with #28958 exists. - `examples/static_cache_generation.py` nonpad-before-scatter check (`verify-example-nonpad`). - Pre-existing `RUF067` ruff version-skew in `pyproject.toml`. - onnxruntime-genai#2204-blocked bias-decoder external-KV work (#349). ## References - [onnx/onnx#8068](onnx/onnx#8068) — causal top-left → bottom-right errata - [microsoft/onnxruntime#28958](microsoft/onnxruntime#28958) — Flash eligibility widening --- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Signed-off-by: titaiwang <titaiwang@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bab4068 commit 1ad4160

8 files changed

Lines changed: 2099 additions & 30 deletions

src/mobius/_builder.py

Lines changed: 86 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -213,29 +213,95 @@ def forward(self, op, input_ids, attention_mask,
213213
trace=trace_optimization,
214214
)
215215

216-
# Lower default-domain opset from 24 to 23 when the target EP doesn't
217-
# register opset 24 kernels for standard ops (Reshape, RMSNormalization,
218-
# etc.). Without this, those ops fall to CPU and produce ~280 memcpy
219-
# nodes that destroy performance. The flag defaults to True; set
220-
# MOBIUS_ORT_LOWER_OPSET_FOR_EP=0 to disable for EPs that support
221-
# opset 24 natively.
222-
if flags.ort_lower_opset_for_ep and execution_provider != "default":
223-
for name, model in pkg.items():
224-
if "" in model.graph.opset_imports:
225-
original = model.graph.opset_imports[""]
226-
model.graph.opset_imports[""] = 23
227-
logger.warning(
228-
"Lowered opset %d→23 for '%s' (EP=%s). "
229-
"ORT does not yet register opset %d kernels for this EP. "
230-
"Track https://github.com/microsoft/onnxruntime/issues/27729",
231-
original,
232-
name,
233-
execution_provider,
234-
original,
235-
)
216+
_maybe_apply_opset_lowering(pkg, execution_provider)
236217
return pkg
237218

238219

220+
# Attention input index for the optional ``nonpad_kv_seqlen`` operand. This
221+
# operand (external/static KV cache length) and the TensorScatter op are
222+
# defined only in opset 24, so a graph using either must not declare opset 23.
223+
_ATTENTION_NONPAD_KV_SEQLEN_INPUT_INDEX = 6
224+
225+
226+
def _maybe_apply_opset_lowering(pkg: ModelPackage, execution_provider: str) -> None:
227+
"""Lower the default-domain opset from 24 to 23 where it is safe to do so.
228+
229+
Some EPs (older ORT builds) don't register opset 24 kernels for standard
230+
ops (Reshape, RMSNormalization, etc.). Without lowering, those ops fall to
231+
CPU and produce ~280 memcpy nodes that destroy performance. The
232+
``MOBIUS_ORT_LOWER_OPSET_FOR_EP`` flag (default False) opts a deployment
233+
into the lowering; it is a no-op for the ``"default"`` and ``"cpu"`` EPs
234+
(the CPU EP already has opset-24 kernels), matching the inference-side gate
235+
``ort_inference._should_lower_opset``.
236+
237+
Any sub-model that uses opset-24-only semantics (TensorScatter, or
238+
Attention with a non-empty input #6 ``nonpad_kv_seqlen``) is left at opset
239+
24: declaring opset 23 on such a graph is invalid and would strip the
240+
static-cache Flash path. See :func:`_graph_requires_opset24`. The decision
241+
is made per sub-model, so a mixed package lowers its standard sub-models
242+
while preserving its static-cache sub-models.
243+
"""
244+
if not flags.ort_lower_opset_for_ep:
245+
return
246+
# Mirror ort_inference._should_lower_opset: the "default" EP is a no-op, and
247+
# the CPU EP already registers opset-24 kernels, so lowering there is both
248+
# unnecessary and inconsistent with the inference-side gate.
249+
if execution_provider in ("default", "cpu"):
250+
return
251+
for name, model in pkg.items():
252+
if "" not in model.graph.opset_imports:
253+
continue
254+
if _graph_requires_opset24(model.graph):
255+
logger.info(
256+
"Skipped opset→23 lowering for '%s' (EP=%s): graph uses "
257+
"opset-24-only ops (TensorScatter / Attention nonpad_kv_seqlen). "
258+
"Preserving opset 24 to keep the static-cache Flash path valid.",
259+
name,
260+
execution_provider,
261+
)
262+
continue
263+
original = model.graph.opset_imports[""]
264+
model.graph.opset_imports[""] = 23
265+
logger.warning(
266+
"Lowered opset %d→23 for '%s' (EP=%s). "
267+
"ORT does not yet register opset %d kernels for this EP. "
268+
"Track https://github.com/microsoft/onnxruntime/issues/27729",
269+
original,
270+
name,
271+
execution_provider,
272+
original,
273+
)
274+
275+
276+
def _graph_requires_opset24(graph: ir.Graph) -> bool:
277+
"""Return True if the graph uses opset-24-only default-domain semantics.
278+
279+
Lowering the default-domain opset import to 23 on such a graph is invalid
280+
and would silently break the static-cache Flash-attention path. A graph
281+
requires opset 24 when it contains:
282+
283+
- a ``TensorScatter`` node (default domain), or
284+
- an ``Attention`` node consuming a non-empty input #6 (``nonpad_kv_seqlen``).
285+
286+
The scan is recursive: nodes nested inside ``If``/``Loop``/``Scan``
287+
subgraphs are inspected too, so a future graph that buries one of these ops
288+
in a control-flow body is still detected.
289+
"""
290+
for node in ir.traversal.RecursiveGraphIterator(graph):
291+
if node.domain not in ("", "ai.onnx"):
292+
continue
293+
if node.op_type == "TensorScatter":
294+
return True
295+
if node.op_type == "Attention":
296+
inputs = node.inputs
297+
if (
298+
len(inputs) > _ATTENTION_NONPAD_KV_SEQLEN_INPUT_INDEX
299+
and inputs[_ATTENTION_NONPAD_KV_SEQLEN_INPUT_INDEX] is not None
300+
):
301+
return True
302+
return False
303+
304+
239305
def build(
240306
model_id: str,
241307
task: str | ModelTask | None = None,

src/mobius/_builder_test.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Unit tests for :mod:`mobius._builder` opset-lowering logic.
5+
6+
These tests are CPU-authorable: they construct tiny ONNX-IR graphs directly
7+
(no weights, no network) and exercise the conditional opset 24→23 lowering
8+
that preserves the static-cache Flash-attention path.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import onnx_ir as ir
14+
import pytest
15+
16+
from mobius._builder import _graph_requires_opset24, _maybe_apply_opset_lowering, flags
17+
from mobius._model_package import ModelPackage
18+
19+
20+
def _make_value(name: str) -> ir.Value:
21+
return ir.Value(name=name)
22+
23+
24+
def _graph_with(nodes: list[ir.Node], *, opset: int = 24) -> ir.Graph:
25+
return ir.Graph(inputs=[], outputs=[], nodes=nodes, opset_imports={"": opset})
26+
27+
28+
def _model_with(nodes: list[ir.Node], *, opset: int = 24) -> ir.Model:
29+
return ir.Model(_graph_with(nodes, opset=opset), ir_version=10)
30+
31+
32+
def _attention_nonpad_inputs() -> list[ir.Value | None]:
33+
# q, k, v, attn_mask, past_key, past_value, nonpad_kv_seqlen (input #6).
34+
return [
35+
_make_value("q"),
36+
_make_value("k"),
37+
_make_value("v"),
38+
None,
39+
None,
40+
None,
41+
_make_value("nonpad_kv_seqlen"),
42+
]
43+
44+
45+
def _static_cache_nodes() -> list[ir.Node]:
46+
return [
47+
ir.Node(
48+
"",
49+
"TensorScatter",
50+
inputs=[_make_value("p"), _make_value("u"), _make_value("i")],
51+
),
52+
ir.Node("", "Attention", inputs=_attention_nonpad_inputs(), num_outputs=1),
53+
]
54+
55+
56+
def _standard_nodes() -> list[ir.Node]:
57+
return [ir.Node("", "Reshape", inputs=[_make_value("x"), _make_value("shape")])]
58+
59+
60+
def test_graph_requires_opset24_tensor_scatter() -> None:
61+
# A TensorScatter node (opset-24-only) must force opset 24 retention.
62+
node = ir.Node(
63+
"",
64+
"TensorScatter",
65+
inputs=[_make_value("past"), _make_value("update"), _make_value("idx")],
66+
)
67+
assert _graph_requires_opset24(_graph_with([node])) is True
68+
69+
70+
def test_graph_requires_opset24_attention_nonpad_kv_seqlen() -> None:
71+
# Attention consuming input #6 (nonpad_kv_seqlen) is opset-24-only.
72+
node = ir.Node("", "Attention", inputs=_attention_nonpad_inputs())
73+
assert _graph_requires_opset24(_graph_with([node])) is True
74+
75+
76+
def test_graph_requires_opset24_attention_without_nonpad() -> None:
77+
# A plain Attention (no input #6) does not require opset 24.
78+
inputs = [_make_value("q"), _make_value("k"), _make_value("v")]
79+
node = ir.Node("", "Attention", inputs=inputs)
80+
assert _graph_requires_opset24(_graph_with([node])) is False
81+
82+
83+
def test_graph_requires_opset24_standard_ops_only() -> None:
84+
# Standard ops (Reshape) are safe to lower.
85+
node = ir.Node("", "Reshape", inputs=[_make_value("x"), _make_value("shape")])
86+
assert _graph_requires_opset24(_graph_with([node])) is False
87+
88+
89+
def test_graph_requires_opset24_ignores_custom_domain() -> None:
90+
# Same op names in a non-default domain must not trigger retention.
91+
node = ir.Node(
92+
"com.example",
93+
"TensorScatter",
94+
inputs=[_make_value("a"), _make_value("b")],
95+
)
96+
assert _graph_requires_opset24(_graph_with([node])) is False
97+
98+
99+
def test_graph_requires_opset24_detects_nested_subgraph() -> None:
100+
# An opset-24-only op buried inside an If/Loop/Scan body must still be
101+
# detected: the scan is recursive (RecursiveGraphIterator).
102+
then_branch = _graph_with([ir.Node("", "TensorScatter", inputs=[_make_value("a")])])
103+
else_branch = _graph_with([ir.Node("", "Identity", inputs=[_make_value("b")])])
104+
if_node = ir.Node(
105+
"",
106+
"If",
107+
inputs=[_make_value("cond")],
108+
attributes=[
109+
ir.AttrGraph("then_branch", then_branch),
110+
ir.AttrGraph("else_branch", else_branch),
111+
],
112+
)
113+
outer = _graph_with([if_node])
114+
assert _graph_requires_opset24(outer) is True
115+
116+
117+
def test_maybe_apply_opset_lowering_mixed_package(monkeypatch: pytest.MonkeyPatch) -> None:
118+
# Drive the REAL lowering branch in build_from_module via _maybe_apply_opset_lowering.
119+
# A mixed package must be handled per sub-model: the static-cache sub-model
120+
# keeps opset 24, the standard sub-model is lowered to 23.
121+
monkeypatch.setattr(flags, "ort_lower_opset_for_ep", True)
122+
pkg = ModelPackage(
123+
{
124+
"model": _model_with(_static_cache_nodes()),
125+
"embedding": _model_with(_standard_nodes()),
126+
}
127+
)
128+
129+
_maybe_apply_opset_lowering(pkg, execution_provider="cuda")
130+
131+
assert pkg["model"].graph.opset_imports[""] == 24
132+
assert pkg["embedding"].graph.opset_imports[""] == 23
133+
134+
135+
def test_maybe_apply_opset_lowering_skipped_for_default_ep(
136+
monkeypatch: pytest.MonkeyPatch,
137+
) -> None:
138+
# The "default" EP gate: lowering never fires even with the flag enabled.
139+
monkeypatch.setattr(flags, "ort_lower_opset_for_ep", True)
140+
pkg = ModelPackage({"embedding": _model_with(_standard_nodes())})
141+
142+
_maybe_apply_opset_lowering(pkg, execution_provider="default")
143+
144+
assert pkg["embedding"].graph.opset_imports[""] == 24
145+
146+
147+
def test_maybe_apply_opset_lowering_skipped_for_cpu_ep(
148+
monkeypatch: pytest.MonkeyPatch,
149+
) -> None:
150+
# The "cpu" EP gate: the CPU EP already registers opset-24 kernels, so
151+
# lowering never fires for it even with the flag enabled (mirrors the
152+
# inference-side ort_inference._should_lower_opset CPU skip).
153+
monkeypatch.setattr(flags, "ort_lower_opset_for_ep", True)
154+
pkg = ModelPackage({"embedding": _model_with(_standard_nodes())})
155+
156+
_maybe_apply_opset_lowering(pkg, execution_provider="cpu")
157+
158+
assert pkg["embedding"].graph.opset_imports[""] == 24
159+
160+
161+
def test_maybe_apply_opset_lowering_skipped_when_flag_disabled(
162+
monkeypatch: pytest.MonkeyPatch,
163+
) -> None:
164+
# The flag gate: lowering never fires when the flag is off (the default).
165+
monkeypatch.setattr(flags, "ort_lower_opset_for_ep", False)
166+
pkg = ModelPackage({"embedding": _model_with(_standard_nodes())})
167+
168+
_maybe_apply_opset_lowering(pkg, execution_provider="cuda")
169+
170+
assert pkg["embedding"].graph.opset_imports[""] == 24

0 commit comments

Comments
 (0)