Skip to content

WIP: DSv4 + Qwen3.8 Flash runtime fixes (MTP spec decode, converters, checkpoint-on-device, GDN graph order) - #14

Closed
Nathanw1014 wants to merge 18 commits into
staging/vulkan-stack-20260830from
staging/dsv4-qwen38-20260830
Closed

WIP: DSv4 + Qwen3.8 Flash runtime fixes (MTP spec decode, converters, checkpoint-on-device, GDN graph order)#14
Nathanw1014 wants to merge 18 commits into
staging/vulkan-stack-20260830from
staging/dsv4-qwen38-20260830

Conversation

@Nathanw1014

Copy link
Copy Markdown

Additive on top of #13 (base is set to its branch so the diff shows only these 18 commits). Focused on the two priority models: DeepSeek V4 and Qwen3.8 Flash.

Qwen3.8-Flash (qwen4exp)

MTP / speculative serving

  • Full checkpoints for MTP rollback (token-exact after rejected drafts)
  • Speculative and prompt-phase checkpoints kept on device (fixes the MTP prefill cliff on UMA; see the fork's issue 12 trace)
  • Do not re-verify replayed draft tokens after a checkpoint restore (livelock fix)
  • DFlash2 rejection-sampling verifier with proposal-dist truncation, plus a capability gate on the DFlash2 draft path

Model correctness

  • GDN state updates scheduled in graph order for the qwen35 family (fixes long-context nondeterminism; also affects Qwen3.6-35B)
  • Read special token ids of no_vocab models

Validation (Strix Halo, gfx1151/RADV)

  • All 18 commits cherry-picked onto the base branch with zero conflicts
  • Full Vulkan build green
  • test-backend-ops, test-llama-archs and a Qwen3.8 MTP end-to-end smoke are running now; results will be posted in a comment
  • DSv4 kernels are covered by the backend suite; DSv4 end-to-end needs a 128GB box, local testing is not meaningful on 64GB

AI usage disclosure: YES. Claude assisted with the rebase decomposition, cherry-picks, test execution, and this description. Nathan Wilson owns and maintains the changes.

🤖 Generated with Claude Code

Nathanw1014 and others added 18 commits August 30, 2026 15:28
is_dflash2 was armed from the dflash.selector_top_k metadata key alone. A
draft GGUF carrying the key without a decode graph that builds the selector
lattice made the host read hidden states as a lattice: every draft rejected
(0/1556 accepted), 2.4x slower than no speculation, no diagnostic.

Gate on llama_model_dflash2_top_k(), which reports the selector top-k only
when the decode graph builds the lattice, warn and fall back to the DFlash
draft path on mismatch, and refuse at load to attach selector tensors to
the DSV4 backbone (graph_dsv4 has no build_post_sampling).

Assisted-by: Claude Fable 5
Drop COMMON_SPECULATIVE_TYPE_DRAFT_MTP from the need_n_rs_seq()
predicate so no recurrent snapshot rollback planes are allocated for
embedded MTP, forcing full checkpoints while keeping snapshot support
for Eagle3, DFlash, and DSpark. Partial recurrent snapshot restoration
diverged after rejected drafts; full checkpoints restore token-exact
Qwen3.8 MTP output.

Ported from gaetan-puleo/llama-cpp-strix-halo commit f82e59263.

Assisted-by: Claude Fable 5
Fixes run-to-run non-determinism observed on Qwen3.6 35B-A3B UD on
ROCm (Strix Halo): two consecutive runs of the same prompt produced
different outputs at long context, and the stock build itself was not
bit-stable (stock vs stock KL divergence 0.00384 without the fix, 0
with it).

The layer loop called ggml_build_forward_expand(gf, cur) on the
attention-norm output before building the recurrent attention layer.
build_layer_attn_linear() and build_recurrent_attn() add the GDN state
updates (ggml_cpy into the convolution and SSM state caches) later,
and those writes are sink nodes with no consumers in the compute DAG,
so their scheduling position is fixed only by the graph expansion
order. Closing the graph branch at the pre-layer norm left the state
cache writes ordered against the wrong node set, which could schedule
them independently of the following layers and race with the state
reads of the next token or layer.

Move the expansion to after the recurrent layer is fully built. The
full-attention branch keeps its original expansion position, so only
the recurrent path changes.

Matches upstream PR ggml-org#22661 (models: fix linear attention state
corruption in recurrent layers, introduced by commit 2446419), which
reported intermittent generation corruption on Vulkan. On this ROCm
machine the same ordering surfaced as non-determinism; after the fix,
repeated runs are bit-identical, and both the isolated LDS decode step
and the MMQ-pair prefill show zero KL divergence against the stock
reference. No throughput regression (tg128 47.08/45.21/41.71 t/s at
0/10k/30k, prefill unchanged).

Assisted-by: OpenCode
(cherry picked from commit be236e33d1e50be5b599cdc64c8b00a163963acf)
Request device-backed storage when saving and restoring the rolling target and draft checkpoints used by speculative decoding.

Leave prompt-cache checkpoints host-resident because they retain multiple historical states, while each speculative sequence owns one rolling checkpoint. The validated path preserved all 128 generated tokens and the 106/77 draft trajectory while improving MTP3 from 12.61253 to 12.68163 tokens per second.

Ported from gaetan-puleo/llama-cpp-strix-halo commit c8b681b6f.

Assisted-by: OpenCode
…store

With full-checkpoint rollback, a partial draft acceptance restores the
pre-round state and re-decodes the accepted tokens to rebuild it. The replay
went through the same verification as a fresh draft. On backends where logits
change with batch shape or memory layout (Vulkan), that re-verification can
reject a token the original verification accepted; the rejection restores the
same checkpoint and replays again, and the slot loops on one position without
emitting anything. qwen35moe with --spec-type draft-mtp stalled this way a few
hundred tokens into long generations (the v0.6.8 MTP hang): the loop repeated
"accepted 2/3, restore at pos 995" every 27 ms with the GPU at 90 percent.

Accept the replayed tokens without re-verifying and sample only the
continuation from the final position. The replayed prefix was accepted by the
verification that triggered the restore; the replay exists to rebuild state.
On backends with batch-shape invariant logits the re-verification always
agreed, so behavior there is unchanged (verified bit-identical on CPU with
and without this change, 800-token greedy pair, 118 restore rounds).

Assisted-by: Claude Fable 5
The no_vocab branch returned before the special token KVs were read, so
a declared mask token id was lost and llama_vocab_mask() returned -1. A
diffusion drafter converted without a tokenizer never tokenizes text,
but it does need the mask id to build its denoise batch. Read the ids
in the no_vocab branch and validate them against the dummy vocab size.

Assisted-by: Claude Fable 5
The qwen4exp converter drops the MTP head, but community sidecar drafters now
exist (agentionai's Qwen3.8-Flash-Next-MTP-Q8_0: arch qwen4exp, block_count 49,
nextn_predict_layers 1, upstream tensor naming, hc_norm gammas verified
(1+w)-folded against the raw HF checkpoint by range read - exact +1.0 offset).

Follows the deepseek4 pattern: optional NEXTN_PREDICT_LAYERS KV with a
tensor-presence downgrade, mtp_only detection when the trunk is absent, trunk
tensors TENSOR_NOT_REQUIRED in that case, NextN tensors TENSOR_SKIP unless the
context requests them.

qwen4exp-specific differences from deepseek4, both dictated by the file format:
- the NextN block is always a full-attention QSA layer (gated attention, own
  nh=4 indexer, full 512-expert MoE, both HC pairs); is_recr() is derived from
  full_attention_interval and would misclassify blk.48 as linear attention, so
  the MTP layer forces the full-attention branch.
- nextn.hnorm is hc-space ({hc_dim}, 10240), not {n_embd}: the draft head
  consumes the target's 4-stream hyper-connection state.

A standalone load of a sidecar now aborts with a clear message instead of
segfaulting in the first hc_mix (the trunk tensors are null by design; the
mainline graph cannot be built from a drafter file).

Draft graph and --spec-type draft-mtp wiring are the follow-up commit; this one
is loader-only and mainline GGUFs are unaffected (KV absent -> n_layer_nextn 0,
identical load path).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 23d8b2c)
…o end

Second half of the MTP wiring (loader was the previous commit). Verified end to
end on the 64 GB box: Q4_K_M target + agentionai's community MTP sidecar,
--spec-type draft-mtp, coherent temp-0 generation through the Vulkan lane.

- graph_mtp: [enorm(embd(tok)) repeated across streams ; grouped-RMS hnorm(h)]
  -> eh_proj per stream -> one HC-wrapped full-attention block -> the shared
  head mixer (which doubles as the output norm). h arrives hc-space: the
  mainline graph exports the 4-stream residual under cparams.embeddings_nextn.
- the MTP context holds a PLAIN attention cache over the nextn layer(s), dense,
  no recurrent state - the deepseek32 pattern. Discovered the hard way: a
  hybrid memory with an empty recurrent layer set fails its buffer allocation.
- exports hand the scheduler REAL nodes: a naked reshape view gets no backend
  assignment and the h_nextn extraction asserts.
- the draft runs dense attention (QSA gate is null-safe now); the reference
  precedent is deepseek32, whose MTP head also runs dense.

Correctness only at this point: no acceptance-rate or throughput claims - the
smoke ran under heavy memory pressure and the interactive-trap log spin.
Measured cleanly it may or may not pay; that is the next session's A/B.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit b5b7c8c)
Upstream's converter sets no_mtp unconditionally (HF and vLLM both drop the
block), so a qwen4exp drafter could only come from a third party. Opt into the
shared _QwenMtpMixin machinery so --mtp writes a sidecar and --no-mtp keeps the
target clean, and add the three NEXTN tensors to the arch's tensor list.

Two things the mixin does not cover:

- this arch splits the eh projection into fc_embedding + fc_hidden where other
  Qwen MTP heads carry a fused fc. The draft graph builds
  ggml_concat(enorm(embedding), hnorm(hidden), 0), so eh_proj is cat on the
  input axis with embedding first. Verified byte-exact against the reference
  checkpoint rather than inferred: eh_proj[:2560] == fc_embedding,
  eh_proj[2560:] == fc_hidden.
- qwen4exp has no output_norm - the hyper-connection head mixer is the final
  norm - and the MTP block carries its own under mtp.hyper_connection_mixer,
  outside the mixin's mtp.layers.N rewrite. Map it onto output_hc_*, folding
  hc_norm to (1 + w) as the inherited norm.weight rule would.

The PLE key group is skipped for a sidecar: the draft block has no PLE layer
and its hash constants are filtered out with the rest of the target.

Dry run: 34 tensors, 7.8G, names and shapes matching a known-good sidecar.

Assisted-by: Claude (Opus 5)
llama-quantize rejected our own MTP sidecar with

  key qwen4exp.attention.compress_ratios has wrong array length; expected 49, got 48

The array was built from num_hidden_layers, but the MTP block makes the model
49 blocks and the loader wants one entry per block including nextn layers.

Worth recording how this was found: the sidecar was already 34/34 tensors
byte-identical to a known-good reference. A tensor-level comparison cannot see
a short metadata array; the quantizer caught it immediately. Load-bearing
metadata deserves the same verification as weights.

The MTP block gets 0 rather than the compression stride, matching the
reference: it carries indexer weights, but the draft graph gates QSA off and
runs dense (deepseek32's MTP precedent), so it has no stride of its own.

Assisted-by: Claude (Opus 5)
Port of the fp8 scale gate onto the LazyChunkedTensor shard loader. The
raw lazy re-read bypasses base.py's f32 upcast, so shards arrive as e4m3
and a plain cast writes the raw code values. Gate on the checkpoint scale,
never on dtype.

Assisted-by: Claude Fable 5
Port of the host-gather half of the old decode-graph commit onto the
hoisted build_inp_ple: the in-graph get_rows on the CPU-resident table
split the graph and cost a GPU->CPU->GPU round trip per layer group.
The graph-reuse half is upstream already. LLAMA_PLE_HOST_GATHER=0
restores the in-graph gather.

Assisted-by: Claude Fable 5
The half of the fork's lazy-read work upstream did not take: --lazy-mode
applies MADV_RANDOM and drops the eager pull-in, but replaces the kernel
readahead with nothing. Re-add prefetch_rows (posix_madvise WILLNEED /
PrefetchVirtualMemory in page runs), gather_tables() nomination with
start-offset matching, and the decline guard that strips the advice from
any range no arch prefetches - the advice alone measures slower than off.
qwen4exp nominates its PLE table and queues the faults in set_input.

Assisted-by: Claude Fable 5
Port of danielhanchen's open PR, applied as-is (byte-clean on this base):
- the update context never built ctx_idx, so a cross-stream seq_cp never
  copied the indexer key buffers and the destination kept stale keys
  (reachable through the OpenAI n parameter, silent wrong output); the
  indexer hparams also pin ROPE_TYPE_NONE so the now-live update path
  cannot K-shift keys that are stored unrotated
- QSA blocks keyed on the cells' seq sets, not position alone (kv-unified)
- M-RoPE rank ordering so image cells stop collapsing into one block slot
- load_arch_hparams metadata asserts become throws
- norm-only reshape dodges the CUDA gridDim.y 65535 cap at n_kv 262144

PPL verified bit-identical by the author on UD-IQ1_S; runtime-only, no
GGUF rebuild.

Assisted-by: Claude Fable 5
…ggml-org#27977)

Port of ServeurpersoCom's open PR, applied as-is with one hardening:
- for_each_token_in stops once every present sequence has been seen
- get_prev_tokens scans only the n-gram window; the from-zero below[]
  fill is lazy and only runs on the position-gap fallback
- QSA generation gathers the selected cells into a per-query window
  instead of masking the whole cache into flash attention; prefill
  keeps the scan path (Vulkan mask-opt already prunes it block-wise)
- the indexer four-head sum is three strided-view adds, dropping the
  transpose that copied the score surface twice
- the used-cell set is a bitmap walked 64 bits at a time

Fork delta vs the PR: the gather's kq_mask stride requirement is part of
the dispatch condition, falling back to the scan path instead of aborting
on a padded mask.

Assisted-by: Claude Fable 5
The generation-path checkpoint sites went on-device in v0.7.0; the
prompt-phase create_checkpoint stayed host-resident, and with a draft
model loaded it serialises the draft's whole KV cache through the
one-blocking-copy-per-row host path. On UMA that collapses prefill 10x+
once the prompt spans more than one batch (toolbox issue #12). GTT is
the same physical RAM on this platform, so the device-residency cost
argument does not apply.

Assisted-by: Claude Fable 5
Restores the stochastic verifier from pre-rebase 015f09c on top of
upstream DFlash2 (ggml-org#27342, ggml-org#27816), which reverted
draft sampling. The surviving delta:

- common/speculative.h: common_speculative_token_dist plus dists /
  temperature / seed draft params
- common/sampling: sample_and_accept_n overload doing maximal-coupling
  accept with residual sampling on rejection, using a dedicated
  verifier RNG (chain seed ^ 0x9e3779b9) threaded through
  init/reset/clone/copy
- common/speculative.cpp: re-add the dist capture to the DFlash2
  selector walk; temperature > 0 samples the predecessor from the
  softmaxed selector scores and records the distribution,
  temperature <= 0 keeps upstream's greedy path with its p_min break
- server, speculative-simple: use the dist verifier when the context
  can roll back partial acceptance and sampling is stochastic

Full Vulkan build passes (build-vk, glslc from ~/shaderc).

Assisted-by: Claude Fable 5
The dispatcher clamps the draft to dp.n_max after the impl runs, which
left dp.dists longer than the draft on the final block of a generation
and the server's size guard fell back to the greedy verifier there.
Keep the dists aligned so the truncated block is dist-verified too.

Found in a DFlash2 smoke test at temp 0.7 (Qwen3.8-27B UD-Q4_K_XL +
DFlash2 Q8_0 drafter): 41/42 verifies took the dist arm, the clamped
final block read dists=7 draft=5 and took the plain arm; with this fix
it reads dists=5 draft=5 and takes the dist arm.

Assisted-by: Claude Fable 5
@Nathanw1014

Copy link
Copy Markdown
Author

Validation results for this branch (Strix Halo, gfx1151/RADV, build a6e38dd lineage):

  • Full Vulkan build: green
  • test-backend-ops -b Vulkan0: 25402/25402 pass
  • test-llama-archs: green
  • Qwen3.8 MTP end-to-end smoke (Qwen3.8-27B Q8_0, --spec-type draft-mtp, temp 0, one request per server launch): MTP draft context created, draft acceptance 0.66 (45 accepted / 68 generated, mean len 2.32), output coherent, 7.81 t/s on this 64GB box. Single request, so treat the throughput as a functionality check rather than a benchmark; acceptance is prompt dependent.

DSv4 end-to-end still needs a 128GB tester; the DSv4 kernels themselves are covered in the backend suite above.

@Nathanw1014

Copy link
Copy Markdown
Author

Closing along with #13. The Vulkan half is now in #17. The Qwen3.8/DSv4 runtime fixes from this branch (MTP spec decode, converters, checkpoint-on-device, GDN graph order) are held back deliberately: 7 of them are written against upstream changes this fork does not carry yet (ggml-org#27880 build_inp_ple hoist, ggml-org#27969 lazy-mode rename, the ggml-org#27941/ggml-org#27977 ports), so they will be re-proposed after the upstream sync (#9) lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants