Fix HF v5 inference

#1
by Qubitium - opened

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 with
AutoModelForCausalLM.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 via
enable_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 ricursive model
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] whenever
input_ids.shape[1] != 1. Transformers 5 pre-creates an empty DynamicCache and passes
cache_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 on
bsz, 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 wrote 1.0 for 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 from
transformers.generation.utils, and listing it after PreTrainedModel is valid when
PreTrainedModel 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

Qubitium changed pull request status to open
Ricursive Intelligence org

Thank you for the PR, couple of comments before merge:

  1. 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.
  2. 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.

@ebrahim-ricursive

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:

  1. 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.

  2. 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_position path is the 4.x compatibility contract (5.x caches ignore the kwarg, 4.x StaticCache needs 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.

Ricursive Intelligence org

Great, Thank you. Justed tested on my side and it works.

ebrahim-ricursive changed pull request status to merged

Sign up or log in to comment