GGUF: support all quant types, add qwen35moe - #131
Conversation
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
…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)
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.
|
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 I missed that I went with the same slice you proposed, in a slightly different place. Rather than a nested class in 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. I checked the other adapters: gemma4 always takes the tied path, so Verified on Ornith-1.5-35B-A3B IQ3_S, untied lm_head, temperature 0: 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, No need to send a PR for this one, but the report was genuinely useful and I would take more of them. |
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, ...").
|
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:
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.
|
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. 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
The launcher now issues token-aligned chunks. Moving the pair index into Tests in
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 One thing you should hit next, at 96k context. There are two other prefill limits I have measured, neither of which is this bug:
If you pull again you will get all three. I would be glad of more reports like this one. |
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]".
|
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 Three small HIP fixes were required on top, submitted as vcruz305#1:
Also worth noting for other testers: setting |
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.
|
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:
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. |
Preserve upstream author history for 3178a59 while integrating the GGUF CPU/hybrid executor reachability fixes into the gfx1101-validated ROCm split-JIT branch.
…iew baseline Synthetic baseline for the ROCm GGUF operation-split review. Preserve donor history.
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.
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.
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_SHAPEnow 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.pyderives the block sizes fromggml-common.hat runtime and extracts thecaselabels fromgguf_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 ingguf_kernel.cuhad adefault:.Yis allocated withtorch::empty, so an unsupported type returned uninitialized memory instead of raising, andggml_moe_get_block_sizereturned 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_ggufroutes them throughggml_dequantizeplus a torch matmul at prefill. That branch already existed but was unreachable, since_MMQwas tested before_DEQUANTand the two sets were identical.qwen35moe
New
models/qwen3_5_moe/gguf.pyandgguf_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), andparse_gguf_configasserts thessm.inner_sizerelation instead of trusting it.Four things llama.cpp's converter does that the loader has to undo. I found these by reading
conversion/qwen.pyafter a lot of wasted time, so listing them in case they save someone else the trouble:ssm_aholdsA = -exp(A_log), notA_log. Every value in the file is negative, spanning roughly -70 to -0.02.gdn.pycomputes-A_log.exp(), so passingAstraight through makes the recurrent decay gate about -2.5e-5 and the recurrence dies. The loader yieldslog(-A).(1 + w)RMSNorm shift is already folded in by the converter.weight.pyadds it for HF checkpoints because HF stores raww, 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 rawwwould give.num_k_heads != num_v_heads, soggml_repeatcan 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_outis 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.qkv_projis q and k IQ3_S with v Q4_K, and the GDNin_projisattn_qkvQ4_K with the rest IQ3_S. Concatenating packed rows needs matchingrow_bytes(880 vs 1152 here), so there's aGGUFMergedLinearthat 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_SCHEMASgets a"gguf"entry, and the(gate_up, down)ggml types are threaded fromModelConfigthroughExpertBanksandOffloadMoeCacheto the kernels, because a GGUF's row stride is a property of the file rather than the format.fused_experts_gguftakes a separatedown_quant_typesince 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.cuhaddresses the pool asexpert * 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_banksraises 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 splitffn_down_expsacross two types. Quantizing with--pureavoids 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_overridenow declines forexpert_quant == "gguf", otherwise qwen3_5_moe's NVFP4setup_offload_expert_bankshijacks the GGUF load and fails deep inside an NVFP4 reader.Tokenizer
convert_gguf_tokenizeris keyed on transformers' own model_type, so passing the GGUF arch string through raisedKeyError('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, andunkdefaulted to a<unk>token that also isn't there. gemma4 behaviour is unchanged.Numbers
Correctness checks I ran, since "it produces text" isn't much of a claim:
gate_upfusion laying down every expert's gate before any up.First token argmax matches llama.cpp CPU on 2 of 6 single token prompts. Every disagreement is a plausible near tie (
Pariscontinuing " de" against " is",importcontinuing " 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
_gguf_banksraisesNotImplementedErrorfor the parallel reader since that path assumes safetensors and a GGUF is one packed file.max_model_lenstill reports the metadata context length even when KV is capped lower.