Skip to content

Commit 0e78f1c

Browse files
authored
refactor: move prune_lm_head from global flag to CausalLMTask option
Remove the MOBIUS_PRUNE_LM_HEAD feature flag and replace it with a prune_lm_head constructor parameter on CausalLMTask (and HybridCausalLMTask), mirroring the stable-API pattern already used by the static_cache option. When prune_lm_head=True, the task inserts Gather(axis=1, index=-1) followed by Unsqueeze(axis=1) on the logits output after calling module(), changing the output shape from [B, S, vocab] to [B, 1, vocab]. ONNX Runtime's graph optimizer can push this Gather backward through the LM head MatMul to avoid computing all-token logits during prefill. Update tests to use CausalLMTask(prune_lm_head=True) instead of the override_flags context manager. Also merges main branch changes (tencent_q1_0_use_native_2bit and static_cache_bias flags). Signed-off-by: Copilot <copilot@github.com> Signed-off-by: GitHub <noreply@github.com>
2 parents bc1f6e8 + 9b22d8d commit 0e78f1c

471 files changed

Lines changed: 62364 additions & 4474 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/adding-a-new-model/SKILL.md

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ class MyCausalLMModel(CausalLMModel):
112112
self.lm_head = Linear(config.hidden_size, config.vocab_size, bias=False)
113113
```
114114

115+
> If your text model **subclasses `TextModel`** (rather than `nn.Module`) and
116+
> overrides `__init__` with `nn.Module.__init__(self)` to swap in a custom
117+
> decoder layer, you must also set `self.config = config` in that subclass —
118+
> `TextModel.forward` reads `self.config`. See troubleshooting §7.
119+
120+
115121
#### Class metadata attributes
116122

117123
Every registered model class should set two class-level attributes:
@@ -190,9 +196,28 @@ Also export from `src/mobius/models/__init__.py`.
190196

191197
### 6. Update `ArchitectureConfig.from_transformers` if needed
192198

193-
If the model has unusual config fields, update `from_transformers()` in
194-
`_configs.py`. Use safe defaults (1.0 for multipliers, None for optional
195-
features) so existing models are unaffected.
199+
If the model has unusual top-level config fields (vocab size, head counts,
200+
RoPE knobs, etc.), update `from_transformers()` in `src/mobius/_configs/_base.py`.
201+
Use safe defaults (1.0 for multipliers, None for optional features) so
202+
existing models are unaffected.
203+
204+
**For audio- or vision-capable models** (i.e. models whose HF config has an
205+
`audio_config` or `vision_config` sub-object), prefer adding a per-model hook
206+
under `src/mobius/_configs/per_model/` rather than editing the central file:
207+
208+
```python
209+
# src/mobius/_configs/per_model/_my_model_vision.py
210+
from mobius._configs._extractors import register_vision_hook
211+
212+
@register_vision_hook("my_model_type")
213+
def _my_model_vision(config, parent_config, model_type, fields):
214+
fields.update(hidden_size=..., num_attention_heads=..., ...)
215+
return None # contribute fields, defer VisionConfig instantiation
216+
```
217+
218+
Then add the new module to `src/mobius/_configs/per_model/__init__.py` so its
219+
side-effect registration runs at import time. The dispatcher filters hooks by
220+
the declared model_type strings, so unrelated models never see your hook.
196221

197222
### 7. Write tests
198223

@@ -343,9 +368,10 @@ config fields (`embedding_multiplier`, `attention_multiplier`,
343368

344369
### 4. Config fields not extracted
345370

346-
**Symptom:** Model builds but multipliers default to 1.0. Add extraction
347-
to `ArchitectureConfig.from_transformers()` in `_configs.py` with safe
348-
defaults.
371+
**Symptom:** Model builds but multipliers default to 1.0. Add extraction
372+
to `ArchitectureConfig.from_transformers()` in `src/mobius/_configs/_base.py`
373+
with safe defaults. For audio/vision-specific fields, register a per-model
374+
hook under `src/mobius/_configs/per_model/` instead — see step 6 above.
349375

350376
### 5. Debugging workflow for logit mismatches
351377

@@ -376,6 +402,25 @@ differs between loads, suspect `_init_weights` corruption. Set
376402
deterministic, `_init_weights` is the culprit. Then compare specific
377403
parameters between the loaded model and the safetensors checkpoint.
378404

405+
### 7. `AttributeError: '<X>TextModel' object has no attribute 'config'`
406+
407+
**Symptom:** Building (or running build-graph / GQA rewrite-rule tests for)
408+
a model raises `AttributeError: '…TextModel' object has no attribute
409+
'config'` from inside `TextModel.forward` (e.g. `_gqa_local_window_size`).
410+
411+
**Root cause:** The base `TextModel.__init__` sets `self.config = config`,
412+
and `TextModel.forward` relies on it (sliding-window detection, etc.). A
413+
`TextModel` **subclass** that overrides `__init__` with
414+
`nn.Module.__init__(self)` — instead of `super().__init__(config)` — to swap
415+
in a custom decoder layer must re-establish the contract by setting
416+
`self.config = config` itself. Forgetting it crashes only that model.
417+
418+
**Fix:** Add `self.config = config` right after `nn.Module.__init__(self)`
419+
in the subclass `__init__`. Real examples: `Glm4TextModel` (`models/glm.py`),
420+
`_LoRATextModel` (`models/phi.py`). Do **not** paper over it with
421+
`getattr(self, "config", None)` in the base — that silently disables
422+
config-driven features for the offending subclass.
423+
379424
> For additional troubleshooting (gated attention split ordering, DeltaNet
380425
> scaling, identity node folding, fp32 upcast patterns, multi-token prefill,
381426
> embedding table off-by-one), read

.agents/skills/attention-optimization/SKILL.md

Lines changed: 93 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ Use this skill when:
2323
| Scenario | Recommended | Why |
2424
|----------|------------|-----|
2525
| Causal only | `attn_mask=None` + `is_causal=1` | Enables Flash (fastest for prefill) |
26-
| Padding (batch>1) | `nonpad_kv_seqlens` (best) or bool mask | `nonpad_kv_seqlens` enables Flash + shared buffer with no mask |
27-
| Sliding window (simple) | Bool mask | Equally precise as float, uses less memory |
26+
| Padding (batch>1) | `nonpad_kv_seqlens` (+ static cache) or bool mask | `nonpad_kv_seqlens` enables Flash with no mask; pair with `TensorScatter` static cache for decode (can't combine with `past_key`/`past_value` inputs) |
27+
| Sliding window (simple) | GQA `local_window_size` or bool mask | `local_window_size` keeps the fast GQA path; bool mask if you need ONNX Attention |
2828
| Complex (sliding+KV-shared+dual head_dim) | Float additive bias | Avoids mask construction bugs in multi-constraint patterns |
2929
| Custom pattern | Float additive bias | Arbitrary values |
3030

@@ -35,7 +35,7 @@ Use this skill when:
3535
| Pattern | Recommended mask type |
3636
|---------|----------------------|
3737
| Simple causal-only | No mask — use `is_causal=1` (enables Flash) |
38-
| Sliding window (simple model) | Bool mask (precise, less memory) |
38+
| Sliding window (simple, GQA path) | GQA `local_window_size` (fast path); bool mask only if you must use ONNX Attention |
3939
| KV-shared layers | Float additive bias |
4040
| Mixed head_dim (e.g. Gemma4) | Float additive bias |
4141
| Padding + causal | `nonpad_kv_seqlens` or bool mask |
@@ -116,26 +116,67 @@ single-token decode (memory-bandwidth bound regardless of kernel):
116116
| Symmetric heads | `head_size == v_head_size` |
117117
| GPU | SM≥8.0 (Ampere or newer) |
118118

119-
### `nonpad_kv_seqlens`the best padding solution
119+
### `nonpad_kv_seqlens`variable-length without an explicit mask
120120

121121
ONNX Attention opset 24 adds `nonpad_kv_seqlens` input, which tells
122122
the kernel the actual (non-padded) KV sequence length per batch item.
123123
This enables Flash Attention with variable-length sequences **without
124124
providing an explicit mask** — the kernel applies causal masking
125125
internally using the sequence length info.
126126

127+
> ⚠️ **Cannot be combined with the `past_key` / `past_value` inputs.** ORT
128+
> *rejects* `nonpad_kv_seqlens` when `past_key`/`past_value` are supplied:
129+
> *"nonpad_kv_seqlens should not be used together with past_key and
130+
> past_value inputs."* It is therefore **not** usable with the *growing*
131+
> (dynamic) cache mode beyond the prefill pass. It **is** used at every
132+
> decode step in the **static-cache** mode below, where the full cache is
133+
> passed in the `key`/`value` slots and `past_key`/`past_value` are unused.
134+
135+
#### In-place KV for ONNX Attention via `TensorScatter` (static cache)
136+
137+
The opset-24 ONNX `Attention` schema has **no `past_present_share_buffer`
138+
attribute**, so the naive dynamic mode does `present = concat(past, new)`
139+
every step (an O(N) copy of distinct `past`/`present` tensors). But ONNX
140+
`Attention` **can** still update a KV cache *in place* — by pairing it with
141+
the opset-24 **`TensorScatter`** op:
142+
143+
1. Pre-allocate a fixed-size KV buffer (`StaticCacheState` in mobius).
144+
2. `TensorScatter` writes the new token(s) into the buffer at
145+
`write_indices` (in place when the buffer is IO-bound to the same
146+
device memory) — no growing concat.
147+
3. Pass the **full** scattered cache in the `key`/`value` slots (not
148+
`past_key`/`past_value`), with `nonpad_kv_seqlens` giving the valid
149+
length and `is_causal=1` for masking.
150+
151+
So in-place KV is **not** GQA-exclusive. The contrib **GroupQueryAttention**
152+
op has a built-in shared buffer (`past_present_share_buffer`); ONNX
153+
`Attention` reaches the same effect explicitly with `TensorScatter` +
154+
static cache. GQA's residual decode edge comes mostly from its dedicated
155+
`seq==1` decode kernels (XQA / Flash-decode), not from buffer management
156+
alone.
157+
127158
```python
128-
# Enables Flash + past_present_share_buffer for efficient KV cache
159+
# Dynamic (growing) mode — nonpad_kv_seqlens is PREFILL ONLY here,
160+
# because it cannot be combined with past_key/past_value:
129161
attn_out = op.Attention(
130162
query, key, value,
131-
attn_mask=None, # nullptr → Flash eligible
132-
past_key=past_k,
133-
past_value=past_v,
134-
nonpad_kv_seqlens=seqlens_k, # opset 24
163+
attn_mask=None, # nullptr → Flash eligible
164+
nonpad_kv_seqlens=seqlens_k, # opset 24, prefill pass only
165+
q_num_heads=num_heads,
166+
kv_num_heads=kv_heads,
167+
is_causal=1,
168+
)
169+
170+
# Static-cache mode — in-place KV at EVERY step (prefill + decode):
171+
updated_k = op.TensorScatter(key_cache, key, write_indices, axis=1)
172+
updated_v = op.TensorScatter(value_cache, value, write_indices, axis=1)
173+
attn_out = op.Attention(
174+
query, updated_k, updated_v, # full cache in key/value slots
175+
None, None, None, # no attn_mask, no past_key/past_value
176+
nonpad_kv_seqlens, # valid length per batch — used every step
135177
q_num_heads=num_heads,
136178
kv_num_heads=kv_heads,
137179
is_causal=1,
138-
past_present_share_buffer=1,
139180
)
140181
```
141182

@@ -214,24 +255,61 @@ borrow K/V from a layer with different `head_size`, creating
214255
| Attention bias | ❌ Rejected | ✅ Supported |
215256
| Flash Attention | ✅ (no mask) | ✅ (no mask) |
216257
| XQA kernel |||
217-
| KV cache management | Built-in (`past_present_share_buffer`) | Manual (separate past/present) |
218-
| Variable-length | Via `seqlens_k` | Via `nonpad_kv_seqlens` (opset 24) |
258+
| In-place KV buffer | ✅ built-in `past_present_share_buffer` | ✅ via `TensorScatter` + static cache (no growing concat) |
259+
| Sliding window |`local_window_size` attribute | Via float/bool bias only |
260+
| Variable-length | Via `seqlens_k` (works with past KV) | Via `nonpad_kv_seqlens` (with static cache, not the `past_key`/`past_value` inputs) |
219261

220262
**Guideline:** Use Contrib GQA when you don't need attention bias
221-
(simple causal models). Use ONNX Attention when you need float bias
222-
(sliding window, KV-shared, padding).
263+
(simple causal models, sliding-window via `local_window_size`). Use ONNX
264+
Attention when you need a float bias (KV-shared, dual head_dim, or
265+
mixed/alternating per-layer windows that one global window can't express).
266+
267+
### GQA sliding window via `local_window_size`
268+
269+
GroupQueryAttention takes a `local_window_size` attribute that masks each
270+
query to the most recent `W` keys (positions `[i-W+1, i]`) — exactly
271+
matching HuggingFace `sliding_window=W`. This keeps a uniform-window model
272+
on the fast GQA path instead of forcing it onto ONNX `Attention` with a
273+
baked float window mask. In mobius this is wired in `TextModel.forward`
274+
from `config.sliding_window` (see `GQAContext.local_window_size`), guarded
275+
to uniformly-sliding models — mixed `layer_types` (Gemma2/3/4, gpt-oss)
276+
use custom per-layer `GQAContext`s instead.
277+
278+
> Note: `local_window_size` only *masks* attention; it does not shrink the
279+
> physical KV buffer, so bounding memory still needs a circular/static
280+
> cache. Also, the post-hoc GQA rewrite (`RotaryAttentionToGQA`) cannot
281+
> recover a window from an already-baked float mask, so sliding windows
282+
> must be set on the **direct** GQA path (GQAContext), not via the rewrite.
283+
>
284+
> Decode trade-off: a non-default `local_window_size != -1` disqualifies the
285+
> dedicated `seq==1` **XQA** decode kernel (which requires `local_window==-1`,
286+
> see the CUDA cascade table above), so windowed GQA decode falls back to
287+
> Flash-decode/MEA. This is still on the fast GQA path and is the correct
288+
> trade for models that genuinely need the window; do not set it on
289+
> full-attention models.
223290
224291
## Key takeaways for model builders
225292

226293
1. **Flash requires `attn_mask == nullptr`** — any explicit mask
227294
disables Flash. Use `is_causal=1` instead.
228295
2. **`nonpad_kv_seqlens`** enables Flash with variable-length sequences
229-
without an explicit mask.
296+
without an explicit mask. It cannot be combined with the
297+
`past_key`/`past_value` inputs (so it is prefill-only in the *growing*
298+
cache mode), but it is used at **every** step in the static-cache mode
299+
(full cache in the `key`/`value` slots).
230300
3. **GQA contrib op rejects `attention_bias`** — use standard ONNX
231301
`Attention` if you need bias with GQA.
232302
4. **SM≥8.0** (Ampere+) required for Flash on all paths.
233303
5. **Float bias is safer** than bool mask for complex attention patterns.
234304
6. **MEA requires alignment**`total_kv % 4 == 0` for bias tensors.
305+
7. **In-place KV is not GQA-exclusive.** GQA has a built-in shared buffer
306+
(`past_present_share_buffer`); ONNX `Attention` (opset 24) has no such
307+
attribute but reaches the same effect with **`TensorScatter` + a static
308+
cache** (vs. the naive growing `concat(past, new)`). GQA's residual
309+
decode edge is mostly its dedicated `seq==1` kernels (XQA/Flash-decode).
310+
8. **Sliding window on the fast path:** set GQA `local_window_size` (=
311+
`config.sliding_window`) for uniform-window models instead of baking a
312+
float window mask into ONNX `Attention`.
235313

236314
## Cross-references
237315

.agents/skills/debugging-memcpy/SKILL.md

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,12 @@ pkg = build(model_id, execution_provider='cuda', dtype='float16')
6565
tmpdir = tempfile.mkdtemp(prefix="memcpy_profile_")
6666

6767
for name, model in pkg.items():
68-
# IMPORTANT: lower opset to 23 — CUDA EP doesn't register many ops
69-
# (including Reshape, Cast) at opset 24. Without this, you'll see
70-
# hundreds of false-positive Memcpy from ops that work fine at opset 23.
71-
if model.opset_imports.get("", 0) > 23:
72-
model.opset_imports[""] = 23
68+
# NOTE: ORT ≤1.24.x didn't register CUDA kernels for some opset 24
69+
# standard ops. If profiling on older ORT, lower opset to 23 to
70+
# avoid false-positive Memcpy nodes. On current ORT this is
71+
# unnecessary — the correct fix is updating ORT kernel registration.
72+
# if model.opset_imports.get("", 0) > 23:
73+
# model.opset_imports[""] = 23
7374

7475
path = os.path.join(tmpdir, f"{name}.onnx")
7576
ir.save(model, path, external_data=f"{name}.onnx.data")
@@ -120,14 +121,16 @@ This gives you the exact node names causing Memcpy. Common categories:
120121
- **Equal/Cast on token IDs** — usually low-impact (small tensors)
121122
- **CumSum on INT64** — inherent, no GPU kernel
122123

123-
### Critical: opset 24 false positives
124+
### Critical: opset 24 false positives (ORT ≤1.24.x)
124125

125-
**Always lower opset to 23 before profiling.** ORT CUDA EP (≤1.24.x)
126-
does not register kernels for many standard ops at opset 24, including
127-
`Reshape`, `Cast`, and others. A Gemma4 decoder at opset 24 shows
128-
**280 Memcpy** nodes; at opset 23, it shows **4**. The
129-
`ort_lower_opset_for_ep` flag in `_flags.py` handles this at runtime,
130-
but you must apply it manually when profiling with raw ORT sessions.
126+
**On ORT ≤1.24.x**, CUDA EP did not register kernels for many standard
127+
ops at opset 24, including `Reshape`, `Cast`, and others. A Gemma4
128+
decoder at opset 24 showed **280 Memcpy** nodes; at opset 23, just **4**.
129+
The correct fix is to update ORT kernel registration for the missing
130+
opset versions — not to lower the model's opset. The
131+
`ort_lower_opset_for_ep` flag in `_flags.py` is available as a
132+
workaround (disabled by default, opt-in via
133+
`MOBIUS_ORT_LOWER_OPSET_FOR_EP=1`).
131134

132135
## CPU-only op reference (ORT CUDA EP)
133136

@@ -223,6 +226,11 @@ and the `GreaterOrEqual` for causality.
223226
with `is_causal=1`. Not applicable to `GroupQueryAttention` (GQA handles
224227
masking internally via `local_window_size`).
225228

229+
> **Note**: For complex attention patterns (e.g. dual head_dim, KV-shared
230+
> layers, mixed sliding/full attention), prefer **float additive bias** over
231+
> bool masks. Float masks are batch-safe and work correctly with MEA
232+
> (Memory Efficient Attention) on CUDA.
233+
226234
### Pattern 3: Use GQA's built-in local_window_size
227235

228236
**Problem**: Sliding-window attention requires an explicit mask (CumSum-based)
@@ -303,12 +311,14 @@ at opset 23. Results after optimization:
303311
| Decoder | 4 | `input_ids` (input), 2× `Equal` (token masks), `Where` (bool mask) |
304312
| Embedding | 3 | `input_ids` (input), 2× `Equal` (token masks) |
305313

306-
### Opset 24 trap
314+
### Opset 24 historical note (ORT ≤1.24.x)
307315

308-
Without opset lowering, the decoder showed **280 Memcpy** nodes because
309-
CUDA EP doesn't register `Reshape`, `Cast`, and other standard ops at
310-
opset 24. The `ort_lower_opset_for_ep` flag (enabled by default) fixes
311-
this at runtime. Always lower to opset 23 before profiling.
316+
On ORT ≤1.24.x, the decoder showed **280 Memcpy** nodes because
317+
CUDA EP didn't register `Reshape`, `Cast`, and other standard ops at
318+
opset 24. This has been fixed in newer ORT versions. The
319+
`ort_lower_opset_for_ep` flag (disabled by default, opt-in via
320+
`MOBIUS_ORT_LOWER_OPSET_FOR_EP=1`) is available as a workaround for
321+
older ORT builds.
312322

313323
## Impact assessment
314324

.agents/skills/debugging-multimodal/SKILL.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,9 +254,28 @@ ORT bug: microsoft/onnxruntime#28107
254254

255255
### Opset 24 kernel registration
256256

257-
ORT ≤1.24.x CUDA/TRT EPs don't register kernels for opset 24.
258-
**Fix:** Use the `ort_lower_opset_for_ep` feature flag (enabled by
259-
default). See `src/mobius/_flags.py`.
257+
ORT ≤1.24.x CUDA/TRT EPs didn't register kernels for opset 24.
258+
This has been fixed in newer ORT versions. The `ort_lower_opset_for_ep`
259+
feature flag is available as a workaround (disabled by default, opt-in
260+
via `MOBIUS_ORT_LOWER_OPSET_FOR_EP=1`). See `src/mobius/_flags.py`.
261+
262+
### Encoder input dtype alignment
263+
264+
Encoder task inputs should be declared with `dtype=config.dtype` so
265+
entry tensors match the model compute dtype (float32/float16/bfloat16).
266+
In the current codebase, multimodal encoder task builders set encoder
267+
inputs directly to `config.dtype` (there is no `_cast_encoder_input()`
268+
helper in `src/mobius/tasks/_base.py`).
269+
270+
### GQA for KV-shared layers
271+
272+
Gemma4 KV-shared layers now emit `GroupQueryAttention` with empty K/V
273+
inputs (`kv_sequence_length=0`) and borrowed source-layer KV wired via
274+
`past_key`/`past_value`, avoiding extra Transpose/Reshape cache ops.
275+
276+
Runtime support depends on ORT having KV-shared GQA support (tracked in
277+
microsoft/onnxruntime#28242; still upstreaming as of this writing). On
278+
ORT builds without that support, this path can fail at runtime.
260279

261280
### NaN for large head_dim (> 256)
262281

0 commit comments

Comments
 (0)