Skip to content

llama : log the QSA maskless/masked route decision and why it was refused - #110

Open
klucsik wants to merge 251 commits into
halo-box:masterfrom
klucsik:feat/qsa-route-log
Open

klucsik wants to merge 251 commits into
halo-box:masterfrom
klucsik:feat/qsa-route-log

Conversation

@klucsik

@klucsik klucsik commented Sep 29, 2026

Copy link
Copy Markdown

Overview

When a qwen4exp ubatch falls back from the maskless selected-key path to the masked route, the
server log stays silent, so an operator cannot tell a wrong-kernel route apart from a starved slot
or a cold PLE gather. We hit exactly this while measuring -np 2 serving on gfx1151: a
19,765-token request ran at 149.35 t/s (6.70 ms/tok) while a concurrent 104,661-token request held
876-912 t/s (1.18 ms/tok) on the other slot, same build and moment. Our first read blamed the
masked path (via graphs reused = 0 — a misread: n_reused is a cumulative context counter, so 0
is simply what the first task after load prints); it was actually cold-PLE I/O (243 MB/s disk-read
peak in the same window). Nothing in the server log could rule the mask out directly.

This PR turns the qwen4exp_use_block_selection() conjunction into a reason classifier returning
the first clause that refused the maskless path, and reports the route: LLAMA_LOG_DEBUG on every
decision (once per ubatch, at the existing qsa_inps per-ratio dedup), plus one LLAMA_LOG_INFO
line when the route or the refusing clause changes — rate-limited to 1 line / 5 s, keyed per memory
object (trunk and MTP draft refuse the path for different reasons, and a single global would toggle
on every rebuild). The original gate survives as a one-line wrapper, evaluating its clauses in
exactly the previous short-circuit order, so the boolean and the reason cannot disagree.
can_reuse() recomputes the same gate but does not log: a reused graph repeats an already-recorded
decision.

Measurements

Observability-only change: the route selection, tensors and kernels are untouched, so no
throughput, latency, memory-use or output movement is possible — that is why there are no
before/after llama-bench tables. The only runtime-visible effect is log volume at -lv >= 4,
which needs a gfx1151 run we could not perform here (see NOT verified).

Device:     Ryzen AI Max+ 395 / Framework Desktop        <- confirm mini-PC model
Memory:     128 GB LPDDR5X-8000
Power:      ryzenadj: STAPM 90 W, PPT fast 120 W, PPT slow 100 W, TCTL 85 C, CO all-core -20
BIOS:       UMA split <fill in: amdgpu BIOS carve on this box>
Kernel:     7.2.6-1-cachyos, amdgpu params: <fill in gttsize/ttm.pages_limit or "none">
Backend:    ROCm 10.x (klucsikkp/rocm-10.0-strix-llama image, retained-PM4 ROCr),
            HIP_LAUNCH_BLOCKING unset
Build:      Dockerfile.rocm-10.0-strix-llama flags: -DGGML_HIP=ON -DGGML_HIP_GRAPHS=ON
            -DGGML_HIP_NO_VMM=ON -DGGML_CUDA_FA=ON -DGPU_TARGETS=gfx1151 -DCMAKE_BUILD_TYPE=Release;
            this commit additionally compiled as a CPU object (g++ 14.2, -O1 -Wall)
Baseline:   7a9196dad (origin/master == branch parent, built & deployed in same session as the
            serving data quoted below)
Change:     aa6536531 (feat/qsa-route-log)
Model:      unsloth/Qwen3.8-Flash-Next-GGUF, UD-IQ4_XS

Baseline: n/a by construction — no compute path differs. Serving reference from this box on the
baseline commit (matched ini: -c 262144 -np 2 -b 4096 -ub 4096 -fa on -ctk/-ctv f16 -lm dio,
live traffic): solo pp chunk median 603 t/s.

After: n/a by construction; the branch changes only log statements. A 10-minute gfx1151 smoke
(-lv 4, text + one image burst) confirming the route lines appear as designed is listed as not
done below.

Correctness: exhaustive equivalence harness over the extracted gate logic: 570,240 input
combinations across every clause, including both n_kv boundaries (indexer_top_k + ratio - 1 and
16,777,216) — old_gate == (miss == nullptr) in all cases, 0 mismatches, and all 14 refusal
strings reachable at least once. Translation unit builds clean with -c -O1 -Wall (the only
warnings, two prefix.h unused-function ones, are present identically on pristine master).
test-backend-ops not run: no op or kernel touched. Logits/PPL not re-measured: graph outputs are
produced by the same tensors and kernels as before by construction. Harness source (<1 s to run)
available on request for CI.

Additional information

Requirements

  • I have read and agree with the contributing guidelines
  • This change is Strix Halo specific, or justified by measurements on Strix Halo. General llama.cpp improvements
    belong in halo-box/llama.cpp instead — qwen4exp/QSA gates are
    fork-specific code, so this is Strix Halo specific by location, and motivated by gfx1151 serving data.
  • AI usage disclosure: AGENT-AUTHORED — code, commit message and this description were written by a coding
    agent (Piclaw, qwen3.8-flash) under human review; the human owns the key, pushed the branch and files the PR.
    Assisted-by: trailer on the commit.
  • What was NOT verified: no ROCm/GPU execution of this branch (the build sandbox has no amdgpu device) — so no
    live -lv 4 log-volume observation and no on-hardware smoke of the DBG/INF lines; no test-backend-ops (no op
    was touched); the equivalence proof covers the extracted gate logic as a standalone harness, not the linked
    llama binary; UMA/gtt values above are placeholders to fill in on the host.

Assisted-by: Piclaw (qwen3.8-flash)

Laurent Zuijdwijk and others added 30 commits August 27, 2026 20:18
speculative: size drafts from measured acceptance (--spec-draft-adaptive)
Removed the prohibition on using AI for writing posts.
Revise AI usage guidelines in CONTRIBUTING.md
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ngs, grace, usage telemetry) (halo-box#5)

Co-authored-by: voidsurfer <18734507+voidsurfer@users.noreply.github.com>
…-box#8)

Co-authored-by: voidsurfer <18734507+voidsurfer@users.noreply.github.com>
Co-authored-by: Marshall <assistant@llama.cpp>
Co-authored-by: Harkirat Gill <harkirat.gill@amd.com>
Co-authored-by: Bartowski <3266127+bartowski1182@users.noreply.github.com>
Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
Co-authored-by: Aparna M P <aparmp@qti.qualcomm.com>
Co-authored-by: cqderek <cqderek@gmail.com>
Co-authored-by: Daniele Zannotti <d.zannotti@me.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ruixiang Wang <wangruixiang07@outlook.com>
Co-authored-by: Sigbjørn Skjæret <sigbjorn.skjaeret@huggingface.co>
Co-authored-by: Georgi Gerganov <ggerganov@gmail.com>
Co-authored-by: Brad Smith <1472326+infinitewarp@users.noreply.github.com>
Co-authored-by: Nathanw1014 <67372905+Nathanw1014@users.noreply.github.com>
Co-authored-by: Titaniumtown <titaniumtown@proton.me>
Co-authored-by: Ozymandias_EBON <112784549+johnkarlhill@users.noreply.github.com>
Co-authored-by: Zijun Yu <zijun.yu@intel.com>
Co-authored-by: Mostafa Faheem <mostafaaafaheem@gmail.com>
Co-authored-by: Ravi Panchumarthy <ravi.panchumarthy@intel.com>
Co-authored-by: zhaixuejun1993 <xuejun.zhai@intel.com>
Co-authored-by: Strongtut <Strongtut@users.noreply.github.com>
Co-authored-by: Strongtut <8432058+Strongtut@users.noreply.github.com>
Co-authored-by: ravel7524 <58877666+ravel7524@users.noreply.github.com>
Co-authored-by: Jeff Bolz <jbolz@nvidia.com>
Co-authored-by: Tekin Ertekin <tekin.ertekin@gmail.com>
Co-authored-by: Eric A Stalee <87948564+Eric-A-Stalee@users.noreply.github.com>
Co-authored-by: kurquhar <kurquhar@qti.qualcomm.com>
Co-authored-by: Kristopher Urquhart <kurquhar@qti.qualcom.com>
Co-authored-by: Jhen-Jie Hong <iainst0409@gmail.com>
Co-authored-by: Nick Farrell <nick.farrell@aiven.io>
Co-authored-by: Niklas Wenzel <dev@nikwen.de>
Co-authored-by: Hongqiang Wang <wangh@qti.qualcomm.com>
Co-authored-by: Li He <lih@qti.qualcomm.com>
Co-authored-by: codemonkey <441345965@qq.com>
Co-authored-by: Aman Gupta <amangupta052@gmail.com>
Co-authored-by: fairydreaming <166155368+fairydreaming@users.noreply.github.com>
Co-authored-by: Stanisław Szymczyk <sszymczy@gmail.com>
Co-authored-by: AesSedai <7980540+AesSedai@users.noreply.github.com>
Co-authored-by: QuintinShaw <github@xyt.email>
Co-authored-by: Max Krasnyansky <maxk@qti.qualcomm.com>
Co-authored-by: Marco Colombo <mcolombo@qti.qualcomm.com>
Co-authored-by: Alexander Lu <alexlu@qti.qualcomm.com>
Co-authored-by: Ehsan Bateni <ebateni@qti.qualcomm.com>
Co-authored-by: Ryan C <ryan5rdx@users.noreply.github.com>
Co-authored-by: Alessandro de Oliveira Faria (A.K.A.CABELO) <cabelo@opensuse.org>
Co-authored-by: Daya Adianto <addianto@users.noreply.github.com>
Co-authored-by: Nils Gladitz <nilsgladitz@gmail.com>
Co-authored-by: JamePeng <jame_peng@sina.com>
Co-authored-by: LunalFresh <165352784+LunalFresh@users.noreply.github.com>
Co-authored-by: itterative <190138728+itterative@users.noreply.github.com>
Co-authored-by: Pascal <admin@serveurperso.com>
Co-authored-by: Marshall <assistant@llama.cpp>
halo-box#11)

Co-authored-by: Marshall <assistant@llama.cpp>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
halo-box#13)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…UM_COLS > 4) (halo-box#15)

Co-authored-by: Marshall <assistant@llama.cpp>
Co-authored-by: Marshall <assistant@llama.cpp>
Tune MMQ and MMVQ scheduling, add routed expert kernels, share Q8_1 activation quantization, and add measured four-column kernels for quant formats that outperform the generic path on gfx1151.

Assisted-by: OpenCode
Add gfx1151 paths for paired matmuls, SwiGLU quantization, expert aggregation, contiguous binary operations, transposed concat, normalization, and conversion operations.

Assisted-by: OpenCode
Dequantize Q8 KV data directly in tiles, specialize GQA decode grouping, reduce D256 register pressure, and reuse masks across grouped heads on gfx1151.

Assisted-by: OpenCode
Increase gfx1151 column parallelism, tile KDA inputs in LDS, preserve recurrent graph ordering, and schedule compatible Qwen projections together.

Assisted-by: OpenCode
pwilkin and others added 21 commits September 26, 2026 15:17
No textual conflicts, but halo-box#87 (gufo's Flash-Next prefill ideas) added two
mmb calls with the old context-free signatures, which no longer compile
now that mmb is gated per backend context:

- ggml_cuda_mmb_cache_produce: mmb_enabled() -> mmb_enabled(ctx)
- ggml_cuda_bf16_for_mmb_reader: ggml_cuda_mmb_supported_mm(ctx, ...)

Both functions already have the context in scope, so the producer-side
BF16 cache now follows the same per-context opt-in as the GEMM it feeds.

Assisted-by: Claude Opus 5.5
init_tensor zeroed the padding of quantized tensors with a synchronous
cudaMemset. On HIP, a synchronous memset or memcpy fails with
hipErrorStreamCaptureImplicit while any stream in the process is
capturing a graph, in every capture mode including relaxed, and it
invalidates that capture too (CHECK_STREAM_CAPTURING in clr). CUDA only
rejects this for blocking streams. test-backend-ops -j <n> allocates
tensors on one worker while another captures, so with GGML_HIP_GRAPHS
every -j run aborted after a random number of cases ("operation would
make the legacy stream depend on a capturing blocking stream" from
init_tensor, then "operation failed due to a previous error during
capture" from hipStreamEndCapture). Upstream master has the same memset
and aborts the same way.

Use cudaMemsetAsync on cudaStreamPerThread plus a sync of that stream,
as memset_tensor and set_tensor already do. Thread-local capture mode
would not help: HIP applies the check in every mode.

Checked on gfx1151 (ROCm 7.15 runtime), test-backend-ops -b ROCm0 on
halo-box master 0fc7226: without the fix -j 8 aborted in 2/2 runs,
and passed 29800/29800 with GGML_CUDA_DISABLE_GRAPHS=1; with the fix
-j 8 passed 29800/29800 in 5/5 runs with graphs on (about 10.5k captures
per run), and serial passed 29800/29800. One other serial run failed
only ADD_ADD(type=f16,type_addend=f16,ne=[64,5,4,3]) on NMSE 1.03e-7 >
1e-7; that case fails 5/100 runs without the fix and 3/100 on upstream
master too.

Assisted-by: Claude Opus 5.5
cuda: enable the BF16 WMMA matmul path only for qwen4exp
fix: guard the qwen4exp PLE prefetch on the model architecture (SIGSEGV at ubatch >= 4096)
The non-hipCUB fallback for rows wider than 1024 used an atomic gather for scores equal to the k-th largest. QSA scores often tie at exactly zero, so different schedules selected different blocks and changed long-context results. Use the existing stable-tie gather and cover the 1025/2047/2048/2049-column boundary with exact selected-index checks.

Assisted-by: OpenCode (gpt-6-sol)
When the allocator reuses dead logits storage for routing outputs, decline the fusion only if non-logit inputs overlap. Copy contiguous logits to the device pool before the fused kernel when needed; otherwise unrelated buffer placement chooses between fused and unfused routing and changes the model output. Existing TOPK_MOE_LIFETIME scheduler-allocation tests cover the production 512-expert shape and alias layouts.

Assisted-by: OpenCode (gpt-6-sol)
The detached prefetch thread captured the model and hparams by reference; a context can release them before prefetch finishes. Hold a shared pointer to the disk table and copy the small immutable hash arrays into the thread. This addresses the detached-thread lifetime hazard without changing row selection or prefetch I/O. The reported dense-model crash in issue halo-box#79 occurs before thread launch and is not claimed fixed here.

Assisted-by: OpenCode (gpt-6-sol)
With --spec-draft-mtp-vocab N, the MTP context copies the LM head rows of token ids < N plus control and user-defined tokens into a smaller head when it is created. The qwen35 and qwen35moe MTP graphs compute the draft logits over that head and scatter them into a full-vocabulary row of -inf (ggml_fill + ggml_set_rows). Only the draft changes: the draft cannot propose other tokens, verification is unchanged.

The subset is built only when token ids follow BPE merge order, so that low ids are the frequent tokens; otherwise the option is ignored with an error. Probe contexts with unallocated weights skip the build.

New field llama_context_params::mtp_draft_vocab (0 = off).

Assisted-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01MkuKwgPNgpAg7EbxKR2iWC
The subset was stored in mutable llama_model state by the first MTP context that asked for one, and the qwen35 and qwen35moe MTP graphs used it whenever it existed. A second context over the same model with mtp_draft_vocab = 0, or a different N, silently drafted over the first context's subset.

The subset is now an immutable llama_mtp_draft_vocab obtained with llama_model::mtp_draft_vocab_get(n_keep). The model keeps weak references keyed by (n_keep, buffer type) under a mutex, so contexts that ask for the same N at the same time share one copy, and it is freed with the last context holding it. Each context holds its own reference and passes it to its graphs through llm_graph_params; the MTP graphs use only that one, and graph reuse compares it. llama_cparams::mtp_draft_vocab records the N actually in use (0 when no subset was built: probe contexts with unallocated weights, a tokenizer that fails the merge-order guard, or a model whose MTP block has its own LM head, which now logs a warning instead of building an unused subset).

Assisted-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01MkuKwgPNgpAg7EbxKR2iWC
Contexts over one model with different mtp_draft_vocab settings (N then 0, 0 then N, N1 then N2, the same N twice, two contexts created from two threads at once, a subset requested again after its last context was freed) are decoded interleaved with the same inputs. A context with N > 0 must have -inf exactly outside its own draft vocabulary and match a full-vocabulary context inside it; a context with N = 0 must have finite logits everywhere.

Needs a qwen35 or qwen35moe model with an MTP head (-m), so it is built but not registered with ctest.

Assisted-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01MkuKwgPNgpAg7EbxKR2iWC
nodes[0] alone gives the same key to graphs that share one meta arena (different topologies, or one topology at a varying verify width), so those graphs never replay. Mix the node count, the last node and three node shapes into the key.

Split out of halo-box#58.

Assisted-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01MkuKwgPNgpAg7EbxKR2iWC
err(a, b) receives the backend under test as a and the CPU reference as b.
The stable-tie check read b, so it validated the CPU's selection and passed
regardless of what ROCm returned.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_014qCLEUsSbxtwb71y5qRXAi
spec : MTP draft vocabulary subset --spec-draft-mtp-vocab (split 3/4 of halo-box#58)
…h-lifetime

qwen4exp: keep detached PLE prefetch inputs alive
hip: keep topk-moe fusion stable across buffer aliasing
hip: make tied TOP_K block selection deterministic
The HC residual has an extra stream dimension; using ggml_nrows to decide when its BF16-only store is safe applies the mark to small microbatches and makes the output depend on scheduler split placement. Check the actual token dimension for batches of at most 512, then allow incremental indexer-key updates through 512 tokens. QSA_FAST_MAX=127 retains the full re-pool comparison path. With the MoE alias fix as the base, 256- and 512-token runs at 4096 context produced identical probability files with both paths.

Assisted-by: OpenCode (gpt-6-sol)
A residual whose next fused combine lands in a later split used to lose
its BF16-only mark: ggml_cuda_marks_readers_local only sees one split, so
precision depended on where the input-count cuts (GGML_SCHED_MAX_SPLIT_INPUTS)
fell. The QSA fast path adds three host inputs per layer and moves those
cuts, which is what made 128..512-token incremental keys disagree with a
full re-pool. Optimize still runs per split, but marks live for the whole
graph and every split is optimized before any is computed, so carry the
remaining reader count forward (mmb_res16_pending) and mark from the split
that completes the check. Restores the res16 store at 512-token ubatches
that 063d804 had disabled and removes the now-redundant ne[2] test.

Add test-qsa-fast: CPU-only regression for the lifted qsa_fast gate
(size boundaries 1..2048, incremental block selection, prefix/commit/
truncate gates, QSA_FAST_MAX parsing).

Validated on gfx1151 (ROCm 10): fast vs full re-pool logits byte-identical
at ub 64/128/256/512/1024/4096, with and without split cuts; ctest -R
^test-qsa passes; test-backend-ops MUL_MAT/GET_ROWS passes.

Assisted-by: OpenCode (kimi-k3)
…2-on-moe

qwen4exp: stabilize 128–512-token incremental QSA keys
ggml-cuda : shape-aware graph cache key (split 2/4 of halo-box#58)
…used

The maskless selected-key path is where the prefill wins live, but when a
ubatch falls back to the masked route the server log stays silent, so an
operator cannot tell a wrong-kernel-route from a starved slot from a cold
PLE gather. The only symptom is a request that is several times slower
than the one running next to it.

Turn the block-selection gate into a reason classifier that returns the
first clause that refused the maskless path, and report the chosen route
per ubatch at LLAMA_LOG_DEBUG, plus one LLAMA_LOG_INFO line when the route
or the refusing clause changes. The conjunction clauses are evaluated in
exactly the order they were tested in before, so the boolean gate and the
reason string cannot disagree: the existing gate is now a one-line wrapper
over the classifier.

The transition line is rate-limited to one per 5 s per memory object,
because text/image-alternating workloads flip the route as their steady
state and would otherwise print twice per request. It is keyed per memory
object because the trunk and the MTP draft refuse the maskless path for
different reasons and would otherwise toggle a single global on every
rebuild. The graph-reuse path recomputes the same gate but does not log:
a reused graph repeats the decision recorded when it was built.

No behavior change: the route selection, tensors and kernels are
identical. Verified: -fsyntax-only and -c -O1 -Wall build of the
translation unit (g++ 14.2, CPU target); an exhaustive 570,240-case
equivalence harness over every gate input confirms old_gate ==
(miss == nullptr) in all cases and that all 14 refusal reasons are
reachable. Not verified: no ROCm run, this container has no GPU; log
volume at DBG verbosity was not measured on a live server.

Assisted-by: Piclaw (qwen3.8-flash)
@github-actions github-actions Bot added the model label Sep 29, 2026

@dzannotti dzannotti left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified on Strix Halo (gfx1151, HIP), base 7a9196dad vs this PR, Qwen3.8-Flash-Next Q2_0, ABBA n=4:

base PR
pp512 651.0 ± 34.9 655.2 ± 39.5 (+0.7%)
pp2048 909.2 ± 19.6 912.0 ± 17.2 (+0.3%)
tg128 30.1 30.0
PPL, prose corpus, c6144 1.0034 ± 0.00100 1.0034 ± 0.00100 (identical)

The route logs appear as described (qsa route: maskless (...)). Two issues:

  • Dedup key: the key is mctx_hyb, a per-batch memory context rather than a long-lived object, so the same route is logged again whenever a new context is created. It logged 4 times in one perplexity run (warmup plus 3 chunks), so "INFO only on change" depends on the allocator reusing addresses.
  • Thread safety: the dedup map is a function-level static std::map with no lock, so two contexts building graphs on different threads race on it. thread_local or a mutex would fix it.

Comment generated via automated review of clanker Claude Code.

@dzannotti

Copy link
Copy Markdown
Collaborator

@klucsik with #115 merged is this PR still relevant?

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.