Skip to content

perf(qwen4_exp): pipelined stages without peer access, a shared kv pool and an exact message checkpoint - #115

Open
quardex wants to merge 8 commits into
iamwavecut:masterfrom
quardex:claude/flash-next-pipeline-windows
Open

quardex wants to merge 8 commits into
iamwavecut:masterfrom
quardex:claude/flash-next-pipeline-windows

Conversation

@quardex

@quardex quardex commented Oct 21, 2026

Copy link
Copy Markdown

What

Split Flash-Next models on GPUs without peer access (every GeForce pair under Windows/WDDM, here
on PCIe x1 risers) queued every stage from one host thread, which a cross-device wait blocks, so
the stages took turns. This branch adds three opt-in modes and two defaults for such pipelines, and
two fixes they needed on master:

  • Rank threads (NINFER_QWEN4_RANK_THREADS=1): each stage gets its own host thread, so prompt
    chunks fill every stage at once.
  • Pipeline windows (NINFER_QWEN4_PIPELINE_WINDOWS=1, with rank threads): while the head stage
    verifies a window, the front stages run the next one, guessed by the MTP block, from shadow
    states; a wrong guess is dropped. One request at a time, MTP drafts, device experts, and no
    grammar or logprobs (such requests run the serial loop).
  • Decode groups (NINFER_QWEN4_DECODE_GROUPS=2..8): several decoding requests step as
    round-robin groups across the stages.
  • The head stage's embedding copy (default for split models, NINFER_QWEN4_HEAD_EMBEDDING=0
    turns it off): the MTP cells' embeddings come from a mapped copy of the table in pinned host
    memory (322 MiB for the IQ3_S release), so the draft chain stays on the head device.
  • A shared KV pool: a --kv-capacity of at least --max-context but below a window per
    sequence makes the sequences share one paged pool (with --host-state-slots 0 --host-kv-mib 0).
  • The message checkpoint (default with prefix reuse): a prompt of 8,192+ tokens keeps a second
    snapshot at the last chunk boundary before its last message, so a turn whose client rewrote that
    message (Cline-family clients drop an environment block from it) resumes there instead of
    prefilling the whole prompt again. It is taken only past position 0 or another such snapshot, so
    it lies on the chunks of a prefill from position 0: taking it splits no chunk, and a resumed
    prompt computes exactly what a prefill from scratch computes. 74 MiB of device memory a sequence.
  • Fixes: a pooled server failed at startup on master (qwen4_exp: sequence images need per-sequence KV windows, not a shared KV pool) and now runs without the image store; MSVC does
    not declare _cvtsh_ss (the same line as build/windows-release, so the two merge cleanly).

Exactness against master

Master f6ec88b as pulled (only the MSVC line added), run as an API ran before these modes (MTP3,
one slot), against this branch as the API runs it (rank threads, pipeline windows, decode groups of
four, MTP2, pooled slots: four on B, two on A), both with 2,048-token chunks, greedy, no logprobs:
34 of 34 texts byte for byte, 17 on each layout. Per layout: a 206K prompt, a 33K prompt, a code
prompt and two 1,024-token chats one at a time; four turns of an agent session over a 67K docs
prompt (turns 2 and 3 drop the environment block of the user message before them, turn 4 only
appends); four chats at once; the 33K prompt beside three chats. Master's plain decode
(--spec none) gave its MTP3 texts on B's five single prompts.

Speed (same runs)

A: TITAN RTX + four RTX 3060 (8,8,8,8,16, rk8v4). B: two RTX 3060 + TITAN RTX + RTX 3090
(8,8,17,15, int8). Windows 11, WDDM, every card on PCIe x1, the RTX 3060s held at >= 1500 MHz.

A master A this branch B master B this branch
206K prompt, prefill tok/s 531 1,750 700 1,610
206K prompt, decode tok/s 35.1 46.0 43.8 64.7
33K prompt, prefill / decode tok/s 569 / 40.3 1,600 / 56.7 751 / 50.4 1,540 / 70.5
code prompt, decode tok/s 63.7 96.3 74.2 115.9
1,024-token chats, decode tok/s 50.0, 58.0 69.5, 90.0 56.6, 67.8 90.9, 118.1
agent turns 2 and 3 (rewritten), first token 2 min 0 s 4.5, 4.9 s 1 min 31 s 3.5, 3.9 s
four chats (master one after another) 38.6 s 25.9 s (2 slots) 32.8 s 14.0 s

On A's two slots a chat beside the 33K prompt's prefill decoded at 12.6 tok/s until that prefill
was done.

Power

One server per row, adding one mode to the row before (A) or switching one (B); the 33K prompt with
one output token, and a 1,024-token chat. nvidia-smi sampled every card each 100 ms; energy is the
layout's cards' mean power over the request times its wall time, per token.

A Prefill tok/s W J/token Decode tok/s W J/token
master, plain decode 761 384 0.505 34.7 254 7.41
master, MTP3 573 345 0.603 48.0 278 5.89
+ the head stage's embedding copy 702 377 0.539 49.2 280 5.79
+ rank threads 1,653 549 0.333 49.0 274 5.69
+ MTP2 1,650 550 0.335 47.2 258 5.58
+ pipeline windows 1,652 551 0.335 67.6 423 6.43
+ decode groups, two pooled slots 1,653 553 0.336 67.7 424 6.45
the same without the RTX 3060 clock floor 1,620 546 0.338 59.2 369 6.63
B Prefill tok/s W J/token Decode tok/s W J/token
master, MTP3 761 428 0.563 55.6 307 5.62
rank threads, decode groups, MTP2, four pooled slots 1,570 548 0.350 57.6 301 5.32
+ pipeline windows 1,572 554 0.354 90.4 593 6.74

Pipeline windows are the only mode that computes work it may throw away: 45% (A) and 47% (B) of the
guessed windows held, and the dropped ones cost 15% and 27% more energy a generated token for 43%
and 57% more speed. Rank threads run the same passes at once (38% less energy a prompt token). Four
chats at once on B: 127.8 tok/s in all with decode groups (5.62 J/token), 53.7 one after another on
master (5.41), 41.3 in the engine's batched rounds without decode groups (7.40). The decode groups'
texts were master's one-at-a-time texts; the batched rounds' were not (four of four differed).

Prompt chunk

At master's default 1,024-token chunk this branch gave master's 1,024-token texts as well (the 206K,
code and chat prompts on B); the 206K prompt prefilled at 1,457 tok/s on B and 1,557 on A, against
1,614 and 1,753 with 2,048-token chunks. Master's own 206K answer differs between the two chunk sizes.

Master itself on this rig

Measured with the same settings on master 15a52bd (the base before) and f6ec88b, this rig saw no
speedup from master's new work: the expert tier and the CPU share do not run with device experts,
and the RTX 3090's new cuBLAS gate is loaded (the server log names it) but the 27B's prefill did not
move.

206K prompt unless noted, tok/s 15a52bd f6ec88b
A, master (MTP3, 2,048-token chunks): prefill / decode 517 / 35.2 510 / 33.4
B int8, master: prefill / decode 678 / 43.5 699 / 42.4
A, these modes: prefill / decode 1,720 / 45.4-46.2 1,754 / 45.8
B, these modes, four pooled slots: prefill / decode 1,590 / 61.7 1,610-1,617 / 61.8-63.3
B, these modes, a short chat alone 90.7-92.6 86.8, 90.5
Qwen3.8-27B on the RTX 3090 (7 slots, MTP3): prefill / decode 995-1,000 / 64.1-64.2 993 / 62.0
Qwen3.8-27B, seven chats at once, in all 312-314 310

One run per cell; two runs of one build differ by up to 2.5%. Texts moved between the two masters
(Flash-Next on B with logprobs, five prompts: one text the same, the others a different token after
32-192 tokens, each where one of the two had a tie or a 0.125-nat lead, one BF16 step of the
logits; chosen-token logprobs before it 0.003-0.035 nats apart on average, 0.24 at most).

Also seen on master, not changed here: the 27B (seven slots, MTP3) answered differently from its
plain decode one request at a time in 10 of 11 prompts, and seven chats at once differently from the
same chats one at a time (7 of 7), the near-ties of verify widths that docs/serving.md describes.

Not covered

  • Windows only. The modes are off by default except the embedding copy and the checkpoint; no Linux
    pipeline was run.
  • Built and measured on f6ec88b; rebased onto 92a2b33 without conflicts. Its six commits touch the
    host-expert CPU pool and the expert tier, none of these files; this tree was not rebuilt.
  • No new unit test; the evidence is the end-to-end comparison above.

A. Ugryumov added 8 commits October 21, 2026 05:50
… stages without peer access

Split Flash-Next models on GPUs without peer access (Windows/WDDM) queued every stage from one host thread, which a cross-device wait blocks, so the stages took turns. Opt-in rank threads give each stage its own host thread; pipeline windows run the guessed next MTP window on the front stages from shadow states while the head verifies; decode groups step several requests as round-robin groups. The head stage now reads its MTP cells' embeddings from a mapped host copy of the table, so split models' draft chains replay their graph.
An explicit --kv-capacity of at least --max-context but below a window per sequence makes Flash-Next's sequences share one paged KV pool: one set of planes per sparse-attention layer and the MTP layer, a block table per sequence filled as it grows, pages returned at reset and taken from idle sequences when short. Prompts wait while the running requests hold the pages they need; a decode step without pages finishes at context capacity. Pipeline windows now serve whichever request decodes alone. Also: a threaded verification of several sequences hashed every part from the first one's n-gram context; rank threads run without CUDA Graphs again, since the draft/verify ordering fix removed the warm-up's illegal access.
Master pins the host image store's first pages at startup by the size of a whole-context image, which a shared KV pool refuses to give (its sequences keep no images), so a pooled server failed at startup. The pool already requires --host-state-slots 0 --host-kv-mib 0; it now runs without the store.
MSVC does not declare _cvtsh_ss, so the Windows build stopped at streamed_cpu_avx2.cpp; the conversion takes the vector F16C form, as gguf_expert_cpu_avx2.cpp does. The change is the one build/windows-release makes, line for line.
…t's last message

Agent clients rewrite the last message of the previous turn's prompt (Cline-family clients drop an environment block from it), which breaks the turn closure's prefix, so every such turn of a long session prefilled its whole prompt again: an API log showed 50 turns of 214-228K tokens at 0% reuse, 2 min 10 s to each first token. A prompt of 8,192 tokens or more to its anchor now also keeps a snapshot at the last chunk boundary of its prefill before its last message, and a prompt that rewrites that message resumes from it (the long-anchor reuse path). The snapshot is taken only when the prefill started at position 0 or at another such snapshot, so it lies on the chunks a prefill from position 0 runs: taking it splits no chunk, and a prompt resumed from it computes what a prefill from scratch computes. It costs a second 74 MiB snapshot per sequence; the expert cache leaves room for both.

Measured on B (int8, four pooled slots) over a 67K docs prompt: the two rewritten turns resumed 65,536 tokens and answered in 3.5 and 3.9 s instead of 1 min 31 s, and every turn's text was master's byte for byte. A snapshot at the message's own start (an earlier form of this change) split a chunk, and a 206K turn resumed from it kept a cold prefill's first 85 of 128 tokens only.
Each mode added in turn on layout A, the 206K prompt on A and B, four pooled slots beside chats, what the RTX 3060s do without the clock floor, and the message checkpoint against a cold prefill.
…ir power

Master f6ec88b as pulled against this branch as the API runs it, on both layouts: 34 of 34 texts byte for byte (single prompts, agent turns that rewrite or append, requests at once), the speed of each kind of request, the GPU power and energy a token of each mode, and master's default prompt chunk.
@iamwavecut

Copy link
Copy Markdown
Owner

Review of head 0d28854 (base 92a2b33; 8 commits, 5 files, +2290/−165; no tests). To get CI on it I pushed the head to a repository branch (run 39669204791, branch deleted since). Line numbers below are of this head.

Verdict: not mergeable yet. Items 1–4 need changes; after them a re-review and Linux measurements.

1. Does not build under GCC (blocker)

All three CUDA build and tests jobs (sm_75, sm_86, sm_120a; GCC 15.2) fail the same way:

  • src/models/qwen4_exp/executor.cpp:642 — static inline thread_local PassMode pass_mode{}; inside Impl: GCC needs the nested class's default member initializers complete before the end of the enclosing class (long-standing GCC behaviour, bug 96645; clang and MSVC accept it).
  • Consequences: RankTask with its PassMode mode; member is not default-constructible, so std::make_shared<RankTask>() at 2783 fails, and so do the brace assignments to pass_mode at 2654, 4739, 4744, 4767 and 4772.
  • Ninja stops at that translation unit, so qwen4_exp_core.cpp has not been compiled with GCC at all.

Fix: move PassMode to namespace scope, or define pass_mode after the class. The MSVC shards that failed did so on M_PI in tests/ops/test_gguf_expert_cpu.cpp:499, master's own breakage fixed in aa9cf32 after this PR's base; the PR's files pass under MSVC.

2. Conflicts with master

After the rebase, take_guess's assumptions (guessed_tokens.size() == drafts + 1, ahead_buffer sized stack_bytes(verify_width)) need re-checking against #116's draft().

3. Modes through environment variables

Eight NINFER_QWEN4_* variables (RANK_THREADS, PIPELINE_WINDOWS, DECODE_GROUPS, GROUP_STEPS, HEAD_EMBEDDING, STAGING_RING, PIPELINE_FORCE_MISS, ROUND_TIMING) are documented in the user guide as the way to configure the engine. In this repository environment variables are for diagnostics only; execution modes are EngineOptions fields and ninfer-serve flags (visible in --help, in server_start's argv, in launcher profiles and in docs/serving.md). Keep FORCE_MISS, ROUND_TIMING and STAGING_RING as diagnostics; the rest should become flags.

Current defaults: rank threads and windows off (need =1, and only with device-resident experts, no hybrid/misses/stream: executor.cpp:1676-1690, 2436-2442); decode groups off (0); the head-embedding copy on for every split model (1799); the checkpoint on; the pool implicit (next item).

4. On by default without measurements

  • Shared KV pool is switched on by --kv-capacity arithmetic (an explicit value in [max_context, C × max_context)); master ignores --kv-capacity for Flash-Next (kv_capacity = max_context). The memory semantics change silently, and a configured host KV cache becomes an error. This needs an explicit flag, or at least the semantics written into docs/serving.md and docs/cli.md.
  • Message checkpoint: grow_expert_cache(kGrowthMargin + 2 · sequences · snapshot_bytes) takes about 74 MiB per sequence out of the expert cache, plus a snapshot on every prompt of 8192 tokens or more (kCheckpointMinPrompt). It helps only clients that rewrite the last message; on a 24 GB card with host experts (the main deployment) a smaller expert cache means slower decode. Make it opt-in or reserve lazily, and measure on a 3090/4090.
  • Head-embedding host copy (mirror_embedding_for_head): 322 MiB pinned, on even on Linux with peer access, where nothing was run ("no Linux pipeline was run"). Enable it only without peer access, or measure it.

5. Rank-thread robustness

  • worker_error (executor.cpp:2433, 2501-2502, 2550) is never reset: after one worker failure every later call rethrows the same exception until restart. It needs a reset path or an explicit fatal state of the engine.
  • drain_quietly() / drain_head_quietly() (2520-2529) swallow exceptions in logits(), head_stream() and abort() (5547, 5575, 5611): a failed pass can surface as wrong logits instead of an error.
  • issue_group (qwen4_exp_core.cpp:2192-2220) captures &group, an element of the local std::vector<DecodeGroup> groups in grouped_step (2246). If cudaEventSynchronize or decode() throws, groups is destroyed while the head thread may still run a callback: use-after-free. A shared_ptr<DecodeGroup>, or a drain before unwinding.
  • enqueue_plain_sample (2098-2145) runs on the head-rank thread and uses sample_workspace, a DeviceArena with no synchronization, which the main thread also uses in sample_deferred and prefill sampling. Today they are sequential (the main thread only waits in grouped_step), but nothing enforces it: an assertion, or a separate arena for the head thread.
  • Decode groups move concurrent MTP requests to plain decode (1994-2007), a different sampling path from master's batched MTP rounds. The description says the texts match one-at-a-time output; this needs a test with two or more concurrent seeded requests.

6. The pipelined-round gate

pipelined_round (2386-2391): windows + batch == 1 + lookup_ngram == 0 + no adaptive controller + draft_min_p == 0 + no grammar/logprobs. The automatic floor 0.5 (engine.cpp:73) applies only with host/disk experts while rank threads need device residency, so the two do not meet. A user-set --draft-min-p silently disables the pipelined path, though; document it.

7. Docs and the PR description

  • The user guide is updated, but docs/maintainer/pipeline-stages.md (rank threads, windows and the pool change the stage design) and docs/maintainer/qwen4-exp-model.md are not, and docs/serving.md does not describe --kv-capacity for Flash-Next.
  • The "master bug" in the description ("sequence images need per-sequence KV windows") is thrown by this PR's own image_regions() when pool_pages != 0: introduced in 5d530b5 and fixed in 2ca1117 inside this PR. It is not a master bug.

8. Tests and evidence

No tests. Needed: a CPU test of the pool arithmetic (allocate_pool / reserve_pages / release_pages, KvPoolExhausted), state equality after a checkpoint restore, decode-group ordering. On GPUs: a Linux two-GPU A/B against master with device experts (texts byte-equal for one and for several requests, prefill and decode with and without windows, 2–4 concurrent requests, pool exhaustion), and single-GPU host experts for the checkpoint's effect on the expert cache and decode. The current evidence is Windows only (34/34 texts equal, prefill 2.3–3.3×, decode +30–60%).

Minor

thread_local pass_mode is semantically fine (see item 1). drain() was added to many public entry points; without rank_threads it should be a no-op, worth checking its cost on a single GPU. The decode(..., AfterHead) extension in executor.h is fine.

Suggestion

Split into four PRs with their own measurements: (a) rank threads + windows + head embedding, (b) KV pool, (c) message checkpoint, (d) decode groups.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants