Fix HF v5 inference
https://github.com/ModelCloud/GPTQModel/pull/3545
Fix HF v5 inference + DFlash2 verification (Ripple-65B)
This updates modeling_ricursive.py with the native Transformers 5.x fixes plus the
DFlash2 verification fixes. Validated on CPU float32 tiny fixtures; no benchmark
numbers are included here.
Bug β Fix
| # | Area | Symptom | Root cause | Fix |
|---|---|---|---|---|
| 1 | RicursiveAttention.forward |
Multi-token verify block on a warm cache returns wrong logits (measured max abs diff 0.5370 on the tiny fixture before the fix) | SDPA's implicit causal mask is top-left aligned; when q_len < kv_len it hides valid context |
Build an end-anchored mask (key_pos <= query_pos) before SDPA; keep is_causal only when no mask was built |
| 2 | _apply_sliding_window_mask |
The same block is still wrong when kv_len <= sliding_window |
The short-circuit returned None for every no-mask case, assuming only prefill (q_len == kv_len) or decode (q_len == 1) |
Return None only for q_len == 1 or q_len == kv_len; otherwise fall through to the explicit mask |
| 3 | RicursiveForCausalLM.forward |
DFlash-style callers pass logits_to_keep=1 but get [batch, seq, vocab], materialising the full 100k-way projection |
The argument was swallowed by **kwargs and ignored |
Slice the LM head input to the last k rows; prepare_inputs_for_generation requests logits_to_keep=1 when caching |
| 4 | RicursiveMLP.forward |
Prefill activation peak is tokens Γ 40960 for gate, up and silu(gate) * up |
The full row dimension was expanded at once | Chunk rows at RIPPLE_MLP_CHUNK (default 2048, 0 disables) and concatenate; every op is row-wise, so the result is exact |
| 5 | prepare_inputs_for_generation |
generate() crashes on first prefill: not enough values to unpack (expected 3, got 2) |
input_ids[:, cache_position] runs with cache_position=None under TF5, adding a spurious axis |
Rebuild cache_position; slice only when the cache is warm |
| 6 | _update_causal_mask |
Padded batches decode to a constant token | Decode rows were left fully masked; 1.0 was used for visible keys; the removed get_max_cache_shape() was called |
Size the key axis from cache_position; 0.0 visible / finfo.min masked |
| 7 | prepare_inputs_for_generation |
Left-padded rows get wrong RoPE phases | position_ids were overwritten per row from a shared vector |
Derive per-row positions from attention_mask.cumsum(-1) - 1 |
| 8 | _apply_sliding_window_mask |
Long-context sliding-window attention masks valid keys after eviction | arange(kv_len) assumed keys start at position 0 |
Anchor key positions at cache_position[-1] - kv_len + 1 |
| 9 | _apply_sliding_window_mask |
Hybrid cache: expanded size (1024) must match existing (1025) |
The shared mask is longer than the sliding layer's key tensor | Trim the mask to [..., -kv_len:] |
| 10 | RicursiveForCausalLM |
generate is missing entirely |
Transformers 5 no longer injects GenerationMixin into remote-code classes |
Inherit GenerationMixin explicitly |
Rows 1β4 are the new DFlash2 fixes; rows 5β10 are the TF5 fixes already in this PR.
Validation
Transformers 5.22.1, CPU, float32, native file loaded withAutoModelForCausalLM.from_pretrained(..., trust_remote_code=True, dtype=torch.float32).
Fixtures: tiny (6 layers, hidden 64, vocab 256, sliding_window=32) and small
(2 layers, hidden 256, vocab 256, sliding_window=32).
| Check | tiny | small |
|---|---|---|
generate() from CPU input_ids |
works | works |
| cached == uncached greedy tokens | [113, 106, 243, 113, 243, 182] both |
[25, 75, 144, 249, 75, 144] both |
| warm-cache block (N=12/16, M=6) vs full forward, max abs diff | 2.682e-07 | 0.0 |
| same block with explicit all-ones mask, max abs diff | 2.682e-07 | 0.0 |
| warm-cache block after SWA eviction (total 36/46 > 32), max abs diff | 2.980e-07 | 0.0 |
logits_to_keep=1 shape |
(1, 1, 256) |
(1, 1, 256) |
logits_to_keep=1 vs last full-logits row, max abs diff |
1.192e-07 | 2.384e-07 |
chunked MLP (chunk=64 vs 0, 80 rows), torch.equal |
True | True |
| output dtype | float32 | float32 |
The pre-fix file gives max abs diff 0.5370 (mean 0.1263) on the tiny warm-cache block,
confirming the mask bug is real; the fixed file is within fp32 roundoff. logits_to_keep=1
is not bit-identical to the full projection (1β2e-07 max diff) because the GEMM batch shape
differs, but it matches within fp32 tolerance.
GPTQModel load-time patches
GPTQModel additionally applies decode-speed patches at load time (fused RMSNorm, GQA viaenable_gqa, sliding-window mask short-circuit, chunked MLP) on top of this file. Those are
numerical optimisations; the correctness fixes above live in the native file.
Files
modeling_ricursive.py 39308 bytes sha256 4f96dc4df46540304e51d9f34ce9173e7547dbbc391ed87bee3806db1ca5d4e0
Only modeling_ricursive.py is changed.
Update: modeling_ricursive.py in this PR now also carries the DFlash2 verification fixes (multi-token warm-cache mask, logits_to_keep, chunked MLP). See the PR description for the full bug/fix table and validation.
Tracking PR in GPTQModel: https://github.com/ModelCloud/GPTQModel/pull/3545
This upstream fix is the same code that ships in GPTQModel's
ricursivemodel
definition. That PR adds Ripple support plus the load-time compatibility patch and
carries the full test suite (tests/test_ricursive_support.py) and benchmark numbers.
Fix generate() and attention masking under Transformers 5.x
Symptom
Under Transformers 5.x, generate() fails on the very first prefill:
RuntimeError: not enough values to unpack (expected 3, got 2)
prepare_inputs_for_generation does input_ids[:, cache_position] wheneverinput_ids.shape[1] != 1. Transformers 5 pre-creates an empty DynamicCache and passescache_position=None at prefill, so that evaluates input_ids[:, None] and adds a
spurious axis: (batch, seq) becomes (batch, 1, seq). The embedding lookup then yields
4-D hidden states and the first decoder layer dies onbsz, q_len, _ = hidden_states.shape.
That line is only the first of six defects. Padded batches decode to garbage and
generations longer than sliding_window crash.
Bug β Fix
| # | Location | Bug | Fix |
|---|---|---|---|
| 1 | prepare_inputs_for_generation |
input_ids[:, None] on prefill β 4-D hidden states β crash |
rebuild cache_position; slice only when warm |
| 2 | _update_causal_mask |
decode row left fully -inf β constant token; 1.0 for visible keys; removed get_max_cache_shape() |
key axis from cache_position; 0.0 / finfo.min |
| 3 | prepare_inputs_for_generation |
position_ids overwritten per row β wrong RoPE under left padding |
derive from attention_mask.cumsum(-1) - 1 |
| 4 | _apply_sliding_window_mask |
arange(kv_len) assumes keys at 0..kv_len-1; after eviction valid keys masked (window 16 @ pos 32 β 0/16 visible) |
anchor at cp - kv_len + 1; skip when non-binding |
| 5 | _apply_sliding_window_mask |
hybrid cache: layer holds 1024 keys, mask has 1025 β expanded size (1024) must match existing (1025) |
trim mask to [..., -kv_len:] |
| 6 | RicursiveForCausalLM |
transformers 5 drops GenerationMixin for remote code β no generate |
inherit GenerationMixin explicitly |
Notes on the two that are easy to miss:
- #2 only shows up with an explicit padding mask β an all-ones mask returns early. So
single-sequence generation looked healthy while batched evaluation silently scored 0%.
The old code also wrote1.0for visible keys, adding a constant bias to every score. - #4 is a silent correctness bug, not a crash. The window mask progressively excluded
keys that were still valid, degrading long-context output without any error.
Also fixed: position_ids derived by transformers from caller tensors arrive on CPU while
the model is on GPU, so RoPE fails with mat2 is on cpu. input_ids, attention_mask,inputs_embeds and position_ids are now all moved to the model device.
A module-level RICURSIVE_TF5_COMPAT marker is set on the class so downstream loaders that
monkeypatch older checkpoints stand down instead of stacking patches.
Validation
Transformers 5.22.1. "Bit-identical" means exact tensor equality of generated token
ids / logits β not a tolerance comparison.
| Check | Result |
|---|---|
generate() on prefill |
works (previously raised on the first forward) |
| cached vs uncached, 40 tokens | bit-identical, incl. past sliding_window=32 (eviction path) |
| left-padded batch row 0 vs same row unpadded | bit-identical |
| padded vs unpadded prefill logits | bit-identical |
| native file vs independent reference patch β prefill logits | bit-identical |
| native file vs reference patch β decode, padded, uncached | bit-identical |
output.dtype preserved |
yes |
| real 65B GPTQ checkpoint | loads, marker detected, patch stood down, generates correctly |
The native implementation does not merely approximate the reference patch β it produces
exactly the same tokens on every path exercised.
Sync
The identical modeling_ricursive.py (sha256 b2ca81fa07dcdeabβ¦, 30790 bytes) is
published in all three places:
| Location | Revision | Status |
|---|---|---|
| this PR | refs/pr/1 |
source of truth |
ModelCloud/Ripple-65B-Preview-GPTQ-W4A16-v1 |
main |
byte-identical |
ModelCloud/Ripple-65B-Preview-tf5-patch |
main |
byte-identical (mirror + PATCH.diff) |
The GPTQModel PR (#3535) carries the
equivalent fix as a load-time runtime patch (gptqmodel/utils/ricursive_patch.py) for
checkpoints that still hold the original file. The two implementations were compared
directly on the same checkpoint:
| Path | HF PR file vs GPTQModel runtime patch |
|---|---|
| prefill logits | bit-identical |
| prefill logits, padded | bit-identical |
| decode, 40 tokens, cached | bit-identical |
| decode, 40 tokens, uncached | bit-identical |
| decode, padded batch | bit-identical |
Compatibility
Backward compatible with Transformers 4.x: GenerationMixin is importable fromtransformers.generation.utils, and listing it after PreTrainedModel is valid whenPreTrainedModel already inherits it.
Files
modeling_ricursive.py patched drop-in replacement (only file changed)
Mirror of the patched file and a unified diff:
https://huggingface.co/ModelCloud/Ripple-65B-Preview-tf5-patch
Thank you for the PR, couple of comments before merge:
- Preallocated caches can break causal attention. Line 298 treats cache capacity as occupied context. A six-token prompt in a 16-slot StaticCache gets query positions 10β15. Changing token six then changes token oneβs output in full-attention layers, which must be impossible. The previous commit passes this check. Build the mask from actual cache positions.
- labels with logits_to_keep=1 returns NaN loss. Line 705 trims logits and labels to one position, then the loss calculation shifts both to empty tensors. The same call previously returned a finite loss. Preserve the labeled loss calculation or explicitly reject this combination.
https://github.com/ModelCloud/GPTQModel/pull/3545
both issues are fixed in modeling_ricursive.py (commit 0d2fa09).
1. Preallocated caches no longer define attention context.StaticCache returns its full preallocated capacity from update(), so the mask was built from capacity instead of occupied positions: a 6-token prompt in a 16-slot cache was treated as a 10-token prefix. The mask is now built from the layer's actual get_seq_length() and cache_position; the unwritten tail of k/v is trimmed, and an incoming mask longer than the keys actually present is aligned to them.
Measured on the full model: changing the last token of a 6-token prompt in a 16-slot StaticCache now gives 0.0 difference on tokens 1β5 (previously 0.4830).
2. labels + logits_to_keep=1 no longer returns NaN.
The full sequence is kept whenever labels are supplied, so the shifted causal-LM loss stays defined; only the returned logits are trimmed. logits_to_keep=1 returns (batch, 1, vocab) with the same loss as the untrimmed forward. Unlabeled calls still slice the LM-head input, so generation keeps the memory win.
Full-model validation was run on the quantized checkpoint ModelCloud/Ripple-65B-Preview-GPTQ-W4A16-v2 (Ripple-65B-Preview, bfloat16), with the load-time patches enabled and, separately, disabled so only this file is active β same numbers in both runs:
| Scenario | Result |
|---|---|
| StaticCache causality, 6-token prefill in 16 slots, change last token | max diff tokens 1β5 = 0.0 |
Multi-token verify block, warm DynamicCache, 48 + 8 vs one 56-token forward |
max abs 0.25 (rel 1.4e-2) |
Same with StaticCache (64 slots) |
max abs 0.25 |
| Control: single-token decode step vs same full-forward row (no block mask) | max abs 0.156 (numeric floor) |
| Previous top-left causal mask (reproduced) | max abs 32.31 (rel 1.76), argmax correct 0/8 |
labels + logits_to_keep=1 |
loss 16.5 = full 16.5, logits (1,1,100352) |
logits_to_keep=1 |
(1,1,100352), argmax matches full last row |
| Cached vs uncached greedy generation | identical tokens |
Chunked MLP, RIPPLE_MLP_CHUNK=64 vs 0 |
torch.equal=True |
DFlash2 dflash_generate, block size 1 vs autoregressive |
0 divergences |
| DFlash2 default block vs autoregressive | first divergence is an exact bf16 tie (top-2 both 18.0, gap 0.0); mean acceptance 2.56 |
All numbers above were measured on the quantized checkpoint linked above. The verify-block error (0.25) is within 1.4Γ of the no-mask control (0.156) and about 130Γ smaller than the previous 32.31, so the residual is bfloat16 kernel shape noise, not masking. The DFlash2 token difference is a genuine tie at bfloat16 precision.
The same fixes are mirrored in GPTQModel (ModelCloud/GPTQModel#3551; earlier work in #3545), which applies them at load time for quantized checkpoints.
Thanks for the fix. Almost there, there are two cache issues need fixing:
Wrong mask after sliding-window eviction. With a mask sized to the allocated cache capacity, line 315 selects the unused tail instead of the positions occupied by retained keys. My test produces a maximum logit error of 0.345, versus 0.00000016 with the correctly sized mask. Slice [total_kv - kv_len : total_kv]; otherwise this path silently returns incorrect outputs.
Transformers 4 StaticCache decoding overwrites cached tokens. Line 289 omits cache_position from the cache update. Under 4.57.6, the first decode step overwrites the beginning of the cache, producing a 0.145 maximum logit error. Forward the cache positions and add a decoding regression test. This is a remaining compatibility issue, not introduced by the latest commits.
Companion implementation: https://github.com/ModelCloud/GPTQModel/pull/3545
Both issues from the last review are fixed in modeling_ricursive.py (commit ee974ea). Summary of the two new fixes, then the full bug/fix table and the validation numbers.
Review issues (this round)
| # | Issue | Fix | Evidence |
|---|---|---|---|
| 7 | Wrong mask after sliding-window eviction. With a mask sized to the allocated cache capacity, attention_mask[..., -kv_len:] selected the unwritten tail instead of the positions holding the retained keys. |
Slice to the absolute span the layer holds: [total_kv - kv_len : total_kv], clamped to the mask. Applied both in RicursiveAttention.forward and in _apply_sliding_window_mask (which now takes total_kv). |
Capacity-sized mask now matches the correctly pre-sliced mask exactly (0.0); the old tail slice produced 0.426 max abs error in the same repro. |
| 8 | Transformers 4 StaticCache decode overwrites cached tokens. past_key_value.update(k, v, layer_idx) omitted cache_position, so 4.x fell back to arange(q_len) and the first decode step wrote slots 0..q_len-1. |
Forward cache_kwargs={"cache_position": cache_position} into update(), with a TypeError fallback for caches that do not accept it. |
4-step StaticCache decode (cap 16, 6-token prefill) now matches a full forward to 3.6e-7 max abs error. A spy cache confirms cache_position is received. |
All bugs fixed in this PR
| # | Bug (upstream behaviour) | Fix | Evidence |
|---|---|---|---|
| 1 | prepare_inputs_for_generation sliced input_ids with a None cache_position β (batch, 1, seq) β 4-D embeddings, dies on the first prefill. |
Rebuild cache_position when missing; slice only when the cache is warm. |
generate() works; cached == uncached. |
| 2 | _update_causal_mask seeded finfo.min and wrote the diagonal only when sequence_length != 1 β every decode step fully masked β constant token. Also used 1.0 for visible keys and the removed get_max_cache_shape(). |
Rebuilt: key span from cache_position, 0.0 for visible keys, never an all-masked decode row. |
Decode produces real text; mask oracle passes. |
| 3 | position_ids overwritten with cache_position for all rows; caller-supplied ones not sliced with input_ids. |
Derive from attention_mask.cumsum(-1) - 1, slice alongside input_ids. |
Padded batches decode bit-identically to unpadded. |
| 4 | _apply_sliding_window_mask anchored keys at arange(kv_len), but after eviction they sit at [cp-kv_len+1 .. cp] β progressively masked valid keys (window 16, cp 32 β 0 of 16 visible). |
Skip when the window cannot bind; anchor at the first key the layer holds. | Correct masking + 1.16x decode throughput. |
| 5 | Hybrid cache: sliding layers cap at sliding_window while full layers grow β one mask per forward β RuntimeError: expanded size (1024) must match existing (1025). |
Trim the shared mask to the keys the layer holds. | Long-context generation runs past the window. |
| 6 | position_ids left on CPU β mat2 is on cpu in RoPE bmm when the prompt is a CPU tensor. |
Move position_ids to the model device. |
CPU and GPU prompts both generate. |
| 7 | Mask alignment after eviction (see above). | [total_kv - kv_len : total_kv]. |
0.0 vs 0.426. |
| 8 | StaticCache decode overwrite (see above). |
Forward cache_position. |
3.6e-7. |
Plus two inference fixes: an end-anchored causal mask for multi-token verify blocks against a warm cache, and logits_to_keep slicing the LM head input (labeled calls keep the full sequence so the shifted loss stays defined).
Validation
[PASS] issue1 capacity-sized mask aligns to [total_kv-kv_len, total_kv) (max abs err = 0.000e+00)
[PASS] issue1 test is sensitive: old tail slice is wrong (max abs err = 4.260e-01)
[PASS] issue2 StaticCache decode matches a full forward (max abs err = 3.576e-07)
[PASS] issue2 cache_position is forwarded into update() (received {'cache_position': tensor([9])})
[PASS] labeled loss stays finite with logits_to_keep=1 (loss=4.8866, logits=(1,1,128))
ALL_REVIEW_FIX_TESTS_PASSED
- Tested on transformers 5.22.1; the
cache_positionpath is the 4.x compatibility contract (5.x caches ignore the kwarg, 4.xStaticCacheneeds it). generate()parity: cached and uncached outputs are identical.- Padded batches: bit-identical to unpadded.
- The 65B checkpoint loads, generates, and produces deterministic output with this file.
The same file is published byte-identically in ModelCloud/Ripple-65B-Preview-GPTQ-W4A16-v2 (sha256 da08c5b4...), so downstream users get the identical code.
Great, Thank you. Justed tested on my side and it works.