Skip to content

feat: import qwen4exp (Qwen3.8-Flash-Next) support from upstream PR #27742 - #324

Merged
TheTom merged 47 commits into
TheTom:feature/turboquant-kv-cachefrom
giveen:feature/qwen4exp
Aug 31, 2026
Merged

feat: import qwen4exp (Qwen3.8-Flash-Next) support from upstream PR #27742#324
TheTom merged 47 commits into
TheTom:feature/turboquant-kv-cachefrom
giveen:feature/qwen4exp

Conversation

@giveen

@giveen giveen commented Aug 27, 2026

Copy link
Copy Markdown

Imports upstream PR ggml-org#27742 onto feature/turboquant-kv-cache using a clean sequential cherry-pick, with upstream-compat shims committed on top.

What this contains:

Verified:

  • Build completes on CUDA + Ninja
  • llama-cli loads qwen4exp model and generates
  • llama-bench --moe-cache auto: ~225 t/s prompt, ~44 t/s generation on RTX 5090

Sequential cherry-pick log: /tmp/qwen4exp-sequential.log (26 commits, 0 cherry-pick failures)

giveen and others added 30 commits August 27, 2026 09:05
Adds LLM_ARCH_QWEN4EXP with its hparams and tensor loading. The graph
comes in the next commit; this makes the model load and report correct
metadata.

- hyper-connections set n_embd_out_impl = hc_count * n_embd, so the
  residual stream is 4x wide and there is no output_norm: the final
  mixer's hc_norm is the last norm in the model.
- registered as hybrid and given the same recurrent/attention memory
  filters as Qwen3-Next and Qwen3.5.
- reuses the existing indexer, per_layer_token_embd, SSM and
  compress_ratios keys as-is.
- the PLE table row count is read back from the file rather than
  recomputing the vocab padding rule.

llama-model-loader gains UINT64 array support. That branch previously
threw, so no existing caller changes behaviour; it is needed because the
PLE hash multipliers do not fit in int32.
Implements the decode graph for Qwen3.8-Flash-Next: the hyper-connection
residual stream, gated delta net layers, the MoE block with its gated shared
expert, and dense full attention. The QSA indexer and the PLE n-gram embedding
are not wired up yet and land in later commits.

Hyper-connections are implemented here rather than shared with deepseek4.cpp.
The two formulations agree on the [n_embd, hc, n_tokens] layout and little
else: DeepSeek-V4 mixes with a full-rank projection and Sinkhorn-normalises
it, whereas this model uses a low-rank down/silu/up sigmoid gate and collapses
by a plain mean. Only the ~10 line stream mean is genuinely common, so sharing
would mean touching DSV4's hot path and its three fused CUDA ops to reuse very
little. What is reused is the substantive part: the LLM_KV_HYPER_CONNECTION_*
keys, the n_embd_out_impl wide-residual support already in the loader, and the
layout convention.

Also allows a checkpoint to carry no PLE layers at all, which makes it
possible to bring the graph up and validate it in stages.

Validated against vLLM, the only working reference implementation. On a
scaled-down model with an init scale large enough to give non-uniform logits,
agreement with vLLM sits at the numerical noise floor: llama.cpp f32 against
its own bf16 gives 84.3% top-1 agreement over 255 positions, and this graph
against vLLM gives 85.1%. The comparison was calibrated by seeding three
deliberate bugs (silu instead of sigmoid on the delta net gate, dropping the
1/hc scale in the mix, dropping the 2x in the combine); each drops top-1 to
between 0% and 11%, an order of magnitude below the floor.
Adds the per-layer embedding: a custom I32 graph input hashes each token with
its ngram_size-1 predecessors host-side and the result is a plain row gather
over the shared table, the same shape gemma3n's per-layer embedding uses. The
hash has to run on the host because the splitmix64-derived multipliers reach
2^45, so the products need 64-bit integers and an xor, neither of which ggml
has.

Predecessors that fall outside the ubatch come from a small per-sequence
history on the model, mirroring the per-request ngram_context the reference
carries. It is only trusted when contiguous with the incoming position, so a
fresh prompt or a rewound cache falls back to EOS padding rather than hashing
against stale tokens.

The depthwise conv is written out as a sum of shifted, per-channel-scaled
copies rather than through ggml_conv_1d_dw, which carries a correctness
warning upstream.

Verified two ways. The row indices match a transcription of the reference's
tensor formulation exactly, 1024 of 1024 rows, including sequences with EOS
tokens sprinkled through them to exercise the segment reset. Separately, with
PLE placed on layer 0 so its input is just the token embedding, ple_embd and
ple_gated_value match a PyTorch computation from the same checkpoint to every
printed digit.

End to end over 1023 scored positions the port sits the same distance from
vLLM with PLE as without it, 6.3 points of top-1 against 6.0, so PLE costs no
accuracy relative to the rest of the model. That common offset is vLLM's bf16
activations, which cannot be removed: its QSA kernel refuses float32.

Two bugs found along the way, both caught by the row-index check. The history
was read and updated in the same pass, so a token early in a ubatch could pick
up an earlier token of that same ubatch as prior context; it is now snapshotted
first. And an EOS token was cutting its own context, where the reference takes
the last EOS strictly before the position, so a boundary only hides tokens from
the positions after it.

Known gap: the conv carries no state across ubatches, so it is exact only for a
prefill that starts at position 0. Chunked prefill and decode need the conv
state wired into the recurrent memory, and the conv branch itself is still
numerically unverified because the fixture zeroes its weights.
The PLE depthwise conv was zero-padding on the left, which is only right for a
prefill that starts at position 0. Decode and chunked prefill saw a truncated
history for the first (kernel-1)*ngram_size positions of every ubatch.

The PLE module sits on a layer that is also a delta-net layer, so both need a
conv history in the same recurrent row. Rather than plumb a per-layer state
size through build_rs and build_conv_state, the row is widened once and each
convolution addresses its own slice through a local helper. n_embd_r() gains
the extra span, which is zero for every other architecture because it is
derived from ple_n_heads.

Verified by feeding the same 1024 token sequence in chunks instead of one
shot: at 64 tokens per decode the logits are bit-identical to the single-shot
run, 1023 of 1023 top-1 and a maximum logprob deviation of exactly zero. At
one token per decode they differ slightly, but the no-PLE model differs more
under the same test (94.6% against 97.1%), so that is the usual gemv-versus-
gemm accumulation difference and not the state.

The conv branch is also no longer unverified. With non-zero conv weights the
port sits 6.3 points of top-1 below the numerical floor, the same distance as
with the weights zeroed and as the model with no PLE at all, so the branch
adds no error of its own.

test-llama-archs passes every existing architecture at 0.00e+00, including the
delta-net models that share this code path.
build_rs writes into the state tensor in place, zeroing one row and copying the
carried-over states, so calling it twice for the same layer let the second call
clobber the first write-back. The PLE layer is also a delta-net layer, so that
is exactly what happened: both convolutions gathered the same row. They now
share a single gather per layer.

The earlier claim that the conv state was carried correctly was tested on a
fixture whose conv weights are zero, where the branch contributes nothing and
chunking matches trivially. Re-running with non-zero conv weights showed the
divergence, growing with the number of ubatch boundaries: 97.1% top-1 at one
boundary down to 90.2% at seven. With the shared gather it is bit-identical to
the single-shot run at every chunk size tried, 512, 128 and 64, with a maximum
logprob deviation of exactly zero over 1023 positions. The delta-net-only model
stays bit-identical too, so nothing regressed there.

Also derive the delta-net conv channel count the way load_arch_tensors sizes
wqkv instead of from ssm_d_inner. The two agree for this model, but n_embd_r()
only bounds the row and the convolution has to match the tensor feeding it.

test-llama-archs previously aborted on this architecture and took every later
architecture with it. qwen4exp is marked MoE-only, given the hyper-connection
keys and an ssm_d_inner consistent with its tensor derivation, and skipped for
now: the hyper-connection keys written by get_gguf_ctx are not reaching the
synthesised file, which needs a separate look. The suite completes again, 124
architectures at 0.00e+00.
Groundwork for qwen4exp's QSA sparse attention. Its indexer needs a per-token
key history for the full-attention layers, but a hybrid model cannot use
llama_kv_cache_dsa: that class derives from llama_memory_i rather than
llama_kv_cache, and llama_memory_hybrid constructs its attention cache
directly. No existing architecture pairs recurrent state with a sparse
indexer, so there was nothing to reuse wholesale.

llama_memory_hybrid therefore gains a third, optional cache, shaped the same
way llama_kv_cache_dsa shapes its lightning-indexer cache: a copy of hparams
with n_head_kv forced to 1 and n_embd_head_k_full set to indexer_head_size.
It is built only when a filter_idx callback is passed, which defaults to
nullptr, so every existing architecture gets exactly what it got before. The
per-sequence operations and the batch preparation forward to it under a null
check, matching how the DSA cache prepares its two caches over the same
ubatches.

test-llama-archs passes all 124 architectures at 0.00e+00, including the 12 in
the hybrid family that share this code. The qwen4exp fixtures are unchanged:
same logits against vLLM, and chunked evaluation still bit-identical to
single-shot.
The full-attention layers of this model do not attend to everything. An
indexer scores one mean-pooled key per block of compress_ratio tokens and
keeps a budget of the best blocks, plus the tail of tokens that do not yet
form a complete block. Below indexer_top_k + compress_ratio - 1 cached
tokens every block fits in the budget, so the result is exactly dense.

What is reused rather than rebuilt:

  - the mask machinery. build_attn's DSA overload already turns a list of
    token indices into a KQ mask via ggml_set_rows, so that block is lifted
    out verbatim into build_attn_mask_top_k and shared with a new overload
    on llm_graph_input_attn_kv. DSA's node sequence is unchanged; the new
    overload exists because llama_kv_cache_dsa assumes MLA and cannot be
    dropped into a hybrid model.
  - the indexer key cache, which is the optional third cache added to
    llama_memory_hybrid in the previous commit. It holds raw keys, because
    pooling happens before the norm and the rotation.

The graph expands block scores rather than block indices: giving every
token of a block its block's score needs only a gather, where expanding
indices would need an integer multiply-add that ggml has no op for. Since
the budget is a whole number of blocks and a block's members tie exactly,
the cut still lands on a block boundary.

Everything that depends on cache layout is computed host-side in
set_input_qsa. Blocks are cuts of the position line rather than of the cell
array, so nothing assumes the cache is contiguous.

Measured on the tiny fixture against vLLM, comparing the selected token
indices directly rather than the logits:

  below the budget    selection identical, and 1024-token logits are
                      bit-identical to the pre-QSA dense path
  above the budget    mean jaccard 0.975

The direct index comparison is what made this correct. The reference
rectifies each head's dot product before summing over heads, which an
earlier reading of it had missed; on logits alone the resulting port looked
fine, because on a randomly initialised fixture the known-correct dense
path already disagrees with vLLM by more than the bug did. Comparing the
indices showed 0.794, and fixing the ReLU moved it to 0.975.
The indexer cache found its own slots, independently of the attention
cache. Both are the same size and see the same ubatches, so in a
straight-through prefill they agree, which is why every fixture and every
single-shot parity run passed. They drift once the context is being
rewritten between turns, and then the QSA top-k indices, which are applied
against the attention mask, point at the wrong cells.

The seven-turn chat test caught it on the third turn: llama-server aborted
on the assertion that the two caches report the same n_kv.

The cache is a side buffer addressed by the attention cache's cells, so it
now takes that cache's slot layout instead of computing one. Applying that
layout also marks its cells identically, so the two agree cell for cell by
construction rather than by coincidence, and the assertion can no longer
fire.

Inert where the caches already agreed: test-llama-archs green at 126 archs
and 0.00e+00, and the 4096-token tiny fixture is unchanged at max logit
delta 0.0.
The old note guessed that the hyper-connection keys never reach the file.
They do: dumping the gguf_context handed to llama_model_init_from_user
shows both among its 67 KVs, and the loader still reports one missing.
The arch was skipped with a note guessing that the hyper-connection keys
never reached the synthesised file. They did. The suite builds a model, then
saves and reloads it, and llama_model_saver did not re-emit those keys, so
the failure was in the roundtrip leg rather than the first load. Three gaps,
all in shared code and all additive:

  - add_kv_from_model wrote no hyper-connection, compress-ratio or PLE keys.
    The PLE group only means anything whole, so it is written or omitted
    together; the rest follow the file's existing style of writing every key
    unconditionally, since an architecture that does not read one is
    unaffected by a zero.
  - the saver had no uint64 path at all, which the PLE hash constants need.
  - add_tensors_from_model enumerates model-level tensors by hand and was
    missing per_layer_tok_embd and the three final-mixer tensors.

Two smaller fixes on the qwen4exp side, both found by running the test:

  - build_qsa_top_k divided by the compression ratio before asserting it was
    non-zero, so a file without the key crashed instead of reporting.
  - a layer with no compression ratio now falls back to dense attention,
    which is what the model computes below the budget anyway. The test then
    has to write a ratio to reach QSA at all, and an indexer key length no
    narrower than n_rot, since the indexer ropes with the main attention's
    rotary width.

Full suite: 126 archs, qwen4exp at 0.00e+00 with roundtrip OK. The tiny
fixture is unchanged, max logit delta 0.0 against the pre-QSA dense run.
The n-gram table arrives as 128 shards that were held in a dict and then
torch.cat-ed, so the peak was the shards plus the concatenation: around
300 GB of RSS on the real checkpoint, which rules out machines that could
otherwise convert this model.

Each shard is now written straight into a memory-mapped file at its final
row offset and dropped, so the resident set is one shard and the rest is
the page cache's problem. The temporary file sits beside the output and is
removed once the write finishes, including on failure.

Shards other than the last must be uniform for direct placement, which is
asserted rather than assumed, and a shard arriving before the stride is
known is held instead of misplaced.

Verified on the tiny fixture: the resulting GGUF is byte-identical to the
one the concatenating path produced (md5 2d274efac91ad1e9a6007efb0687e597).
tensor_type_fallback demotes a tensor whose ncols is not a multiple of the
target's block size, but its switch only enumerates the 256-block types. A
target that is already a 32-block type (iq4_nl, q4_0, q5_0, q8_0, ...) falls
into default: and throws, even though the function already knows how to answer
that case: the ncols check right below the switch resolves an unrepresentable
shape to F16.

Route those types into that check instead of throwing. Only paths that abort
today change, so no quantization that currently succeeds is affected.

Found on a 4-wide depthwise conv kernel. llama-quantize reported nothing but
"failed to quantize model from ...", with no tensor name and no exception text,
which made a quant recipe that had simply not pinned the tensor look like a
corrupt model. It now names the tensor and continues.
per_layer_token_embd shares the TOKEN_EMBD category with token_embd.weight, so
--token-embedding-type is returned for it before any --tensor-type pattern is
consulted, and there is no way to give it a tier of its own.

That grouping is fine as a default and stays the default. It is a poor fit for
the size, though: on qwen4exp the table is 97.7 GiB of a 337.6 GiB BF16 file and
about 46% of a 4-bit one, roughly eighty times token_embd.weight, and it is
read by ggml_get_rows rather than a matmul so no imatrix ever covers it.

Allow an explicit --tensor-type pattern to name it, and only it. Nothing
changes unless such a pattern is passed, and token_embd.weight keeps the old
precedence in either case.

Measured on Qwen3.8-Flash-Next, Q4_K_M with an imatrix: the table lands at q8_0
(51.9 GiB, 113.5 GiB total) by following --token-embedding-type, and pinning it
q4_1 gives 30.5 GiB for 92.1 GiB total, 19% off the file.
The per-tensor output buffer was sized `nelements * 4`, described as an upper
bound. It is a very loose one: the output is at most 2 bytes per element
(f16/bf16) and usually well under 1.1 (q8_0 and below), so between 2x and 4x of
it is never touched. The exact size is already known here, since it is what the
quantization loop writes, what new_size sums to, and what the GGUF metadata is
asserted against a few lines later.

On a model whose largest tensor is a few GB none of this matters. On
Qwen3.8-Flash-Next it does: per_layer_token_embd is 51.2 G elements, so the
buffer was 205 GB where 54 GB is needed at q8_0 and 32 GB at q4_1.

Measured on that model, VmHWM of a live llama-quantize was 485 GB per process.
Three of them fit in 2 TB and five did not, which is what an OOM-killed quant
ladder looks like. This removes about 150 GB of that.

Byte-identical output, verified against the same binary built at the parent
commit: q4_K, q8_0, q5_K, q6_K and IQ4_XS, over BF16 and F32 sources, with and
without a PLE table present. Six cases, six matching md5s.
The PLE row indices are computed host-side from ubatch->token, and set_input
returned early when that was null. A multimodal ubatch is exactly that case:
the mtmd layer consumes the image placeholder ids and hands llama_decode
embeddings instead. The early return left the I32 index tensor uninitialised,
so ggml_get_rows indexed a 320 M row table with whatever the buffer happened to
contain, and aborted:

  GGML_ASSERT(i01 >= 0 && i01 < ne01) failed
    ggml_compute_forward_get_rows
    mtmd_helper_decode_image_chunk -> llama_decode

Every image request crashed. Nothing caught it because the vision work had only
ever been verified by converting an mmproj, never by running one.

The reference computes the hash over input_ids, where those positions still
hold the image placeholder, so carry that id through as qwen4exp.ple.image_token_id
and hash it. The key is optional: a file converted before it existed falls back
to the PLE EOS token, which is defined and treats the image as a segment
boundary rather than crashing.

Verified end to end with llama-mtmd-cli, a Q4_K_M base and the F16 mmproj, on a
generated image with known content. The model names the red circle, the blue
square, the inverted green triangle and reads "UNSLOTH 42", each with the right
position.
set_input_qsa asserted n_stream == 1, so llama-server could not serve this
model with more than one slot unless -kvu was passed. With a non-unified
cache each sequence owns its own cells, and a cell index means a different
token in each stream, so a single shared mapping is wrong.

- cell_blk, blk_cells and bias gain a stream dimension. At n_stream == 1
  these collapse to the shapes they had, so the unified path is unchanged.
- Scoring is now batched over streams. ggml_mul_mat matches ne[2] on both
  operands, so stream s's queries only ever meet stream s's blocks; without
  this sequences would score against each other's context.
- set_input_qsa loops per stream and resolves cells through
  v_cells[seq_to_stream[seq_id]], following set_input_kq_mask_impl, instead
  of hardcoding v_cells[0].
- llama_kv_cache_context::get_n_stream() is added, mirroring the ns that
  get_k and get_v already derive from the slot info.

build_attn_mask_top_k needed no change: it already expects
[n_top_k, n_batch, 1, n_stream], so the top-k result is reshaped to meet it.

set_input_qsa has exactly one caller, so the blast radius is qwen4exp only.

Validation, UD-Q4_K_XL on one B200:

- unified cache unchanged within noise: 1802.9/68.85 -> 1807.2/69.11 t/s at
  batch 1, 2262.5/192.43 -> 2270.1/193.75 at batch 4.
- non-unified now runs at npl 1, 4, 16 where it previously aborted, and is
  22% faster than the -kvu workaround at batch 16 (1205 vs 984 t/s total),
  since per-stream cells avoid the cross-sequence masking a unified cache
  pays for.
- no cross-stream contamination: four concurrent sequences each carrying a
  distinct secret all recall their own and no other, on both cache modes.
- test-llama-archs green on qwen4exp, deepseek2, gemma3n, qwen3next, llama.

Note on testing: comparing concurrent output against solo output exactly is
not a valid check. It failed 0/4 with no bug present, and the unified-cache
control failed the same way, because batch composition changes the
floating-point reduction order and near-tied tokens flip. The contamination
test above is what the exit code gates on.
The QSA graph needed a build_attn that attends only to the cells named by a
top_k tensor, and the first version got it by adding a llm_graph_input_attn_kv
overload to llm_graph_context and factoring the mask construction out of the
existing MLA sparse path into a shared build_attn_mask_top_k.

That put a new arch on the shared attention path and made the deepseek32 and
glm-dsa attention build depend on a helper introduced for qwen4exp. Build the
mask in src/models/qwen4exp.cpp instead and leave llama-graph.{h,cpp} exactly as
they were: the MLA path keeps its own copy of the same node sequence.

The nodes emitted are unchanged, so this is bit-identical.
The indexer key cache was added by extending llama_memory_hybrid with an
optional third cache, and the host-side cell/block mapping that drives QSA was
added as set_input_qsa on llama_kv_cache. Both are shared classes that every
hybrid and every attention model goes through.

Move both into a new memory type, llama_memory_hybrid_idx, following
llama_kv_cache_msa: the indexer cache and the pos<->cell translation live with
the sparse-attention memory rather than in the classes that serve every other
architecture. llama-kv-cache.{h,cpp} and llama-memory-hybrid.{h,cpp} are
restored to their unmodified state.

init_batch is repeated from llama_memory_hybrid because the indexer cache has to
be handed the attention cache's slot infos, and those are not reachable through
the context the base returns. Allocating them separately lets the two caches
drift, which is what pointed QSA's top-k at the wrong cells before.

The context derives from llama_memory_hybrid_context so build_inp_mem_hybrid
keeps working unchanged, and get_n_stream is computed from the slot infos
exactly as llama_kv_cache_context did.

Behaviour is unchanged: logits over an 8192-token sequence are bit-identical to
the previous implementation, sparse and dense alike.
llama_memory_hybrid_idx forwarded clear, seq_rm, seq_cp, seq_keep, seq_add and
seq_div to the indexer cache but not state_write / state_read, so a saved
session dropped the indexer keys and a restored one selected QSA top-k against
an empty cache. The effect is invisible until the context passes
indexer_top_k + compress_ratio - 1 cells, because QSA is exactly dense below
that and the indexer contents cannot change the result.

The indexer section is written last rather than next to the attention cache it
mirrors. As a suffix, a reader that does not expect it stops early and the
trailing bytes are caught by the size check in state_load_file; placed between
the attention and recurrent sections it would instead be parsed as recurrent
state, which can succeed and restore silent garbage. It follows the same
LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY gate as the attention cache, since a partial
checkpoint deliberately skips the token-level attention caches.

The indexer restores its own cells instead of taking the attention cache's
restored slots. The two caches share size, padding and every sequence
operation, and init_batch hands the indexer the attention cache's slot infos,
so both state_read_meta calls run find_slot over identical occupancy and land
on identical cells.

The overrides live on llama_memory_hybrid_idx, the only memory type that owns
an indexer cache, so llama_memory_hybrid and every architecture that uses it
write and read exactly the bytes they did before.

The session and sequence state versions are bumped because the qwen4exp state
layout changed. The session path already rejects a short read via its size
check, but llama_state_seq_load_file accepts one silently, so only the version
check stops a pre-fix blob from being half-restored by a fixed build.

(cherry picked from commit 2721542354f8e158c3217625f4e2e7b83e51e3fe)
The PLE hash of a token mixes in the ple_ngram_size - 1 tokens before it, which
a decode ubatch does not carry, so they were remembered in a map on
llama_model_qwen4exp. That is the wrong owner twice over.

A llama_model is shared by every context that loads it, and the map was keyed
only by llama_seq_id, so two contexts running the same sequence id - two server
instances on one model, or a draft/target pair - overwrote each other's window.
The next_pos guard turned that into EOS padding instead of a crash, so it
degraded quality silently.

The map was also in no state blob: grep found ple_hist in neither
llama-kv-cache.cpp nor llama-memory-*.cpp nor llama-context.cpp. A restored
context therefore failed the next_pos check on its first ubatch and hashed the
first tokens after the restore against EOS padding. This is why a session blob
round-tripped byte for byte while the restored context computed different
logits: the state was never in the bytes.

It moves to llama_memory_hybrid_idx, which is per context, is the memory type
qwen4exp always builds, and already does the per-sequence bookkeeping this
needs. Every sequence operation now carries the window with it:

  seq_rm   a rewind (p1 < 0) truncates the window to the surviving prefix and
           moves next_pos to p0, so a rollback keeps exact context; a hole
           punched in the middle leaves the window non-contiguous, so it is
           dropped
  seq_cp   the destination inherits the source's window, truncated to the
           copied position range - a copied sequence continues with the same
           n-grams the source would have used
  seq_keep every other sequence's window is dropped, like its cells
  seq_add  a shift that moves the whole window keeps it and moves next_pos with
           it, which is the context-shift case; one that cuts through it drops
           it
  seq_div  positions stop being consecutive, so an overlapping window is
           dropped
  clear    everything is dropped

Dropping means next_pos = -1, which set_input turns into full EOS padding: the
same thing a fresh sequence gets, and the same thing this code did before it
followed the sequence operations at all, so no case is worse than before.

The state payload is a self-delimiting list, u32 count then per entry
{ i32 seq_id, i32 next_pos, u32 n_toks, i32 toks[n_toks] }, so a whole-context
save and a single-sequence save share one format and a single-sequence restore
can retarget the window at its destination seq_id. It is written after the
indexer section, last, for the same reason that one is: as a pure suffix an
older reader stops early instead of parsing these bytes as something else.

Unlike the indexer section it is not under LLAMA_STATE_SEQ_FLAGS_PARTIAL_ONLY.
The window is recurrent state - it is the input the PLE convolution's own
recurrent state is derived from - and the recurrent cache beside it is written
for partial checkpoints too. Gating it would leave the server's speculative
decoding checkpoints restoring the conv state without the window that produced
it.

No further version bump: LLAMA_SESSION_VERSION 10 and LLAMA_STATE_SEQ_VERSION 3
were introduced for the indexer section in the same unreleased series, and both
changes are qwen4exp-only additions to the same blob layout.

Also fixes the padding of a short window. set_input pads a window shorter than
ngram_size - 1 up to that length, but prev() indexes the snapshot with the most
recent token last, and resize() pads at the back, so the filler EOS landed where
the immediately preceding token belongs. It now pads at the front. A window is
short at a sequence start after a one-token prefill, and after a seq_rm rewind,
which the new bookkeeping makes common.

Every architecture other than qwen4exp builds llama_memory_hybrid rather than
llama_memory_hybrid_idx, has no PLE table and never asks for a history, so
nothing about its graph, its sequence operations or its state bytes changes.

(cherry picked from commit de170364c052c68fcf63285cc0028095edb9f23c)
Rewrite the comments this series adds to the AGENTS.md rules: one or two lines,
no prose hard-wrapped mid-sentence, no narrative or history, and no comment that
only restates the code. Net 146 fewer comment lines, no code change.

Correct the PLE image comment: mtmd does not consume the placeholder ids. An
image is decoded as an embeddings-only batch, so ubatch->token is null and the
per-position ids never exist here. gemma3n and gemma4 hit the same case and
stand in row 0 of per_layer_token_embd; qwen4exp stands in the configured image
token id instead.

Read image_token_id straight from self.hparams in the converter. base.py merges
text_config into the root of hparams, and the key sits at the root of
config.json, so the config.json re-read was redundant.

(cherry picked from commit 205840c12169057da3e8d2f65ec4ceec3e18b980)
(cherry picked from commit 4c30574f81dc1115d08078c47b6cf8c789c0a842)
(cherry picked from commit 37c8c194e6a30e4c46ac29bee3fb264f091596ef)
(cherry picked from commit 528d032b51fa3cf935ed3ef6e0fb1c7401df53b5)
f32_conv_buf held the whole dequantized tensor, which is 204.8 GB for
per_layer_token_embd alone and dies with std::bad_alloc long before the
work buffer is reached. Dequantize and quantize in bands of whole rows
instead, capping the f32 staging at 1 GiB per band.

Rows are independent and the imatrix is indexed by column, so band
boundaries cannot change any output byte. Bands nest inside the existing
per-expert loop so each expert slice keeps its own imatrix, and a band is
kept to at least one quantization chunk per worker thread so the existing
multithreading still has work. F32 sources still stage nothing and are
banded by pointer arithmetic into the tensor.

llama_tensor_dequantize_impl now takes a first element offset; the single
caller is updated.

(cherry picked from commit 658c22549613555dbce57a772be4de8509eba3ee)
@github-actions github-actions Bot added the ggml label Aug 28, 2026
@TheTom

TheTom commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Good catch. Your Flash Attention diagnosis was exactly right, and your patched real-model results are enough to validate the GQA fallback itself.

I pushed two follow-ups:

  • f1a0b0139 adds the mirrored Q/K/V path to handle_flash_attn_ext, using the current upstream implementation
  • 163517b9a completes the DS4 tensor-split configuration that this fork had only partially ported, including mirrored main KV cache and compressed states, grouped output projections, shared-expert tensors, and the matching granularities

Local results on 163517b9a:

  • full test-llama-archs: pass
  • Qwen3Next and Qwen4Exp Meta rows: pass on the local two-device Meta setup
  • test-turbo-quant: pass
  • test_slot_save_restore: pass
  • Release Metal and server builds: pass

I agree with your synthetic Qwen3Next diagnosis. I tried the cheap fixture-growth route, but simply increasing its head/group counts violates another delta-net shape invariant. I dropped that test-only change. The fixture needs a coherent set of larger dimensions, and it should not block the real Flash-Next fix you already validated.

Please sync to 163517b9a and run these two checks:

  1. Flash-Next Q2 with -sm tensor on 4 P100s, mainly confirming the committed FA handler matches your local patch and output stays coherent
  2. DS4 with even -ts 1,1,1,1 first; if that works, repeat the uneven -ts 3,4,4,1 case

For DS4, please post the exact command, whether warm-up completes, the first short completion, and any assertion if it still fails. No need to rerun the known undersized Qwen3Next fixture right now.

Performance correction: the later controlled rerun shows no general tensor-split regression on this box. Dense Qwen3.8-27B is about 1.7x faster with tensor split, while sparse Flash-Next is slower because its roughly 6B active parameters leave very small per-device expert matmuls while still paying the per-layer all-reduce cost. NUMA binding also stabilizes the four-GPU result. Recommendations should account for model workload and topology, not topology alone.

@apollo-mg

Copy link
Copy Markdown

Correction to my throughput note. I said tensor split was 2.5x slower and implied that was a
property of this box. That was wrong, and it came from one sample on the noisiest arm.

Re-ran the historical benchmark apples-to-apples — same model (Qwen3.8-27B-Q6_K), same tool
(llama-bench -ngl 99 -p 512 -n 128 -r 3), GGML_CUDA_ALLREDUCE=internal, clocks pinned
1063 MHz / 150 W:

arm Aug 21 (different build) today, d929da17b + FA patch
2 GPU, -sm layer 7.70 ± 0.00 7.83 ± 0.00
2 GPU, -sm tensor 13.00 ± 0.02 13.20 ± 0.01
4 GPU, -sm tensor 15.34 ± 0.05 15.83 / 15.75 / 12.80 / 12.56

No regression anywhere — the PR build matches or slightly beats every historical arm. And on
dense 27B, tensor split is ~1.7x faster than layer split, not slower.

The 4-GPU arm is bistable

Four unbound runs gave two clean modes, ~12.6 and ~15.8 — not a spread. My first sample landed in
the slow one. The tell I should have chased: llama-bench reported ± 1.20 where the historical
run reported ± 0.05, a 24x jump in its own error bar.

The historical script binds the 2-GPU arms with numactl but leaves the 4-GPU arm unbound, since
those GPUs span both sockets. Binding removes the slow mode:

binding tg128
none 12.80, 12.56, 15.83, 15.75
numactl --interleave=all 15.35, 14.68
numactl --cpunodebind=0 --membind=0 15.88, 15.21

4 of 4 bound runs >= 14.68; 2 of 4 unbound at ~12.6. With GPU0/1 on NUMA 0 and GPU2/3 on NUMA 1,
an unbound process lands on whichever socket the scheduler picks, which changes host-memory and
PCIe locality for the per-layer all-reduce. n=4 per condition, so suggestive rather than settled —
but enough that I'll NUMA-bind and repeat every 4-way number from here.

What is actually true about Flash-Next

The 6.20 tok/s figure holds, but it is a property of the model, not the topology:

model -sm layer -sm tensor, 4 GPU
Qwen3.8-27B (dense, 27B active) 7.83 (2 GPU) 15.8 — tensor wins
Qwen3.8-Flash-Next (MoE, 6B active) 15.85 6.20 — layer wins

Flash-Next has ~6B active parameters and n_ff_exp = 640; split 4 ways that's a 160-wide
expert matmul
per device, far too small to keep a P100 busy, while still paying an all-reduce
on each of 48 layers. Tensor split trades communication for parallel compute and Flash-Next has
very little compute per layer to trade. 27B dense has ~4.5x the active parameters and
n_embd = 5120, so its split matmuls stay big enough to win.

So: tensor split is a solid win on dense models here and a loss on this sparse MoE. Sorry for the
noise — the mistake was one sample, on the highest-variance arm, generalised across model
families. Everything else in my previous comment (the abort, the FA mirror fix, the coherent 3-
and 4-device output, the VRAM figures) is unaffected.

@TheTom

TheTom commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Correction noted, and thanks for rerunning the controlled matrix. I updated my previous comment so it no longer generalizes the Flash-Next result to the whole topology.

The useful distinction is clear now: tensor split is a strong win for the dense 27B model on this box, while Flash-Next loses because its sparse 6B-active workload leaves tiny per-device expert matmuls but still pays the all-reduce cost. The NUMA binding result is also worth keeping with any four-GPU benchmark.

No code change is needed from this correction. The FA, GQA, VRAM, and coherent-output findings remain intact.

@apollo-mg

Copy link
Copy Markdown

Synced to 163517b9a, clean tree, no local patches. 4x P100 sm_60,
GGML_CUDA_ALLREDUCE=internal, clocks 1063 MHz / 150 W.

1. Flash-Next Q2, -sm tensor, 4 P100s — confirmed

llama-server -m Qwen3.8-Flash-Next-UD-Q2_K_XL-00001-of-00003.gguf \
  -ngl 99 -sm tensor -c 4096 --port 8087 -np 1
' Paris. Paris is the most populous city in France, with a population of 2.1 million people.
  It is also the most visited city in the world, with over 30 million tourists'

VRAM 13603 / 13633 / 13603 / 13603 MiB — byte-identical to my local-patch build, same
completion, 6.02 tok/s. Your committed handler is a superset of what I had (the split-Q /
mirrored-KV branch and the src[3]/src[4] asserts are additions), and it behaves the same on
the all-mirrored path. Confirmed.

2. DS4, even -ts 1,1,1,1 — still fails, and now earlier

llama-server -m DeepSeek-V4-Flash-0731-UD-IQ1_S-00001-of-00003.gguf \
  -ngl 99 -sm tensor -c 8192 --port 8087 -ts 1,1,1,1 -fit off -fa on -ncmoe 40 -np 1

Warm-up does not complete. On c232282aa it reached "warming up the model with an empty run"
and died in graph execution; on 163517b9a it dies during weight loading, before warm-up.

ggml-backend.cpp:472: GGML_ASSERT(offset + size <= ggml_nbytes(tensor)
                                  && "tensor write out of bounds") failed

llama_model_load_from_file -> load_tensors -> load_all_data ->
ggml_backend_meta_buffer_set_tensor -> ggml_backend_tensor_set_2d -> ggml_backend_tensor_set

Loader state at death:

load_tensors: offloaded 44/44 layers to GPU
load_tensors:   CPU_Mapped model buffer size = 46410.60 MiB
load_tensors:   CPU_Mapped model buffer size = 26137.35 MiB
load_tensors:       Meta() model buffer size =  3855.96 MiB

I instrumented the assert to name the tensor:

OOB write: tensor=blk.0.attn_output_a.weight type=q8_0 ne=[4096,8192,0,1]
           nbytes=0 offset=0 size=35651584 overflow=35651584

ne[2] = 0 — zero-width slice, so ggml_nbytes() = 0, and the loader then writes
35,651,584 bytes, which is the whole tensor (4096 x 8192 at q8_0 = 34 B / 32 elem
= 35,651,584 exactly).

So allocation and load disagree completely: the split-state gives this device none of
attn_output_a while the loader believes it holds all of it. attn_output_a matches
pattern_attn_out_weight (attn_output(_[ab])?\.weight) and is part of the grouped output
projection work in this commit. Same zero-width-slice family as the GQA case, just surfacing at
load time instead of graph time.

Did not run -ts 3,4,4,1, since you asked to gate it on the even case passing. Happy to run it
anyway if the failure mode would be useful to compare.

3. On the performance recommendation

Flash-Next loses because it has ~6B active parameters and n_ff_exp = 640; split 4 ways that is
a 160-wide expert matmul per card, far too little to keep a P100 busy while still paying an
all-reduce on each of 48 layers. Dense 27B has ~4.5x the active parameters and n_embd = 5120,
so its split matmuls stay large enough to win.

So I'd suggest the guidance is "tensor split is a win when there is enough per-layer compute to
amortise the all-reduce — dense models yes, small-active-parameter MoE no"
, rather than a
blanket warning about machines without peer links.

@TheTom

TheTom commented Aug 28, 2026

Copy link
Copy Markdown
Owner

I added the central policy in 3042c600b.

Known-safe undersubscribed dense GQA still takes the mirrored path. For every other tensor, the splitter now checks the number of granularity-aligned units before producing device slices. If there are fewer units than devices, it stops at split computation with the tensor name, segment, unit count, device count, and the useful remedies instead of emitting a zero-width slice and letting a later operator fail or produce garbage.

This should turn the undersized synthetic Qwen3Next case into one direct diagnostic rather than a later CONCAT ratio assertion. If DS4 exposes another genuinely undersubscribed tensor, it should identify that tensor at the source too.

Local results on the new head:

  • full test-llama-archs: pass
  • test-turbo-quant: pass
  • test_slot_save_restore: pass
  • git diff --check: clean

The requested real-model checks remain Flash-Next Q2 on four devices and DS4 with the even split first. If either stops, the new first error should now be the only trace we need.

@apollo-mg

Copy link
Copy Markdown

Synced to 3042c600b, clean tree. 4x P100 sm_60, GGML_CUDA_ALLREDUCE=internal,
clocks 1063 MHz / 150 W.

1. Flash-Next Q2, -sm tensor, 4 devices — still good

llama-server -m Qwen3.8-Flash-Next-UD-Q2_K_XL-00001-of-00003.gguf \
  -ngl 99 -sm tensor -c 4096 --port 8087 -np 1
' Paris. Paris is the most populous city in France, with a population of 2.1 million people.
  It is also the most visited city in the world, with over 30 million tourists'

VRAM 13603 / 13633 / 13603 / 13603 MiB — unchanged from 163517b9a. No regression from the
central policy.

2. DS4, even -ts 1,1,1,1 — the policy works exactly as intended

Warm-up does not complete, but this is now a clean stop rather than a corrupt write:

llama-server -m DeepSeek-V4-Flash-0731-UD-IQ1_S-00001-of-00003.gguf \
  -ngl 99 -sm tensor -c 8192 --port 8087 -ts 1,1,1,1 -fit off -fa on -ncmoe 40 -np 1
llama-model.cpp:810: cannot tensor-split blk.0.attn_output_a.weight:
  segment 0 has only 1 splittable units for 4 devices;
  use fewer devices or a different split mode

This is a big improvement. On 163517b9a the same config died at
ggml-backend.cpp:472 tensor write out of bounds during weight loading, and it took me an
instrumented build to learn that the tensor was blk.0.attn_output_a.weight with ne=[4096,8192,0,1].
Your policy names it at the source, first try, with the reason. Exactly the single trace you wanted.

But the suggested remedy cannot apply here

segment 0 has only **1** splittable unit. So I tested two devices as well:

CUDA_VISIBLE_DEVICES=0,1 ... -ts 1,1
llama-model.cpp:810: cannot tensor-split blk.0.attn_output_a.weight:
  segment 0 has only 1 splittable units for 2 devices; use fewer devices or a different split mode

One unit fails at any device count >= 2, so "use fewer devices" bottoms out at one device,
i.e. not tensor split at all. For this tensor the only workable options are the ones you already
built for undersubscribed dense GQA — mirror it — or exclude it from the split.

That reads consistent with the architecture rather than a bug: DeepSeek-V4 is MLA with
head_count_kv = 1, so there is genuinely nothing per-head to divide. attn_output_a is the
grouped output projection over a shared latent, and the split-state already mirrors the
corresponding weights on exactly this reasoning —

// DS4 q_a/kv are low-rank down-projections feeding per-row norms; column split would split
// the norm row, so mirror them
pattern_ds4_q_a_kv_weight -> GGML_BACKEND_SPLIT_AXIS_MIRRORED

So the mirrored path may simply need to extend to attn_output_a/attn_output_b for MLA.
Whether the rest of DS4 then splits usefully, I can't say without trying it — happy to test any
patch on 2 and 4 cards.

Worth noting the error text itself might be worth a tweak: when unit count is 1, "use fewer
devices" is unreachable advice. Something like "this tensor cannot be tensor-split at all;
it must be mirrored" would have saved me the second run.

3. Status summary

check 163517b9a 3042c600b
Flash-Next Q2, 4 dev coherent coherent, VRAM identical
DS4 -ts 1,1,1,1 OOB write at load, tensor unidentified without instrumentation named at source, one clear diagnostic
DS4 -ts 1,1 (2 dev) not run same refusal — 1 unit for 2 devices

Did not re-run the undersized Qwen3Next fixture, per your note.

@TheTom

TheTom commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Thanks, Mark. The guard did its job, and your two-device check proves this is an architecture policy case rather than a merely undersized four-device split.

I pushed 1336de3bf:

  • DS4 attn_output_a and attn_output_b are now mirrored because each grouped shared-latent projection has one indivisible unit
  • the one-unit diagnostic now says the tensor must be mirrored or use another split mode
  • multi-unit failures now state the actual maximum device count

Local results on the new head:

  • full test-llama-archs: pass
  • test-turbo-quant: pass
  • test_slot_save_restore: pass
  • Release Metal and server builds: pass
  • git diff --check: clean

Flash-Next Q2 on four P100s is confirmed coherent and unchanged at 13603 / 13633 / 13603 / 13603 MiB, so there is no need to repeat that run.

Please sync to 1336de3bf and retry DS4 with even -ts 1,1 first, then even -ts 1,1,1,1 if two devices pass. If four devices pass too, try -ts 3,4,4,1. As before, the exact first failure is enough if another central policy stop appears.

@apollo-mg

Copy link
Copy Markdown

Synced to 1336de3bf. DS4 gets materially further — it now reaches warm-up — and the mirroring
of attn_output_a/b is clearly correct. Two gaps left, both traced to a specific line.

DS4, -ts 1,1 (two devices) — two blockers, in order

Blocker 1: ROPE_BACK is routed to the wrong handler

First failure was ggml-backend-meta.cpp:535 GGML_ASSERT(ret.axis != GGML_BACKEND_SPLIT_AXIS_UNKNOWN).
Instrumented:

SPLIT UNKNOWN: op=ROPE_BACK dst=node_47 scalar_only=1
   src[0] = attn_raw-0 (reshaped) (view)      axis=1    <- split
   src[1] = Meta(CUDA0,CUDA1)#leaf_11#0       axis=10   <- MIRRORED

ROPE_BACK has the same source signature as ROPE (data + mirrored positions/freqs), but the
dispatch sends it somewhere else:

case GGML_OP_ROPE:      split_state = handle_rope(src_ss);                        break;
case GGML_OP_ROPE_BACK: split_state = handle_generic(src_ss, /*scalar_only=*/true); break;

handle_generic requires every source to share a split state, so split data against mirrored
positions yields UNKNOWN and aborts — and scalar_only=true would force UNKNOWN for any real
split axis even if they agreed. handle_rope already encodes the right rule
(GGML_ASSERT(src_ss[1].axis == MIRRORED); return src_ss[0];).

Tested: routing GGML_OP_ROPE_BACK to handle_rope clears this and DS4 then reaches
warm-up
. One line:

 case GGML_OP_ROPE_BACK: {
-    split_state = handle_generic(src_ss, /*scalar_only =*/ true);
+    split_state = handle_rope(src_ss);
 } break;

I have not checked whether any arch relies on the old scalar_only behaviour for ROPE_BACK, so
treat that as a proposal rather than a verified-safe change.

Blocker 2: handle_mul_mat has no MIRRORED x AXIS_2 case

With that applied, the next stop is ggml-backend-meta.cpp:593, the GGML_ABORT("fatal error")
at the end of handle_mul_mat. Instrumented:

MUL_MAT unhandled: dst=attn_wo_a-0
  src0 = blk.0.attn_output_a.weight (reshaped)      axis=10  <- MIRRORED (your new fix)
  src1 = attn_derope-0 (reshaped) (permuted)        axis=2   <- split on AXIS_2

handle_mul_mat covers MIRRORED x MIRRORED, AXIS_1 x MIRRORED, MIRRORED x AXIS_1, and
AXIS_0 x AXIS_0. MIRRORED x AXIS_2 is not covered — and that combination is exactly what
mirroring attn_output_a creates, since the activation it multiplies is still head-split.

By analogy with the existing MIRRORED x AXIS_1 case (which returns src_ss[1]), a mirrored weight
against a head-split activation should presumably yield the head-split result. But that is your
invariant to set, not mine, so I stopped here rather than guess.

Summary

head DS4 -ts 1,1 first failure
c232282aa :730 SET_ROWS split mismatch (graph)
163517b9a ggml-backend.cpp:472 OOB write during weight loading
3042c600b central policy stop: attn_output_a, 1 unit for 2 devices
1336de3bf :535 UNKNOWN axis on ROPE_BACK
1336de3bf + ROPE_BACK fix :593 MUL_MAT MIRRORED x AXIS_2, after warm-up

Steady progress each time. Did not run -ts 1,1,1,1 or -ts 3,4,4,1, since you gated those on
two devices passing.

Tree left clean at 1336de3bf; all instrumentation reverted. Happy to test whatever you push.

@TheTom

TheTom commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Thanks, Mark. Both traces identified missing Meta propagation rules, not another tensor-placement problem.

I pushed 16f65b057:

  • ROPE_BACK now uses the same split-state handler as ROPE, matching the current upstream DS4 tensor-split implementation
  • a mirrored matrix multiplied by an activation split over batch/head axis 2 now preserves that activation split in the result
  • unsupported matrix split combinations now report the node, both sources, and both axes instead of fatal error

The matrix rule is deliberately narrow. It only accepts a replicated left operand with a right operand split over axis 2 or 3, where the split is outside the contraction dimensions.

Local results on the exact head:

  • full test-llama-archs: pass
  • test-turbo-quant: pass
  • test_slot_save_restore: pass
  • Release Metal and llama-server builds: pass
  • git diff --check: clean

Please sync to 16f65b057 and retry DS4 with -ts 1,1. If that passes, continue with -ts 1,1,1,1, then -ts 3,4,4,1. Exact first failure and tensor names are enough if it finds another gap.

@apollo-mg

Copy link
Copy Markdown

Synced to 16f65b057. Both propagation rules work — DS4 gets past ROPE_BACK and past the first
projection — and the new diagnostic did exactly what you built it for. It stops at two devices, one
matmul later.

DS4, -ts 1,1 — one more gap, cleanly reported

llama-server -m DeepSeek-V4-Flash-0731-UD-IQ1_S-00001-of-00003.gguf \
  -ngl 99 -sm tensor -c 8192 -ts 1,1 -fit off -fa on -ncmoe 40 -np 1

Warm-up reached, then:

ggml-backend-meta.cpp:597: unsupported mul_mat split states:
  node = attn_out-0
  src0 = blk.0.attn_output_b.weight            axis=10   (MIRRORED)
  src1 = attn_wo_a-0 (permuted) (cont)         axis=0    (AXIS_0)

No instrumentation needed on my side this time — node, both sources, both axes, straight out of the
log. That is a real improvement over chasing GGML_ABORT("fatal error") with a custom build.

Where the axis-0 split comes from

Your axis-2 rule fired correctly and produced attn_wo_a carrying the activation's head split. A
permute + cont then lands that same data on axis 0, and the second half of the grouped
projection (attn_output_b) multiplies it. So this is the immediate downstream consequence of the
rule you just added, not an unrelated gap.

Why this one is the contraction-dimension case

Your new rule is scoped to a replicated left operand with the right operand split over axis 2 or 3,
outside the contraction dims. Here the right operand is split over axis 0, which is the
contraction dimension. Each device holds the full attn_output_b and a slice of the activation
along the contracted axis, so each computes a partial sum and the true result is their sum.

That is the same situation the existing branch at :595 already handles for the both-split case:

if (src_ss[0].axis == AXIS_0 && src_ss[1].axis == AXIS_0) {
    GGML_ASSERT(split_states_equal(src_ss[0], src_ss[1]));
    return {assume_sync ? MIRRORED : PARTIAL, {0}, {1}, 1};
}

So MIRRORED x AXIS_0 looks like it should yield PARTIAL (or MIRRORED under assume_sync) by the
same argument — a replicated weight against a contraction-split activation is still a partial
product. PARTIAL appears to be fully wired downstream (9 sites in this file, including the
all-reduce path), so it should not be a new concept.

Stated as an observation, not a patch. You scoped the last rule deliberately narrowly, and
whether a mirrored-times-contraction-split matmul should silently become an all-reduce is your
invariant to set — it changes the communication pattern, not just the bookkeeping. I did not test a
change this time; happy to if you want it de-risked on real hardware before you push.

Trajectory

head DS4 -ts 1,1 first failure
c232282aa :730 SET_ROWS split mismatch
163517b9a ggml-backend.cpp:472 OOB write during weight loading
3042c600b central policy stop — attn_output_a, 1 unit for 2 devices
1336de3bf :535 UNKNOWN axis on ROPE_BACK
1336de3bf + ROPE_BACK fix :593 MUL_MAT MIRRORED x AXIS_2
16f65b057 :597 MUL_MAT MIRRORED x AXIS_0, one matmul further, self-reported

Did not run -ts 1,1,1,1 or -ts 3,4,4,1, per your gating. Tree clean at 16f65b057, no local
modifications.

@TheTom

TheTom commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Thanks, Mark. The new diagnostic made the next issue clear.

I did not add a MIRRORED x AXIS_0 -> PARTIAL rule. That would label the result correctly in the abstract, but each device would still be multiplying a full-width mirrored output_b matrix by a contraction-dimension slice, so the local matrix dimensions would not match.

I pushed 4b0e2ee98 with the placement fix instead:

  • attn_output_a is split on its physical axis 1 in whole LoRA-rank groups
  • attn_output_b is split on axis 0 using the same group boundaries
  • the two local operands now have matching contraction widths
  • both tensors have o_group_count splittable units instead of one, so 2-way and 4-way splits do not create zero-width slices

This restores the grouped policy from the original DS4 tensor-split implementation while retaining the central zero-width guard and the Meta propagation fixes we added.

Local results on the exact head:

  • full test-llama-archs: pass
  • test-turbo-quant: pass
  • test_slot_save_restore: pass
  • Release Metal and llama-server builds: pass
  • git diff --check: clean

Please sync to 4b0e2ee98 and retry -ts 1,1. If it passes, continue with -ts 1,1,1,1, then -ts 3,4,4,1. Please include the first failure if any, plus coherent output and per-device VRAM for each passing run.

@apollo-mg

Copy link
Copy Markdown

Synced to 4b0e2ee98. Your correction on the PARTIAL rule was right — I was labelling the result
correctly without making the local dimensions work. The grouped placement gets DS4 substantially
further: with one added matmul rule it now loads and reaches server-ready, and the next stop is
a different class of problem entirely.

-ts 1,1 on 4b0e2ee98 as pushed

ggml-backend-meta.cpp:597: unsupported mul_mat split states:
  node = attn_wo_a-0
  src0 = blk.0.attn_output_a.weight (reshaped)        axis=2
  src1 = attn_derope-0 (reshaped) (permuted)          axis=2

Your grouped split works — attn_output_a now carries a real split rather than 1 unit. But after
the reshape both operands present on axis 2, and handle_mul_mat has no case for
AXIS_2 x AXIS_2. It covers MIRRORED x MIRRORED, AXIS_1 x MIRRORED, MIRRORED x AXIS_1,
AXIS_0 x AXIS_0, and (from 16f65b057) MIRRORED x AXIS_2/3.

Tested addition

ne[2]/ne[3] are batch dimensions, outside the contraction. If both operands carry the same
split there, each device holds matching batch slices of both sides, computes an independent slice,
and no communication is needed — so the result simply carries the split:

// Both operands split on the SAME batch axis (ne[2]/ne[3]), outside the contraction dims:
// every device holds matching batch slices of both sides and computes its own slice.
if (src_ss[0].axis == src_ss[1].axis &&
        (src_ss[0].axis == GGML_BACKEND_SPLIT_AXIS_2 || src_ss[0].axis == GGML_BACKEND_SPLIT_AXIS_3) &&
        split_states_equal(src_ss[0], src_ss[1])) {
    return src_ss[0];
}

With that, DS4 clears the split-state phase entirely and the server reaches ready (185 s).
Offered as a proposal, not a patch — the split_states_equal guard is doing real work there and
you may want it scoped more tightly.

Next stop: memset_tensor is unimplemented on the Meta buffer

ggml-backend.cpp:552: GGML_ASSERT(buf->iface.memset_tensor != NULL
                                 && "memset not implemented by backend buffer") failed

This is your own marked TODO:

/* .memset_tensor   = */ nullptr, // TODO implement     // ggml-backend-meta.cpp:1516

Triggered on the first prompt, at sequence reset:

slot operator(): id 0 | task 0 | new prompt, n_ctx_slot = 8192, task.n_tokens = 5
slot operator(): id 0 | task 0 | cached n_tokens = 0, memory_seq_rm [0, end)

DS4 looks like the first architecture to reach it. Ordinary attention KV never needs a memset —
it is overwritten — but DS4's recurrent compressor states (llama_dsv4_comp_state, plus the HCA/CSA
and lightning-indexer caches) have to be zeroed on a sequence clear. That is a split-buffer
memset across devices rather than a split-state rule, so it is squarely yours to shape.

Ladder status

Did not reach -ts 1,1,1,1 or -ts 3,4,4,1 — both gated on two devices passing.

head DS4 -ts 1,1 first failure server ready?
163517b9a OOB write during weight loading no
3042c600b central policy stop — 1 unit for 2 devices no
1336de3bf :535 UNKNOWN axis on ROPE_BACK no
16f65b057 :597 MUL_MAT MIRRORED x AXIS_0 no
4b0e2ee98 :597 MUL_MAT AXIS_2 x AXIS_2 no
4b0e2ee98 + the rule above ggml-backend.cpp:552 memset_tensor unimplemented YES, 185 s

Tree left clean at 4b0e2ee98; all local changes reverted. Happy to test whatever comes next.

@giveen

giveen commented Aug 29, 2026

Copy link
Copy Markdown
Author

@TheTom @apollo-mg

I go away for a week and this somehow turns into a DS4 test PR.

@TheTom

TheTom commented Aug 29, 2026

Copy link
Copy Markdown
Owner

@TheTom @apollo-mg

I go away for a week and this somehow turns into a DS4 test PR.

That will teach you to take time off

@TheTom

TheTom commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Thanks, Mark. Your tested AXIS_2 x AXIS_2 rule matches the upstream DS4 implementation, including the split_states_equal guard, so I pushed it in 85eb0596a.

That head also ports the upstream Meta buffer memset_tensor implementation. It handles:

  • segmented axis-0 and axis-1 tensors
  • ordinary axis-0, axis-1, and axis-2 splits
  • partial tensors, where only zero is valid
  • mirrored tensors, including the DS4 recurrent compressor and indexer state that triggered this path

Local results on the exact head:

  • full test-llama-archs: pass
  • test-turbo-quant: pass
  • test_slot_save_restore: pass
  • Release Metal, backend-op test target, and llama-server builds: pass
  • git diff --check: clean

Please sync to 85eb0596a and run the full two-device prompt, not just server startup. If the completion is coherent, continue with even four-device and uneven -ts 3,4,4,1 runs. Please include per-device VRAM and the first failure if anything remains.

@apollo-mg

Copy link
Copy Markdown

@TheTom @apollo-mg

I go away for a week and this somehow turns into a DS4 test PR.

That's what you get for taking a vacation :P


Synced to 85eb0596a. DS4 generates coherent text under -sm tensor on all three
topologies
— 2 devices, 4 devices, and uneven -ts 3,4,4,1. Your memset implementation holds
up; one op still needs a dispatch entry, and with that added nothing else stops it.

4x P100 sm_60 @ 150 W / 1063 MHz, DeepSeek-V4-Flash-0731-UD-IQ1_S,
-ngl 99 -c 8192 -fit off -fa on -ncmoe 40 -np 1, GGML_CUDA_ALLREDUCE=internal.

The one remaining stop

As pushed, the run reaches server-ready and then aborts on the first decode (observed on 2
devices — both 4-device arms were run with the dispatch already in, so I did not watch them fail
without it):

ggml-backend-meta.cpp:1035: ggml op not implemented: LIGHTNING_INDEXER

Rather than guess a rule I instrumented the op to print its sources' split states. Every
occurrence, every layer:

LI-DIAG node=lid_score_masked-2 ne=[256,4,1,1]
  src0=lid_q_rot-2 (view)                      axis=10  ne=[128,64,4,1]
  src1=lid_k-2                                 axis=10  ne=[128,1,256,1]
  src2=lid_weights-2 (view)                    axis=10  ne=[64,4,1,1]
  src3=Meta(CUDA0,CUDA1)#dsv4_lid_kq_mask#0    axis=10  ne=[256,4,1,1]

All four sources MIRRORED. So the indexer needs no rule of its own, just routing — it sits with
the other DS4 ops:

             case GGML_OP_DSV4_HC_COMB:
             case GGML_OP_DSV4_HC_PRE:
-            case GGML_OP_DSV4_HC_POST: {
+            case GGML_OP_DSV4_HC_POST:
+            case GGML_OP_LIGHTNING_INDEXER: {
                 split_state = handle_generic(src_ss, /*scalar_only =*/ true);
             } break;

scalar_only = true deliberately: the indexer reduces over both ne[0] (head dim) and ne[1]
(indexer heads) of q, so an all-MIRRORED graph passes and a genuinely dimension-split one aborts
loudly rather than computing on a wrong slice. If you would rather it handle a real split, that
needs a rule I have no case to test against — nothing in this model produces one.

Results with that one line added

Three prompts per arm, not one: a fresh prompt, an unrelated prompt (forces a full sequence
reset), and one sharing a long prefix with the second (forces cached n_tokens > 0). Your memset
has five distinct paths and I did not want to claim it from a single call.

arm ready asserts 3/3 prompts VRAM per device (MiB, after prompts)
-sm tensor -ts 1,1 172 s none yes 7177 / 7177
-sm tensor -ts 1,1,1,1 172 s none yes 4535 / 4535 / 4535 / 4535
-sm tensor -ts 3,4,4,1 182 s none yes 4815 / 5381 / 5857 / 3239
-sm layer -ts 1,1 (control) 194 s none yes 5205 / 9267

The even tensor arms are exact to the megabyte; layer split at -ts 1,1 is 1.78x lopsided.

Sample completion, 4 devices, -sm tensor -ts 1,1,1,1:

Paris. It is the largest city in France, with a population of over 2 million people. Paris is known for its iconic landmarks such as the Eiffel Tower, the Louvre Museum, and Notre-Dame Cathedral. It is also a major cultural and economic center in Europe...

and on the primes prompt it returns 2, 3, 5, 7, 11 with 9 is not prime because it is divisible by 3. Same facts as the layer-split control, no ////.

One thing I nearly reported as a bug, and the control that stopped me

The uneven -ts 3,4,4,1 arm loops — </div> twenty times on one prompt, a doubled </think>
block on another — where the even arms do not. That reads like uneven slices breaking numerics.

It is not. -sm layer -ts 3,4,4,1, same binary, same prompts, reproduces the doubled </think>
block byte for byte and degenerates worse on the other prompt. It is the 1-bit quant with an
uneven layer distribution, and it is not attributable to tensor split.

test-llama-archs on sm_60

Ran it twice at 2 devices — on the head as pushed, and again on the patched tree, since the patch
is what I am proposing.

  • As pushed: rc=0, 458 OK rows, every Meta row OK or SKIP.
  • With the LI dispatch: rc=0, 458 OK rows, no failures. Unchanged.
  • All four devices visible: aborts, but at your policy stop, working correctly —
    llama-model.cpp:818: cannot tensor-split blk.0.attn_q.weight: segment 0 has only 2 splittable units for 4 devices. The synthetic archs have head_count_kv = 2.

Flagging that only because it means the suite is not runnable as-is on a >2-GPU box, which you
would not see on the 5090 or the M5 Max.

Separate, small: the AllReduce warning blames the wrong thing on Pascal

Every tensor arm logs this (no layer arm does), with exactly 2 devices visible and
GGML_CUDA_ALLREDUCE=internal set:

W internal AllReduce init failed (n_devices != 2?); falling back to meta-backend butterfly

At -lv 5 the real reason shows up one line above, at DEBUG:

D ggml_cuda_ar_pipeline_init: internal AllReduce requires compute capability >= 700
                              (device 0 has cc=600); falling back

allreduce.cu:404 — the chunked kernel needs __nanosleep, so sm_70+. P100 is sm_60 and never
gets past that check; the device count is fine. Nothing is broken, the butterfly fallback works,
but the only message visible at default verbosity names a cause that is false on Pascal, and the
true one is DEBUG-only. Might be worth putting the reason in the WARN.

Useful to me either way: it means internal AllReduce has never been active on any of my Pascal
tensor-split runs, so every number I have sent you came through the meta-backend butterfly.

Not claiming a throughput number

These are single samples, and the multi-GPU arm on this dual-Xeon box is bistable when NUMA-
unbound (I measured 12.80 / 12.56 / 15.83 / 15.75 on one binary and one set of flags last week and
posted a correction for it). Correctness only here. Happy to do a bound, repeated throughput
comparison against -sm layer -ts 3,4,4,1 if that is useful.

Tree is otherwise clean at 85eb0596a; the only local change is the two-line dispatch above.

@gopinath87607

Copy link
Copy Markdown

thanks for fix the gpu split issues but its still doest working well when prompt is getting to process. meaning its worked initially but after 30k context its crashed even having the space in the gpu.

gopi@gopi-Super-Server:~$ echo 0 | sudo tee /proc/sys/kernel/numa_balancing
CUDA_VISIBLE_DEVICES=2,3,0,1
numactl --interleave=all
~/llama-cpp-turboquant/build/bin/llama-server
--model /mnt/nvme/Qwen3.8-Flash-Next-Q8_0-00001-of-00006.gguf
--chat-template-kwargs '{"reasoning_effort":"xhigh"}'
--tensor-split 3,4,1,1
-cmoe
--numa distribute
--load-mode none
-c 260000
--batch-size 6700
--ubatch-size 6700
--temp 1.0
--top-p 0.95
--top-k 20
--min-p 0.0
--presence-penalty 0.0
--repeat-penalty 1.0
--parallel 1
--threads 42
--threads-batch 42
-ngl 100
--host 127.0.0.1
--port 8082
--jinja
0
0.01.121.994 I cmn common_param: common_params_print_info: verbosity = 3 (adjust with the -lv N CLI arg)
0.03.355.528 W srv llama_server: -----------------
0.03.355.612 W srv llama_server: CORS is set to allow all origins ('*') and no API key is set
0.03.355.637 W srv llama_server: this can be a security risk (cross-origin attacks)
0.03.355.661 W srv llama_server: more info: ggml-org#25655
0.03.355.683 W srv llama_server: -----------------
0.03.360.347 I srv load_model: loading model '/mnt/nvme/Qwen3.8-Flash-Next-Q8_0-00001-of-00006.gguf'
16.16.237.213 I srv load_model: initializing, n_slots = 1, n_ctx_slot = 260096, kv_unified = 'false'
16.16.246.747 I srv init: chat template supports preserving reasoning, consider enabling it via --reasoning-preserve
16.16.246.830 I srv llama_server: model loaded
16.16.246.838 I srv llama_server: listening on http://127.0.0.1:8082
47.28.550.895 I slot get_availabl: id 0 | task -1 | selected slot by LRU, t_last = -1
47.28.551.020 I slot launch_slot_: id 0 | task 0 | processing task, is_child = 0
47.33.596.741 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 42, progress = 0.79, t = 5.05 s / 8.32 tokens per second
47.33.994.289 I slot print_timing: id 0 | task 0 | prompt processing, n_tokens = 49, progress = 0.92, t = 5.44 s / 9.00 tokens per second
47.36.958.436 I slot print_timing: id 0 | task 0 | prompt eval time = 5855.91 ms / 53 tokens ( 110.49 ms per token, 9.05 tokens per second)
47.36.958.441 I slot print_timing: id 0 | task 0 | eval time = 2551.46 ms / 31 tokens ( 82.31 ms per token, 12.15 tokens per second)
47.36.958.443 I slot print_timing: id 0 | task 0 | total time = 8407.36 ms / 84 tokens
47.36.958.448 I slot print_timing: id 0 | task 0 | graphs reused = 0
47.36.958.492 I slot release: id 0 | task 0 | stop processing: n_tokens = 83, truncated = 0
47.52.480.444 I slot get_availabl: id 0 | task -1 | selected slot by LRU, t_last = 3357469323
47.52.769.600 I slot launch_slot_: id 0 | task 34 | processing task, is_child = 0
48.18.371.055 I slot print_timing: id 0 | task 34 | prompt processing, n_tokens = 6702, progress = 0.07, t = 25.60 s / 261.78 tokens per second
48.53.345.151 I slot print_timing: id 0 | task 34 | prompt processing, n_tokens = 13402, progress = 0.15, t = 60.58 s / 221.24 tokens per second
49.33.622.658 I slot print_timing: id 0 | task 34 | prompt processing, n_tokens = 20102, progress = 0.22, t = 100.85 s / 199.32 tokens per second
50.18.834.866 I slot print_timing: id 0 | task 34 | prompt processing, n_tokens = 26802, progress = 0.29, t = 146.07 s / 183.49 tokens per second
51.13.070.679 I slot print_timing: id 0 | task 34 | prompt processing, n_tokens = 33502, progress = 0.37, t = 200.30 s / 167.26 tokens per second
51.13.245.324 E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 5957.76 MiB on device 1: cudaMalloc failed: out of memory
51.13.245.332 E ggml_gallocr_reserve_n_impl: failed to allocate CUDA1 buffer of size 6247161088
Segmentation fault (core dumped) CUDA_VISIBLE_DEVICES=2,3,0,1 numactl --interleave=all /llama-cpp-turboquant/build/bin/llama-server --model /mnt/nvme/Qwen3.8-Flash-Next-Q8_0-00001-of-00006.gguf --chat-template-kwargs '{"reasoning_effort":"xhigh"}' --tensor-split 3,4,1,1 -cmoe --numa distribute --load-mode none -c 260000 --batch-size 6700 --ubatch-size 6700 --temp 1.0 --top-p 0.95 --top-k 20 --min-p 0.0 --presence-penalty 0.0 --repeat-penalty 1.0 --parallel 1 --threads 42 --threads-batch 42 -ngl 100 --host 127.0.0.1 --port 8082 --jinja
gopi@gopi-Super-Server:
$

@TheTom

TheTom commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Thanks, Mark. That completes the DS4 hardware matrix.

I pushed 06b66fd4f with the upstream LIGHTNING_INDEXER Meta handler. It requires all four inputs to be mirrored and returns a mirrored result, matching every occurrence you traced while rejecting any unsupported split input rather than silently accepting it.

Local results on the exact head:

  • full test-llama-archs: pass
  • test-turbo-quant: pass
  • test_slot_save_restore: pass
  • Release Metal and llama-server builds: pass
  • git diff --check: clean

Your patched-tree results already cover the important real-hardware cases on this exact rule: coherent output on 2 devices, 4 devices, and uneven 3,4,4,1, including fresh prompts, sequence reset, and prefix reuse. No further DS4 retest is needed unless fresh CI finds something.

I am tracking the long-context Qwen4Exp allocation report separately. Its log shows a 5.96 GiB compute-buffer request failing with --batch-size 6700 --ubatch-size 6700; the subsequent segfault after allocation failure is not an acceptable failure mode, but it is separate from the DS4 split correctness work above.

@TheTom
TheTom merged commit 5ff1540 into TheTom:feature/turboquant-kv-cache Aug 31, 2026
9 of 26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conversion documentation Improvements or additions to documentation ggml model server testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants