Skip to content

Pull 9 high-value upstream candle PRs into the fork (Tier 1) - #37

Merged
astorise merged 13 commits into
mainfrom
feat/upstream-tier1
Jul 30, 2026
Merged

astorise merged 13 commits into
mainfrom
feat/upstream-tier1

Conversation

@astorise

Copy link
Copy Markdown
Owner

Cherry-picks 9 high-value open PRs from huggingface/candle into the fork ahead of upstream review, prioritised by the Tachyon serving roadmap (loader security, correctness on served models, KV management, OpenAI-parity sampling). Original commits and authorship are preserved.

Included (upstream PR → author)

Loader security

Correctness on served models

Generation

KV cache management

Fork adaptations (2 extra commits)

CI gate (candle's own, all green)

  • cargo fmt --all -- --check
  • cargo check --workspace
  • cargo clippy --workspace --tests --examples --benches -- -D warnings
  • Targeted tests for the pulled PRs: pickle 4/4, generation 11/11, no_repeat_ngram 4/4, phi3 4/4, DeBERTa F16 2/2, + 60 candle-transformers lib tests (no regression)

Deliberately excluded

Not addressed here

The Qwen3 "causal mask for batch>1" fix (competing PRs huggingface#3743/huggingface#3484/…) is left for a separate, deliberate single-PR pick.

PCfVW and others added 13 commits July 29, 2026 11:39
…e#3617)

A crafted `.pth` could drive the pickle reader's value stack/memo to
multi-GiB heap (an N-flood, or BINGET replay of a memoised subtree;
CWE-1325) or build an arbitrarily deep value whose recursive Drop
overflows the stack (a Q-chain; CWE-674) -- both on the normal load path
(read_pth_tensor_info -> Stack::read_loop) before any tensor data is
touched. This is the pickle sibling of the GGUF DoS fixed in huggingface#3556/huggingface#3585.

Add three always-on bounds, charged O(1) per opcode via a parallel
depth/size metadata stack (a per-push deep walk would itself be an
O(n^2) CPU-DoS):

- PICKLE_MAX_WORKING_SET (512 MiB): every push and memo-clone deep size
  is charged to a cumulative floor; memo clones are charged before the
  clone, so an over-budget replay is rejected without allocating.
- PICKLE_MAX_DEPTH (64, matching GGUF_MAX_VALUE_DEPTH): construction
  nesting depth is capped, so an over-deep value never forms and
  recursive Drop stays shallow.
- PICKLE_MAX_PAYLOAD (64 MiB): a BINUNICODE length is checked before the
  backing buffer is allocated.

Also reject LONG1 values wider than 8 bytes, fixing a pre-existing
arithmetic-overflow panic (`<< (i * 8)` for n_bytes >= 9; CWE-190) that
fuzzing this change surfaced; anamnesis's reference parser already
handles LONG1 this way.

Availability-only: reduce never invokes the callable. No public API
change; legitimate state_dict files are unaffected. Five unit tests
cover the four vectors plus a positive parse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`RotaryEmbedding::new` cast `inv_freq` and the `t` position vector
to `dtype` (typically bf16) *before* the matmul, then computed
`.sin()` / `.cos()` on the bf16 result. bf16 has a 7-bit mantissa
and cannot represent integer positions above ~256 exactly; e.g.
position 15962 rounds to neighbouring bf16 values 15936 or 15968
(16-step granularity at this magnitude). For low-frequency dims
where `inv_freq[0] = 1.0`, the angle then becomes the rounded
position, and `cos(15962) ≈ -0.547` vs `cos(15968) ≈ -0.882` —
neighbouring representable bf16 inputs produce essentially
uncorrelated cosines.

The result: at long-context positions the cos/sin tables become
noise rather than trigonometric values, and any model using this
RoPE diverges from `transformers`' reference output once positions
exceed ~10k tokens. The error is hidden by mean-pool + L2-norm
for short sequences (per-position errors average toward zero) but
surfaces at long context where the per-position error pattern
systematically bends the pooled vector.

The HuggingFace Python reference correctly forces fp32 for the
angle computation:

    with torch.autocast(device_type=device_type, enabled=False):
        freqs = (inv_freq_expanded.float() @ position_ids_expanded.float()).transpose(2, 3)
        emb = torch.cat((freqs, freqs), dim=-1)
        cos = emb.cos() * self.attention_scaling
        sin = emb.sin() * self.attention_scaling
    return cos.to(dtype=x.dtype), sin.to(dtype=x.dtype)

candle's own `qwen3_vl/text.rs::RotaryEmbedding::new` also keeps
inv_freq/t in fp32 and casts only the final `.cos()` / `.sin()`
outputs to dtype. This change applies the same pattern to qwen2.

The fix is minimal (drop the two `.to_dtype(dtype)?` calls before
the matmul; add them after the trig).

## Empirical evidence

Discovered via a six-step layer-by-layer bisection on Jina V4
(Qwen2.5-VL backbone via `trust_remote_code=True` vendored copy of
qwen2 logic, but the bug is in candle-transformers' qwen2.rs that
many downstream models build on). Using a 1000-sample stratified
cosine harness against `transformers` 4.x + `flash_attn 2.8.3`,
on cuda:0 bf16:

| Stratum | n | P95 before fix | P95 after fix |
|---|---|---:|---:|
| retrieval.query | 200 | 0.9996 | 0.9996 |
| retrieval.passage | 200 | 0.9970 | 0.9997 |
| text-matching | 200 | 0.9995 | 0.9995 |
| code | 200 | 0.9959 | 0.9990 |
| matryoshka | 100 | 0.9995 | 0.9996 |
| **long-context** (~16k tokens) | 100 | **0.7705** | **0.9999** |

Long-context min cosine: 0.7032 → 0.9994. Per-position max-abs
diff at post-RoPE Q (15962-token sample): 38.0 → 0.25.

## Other models with the same pattern

A grep of `candle-transformers/src/models/` for the
`.to_dtype(dtype)?` pre-matmul pattern returns a number of similar
RotaryEmbedding implementations (gemma.rs, olmo.rs, olmo2.rs,
chatglm.rs, phi3.rs, mixtral.rs, falcon.rs, recurrent_gemma.rs,
glm4.rs, …). I'm scoping this PR to qwen2.rs since that's the
model I have direct empirical evidence for, but maintainers may
want a coordinated follow-up fixing the same pattern across the
qwen-family adjacents.

## Note on M-RoPE

Qwen2.5-VL uses M-RoPE (multimodal rotary), not standard RoPE.
The interaction matters here only for text-only inputs: M-RoPE's
section split + per-grid position handling degenerates to standard
RoPE when all three position grids are identical. So the bug
above (which affects standard RoPE in candle's qwen2.rs) propagates
to any text-only Qwen2.5-VL forward that builds on this code path,
even though the immediate model isn't qwen2 itself.
`Config` for the Yi models declares `rope_theta: f64` and both
`config_6b()` and `config_34b()` set it to `5_000_000.`, which is the RoPE
base Yi was trained with. But `RotaryEmbedding::new` hardcodes `10000f32`
when building `inv_freq`, so `cfg.rope_theta` is never read and the rotary
frequencies are computed with the wrong base for every Yi inference.

Every other model that carries a `rope_theta` config threads it into the
same `inv_freq` loop (see mistral.rs, mixtral.rs, olmo.rs, stable_lm.rs,
starcoder2.rs). Use `cfg.rope_theta` here too, matching those siblings.
disentangled_attention_bias initialized its score accumulator as F32 and
XSoftmax::apply built its fill tensors as F32 regardless of the input
dtype, so forward failed with a dtype mismatch for F16 models. Cast the
scalars to the working dtype; the F32 path is unchanged.

Signed-off-by: tobocop2 <5562156+tobocop2@users.noreply.github.com>
Phi-3-mini long-prompt degradation had two independent causes:

1. RoPE phase table was built in the model dtype (BF16/F16), which
   quantizes positions and phases on long axes. Compute in F32 and cast
   sin/cos afterward, matching mistral/llama. Apply the same RoPE fix to
   qwen2, gemma, and gemma2.

2. config.json sliding_window was ignored. Parse the field, apply it in
   the causal mask (mask key j when q_pos - j >= window), and keep a
   mask on single-token decode once KV length exceeds the window.

Adds unit tests for Config serde, RoPE dtype, and window mask rules.

Fixes huggingface#3698
- Exercise real prepare_decoder_attention_mask (prefill + decode offset)
- Build tiny zero-weight model and run prefill/decode forward
- Assert RoPE BF16 path matches F32 reference at high positions
- Config serde: sliding_window present and absent
- Collapse identical if-branches for clippy::if_same_then_else
…tized_qwen3_moe

Both model types kept their per-layer KV cache fields private with no
accessor beyond clear_kv_cache() (reset-only) -- neither exposed any
way to extract the current cache state or restore a previously-saved
one, so a caller couldn't persist and later resume attention state
without reprocessing every token from scratch.

Both new methods follow the same shape and pairing convention
(Vec<Option<(Tensor, Tensor)>>, one entry per layer, errors rather than
silently partial-restoring on a layer-count mismatch) so callers don't
need per-architecture branching to handle either backend's state.
…antized_glm4

The fork's quantized_glm4 LayerWeights nests kv_cache under self_attn
(AttentionWeights), unlike upstream where it sits directly on the layer.
Route clear_kv_cache through layer.self_attn.kv_cache accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fork's toolchain (clippy 1.95) flags repeat().take() as
manual_repeat_n; rewrite the three test-only occurrences with
std::iter::repeat_n.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@astorise
astorise merged commit 52fd4f1 into main Jul 30, 2026
22 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants