Skip to content

GGUF: support all quant types, add qwen35moe - #131

Open
vcruz305 wants to merge 36 commits into
FlashML-org:mainfrom
vcruz305:feat/generic-gguf
Open

GGUF: support all quant types, add qwen35moe#131
vcruz305 wants to merge 36 commits into
FlashML-org:mainfrom
vcruz305:feat/generic-gguf

Conversation

@vcruz305

Copy link
Copy Markdown

Two related things here: GGUF quant types beyond the three the Python side knew about, and qwen35moe support so llama.cpp MoE checkpoints load. I did the second one first and the first one fell out of it, so they share a branch. Happy to split if you'd rather review them separately.

Tested on an RTX 4060 Laptop (8GB) with Ornith-1.5-35B-A3B IQ3_S, a 16GB qwen35moe quant, experts offloaded to host RAM.

Generic quant types

csrc/gguf/ already dispatches 19 ggml types but the Python tables only described 6, so K-quants and I-quants were unreachable. BLOCK_SHAPE now covers all 21 types and there are five frozensets mirroring the C dispatch switches, each with the file:line it mirrors so they can be re-checked. tests/models/test_gguf_type_tables.py derives the block sizes from ggml-common.h at runtime and extracts the case labels from gguf_kernel.cu, so the tables can't drift from the kernels without a test failing.

One thing worth flagging on its own: none of the five switch (type) blocks in gguf_kernel.cu had a default:. Y is allocated with torch::empty, so an unsupported type returned uninitialized memory instead of raising, and ggml_moe_get_block_size returned 0. They all raise now. That's a latent bug independent of everything else in this PR.

I-quants have MMVQ and dequant kernels but no MMQ kernel, so fused_mul_mat_gguf routes them through ggml_dequantize plus a torch matmul at prefill. That branch already existed but was unreachable, since _MMQ was tested before _DEQUANT and the two sets were identical.

qwen35moe

New models/qwen3_5_moe/gguf.py and gguf_experts.py, following the gemma4 pattern. The config comes from GGUF metadata and the head layout is derived rather than assumed: attn_qkv's packed width of 8192 is exactly q(group_count * state_size) + k(group_count * state_size) + v(time_step_rank * state_size), and parse_gguf_config asserts the ssm.inner_size relation instead of trusting it.

Four things llama.cpp's converter does that the loader has to undo. I found these by reading conversion/qwen.py after a lot of wasted time, so listing them in case they save someone else the trouble:

  • ssm_a holds A = -exp(A_log), not A_log. Every value in the file is negative, spanning roughly -70 to -0.02. gdn.py computes -A_log.exp(), so passing A straight through makes the recurrent decay gate about -2.5e-5 and the recurrence dies. The loader yields log(-A).
  • The (1 + w) RMSNorm shift is already folded in by the converter. weight.py adds it for HF checkpoints because HF stores raw w, but a GGUF arrives pre-shifted and adding it again double counts. The measured means gave this away, around 1.0 rather than the 0.0X raw w would give.
  • V heads are stored tiled rather than grouped when num_k_heads != num_v_heads, so ggml_repeat can replace an interleaved repeat. FreeToken pairs K head k with V heads [k*R, (k+1)*R) like HF does, so all seven affected tensors get un-tiled on load. Row permutations are done on the packed bytes directly, which is safe because each output row is its own run of blocks. ssm_out is the exception since its columns carry the V dimension and a 128 wide head straddles the 256 element blocks, so that one tensor is dequantized to dense bf16, about 500MB across 30 layers.
  • Merged projections are not uniformly typed. In IQ3_S, qkv_proj is q and k IQ3_S with v Q4_K, and the GDN in_proj is attn_qkv Q4_K with the rest IQ3_S. Concatenating packed rows needs matching row_bytes (880 vs 1152 here), so there's a GGUFMergedLinear that keeps one buffer per part and concatenates the outputs instead. Same result as the merged GEMM since every part reads the same input. gguf_merged_or_plain() keeps the uniform case on the cheaper single kernel path.

None of these are visible to shape, dtype or byte-identity checks. The model loads, runs at full speed and produces fluent nonsense, which is a slow way to find out.

Expert banks

_BANK_SCHEMAS gets a "gguf" entry, and the (gate_up, down) ggml types are threaded from ModelConfig through ExpertBanks and OffloadMoeCache to the kernels, because a GGUF's row stride is a property of the file rather than the format. fused_experts_gguf takes a separate down_quant_type since the two banks have separate slot pools and can legitimately differ.

What is not supported is a bank whose type varies by layer. moe_vec.cuh addresses the pool as expert * nrows * (ncols / qk) with no padding allowance, and the pool is one allocation shared by every layer, so two strides in one pool would read every block at the wrong offset. _gguf_banks raises and names the offending layers. In practice this means llama.cpp's mixed levels don't load: for Ornith-1.5-35B, IQ3_S and IQ3_XXS have uniform banks while IQ3_M, IQ2_M, IQ2_XXS and IQ1_S split ffn_down_exps across two types. Quantizing with --pure avoids it. Per type slot pools would fix it properly but that's surgery on the hot path and I didn't want to bundle it here.

_model_setup_override now declines for expert_quant == "gguf", otherwise qwen3_5_moe's NVFP4 setup_offload_expert_banks hijacks the GGUF load and fails deep inside an NVFP4 reader.

Tokenizer

convert_gguf_tokenizer is keyed on transformers' own model_type, so passing the GGUF arch string through raised KeyError('qwen35moe') and killed the detokenizer worker during load. qwen35moe is a GPT2 style BPE so the existing qwen2 converter handles it. Two neighbouring gemma4 assumptions are now per-arch: eos preferred the literal <turn|> and the stop ids were hardcoded to <eos>/<turn|>, neither of which exists in a Qwen vocab, and unk defaulted to a <unk> token that also isn't there. gemma4 behaviour is unchanged.

Numbers

load                65s (serial GGUF read)
VRAM                6879 / 8188 MiB
host RAM            20GB pinned expert banks
decode              46.7 to 50.1 tok/s
end to end          29.4 tok/s including prefill
llama.cpp CPU ref   11.1 tok/s

Correctness checks I ran, since "it produces text" isn't much of a claim:

  • 573 weights reconciled against the constructed module: every name exists, every shape and dtype matches, no non-expert buffer left unfilled.
  • Expert banks checked byte for byte against their source rows, per expert, at both ends and the middle. This caught the gate_up fusion laying down every expert's gate before any up.
  • MMVQ, MMQ and the grouped MoE GEMV checked against dequant plus matmul on real tensors from the file, cos 0.9999 or better for IQ3_S, Q4_K and Q6_K.
  • 8/8 on short factual questions with greedy decoding, including arithmetic.

First token argmax matches llama.cpp CPU on 2 of 6 single token prompts. Every disagreement is a plausible near tie (Paris continuing " de" against " is", import continuing " java" against " sys"). FreeToken runs CUDA W4A8 MMVQ and llama.cpp runs CPU AVX, so I don't think exact agreement is reachable, but I'd rather state the number than imply it's bit exact.

Known gaps

  • TP=1 only, same as the gemma4 GGUF path.
  • The NextN/MTP block is dropped, so no speculative decoding.
  • Bank loading is serial. _gguf_banks raises NotImplementedError for the parallel reader since that path assumes safetensors and a GGUF is one packed file.
  • max_model_len still reports the metadata context length even when KV is capped lower.
  • I only tested the flashinfer attention backend. The triton fallback is untouched but unexercised here.

vcruz305 and others added 19 commits August 23, 2026 19:40
The vendored sgl-kernel/llama.cpp CUDA kernels under csrc/gguf/ already
dispatch 19 quant types, but the Python side only ever described 6, so
K-quants and I-quants were unreachable. This wires the Python tables up to
what the kernels actually implement.

- csrc/gguf/gguf_kernel.cu: add default: TORCH_CHECK guards to all five
  switch (type) blocks. Y is allocated with torch::empty, so an unsupported
  type previously returned uninitialized garbage rather than raising;
  ggml_moe_get_block_size returned 0. Also null-check the ggml_get_to_cuda
  function pointer, which ggml_dequantize called unconditionally.

- models/gguf/dequant.py: extend GGML_* constants, BLOCK_SHAPE and GGML_NAME
  to all 22 types (block geometry derived from ggml-common.h with QK_K=256,
  K_SCALE_SIZE=12). Add five frozensets mirroring the C dispatch switches,
  each citing the file:line it mirrors. The pure-torch reference dequantize()
  still implements Q4_0/Q6_K only and now says so explicitly.

- layers/gguf.py: replace the three hardcoded {Q4_0, Q8_0, Q6_K} sets with
  the shared tables. Because _MMQ was tested before _DEQUANT and the sets
  were identical, the dequant branch was unreachable dead code; it is now
  the live path for I-quants, which have MMVQ and dequant kernels but no
  MMQ kernel. This is what makes I-quant prefill work at all.

- moe/fused_q4_0.py: add fused_experts_gguf(quant_type), with
  fused_experts_gguf_q4_0 kept as a behaviour-preserving wrapper.

- moe/cpu_executor.py: raise a clear NotImplementedError for GGUF formats
  other than q4_0. WF_Q4_0 is the only GGUF format with an AVX/VNNI kernel
  in cpu_moe_ext.cpp; the limit was previously implicit.

Tests cover the tables against ggml-common.h and the capability sets against
the C switches themselves, so the two cannot drift.

Not covered: per-architecture GGUF shims (GGUF_ARCH_TO_REGISTRY still lists
gemma4 only) and CPU AVX kernels for non-Q4_0 experts. Untested on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
Builds the FreeToken ModelConfig from llama.cpp qwen35moe GGUF metadata and maps
tensor names back to FreeToken parameter names. Derived against
vcruz305/Ornith-1.5-35B-A3B-GGUF.

The GDN projections are the non-obvious part. llama.cpp's qwen3.5 mapping splits
what qwen3next fused -- attn_qkv<-in_proj_qkv, attn_gate<-in_proj_z,
ssm_beta<-in_proj_b, ssm_alpha<-in_proj_a -- and FreeToken's HF loader already
fuses those same pairs (_PT_FP8_FUSE / _PT_BF16_FUSE in weight.py), so we emit the
fused representation and the model code sees one shape regardless of source.

Head layout is pinned by arithmetic rather than assumed: attn_qkv's packed output
width of 8192 equals q(group_count*state_size) + k(group_count*state_size) +
v(time_step_rank*state_size) = 2048+2048+4096, so num_k_heads==ssm.group_count and
num_v_heads==ssm.time_step_rank at head_dim==ssm.state_size==128. parse_gguf_config
asserts the inner_size relation rather than trusting it.

Deliberately NOT added to GGUF_ARCH_TO_REGISTRY yet: iter_gguf_weights is not
implemented, and the routed-expert path cannot serve this checkpoint regardless --
Ornith's experts are IQ3_S (gate/up) + Q4_K (down) as separate stacks, while the
offload/CPU bank machinery keys on a single fused "q4_0" schema. Registering the
arch now would advertise support that does not exist. See the follow-up plan.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
…ommon.h

The header-derived size check dropped block_iq3_s's scales[IQ3S_N_SCALE] field
because the macro table lacked that define, yielding 106 bytes and failing against
BLOCK_SHAPE's 110. 110 is correct and independently confirmed against a real
checkpoint: Ornith-1.5-35B IQ3_M's token_embd.weight is 248320*2048/256*110 =
218,521,600 bytes on disk, matching row_bytes() exactly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
Adds the architecture path plus the mixed-quant machinery llama.cpp checkpoints
actually need.

GGUFMergedLinear (layers/gguf.py): a merged projection whose parts carry different
ggml types. FreeToken fuses projections by concatenating packed rows, which needs a
shared row_bytes -- true for gemma4 (all Q4_0) but not for llama.cpp's mixed quants.
Ornith's full-attention qkv is q,k IQ3_S + v Q4_K and its GDN in_proj is
attn_qkv Q4_K + gate/beta/alpha IQ3_S; 880- and 1152-byte rows cannot interleave.
Concatenating the per-part OUTPUTS is identical to the merged GEMM since every part
reads the same x. gguf_merged_or_plain() keeps the uniform case on the cheaper
single-kernel GGUFLinear.

qwen3_5_moe/gguf.py: iter_gguf_weights + convert_qwen35_to_gguf. Maps the GGUF
tensor table onto the module tree, fusing per layer in the orders gdn.py and
attention.py declare (in_proj = attn_qkv, attn_gate, ssm_beta, ssm_alpha, matching
_in_proj_split = [conv_dim, value_dim, n_v, n_v]). A_log/dt_bias stay fp32 as gdn.py
requires; conv1d is reshaped to [conv_dim, 1, kernel] for its .squeeze(1).

qwen3_5_moe/gguf_experts.py: routed-expert host banks in native packed bytes.

Offload path generalized off the q4_0 literal: a "gguf" bank schema, byte accounting
from row_bytes, and the (gate_up, down) ggml types threaded ModelConfig -> ExpertBanks
-> OffloadMoeCache -> the MoE kernels, because a GGUF's row stride is a property of
the file, not the format. fused_experts_gguf takes a separate down_quant_type: the two
banks own separate slot pools and may legitimately differ.

What is NOT supported, and fails loudly rather than silently: a bank whose ggml type
varies BY LAYER. moe_vec.cuh addresses the pool as expert * nrows * (ncols / qk) with
no padding allowance, and the pool is one allocation shared by all layers, so mixed
strides would read every block at the wrong offset and emit fluent garbage. _gguf_banks
raises with the offending layers named. Ornith IQ3_S and IQ3_XXS have uniform banks;
IQ3_M, IQ2_M, IQ2_XXS and IQ1_S split ffn_down_exps across two types.

Also: _model_setup_override now declines for expert_quant == "gguf". qwen3_5_moe
exports an NVFP4 setup_offload_expert_banks that would otherwise hijack the GGUF load
and fail deep inside an NVFP4 reader.

Not yet run end-to-end; weights have not been loaded on hardware.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
_LAYER_MAP and _FUSE were written before the module tree was known and mapped
attn_q/k/v -> self_attn.q_proj/k_proj/v_proj and ffn_gate_shexp/ffn_up_shexp ->
mlp.shared_expert.gate_proj/up_proj. Those attributes do not exist: Qwen3_5Attention
builds one merged qkv_proj (_qkv_split [8192, 512, 512]) and _SharedExpert one merged
gate_up_proj. iter_gguf_weights always handled them by fusion, so the entries were dead
-- but gguf_name_to_freetoken would hand a caller an invented parameter name.

Replaced both with _MERGED_PARTS, the set of suffixes that are parts of a merged
projection, which gguf_name_to_freetoken now reports as None (handled elsewhere). The
constant documents each merged target and its concat order, including why the GDN target
is in_proj rather than in_proj_qkvz/in_proj_ba (gdn.py only splits those out on the fp8
branch, and a GGUF checkpoint is not fp8).

Also: tests call .forward() explicitly -- BaseOP defines no __call__.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
…names

The name-map test asserted attn_q/k/v -> self_attn.q_proj/k_proj/v_proj and
ffn_gate_shexp/ffn_up_shexp -> shared_expert.gate_proj/up_proj. Those attributes do not
exist on the module (one merged qkv_proj and one merged gate_up_proj), so the test was
locking in names the loader could never populate. Assert None for fusion parts instead,
which is the contract iter_gguf_weights actually implements.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
…type+shape

Three defects the module-tree reconciliation caught on the real checkpoint:

1. The shared-expert suffixes (ffn_gate_shexp / ffn_up_shexp / ffn_down_shexp) were
   handled AFTER the per-layer-kind branch, whose `else: continue` swallows any suffix it
   does not recognize. Every layer's shared_expert.gate_up_proj and down_proj therefore
   went unfilled -- 81 buffers -- while the loader reported success. Moved the handling
   above that branch, where both layer kinds reach it.

2. mlp.shared_expert_gate: llama.cpp stores the single-output gate as a 1-D [hidden]
   vector; LinearReplicated(hidden, 1) declares [1, hidden]. Now reshaped.

3. linear_attn.conv1d.weight was yielded fp32. gdn.py exempts only A_log and dt_bias
   from the model-dtype downcast, so the depthwise conv is bf16.

Found by reconciling every name/shape/dtype iter_gguf_weights yields against the
constructed module's own state_dict, which is the check that catches this class of bug
before it becomes fluent garbage at generation time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
…. filter)

iter_gguf_weights handled token_embd and output_norm as globals, then bailed on
`not name.startswith("blk.")` -- which silently discarded output.weight, the untied LM
head. lm_head.qweight [248320, 1680] stayed at its torch.empty allocation, so the model
would have generated from uninitialized memory. Ornith ships output.weight (hence
tie_word_embeddings=False), so it is a real buffer that must be filled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
…c_layer_banks

Both bugs surfaced only on IQ3_S -- IQ3_M happened to mask them.

convert_qwen35_to_gguf hardcoded quant types for o_proj (Q4_K), out_proj (IQ3_S), the
token embedding and the lm_head, and used quant_map.get(key, DEFAULT) elsewhere. Q4_K is
right for IQ3_M's attn_output but IQ3_S uses IQ3_S there, so every full-attention o_proj
was allocated 2304 bytes/row instead of 1760. A guessed quant type silently mis-sizes a
packed buffer and the only symptom is garbage output, so the new qt() helper raises on a
missing tensor instead of defaulting. _scan_quant_types now also keys the globals
(token_embd/output) under layer -1 so the embedding and head are sized from the file too.

gguf_expert_specs returned {name: [per-layer (shape, dtype)]}, but alloc_layer_banks
takes {name: (shape, dtype)} -- one spec per bank -- so bank allocation died with
"too many values to unpack". The per-layer form was wrong by construction anyway: a
bank's slot pool is one allocation with one stride, so its type must be uniform. It now
returns the flat form and rejects a non-uniform bank.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
…ansformers)

transformers keys its GGUF tokenizer converters by its own model_type, so passing the
GGUF arch string straight through raised KeyError('qwen35moe') and killed the detokenizer
worker during load. qwen35moe is a GPT2-style BPE with merges (tokenizer.ggml.model=gpt2,
pre=qwen35), which the existing qwen2 converter handles.

Two adjacent gemma4-isms were also wrong for any other vocab and are now per-arch:

- eos preferred the literal <turn|>, and gguf_eos_token_ids hardcoded <eos>/<turn|>.
  Neither exists in Ornith's vocab, so generation would have used the wrong eos and
  registered no chat stop id -- every request running to max_tokens. _STOP_TOKENS maps
  arch -> stop names in preference order; gemma4 keeps (<turn|>, <eos>) so its behaviour
  is unchanged, qwen35moe gets (<|im_end|>, <|endoftext|>), matching the file's
  eos_token_id 248046.
- unk defaulted to the literal <unk>. Ornith has no unknown_token_id and no <unk> token,
  and handing PreTrainedTokenizerFast a token absent from the vocab appends it. tok_for
  now drops a default that is not in the vocab.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
…ed_tokens.weight

convert_qwen35_to_gguf existed but nothing called it, so the module tree still held dense
ops and load_state_dict raised KeyError('model.embed_tokens.weight') against a loader that
yields .qweight. gemma4 performs its swap at the end of the model's __init__ behind
is_gguf_model(); qwen3_5_moe now does the same.

Unlike gemma4 -- whose checkpoint is uniformly Q4_0/Q6_K, so its swap needs no file access
-- this swap sizes every packed buffer by the ggml type that tensor actually uses, which
only the file knows. parse_gguf_config therefore publishes ModelConfig.gguf_model_path and
__init__ asserts it is set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
Threading the bank types through the engine, I bulk-replaced both
`quant_format=banks.quant_format` sites -- but one of them is resolve_moe_cache_auto(),
which only sizes the slot pool from bank bytes and has no such parameter. Only the
OffloadMoeCache constructor takes it. TypeError at cache-sizing time, after the weights
and expert banks had already loaded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
…t garbage

The MoE expert banks were built as cat([gate, up], dim=0).reshape(E, 2I, rb). gate and up
each arrive from the reader as [rows, row_bytes] = [E*I, row_bytes(H)], and ggml's
fastest-first dims [H, I, E] make E the slowest axis, so those rows are EXPERT-MAJOR.
Concatenating on dim 0 therefore lays down every expert's gate before any up: expert 0's
slice became its own gate rows plus expert 1's gate rows, and its up rows sat E*I rows
away. Every routed expert in all 40 layers read another expert's weights.

This loaded cleanly, ran at full speed (56 tok/s decode on a 4060), and generated
confident nonsense -- 'r med media media media middle med med middle med...' -- which is
why the fix comes with a byte-exact identity check rather than a reasoned argument: build
the bank, then assert bank[e, :I] is gate rows [e*I, (e+1)*I) and bank[e, I:] is that same
expert's up rows, for experts at both ends and the middle. The old code passes for expert
0's gate and expert 255's up and fails everywhere between, which is exactly the
interleaving signature.

Fixed by reshaping each part to [E, I, rb] and concatenating on the row axis (dim=1), so
each expert's gate and up stay together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
Qwen3.5 stores these norm weights as w with an effective scale of 1+w, and FreeToken's
GemmaRMSNorm multiplies by the raw weight -- so weight.py bakes the +1 in at load time
(_is_gemma_norm / _GEMMA_NORM_SUFFIXES). The GGUF loader yielded them raw, so every
RMSNorm in all 40 layers scaled by w instead of 1+w. w is typically 0.01-0.1, i.e. the
residual stream was attenuated 10-100x at every norm, which is why generation was fluent
nonsense rather than an error.

Shifted: model.norm, input_layernorm, post_attention_layernorm, self_attn.q_norm,
self_attn.k_norm. NOT shifted: linear_attn.norm (ssm_norm) -- the GDN gated norm is a
standard w*x norm and weight.py excludes it too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
Two value-level bugs, both invisible to structural checks -- shapes, dtypes, byte
identity and per-layer activation magnitudes were all correct while the model emitted
fluent nonsense.

1. ssm_a. llama.cpp writes this tensor pre-transformed: it holds A = -exp(A_log), not
   A_log. Every value in Ornith IQ3_S is negative, spanning [-70.11, -0.0189]. gdn.py
   computes g = -A_log.exp() * softplus(a + dt_bias), so feeding it A gives
   -exp(-10.6) ~ -2.5e-5 and the recurrent decay gate collapses to zero in all 30 GDN
   layers. Now yields log(-A), so -exp(A_log) reproduces the stored A exactly, and raises
   if the tensor is not wholly negative rather than silently producing NaN.

2. The (1+w) RMSNorm shift added in the previous commit was wrong for GGUF and is
   reverted. HF stores raw w and weight.py adds 1.0, but llama.cpp's converter already
   folds the +1 into what it writes, so the shift double-counted. Measured means on
   Ornith IQ3_S: attn_norm 0.920, post_attention_norm 1.135, attn_q_norm 1.326,
   output_norm 2.640 -- all centred near 1, whereas raw w centres near 0.0X.

Both were found by reading the actual stored values against what gdn.py and
GemmaRMSNorm expect. Structural verification cannot catch a tensor that is the right
shape and the wrong quantity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
…cts HF order

When num_k_heads != num_v_heads, llama.cpp's converter reorders every tensor that indexes
the V-head dimension so ggml_repeat can replace an interleaved repeat
(conversion/qwen.py, _LinearAttentionVReorderBase):

  HF / FreeToken (grouped by K head):  [G0_v0, G0_v1, G1_v0, G1_v1, ...]
  GGUF            (tiled for ggml):    [G0_v0, G1_v0, ..., G0_v1, G1_v1, ...]

FreeToken's GDN pairs K head k with V heads [k*R, (k+1)*R) -- grouped -- and the
safetensors loader applies no reorder because HF is already grouped. So every K/V pairing
in all 30 GDN layers was wrong. Ornith: 16 K heads, 32 V heads, R=2, head_v_dim=128.

Inverted on all seven tensor groups llama.cpp touches: attn_qkv (V rows only), attn_gate,
ssm_beta, ssm_alpha, ssm_a, ssm_dt.bias, ssm_conv1d (V channels only) and ssm_out
(columns).

Row permutations are applied directly to the packed bytes -- each output row is an
independent run of quant blocks, so reordering rows never splits one. ssm_out is the
exception: its COLUMNS carry the V dimension, and a 128-wide head straddles the
256-element IQ3_S blocks, so it cannot be permuted while packed. That one tensor is
dequantized to dense bf16 (16 MiB per GDN layer, ~503 MiB total) and
convert_qwen35_to_gguf leaves linear_attn.out_proj as a dense Linear.

Like the A_log transform, this is invisible to every structural check: shapes, dtypes,
byte identity against the source, and per-layer activation magnitudes are all correct
while the K/V pairing is scrambled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
…0/Q6_K only)

_to_bf16 goes through models/gguf/dequant.dequantize, which implements only Q4_0 and Q6_K
-- it is the reference/test path. ssm_out is IQ3_S, so materializing it dense for the
column un-tiling raised NotImplementedError. _dequant_any routes through ggml_dequantize,
which covers all 19 types, and returns a CPU tensor so the normal load path places it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
…round

The refusal said which layers disagreed but not why it is impossible or what to do
instead. It now states that the slot pool is one allocation and moe_vec.cuh indexes it
with no padding allowance, that llama.cpp's *_M and *_XXS levels raise the precision of
the first few layers' ffn_down_exps, and that llama-quantize --pure produces a uniform
checkpoint that loads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
Same hybrid GDN/full-attention decoder as qwen35moe with a plain SwiGLU MLP instead of
routed experts, so it reuses the package, the model classes and this adapter.

- _kv() prefixes metadata keys with the checkpoint's own general.architecture rather than
  a hardcoded "qwen35moe", since the dense arch string is "qwen35".
- parse_gguf_config reads feed_forward_length into intermediate_size when expert_count is
  absent, and leaves expert_quant "none" so the engine does not go looking for offload
  banks that do not exist.
- is_gguf_model now keys on gguf_model_path instead of expert_quant, otherwise the dense
  path would skip the op swap entirely.
- iter_gguf_weights maps ffn_gate/ffn_up/ffn_down onto mlp.gate_up_proj and mlp.down_proj
  (Qwen3_5DenseMLP subclasses _SharedExpert, so the same fused shape applies). Handled
  before the per-layer-kind branch, whose else: continue would otherwise drop them.
- convert_qwen35_to_gguf derives the qkv and in_proj split widths from the config instead
  of Ornith's constants. Ornith-1.5 is 16 q heads / 2 kv / 32 v; Qwen3.8-27B is 24 / 4 / 48,
  so the hardcoded [8192, 512, 512] and [8192, 4096, 32, 32] were wrong for anything else.

Dense checkpoints have no expert banks, so the uniform-bank restriction does not apply to
them: Qwen3.8-27B Q4_K_M mixes Q4_K and Q6_K across layers for attn_qkv, attn_v and
ffn_down, and per-tensor quant types already handle that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
vcruz305 added a commit to vcruz305/FreeToken that referenced this pull request Aug 24, 2026
…wen35

Brings the branch onto main so the fork is usable directly. Upstream review is
tracked in FlashML-org#131.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
models.md still said native GGUF was Gemma-4 only, which stopped being true once
qwen35moe and qwen35 landed. Adds a GGUF section listing the architectures by their
general.architecture string, the quant types with the prefill and decode path per family,
and the two constraints a user hits when picking a file: routed-expert banks must use one
ggml type across all layers (llama-quantize --pure avoids it, dense models are exempt),
and GGUF paths are TP=1 with any NextN/MTP block dropped.
iter_gguf_weights asserted include_moe_experts was False unconditionally, because the MoE
variant serves its routed experts from the offload cache. A dense qwen35 checkpoint has no
routed experts, so the engine correctly asks for everything and the assert tripped during
load. Now conditional on the checkpoint actually having experts.
Same failure as qwen35moe had: convert_gguf_tokenizer is keyed on transformers' own
model_type, so an unmapped GGUF arch string raises KeyError and kills the detokenizer
worker during load. The dense variant shares the MoE one's tokenizer exactly (gpt2 BPE,
pre=qwen35, 248320 tokens, eos <|im_end|>), so it maps to the same qwen2 converter and
the same stop tokens.

(cherry picked from commit e43bf64)
vcruz305 and others added 4 commits August 24, 2026 02:38
Multi-shard was the availability blocker: every large model ships split
(-00001-of-000NN), so quant-type support was unreachable for exactly the checkpoints it
mattered for. reader.py now resolves any shard (or a directory of shards) to the ordered
set, reads metadata/arch/tokenizer from shard 1 only, and aggregates tensor tables across
shards. Validated against llama.cpp's convention: split.no is 0-based while filenames are
1-based, and split.tensors.count is the TOTAL across shards, not per shard. A missing or
extra shard raises with the indices named rather than loading a partial model.

qwen3moe support reuses the qwen35moe adapter's shape with the GDN complexity removed:
plain attention on every layer, no shared expert, no MTP, and no (1+w) norm shift
(qwen3_moe uses RMSNormFused, and plain Qwen3 GGUFs are not pre-shifted). Merged qkv still
routes through GGUFMergedLinear because Qwen3 quants mix types across q/k/v.

Also fixes the same defect qwen35moe had: Qwen3MoeForCausalLM never called
convert_qwen3moe_to_gguf, so the module kept dense ops while the loader yielded .qweight.
Caught by the review pass, not by construction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014h3QjryXQL6PXJWQdA6tvu
The sibling-shard glob was f"{base}-?????.of-{total}.gguf" with a literal dot where the
-of- separator belongs. ????? consumes the index, then the dot has to match the hyphen of
-of-, so the pattern matched nothing and a complete set reported every index missing.
Verified against a real 3-shard split of Ornith IQ3_S produced by llama-gguf-split.

Also resolves a directory of shards to its first shard, since a user is more likely to
point at the download folder than at a specific shard, and raises if the folder holds more
than one split model.
The generated tests called gguf.GGUFWriter(path) without its required arch positional, so
six of seven failed on a TypeError before testing anything. Hand-writing the format keeps
these independent of that writer's API and is only about forty lines.

Covers the three properties of llama.cpp's split convention that are easy to get wrong and
invisible when you do: split.no is 0-based while filenames are 1-based, split.tensors.count
is the total across shards rather than per shard, and only shard 1 carries the metadata.
There is also a fixture guard asserting shard 2 genuinely lacks general.architecture, since
otherwise the shard-1-resolution test would pass vacuously.
Adds the qwen3moe row and the split-checkpoint paragraph to models.md, and updates the
fork README's verified table with the sharded Ornith run and Qwen3-30B-A3B. Corrects the
Ornith IQ3_XXS row to 6/6: the earlier 5/6 was a truncated preview in my harness, not a
wrong answer.
…tion

A GGUF checkpoint with untied embeddings had its lm_head swapped for a plain
GGUFLinear, which has no reason to know it is the head and so projected the
whole prefill hidden state. ParallelLMHead and gemma4's GGUFTiedLMHead both
already drop everything but the last position of each sequence, so only the
untied GGUF path was affected, and only since the qwen35moe/qwen3moe adapters
landed.

Logits are [tokens, vocab] and on a large-vocabulary model that is the biggest
tensor in the model. Ornith-1.5's vocab is 248,320, which in bf16 is 486 KiB of
logits per token, so a 1,800-token prompt asked for a single 894 MB allocation.
Every row but one was then thrown away by the sampler.

Measured on an RTX 4060 Laptop (8 GB), Ornith-1.5-35B-A3B IQ3_S, offload
backend. Before, a ~766-token prompt prefilled in 3.6s and ~1,800 tokens either
hung past 300s or killed the worker with "CUDA driver error: device not ready".
After: 1,834 tokens in 6.1s, 2,487 in 9.6s, 3,765 in 13.9s, 4,598 in 16.9s.
Decode was never affected either way, which is what made this look like a memory
problem rather than a head problem: generating 30,000 tokens works fine because
decode only ever has one position per sequence to project.

Adds GGUFLMHead rather than putting the slice in GGUFLinear, since that class is
also every q/k/v/o and MLP projection in the model and must not slice.

Beyond this fix, an 8 GB card still runs out of room around 5-6k prompt tokens,
but in a different place: the GDN chunked kernel's activations
(chunk_gated_delta_rule_fwd -> chunk_fwd_o). That is headroom rather than a
defect and --max-prefill-length handles it; at 2048 the same box prefills 6,160
tokens in 23.2s and 7,784 in 27.5s. Worth knowing that the 8192 default does not
chunk a typical long prompt at all.
@vcruz305

Copy link
Copy Markdown
Author

Thank you for this. Reproduced, and it is fixed in b2f8475.

Your diagnosis is right and your read of the consequence is better than mine was. I had found the same missing slice about an hour before you posted, but I came at it from the memory side: on an 8GB card a ~1800-token prompt was killing the worker, because logits are [tokens, vocab] and Ornith's vocab is 248,320, so that is 486 KiB of logits per token and a single 894 MB allocation. I fixed it as a memory bug and moved on.

I missed that forward_batch does logits[: batch.size], so on smaller prompts that never OOM you do not crash, you just sample the first token off position 0 and get a quietly wrong answer. That is the worse half of the bug and you caught it. The Hello! The capital city of France is case is a good repro precisely because it fits comfortably in VRAM, so nothing looks wrong.

I went with the same slice you proposed, in a slightly different place. Rather than a nested class in convert_qwen35_to_gguf, it is GGUFLMHead in layers/gguf.py:

class GGUFLMHead(GGUFLinear):
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        from freetoken.core import get_global_ctx

        batch = get_global_ctx().batch
        if batch.is_prefill:
            indices = batch.attn_metadata.get_last_indices(batch.size)
            x = x[indices].contiguous()
        return super().forward(x)

Two reasons for the move. qwen3_moe/gguf.py has the identical untied branch and the identical bug, so it needed fixing in both places. And the slice deliberately does not go into GGUFLinear itself, since that class is also every q/k/v/o and MLP projection in the model and must not slice.

I checked the other adapters: gemma4 always takes the tied path, so GGUFTiedLMHead covers it, and those two were the only untied sites.

Verified on Ornith-1.5-35B-A3B IQ3_S, untied lm_head, temperature 0:

Hello! The capital city of France is  ->  " Paris. Paris is located in the northern part of France"
The capital of Japan is               ->  " Tokyo, a city that has been the seat of the"

and on the memory side, same box, 8GB, offload backend: ~1,800 tokens went from killing the worker to 6.1s, and 4,598 tokens now prefills in 16.9s.

One thing you may hit next on an 8GB card. Past roughly 5-6k prompt tokens it still runs out of room, but somewhere else: the GDN chunked kernel's activations, chunk_gated_delta_rule_fwd -> chunk_fwd_o. That one is headroom rather than a defect, and --max-prefill-length 2048 handles it (6,160 tokens in 23.2s, 7,784 in 27.5s on the same box). Worth knowing that the 8192 default does not chunk a typical long prompt at all, so you get one enormous forward pass.

No need to send a PR for this one, but the report was genuinely useful and I would take more of them.

vcruz305 added a commit to vcruz305/FreeToken that referenced this pull request Aug 25, 2026
Untied-embedding GGUF checkpoints installed a plain GGUFLinear as the lm_head,
which projected every prefill position instead of only the last of each
sequence. Two consequences: the first sampled token came from position 0 (wrong
output on any prompt small enough not to OOM), and logits were [tokens, vocab],
which on a 248k-vocab model is a 894 MB allocation at 1800 tokens.

Reported against FlashML-org#131 by an external tester who caught the correctness half;
credit to them. Affected qwen35moe and qwen3moe; gemma4 is always tied.
Two problems, both only reachable on pre-Ampere cards.

The head_dim <= 128 branch of _select_extend_tile returned (128, 64)
unconditionally, with no fits() check at all, unlike every other branch. At
block_d 128 that tile needs 96KB, which is fine on sm_80/sm_89 and fatal on
Turing's 64KB hard limit.

The shared-memory estimate itself also has the wrong shape for Turing. Ampere+
tiles with mma.sync m16n8k16, Turing with m16n8k8, and Triton stages
materially more for the same logical tile as a result. Measured from triton's
own OutOfResources reports on a Quadro RTX 6000 (65536 limit):

  (128,64)@block_d 256 -> 196608    (64,64)@256 -> 131072
  (64,32)@256          ->  98304    (128,64)@128 ->  98304

The existing (block_m + 2*block_n)*block_d*2 estimate scales with N where the
real cost scales with M, so it under-predicts exactly the tall-M tiles that
overflow. A head_dim-256 model on a 64KB card therefore selected (64,32),
which the estimate said fit at 65536, and still died needing 98304.

The corrected bound is applied ONLY when compute capability < 8. The same
tiles measure 114688 for (128,64)@256 on sm_89, so applying it there would
shrink tiles that genuinely fit and cost roughly 2x on large head_dim. Ampere+
selection is unchanged, and the existing parametrised tests over A100/H100/
sm_89/unknown budgets all still pass.

Turing also needs rungs below (64,32) at head_dim <= 256, so the ladder
continues to (32,32) and (32,16) there; Ampere+ keeps its original two-way
choice.

Verified on the hardware: Ornith-1.5-35B-A3B IQ3_S on a Quadro RTX 6000
(sm_75, 24GB) now serves, where before it died at warmup. 64-69 tok/s with
10240 of 10496 expert slots resident. Correctness spot-checked at temperature
0 against known facts ("The capital city of France is" -> " Paris, ...").
@max-nuv

max-nuv commented Aug 25, 2026

Copy link
Copy Markdown

I really hope you can get this fix working on Windows, because after pulling your branch and fixing the build for MSVS, I got a CUDA error: invalid argument in ggml_moe_a8_vec with the first 20k tokens prompt. After running short tests on Qwen 3.6 35B A3B (NVFP4) vs. Ornith 1.5 35B A3B (IQ3_XXS), I got the following results:

Test Qwen Ornith
Logic TTFT 1.09 sec 0.78 sec
Logic speed 26.7 tokens/sec 40.9 tokens/sec
Logic quality Hit the limit. Correct: $100, finish=stop
Code TTFT 0.89 sec 0.69 sec
Code speed 27.4 tokens/sec 40.0 tokens/sec
Code quality Hit the limit. Correct code, but finish=length

So, Ornith, with a smaller quantum, produced better and faster results, and I'm really looking forward to trying the model in real-world tasks.

P.S. 3070 8GB VRAM, context 96k tokens

moe_vec.cuh carries the routed-pair index (token * top_k + slot) in
gridDim.z, which CUDA caps at 65535 on every architecture. All 19 launchers
computed the grid as (block_num_y, 1, tokens * top_k), so a prefill wider than
65535 / top_k tokens failed the launch with cudaErrorInvalidConfiguration,
which torch reports as "CUDA error: invalid argument".

At top_k 8 the ceiling is 8191 tokens. fused_experts_gguf issues two launches,
gate_up as (top_k, N) and down as (1, N * top_k), and both reach z = N * top_k,
so both overflow together. Nothing about this is architecture- or OS-specific;
it needs only a long enough prompt and was reported with a 20k-token one.

Fixed by launching token-aligned chunks rather than reshaping the grid. Moving
the pair index into x would also fit, since gridDim.x allows 2^31-1, but it
would cost the locality the kernel is built around: with the row index in x,
consecutive blocks walk rows of the SAME expert and reuse its weight pages.
Chunking keeps the access pattern and leaves the kernel body untouched. Chunks
are whole tokens so the kernel's own token = blockIdx.z / topk arithmetic stays
valid against the offset vy / dst / topk_ids pointers.

The 19 near-identical launcher bodies now share one moe_vec_launch<> helper.

Tests assert the launch succeeds past the ceiling AND that rows below it are
bit-identical however many chunks were used. The second is the real check: a
wrong per-chunk offset would still launch, look healthy, and emit fluent
nonsense. Verified to z = 160000 (20k tokens x top_k 8), and the kernel
compiles for sm_75 and sm_89.
@vcruz305

Copy link
Copy Markdown
Author

Found it and fixed it in 9952a39. Thank you for the report, and for building it on MSVC to get there.

It is not a Windows problem and not a 3070 problem, which is worth saying because it would have sent me looking in the wrong place. moe_vec.cuh carries the routed-pair index (token * top_k + slot) in gridDim.z, and CUDA caps gridDim.z at 65535 on every architecture. All 19 launchers computed:

const dim3 block_nums(block_num_y, 1, tokens * top_k);

At top_k 8 that ceiling is 8191 tokens. Your 20k prompt asks for z = 160000, the launch is rejected with cudaErrorInvalidConfiguration, and torch surfaces it as the fairly unhelpful "invalid argument". Anyone with a long enough prompt hits it on any GPU and any OS.

fused_experts_gguf makes two launches, gate_up as (top_k, N) and down as (1, N * top_k), and both land on z = N * top_k, so they overflow together.

The launcher now issues token-aligned chunks. Moving the pair index into x would also have fixed it, since gridDim.x allows 2^31-1, but it would cost the locality the kernel is built around: with the row index in x, consecutive blocks walk rows of the same expert and reuse its weight pages. Chunking preserves that and leaves the kernel body untouched. Chunks are whole tokens so the kernel's own token = blockIdx.z / topk arithmetic stays valid against the offset vy / dst / topk_ids pointers. The 19 duplicated launcher bodies now share one moe_vec_launch<> helper.

Tests in tests/kernels/test_moe_vec_grid_z.py check two things, and the second is the one that matters:

  • a launch past the ceiling succeeds (verified to z = 160000, i.e. 20k tokens at top_k 8)
  • rows below the ceiling are bit-identical however many chunks were used

A wrong per-chunk pointer offset would still launch and still look healthy, and would quietly corrupt everything above 8191 tokens. The equality check is what catches that; a crash test would not.

Also verified the kernel still compiles for sm_75 and sm_89.

On your numbers: Ornith IQ3_XXS beating Qwen NVFP4 on both speed and quality is a real effect rather than a fluke of the quant. Ornith's expert banks come out uniform at IQ3_XXS, so the whole MoE stays on the packed moe_vec path with no dequantization, whereas the NVFP4 route materializes more per token. The finish=length on both Qwen tests also suggests it was running into the output cap rather than answering worse, so the quality column may be flattering Ornith slightly.

One thing you should hit next, at 96k context. There are two other prefill limits I have measured, neither of which is this bug:

  • An untied lm_head was projecting every prefill position instead of the last, so logits were [tokens, vocab]. Fixed in b2f8475. On a large-vocab model that is hundreds of MB per prompt and it also made the first sampled token come from position 0.
  • Past roughly 5-6k prompt tokens on an 8GB card the GDN chunked kernel's activations run out of room (chunk_gated_delta_rule_fwd -> chunk_fwd_o). That one is genuine headroom rather than a defect, and --max-prefill-length 2048 handles it. Worth knowing the 8192 default does not chunk a typical long prompt at all, so you get one enormous forward pass.

If you pull again you will get all three. I would be glad of more reports like this one.

vcruz305 added a commit to vcruz305/FreeToken that referenced this pull request Aug 25, 2026
Prefills wider than 65535/top_k tokens (8191 at top_k 8) failed the kernel
launch with cudaErrorInvalidConfiguration. Reported on PR FlashML-org#131 with a 20k
prompt. Launcher now issues token-aligned chunks.
Ported from qwen3_5_moe/gguf_experts.py; llama.cpp emits the same three stacked
tensors for both architectures, so only the arithmetic differs. DeepSeek-V4-Flash
is 43 served layers of 256 experts at moe_inter_dim 2048 over dim 4096.

Not yet reachable: the deepseek4 architecture is not registered and gguf.py does
not exist, so nothing calls this. Landing it separately keeps the verified piece
reviewable on its own.

Verified against the real 164GB antirez/deepseek-v4-gguf Q4KExperts checkpoint:
43 layers uniform Q4_K on both banks, gate_up (256, 4096, 2304) and down
(256, 4096, 1152), each cross-checked against the file's own tensor dims
(Q4_K is 144 bytes per 256 elements, so H=4096 -> 16*144 and I=2048 -> 8*144).

Two things carried over deliberately. The gate_up fusion reshapes to [E, I, rb]
and concatenates on dim=1 so each expert keeps its own gate rows followed by its
own up rows; cat(dim=0) then reshape is the version that looks right and lays
every expert's gate down before any up, which loads and runs at full speed and
emits fluent nonsense. And layers >= num_layers are skipped: the file carries a
trailing NextN/MTP block, and counting it makes a uniform checkpoint look mixed.
parse_config recovers DeepseekV4Args from the checkpoint's inference/config.json,
which ships beside safetensors. A standalone .gguf has no such file, so this
rebuilds the same dataclass from KV keys. Nothing falls back to a dataclass
default: a silently-defaulted hyperparameter here yields a model that loads and
generates confidently wrong text.

block_count does NOT relate to the MTP block consistently across architectures,
so the served layer count is derived rather than assumed. qwen35moe counts its
NextN block inside block_count (Ornith: 41 blocks, blk.40 IS the MTP block, 40
served); deepseek4 does not (43 blocks, blk.0..blk.42 all served, MTP carries no
blk tensors). Subtracting nextn_predict_layers unconditionally silently drops the
last real layer, which is exactly what the first version did.

compress_ratios is the authority instead: one entry per served layer plus MTP.
Entries != 0 mark a layer with an attention compressor and == 4 one with the
lightning indexer, so the schedule is checked against the tensor table on every
load. That check caught the off-by-one immediately (40/20 instead of 41/21)
rather than leaving it to surface as degraded output after a 145 GiB load.

Verified against antirez/deepseek-v4-gguf Q4KExperts: 43 served layers, 41
compressors, 21 indexers, expert types (Q4_K, Q4_K), score_func sqrtsoftplus
from expert_gating_func 4, route_scale 1.5.

iter_gguf_weights and convert_deepseek4_to_gguf still to come; the arch is not
registered yet, so none of this is reachable.
Completes the adapter and registers the architecture. Reconciles against the
real 164GB antirez/deepseek-v4-gguf Q4KExperts file: 1199 model parameters,
1199 matched, 0 unknown destinations, 0 shape mismatches, 0 unfilled.

Four things did not carry over from the qwen adapters:

block_count does not relate to the MTP block consistently. qwen35moe counts its
NextN block inside it, deepseek4 does not. Subtracting nextn_predict_layers
unconditionally dropped the last real layer; the compressor/indexer schedule
cross-check caught it as 40/20 instead of 41/21.

deepseek_v4 is raw nn.Module and loads via load_state_dict over
named_parameters(), unlike the BaseOP-based qwen trees. FreeToken's GGUFLinear
holds qweight as a plain attribute, which that loader cannot see, and assigning
a non-Module over a Module child raises. Hence GGUFLinearNN / GGUFEmbeddingNN
here, carrying the packed bytes in a non-grad uint8 Parameter named .weight so
the loader's .to(p.dtype) cast is a no-op.

dequant.dequantize's pure-torch path implements only Q4_0 and Q6_K; this
checkpoint's attention projections and lm_head are Q8_0, which needs the CUDA
kernel. Unquantized F16/F32 are reinterpreted rather than dequantized.

Three tensors do not behave as their names suggest, and each would have been a
silent mis-wire: attn_output_a is Q8_0 in the file but attn.wo_a is a bare bf16
nn.Parameter (no .weight, never packed); the compressor and indexer projections
are F16, i.e. in GGML_UNQUANTIZED, so GGUFLinear's uint8 buffer cannot hold
them; and Indexer.wq_b is declared Linear(kind="fp8") with a .scale no GGUF
tensor can fill, so it is rebuilt as bf16 rather than populated.

tid2eid is a routing index, not a weight: read as int32 and widened, never sent
through a float dequant that would round token ids.

Registration covers all five points: GGUF_ARCH_TO_REGISTRY, the register.py
ModelSpec, _TOKENIZER_ARCH and _STOP_TOKENS, and the package exports. Stop
tokens were read from the checkpoint's own vocab (eos id 1
<|end_of_sentence|>, plus <|EOT|> 128805) rather than assumed.

Not yet loaded end to end: that needs ~145 GiB of pinned host RAM for the
expert banks.
…lues

GGUFLinear and GGUFEmbedding store every ggml type in a uint8 buffer of
row_bytes width, including F32/F16/BF16, where "packed" just means the raw
value bytes. fused_mul_mat_gguf's unquantized branch multiplied that byte view
directly, so in_features came out as row_bytes -- twice too wide for F16 -- and
the call failed with "mat1 and mat2 shapes cannot be multiplied
(4x2048 and 4096x248320)".

Only reachable when a checkpoint stores a projection unquantized, which is why
it had not surfaced: Ornith's output.weight is Q6_K and takes a dequant path.
Apodex-1.1-mini ships output.weight as F16 and fails at the first forward.

The bytes are now reinterpreted through the type's real dtype before the
matmul. The ACTIVATION is cast rather than the weight: converting the weight
would copy the whole matrix per call (about 1 GB per forward for a 248k-vocab
lm_head) and allocating that during CUDA graph capture fails outright, which is
what the first version of this fix did. x is [tokens, hidden], so casting it is
negligible, and computing in the stored precision matches what llama.cpp does
for these tensors.

Verified on Apodex-1.1-mini Q2_K (qwen35moe, 40 layers, 256 experts, F16
output.weight) on an 8GB RTX 4060: serves, and correct at temperature 0 --
"The capital city of France is" -> " Paris", "The largest planet in our solar
system is" -> " Jupiter. Its diameter is approximately 142,98[4 km]".
@spacewave-bot

Copy link
Copy Markdown

Verified this end-to-end on an AMD Radeon RX 7700 XT (gfx1101, ROCm 7.2/HIP), using the HIP engine port from #137 plus this patch set: Qwen3-30B-A3B IQ4_XS serves coherently with --moe-backend offload at approximately 52 tokens/s decode (eager mode; CUDA graph capture currently requires --cuda-graph-max-bs 0, see below).

Three small HIP fixes were required on top, submitted as vcruz305#1:

  1. gguf.py passes nvcc-only flags (--expt-relaxed-constexpr, -ccbin) to the HIP build; hipcc rejects both, and relaxed constexpr is already its default.
  2. The warp-sync calls in mmvq.cuh, moe_vec.cuh, and gguf_kernel.cu pass 32-bit masks; HIP requires 64-bit. CUDA accepts the 64-bit form as well, so the change is unconditional.
  3. layers/activation.py routes GGUF-MoE activations through a triton kernel that fails LLVM register allocation (couldn't allocate output register for constraint 'f') on some RDNA3 shapes; under HIP this falls back to plain torch ops.

Also worth noting for other testers: setting PYTORCH_ROCM_ARCH=<your gfx> reduces the initial GGUF kernel build time substantially.

nekomario28 added a commit to nekomario28/FreeToken that referenced this pull request Aug 26, 2026
Preserve the exact FlashML-org#131 donor history while composing it with the current ROCm shadow branch for isolated integration validation. This is an internal integration branch, not an upstream proposal.
Three defects with one root: "gguf" is a container tag, not a weight layout.
The checkpoint picks a ggml type per tensor and the concrete CPU format has to
be recovered from the bank types, but two call sites tested the tag directly.

_cpu_moe_executor_viable compared expert_quant against _WFMT_IDS, which answers
False for EVERY GGUF checkpoint. That silently disabled the automatic residency
split on hosts where CUDA pinning is quota-capped -- WSL caps it near 40% of RAM
(measured: 81.78 GiB of 204). The symptom was not a clear refusal but
cudaHostRegister failing partway through the banks, which reads as a memory
shortage rather than a dispatch gap.

gemm1_dot handled bf16 and q4_0 and then FELL THROUGH to the NVFP4 path, which
dereferences scale/global pointers that are null for GGUF banks. An unhandled
format therefore segfaulted inside a worker thread with no Python traceback.
It now raises through TORCH_CHECK naming the format, same reasoning as the
kernel default: guards in FlashML-org#138: an unhandled case that reads null or
uninitialised memory is far worse than one that errors.

GGUFEmbedding dequantized unconditionally, but the unquantized types are raw
value bytes with no dequant kernel at all (ggml_dequantize rejects type 1
outright). DeepSeek-V4 ships token_embd as F16 and died on the first lookup.
The gathered rows are now reinterpreted for those types, matching the fix
already applied to fused_mul_mat_gguf.
Five fixes that stood between a reconciled adapter and a generated token, found
by loading the real 164GB antirez/deepseek-v4-gguf Q4KExperts checkpoint.

convert_deepseek4_to_gguf was never called from the model's __init__, so the
fp8 Linears were never swapped and load_state_dict demanded a wq_a.scale no
GGUF tensor can fill. The reconciliation test passed because it called the
converter explicitly; the engine does not. Third time this hook has been missed
in this package (gemma4 and qwen35moe before it).

GGUFLinearNN passed its input straight to fused_mul_mat_gguf, which takes
[tokens, in_features] and treats dim 0 as the batch. F.linear -- which it
replaces -- accepts any number of leading dims, and deepseek_v4 relies on that:
its attention passes 3-D tensors. Collapsing one silently reshaped q so the
sparse-attention kernel's `b, m, h, d = q.shape` unpack failed. Leading dims are
now folded and restored.

GGUFEmbeddingNN dequantized unconditionally; token_embd is F16 here, which has
no dequant kernel. Reinterpreted instead.

The DSV4 sparse-attention kernel exceeded Turing's 64KB shared-memory block
limit. BLOCK_T was tuned for sm_120's ~99KB budget, but the dominant cost is not
the KV tile: q and acc are [BLOCK_H, D] in fp32, which at BLOCK_H=16 and
head_dim 512 is 65536 B on its own -- an entire Turing block before a single KV
byte, which is why shrinking BLOCK_T alone left the requirement stuck at 66624.
BLOCK_H, BLOCK_T and num_stages are now chosen from the device's opt-in shared
memory; sm_80 and above are unchanged.

_TOKENIZER_ARCH mapped deepseek4 to "llama". The file says
tokenizer.ggml.model = gpt2: the llama converter is sentencepiece-shaped and
encodes a space as U+2581, so against a GPT2-BPE vocab every space was silently
DROPPED on detokenization ("ThecapitalcityofFranceisParis"). The model was
correct throughout; only the detokenizer was wrong, which presents as model
damage and is not. Mapped to the qwen2 (GPT2-BPE) entry.

Verified on a Quadro RTX 6000 (Turing, sm_75, 24GB) with 204 GiB of WSL RAM:
145 GiB of expert banks split 24 layers GPU-pinned / 19 OS-locked for CPU
decode, CPU executor on the Q4_K kernels, ~18GB VRAM. At temperature 0:
  "The capital city of France is" -> " Paris. The capital city of England is
   London..."
  "The largest planet in our solar system is" -> " Jupiter. It is so big that
   more than 1,300 Earths"

Known limitation: CUDA graph capture crashes the worker on this configuration,
so this runs with --cuda-graph-max-bs 0. Decode is slower than it should be as
a result. Not yet diagnosed.
@zyy0212time-del

Copy link
Copy Markdown

Windows/CUDA follow-up: end-to-end verified + a request on merge timing / Windows wheels

Thanks again for b2f8475 and 9952a39. Reporting back from the Windows side for completeness.

Our engine is this branch @ g30aa89115 (it predates both fix commits) with equivalent local patches for the two bugs, so this validates the diagnosis and approach rather than the exact commits:

  • Qwen3.6-35B-A3B Q4_K (pure-quant, abliterated) on an 8GB RTX 5060 Laptop, --moe-backend cpu: ~70s cold start, ~13.4 t/s decode, chat output correct.
  • Prompts around 8K tokens (agent frameworks with large system prompts) no longer crash the worker - before the chunking fix they reliably took down ggml_moe_a8_vec.

One practical concern: FreeToken Desktop on Windows keeps showing the "engine update available" prompt. The latest release (v0.1.2, published 2026-08-19, before this PR was opened) ships Linux-only wheels and has no qwen35moe GGUF support, so Windows users depending on this branch currently have no safe update path.

Is there a timeline for merging this? And could the next release include win_amd64 wheels? Happy to help test Windows builds (CUDA 13.0 / MSVC 17.14) - this machine is set up and idle.

nekomario28 added a commit to nekomario28/FreeToken that referenced this pull request Aug 26, 2026
Preserve upstream author history for 3178a59 while integrating the GGUF CPU/hybrid executor reachability fixes into the gfx1101-validated ROCm split-JIT branch.
nekomario28 added a commit to nekomario28/FreeToken that referenced this pull request Aug 26, 2026
…iew baseline

Synthetic baseline for the ROCm GGUF operation-split review. Preserve donor history.
nekomario28 added a commit to nekomario28/FreeToken that referenced this pull request Aug 26, 2026
Preserve the already validated gfx1101 DSV4 sparse-attention implementation while importing the six non-conflicting files from upstream FlashML-org#131 bb432e8. The omitted conflicting DSV4 tile-planning file is intentionally deferred to its own physical gate; it is unrelated to the Qwen/Ornith GGUF and CPU-backend fixes being qualified here.
nekomario28 added a commit to nekomario28/FreeToken that referenced this pull request Aug 26, 2026
Restore the already integrated ROCm-aware engine implementation after inspecting the latest FlashML-org#131 donor. The donor engine blob would regress GPU binding, the ROCm PyNCCL/RCCL fallback, and GPU-identity-aware profiling. The new GGUF CPU-viability bridge will be qualified as a narrow semantic patch instead of replacing the whole engine file.
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.

4 participants