Skip to content

Two-GPU decode: bounded attention merge (bitwise, opt-in) and pipelined PLE read-ahead / early chain (stacked on #905) - #910

Closed
Hardin22 wants to merge 853 commits into
Niko1221:mainfrom
Hardin22:pr/pipe-team
Closed

Hardin22 wants to merge 853 commits into
Niko1221:mainfrom
Hardin22:pr/pipe-team

Conversation

@Hardin22

@Hardin22 Hardin22 commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #905 (and through it on #859 and #876). Only the last two commits are new.

These are the last pieces of the two-GPU work. With them, main reaches what our fork does on the same PC.

  1. Decode attention: a merge bounded by the selection width (STRATA_ATTN_MERGE_V2=1, opt-in since 9d27f7e: bitwise the same, but not yet measured apart from the other switches).

    • It is bitwise equal to the current merge. attn_merge_parity checks 768 cases (fp16 / int8 / q4_0 / k8v4 KV) on an RTX 5080 and an RTX 4060 Ti.
    • Faster or equal everywhere in the kernel bench, e.g. one query at full width on the 5080: 98 → 21 µs.
    • Off on HIP, where it has never run.

    Same commit: STRATA_MTP_KV=f16 (opt-in), the draft layer's KV in f16. It changes only the drafts, never the verified tokens, and costs ~230 MiB of VRAM.

  2. Pipelined decode (opt-in switches, only inside --pipeline-windows):

    • STRATA_PL_PLE_PREFETCH reads the next window's PLE rows ahead, as the chain's outputs land.
    • STRATA_PL_PLE_LATE gives each stage-0 window its own read handle, so service never blocks on another window's reads. Layer 0's experts are served while its flag waits for the rows.
    • STRATA_PL_EARLY_CHAIN launches the chain before the verdict's bookkeeping.

    The serial loop, run() and the batch windows are unchanged.

Measured

Same setup as #905: 4060 Ti + 5080 split, resident RAM mode (#848), --pipeline-windows 2 --adapt-async 1, --trim-stage-weights, Swift 1.5 IQ3_XXS at 160K (q4_0 KV). Greedy, five prompts × two rounds, decode tok/s:

run 1 run 2
off (STRATA_ATTN_MERGE_V2=0, nothing else set) 117.5 117.5
on (STRATA_PL_PLE_PREFETCH=1 STRATA_PL_PLE_LATE=1 STRATA_PL_EARLY_CHAIN=1 STRATA_MTP_KV=f16, merge v2 by default) 132.1 130.6

That is +12%; I have not split it per switch. For reference, our fork on the same prompts measured 131.7 and 133.7, so the stack is at parity with it.

Checks

  • Exactness: the three pipelined switches give the same greedy text on as off (4/4 prompts, 500 tokens, STRATA_IQ_MT_MIN=1 --pcie-frac 0 --adapt-every 0, blocking tier).
  • Stress: with every switch on and STRATA_PIPELINE_FORCE_MISS=2, 11 varied requests gave no degenerate answer and no stall.

The whole two-GPU stack, on this PC

RTX 4060 Ti 16 GB + RTX 5080 16 GB, i9-14900KF, 32 GB of RAM, Swift 1.5 IQ3_XXS at 160K:

decode tok/s
main today, the 5080 alone (what setup picks for 32 GB) ~29
main today, both cards (--mmap-experts once the file cache is warm) ~64
#848 (resident RAM mode on the split) ~70
+ #859 pipelined windows ~84
+ #876 / #905 async tier beside the pipeline, + #904, --trim-stage-weights ~118
+ this ~131

The last two rows use a fine-tuned draft layer, --spec-min-p 0.65 and Windows power throttling off; the rows above them use the stock draft layer. So read this table as an outline. Each PR has its own A/B with the same settings on both sides.

Niko1221 and others added 30 commits October 2, 2026 09:41
…iko1221#461), the low-RAM resident mode on Windows 32 GB PCs (Niko1221#467), fixes Niko1221#460 Niko1221#459 Niko1221#446 Niko1221#447 Niko1221#457 Niko1221#448 Niko1221#444; setup requires it

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
The fill from the GGUF (--stream-experts) read each expert's three slices,
copied the blob and waited for the copy before the next read: ~12,000-18,500
experts with neither the SSD nor PCIe kept busy. Now the slots are admitted
in profile order first (same placement), reads go in file order by up to 8
threads into page-locked batches of 64, and a batch's copies overlap the next
batch's reads. STRATA_FILL_SERIAL=1 keeps the serial fill.

Tested on the B70: cold page cache, Coder fill 23.4 GiB 76.2 s -> 18.8 s;
launch to first token Coder 82 s -> 26 s, IQ2_XS 120 s -> 41 s; outputs
identical to the serial fill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rb4SRxFuLpffQAAXBf4HxA
… read (Niko1221#471)

The engine's `strata serve: prompt ...` line now says "= 0 reused + 12288 of 98179 read in ... (cancelled)" with the
rate from the tokens read, and DONE gets a 15th field: the prompt tokens actually read. The server's /metrics history
and totals count reused + read for a cancelled read (prompt_total keeps the whole prompt); an older engine's DONE line
(no such field) is read as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
Engine: when the MTP draft head's subset does not fit, it prints what the head needs, the VRAM free, and the smaller
shipped subsets (--draft-vocab cyrillic / en, their MiB from the head's real row size) with how to pick one
(START-HERE.bat / ./setup.sh --draft-vocab en, kept in the model's config). Text only, on the failure path.
Server: a start that ended that way carries the engine's hint (or the same advice for an older engine) in its
"the engine exited before it was ready" error. Setup: a card under 14 GB with the default subset gets a note
recommending --draft-vocab en; nothing is changed, an explicit or saved choice gets no note, and the golden
configs are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
…o1221#475)

In a git clone they run `git pull --ff-only` (a zip copy is told to download the new zip), then `setup.py --update`:
what a plain start does to an install before it starts the model - the Python packages, the ready-made engine when
MIN_ENGINE is newer (a compiled one when its source changed), each model's config upgrades and its draft subset, the
Windows AMD runtime - and nothing else: no question, no model files, no start. Both scripts run from one parsed
block, since the pull can rewrite the running script. Documented in docs/INSTALL.md (Updating) and the README.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
…profile-save, Niko1221#477)

`--expert-profile-save P` (--serve) writes the adaptive tier's learning as a profile in tools/make_profile.py's
format, byte for byte (read_expert_profile reads it back): every pair ranked - the experts resident in VRAM now
first, then the routing counted since the start (the decayed usage summed at each adapt step, before its decay),
then the profile the engine started from. On QUIT / stdin EOF and every --expert-profile-save-every minutes
(default 10, 0 = exit only) between requests, through <P>.tmp and a rename. Without the flag nothing is counted or
written: `heat` stays empty and every added loop is over it.

Server: "expert_profile_save" (and "expert_profile_save_every") in the config pass the flags, and the next start
begins from the saved file instead of the config's --expert-profile when its header matches that profile's
layers x experts. tests/core/expert_profile_save_test.cpp (CPU only) checks the order and the bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
…, the draft-head hint (Niko1221#474), UPDATE.bat (Niko1221#475), --expert-profile-save (Niko1221#477); setup requires it

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
…pt path, grouped on the GPU (opt-in STRATA_PF_FUSED=1)

Strata's own kernels (src/prefill/moe_fused.cu): the layer's input rounded to int8 once per token (a scale per 32
values), the (token, k) pairs grouped by expert on the GPU (counts, offsets, a 64-row tile list - no host sync),
then per batch of up to 128 experts one gate/up launch (mma.sync m16n8k32 s8, the 2-bit codes unpacked in
registers, Q2_0's offset folded into the activation blocks' sums, SwiGLU and H's int8 rounding in the epilogue)
and one down launch into the per-slot rows the existing combine reads.  Experts are read where they are (cache
slot or streamed ring slot, a pointer per expert): no gather.

Used only on the streamed walk (chunks from stream_all_min()) of the canonical Q2_0 layout on sm_80+ CUDA, with
STRATA_PF_FUSED=1; it reuses the MMQ buffers (Xq, H, GU).  Everything else - the default, native/IQ packs, small
chunks, sm_75, HIP - runs the MMQ path unchanged (the old block is only wrapped in the else branch).

tests/cuda/prefill_fused_moe_test (exit 77 below sm_80), RTX 5070: vs a double-precision reference, MMQ rel RMS
1.144e-2 (worst row 4.15e-2), fused 1.144e-2 (4.15e-2); fused vs MMQ 9.0e-5.  One layer at 2048 tokens x top 10
over 512 experts: MMQ path 11.12 ms, fused 3.13 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
…e layer's experts) by default

The fused path launches on batches of a layer's streamed experts, so with the 384-slot ring a layer's last batch
waited for slots its own first batch frees.  With STRATA_PF_FUSED=1 (and the canonical Q2_0 layout) the pinned-RAM
ring is 512 slots; without it nothing changes (384 / 96 as before; STRATA_PREFILL_RING still overrides).

RTX 5070, Q2_0 (cfg-q2rel), greedy, 3 alternating pairs, prompt tok/s: 4K 1,275 -> 1,516 (+18.9%), 32K 2,176 ->
2,422 (+11.3%); at 384 slots the fused path gave +4-7% / +3%.  1024 slots: 4K +24-27%, but the 32K chunk falls
from 8192 to 6144 (+6%).  The default path's output is identical to 0.1.35's on both prompts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
… hold only what the fused kernels need

In a layout whose chunks run the fused experts (STRATA_PF_FUSED=1, the Q2_0 pack, a streamed chunk) GU holds the
grouping tables, H the int8 H rows and Xq the per-token int8 activations, and Hq nothing - instead of MMQ's FP32 GU / H
and per-slot q8_1 rows: ~100 KB a token less in the MoE set, which then no longer sets the shared region's size (the
GDN set does).  A last chunk below stream_all_min() still runs MMQ in the same buffers, so each keeps MMQ's size for
stream_all_min() - 1 tokens.  The ring may grow to 1024 slots with the fused experts (STRATA_PREFILL_RING; the default
stays 512).  Without STRATA_PF_FUSED nothing changes: the same buffers, ring cap (512) and events.

The fused walk now counts the experts it reads from the cache in experts_resident (it stayed 0): every resident expert
of the layer, routed or not - at a streamed chunk's size nearly all are.

RTX 5070, Q2_0 (cfg-q2rel), --prefill auto, 2 alternating pairs, prompt tok/s (borrowed slots):
  4K:   MMQ 1,305 (1621), fused 1,517 (1749), fused + P3 1,512 (1534), + 1024-slot ring 1,605 (2046)
  32K:  MMQ 2,177 (3071), fused 2,453 (3199), fused + P3 2,485 (2641), + 1024-slot ring 2,660 (3153)
  128K (--max-context 131072 --kv-resident 32768): MMQ 2,108 (chunk 8192), fused 2,140 (6144), fused + P3 2,346
        (8192), + 1024-slot ring 2,392 (6144)
A 16K chunk still does not fit the 12 GB card: at 8192 tokens the loan is 2,641 of 3,690 slots, ~2.1K of them per
8K tokens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
…ault (two layers' experts)

With P3's smaller buffers the 1024-slot ring fits beside an 8192-token chunk at 32K (borrowed 3,153 of 3,690 slots,
under the 90% cap).  RTX 5070, Q2_0 (cfg-q2rel), --prefill auto, prompt tok/s at 512 / 1024 slots (2 alternating
pairs): 4K 1,512 / 1,605, 32K 2,485 / 2,660, 128K (KV streaming) 2,346 / 2,392 - there the chunk falls from 8192 to
6144 and is still faster.  Against MMQ (one more pair on this build): 4K 1,299 -> 1,609 (+23.9%), 32K 2,187 -> 2,656
(+21.5%), 128K 2,128 -> 2,454 (+15.3%).  The ring does not change the numbers (the same output as at 512 slots).
Without STRATA_PF_FUSED nothing changes (384 / 96 slots, cap 512).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
…y argmax, the same ids and tokens

- qsa_block_topk: calls of up to 16 queries (decode windows) run on a cluster of 8 CTAs per query
  (block_topk_cluster_kernel): keys in shared memory, digit histograms pushed through distributed shared memory,
  one cluster barrier per radix pass, a parallel digit search, cluster-wide offsets for the ordered emit. The
  threshold and the cut at it are the one-CTA kernels' (same tie rule, NaN keys 0). STRATA_QSA_CLUSTER=0: off.
  RTX 5070 per call: 21.9 -> 15.6 us at 32K, 58 -> 18 at 128K, 200 -> 22 at 262K.
- sample_tokens greedy without penalties: a cluster of 8 CTAs per row (sampler_greedy_cluster_kernel), four loads
  in flight per thread, results merged in CTA 0 (lowest index on ties, NaN never taken, 0 when none).
  STRATA_ARGMAX_MULTI=0: off. 39.6 -> 5.9 us per call at 248,320 logits.
- Both launch through cudaLaunchKernelEx with the cluster attribute (captured in the decode graphs) only where
  they run: CUDA, compute capability 9+ (STRATA_EMULATE_CC respected), the kernel's own PTX/SASS at sm_90+ (an
  older build's JIT copy is a trap), room for one cluster - checked once per device. HIP and sm_75/86/89 keep the
  one-CTA kernels.
- decode_cluster_parity (ctest; skips below sm_90): bitwise vs the one-CTA dispatch and the reference kernel over
  434 top-k cases (contexts 7..262,144, every tail weight, 1-16 queries, ties / NaN / +-inf / -0 / denormal / all
  NaN) and 216 argmax cases, plus a CUDA graph capturing both (first calls inside the capture); --bench timings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
…gate/up IQ2_XXS IQ2_XS IQ2_S IQ3_XXS IQ3_S IQ4_XS, down Q2_0 IQ4_NL; opt-in STRATA_PF_FUSED=1)

src/prefill/moe_fused_iq.cu: the Q2_0 path's design (moe_fused.cu: the grouping on the GPU, a pointer per expert -
cache slot or ring slot, no gather -, SwiGLU and H's int8 rounding in the gate/up epilogue) for the raw GGUF blobs of
the native packs.  The load stage decodes the i-quant blocks to int8 in shared memory (llama.cpp's codebooks and sign
tables, held in shared memory; a 32-value sub-block per thread per 64 values of K, read one stage ahead with 16-bit
loads and two 256-value blocks ahead into L2), with each sub-block's scale (per 16 values for IQ2_XS / IQ2_S, which
multiply at m16n8k16).  The activations are int8 per 32 values in natural order (quantize_act_native); fragments by
ldmatrix; the magic bias 1.5 * 2^23 enters as the mma's C, so a dot becomes a float with one add.  A work item is 256
weight rows x 64 routed rows, or 128 x 128 when the experts have 56-112 rows each (STRATA_PF_FUSED_TILE=64|128).

Wiring (prefill.cpp): a native layer takes these kernels when both its formats are covered, else MMQ (IQ1_M keeps the
FP16 path); the 512-slot ring of the fused path now also for a native pack with such layers.  sm_75, HIP and the
default (no variable) are unchanged: on IQ2_XS, IQ3_XXS and IQ3_S the default output is 0.1.35's (256/256 greedy
tokens, 4K and 32K).

tests/cuda/prefill_fused_iq_test (exit 77 below sm_80), RTX 5070, 256 tokens x 64 experts against a double-precision
reference from ggml's to_float, rel RMS / worst row - MMQ and fused equal to four digits: IQ2_S/Q2_0 1.489e-2 /
2.93e-2 (fused vs MMQ 2.6e-4), IQ2_XXS/Q2_0 1.464e-2 / 2.96e-2 (1.9e-4), IQ2_XS/IQ4_NL 1.465e-2 / 2.41e-2 (3.5e-5),
IQ3_XXS/IQ4_NL 1.458e-2 / 2.73e-2 (1.6e-4), IQ3_S/IQ4_NL 1.470e-2 / 2.65e-2 (6.5e-5), IQ4_XS/Q2_0 1.446e-2 /
2.72e-2 (4.4e-5); both tile shapes.  One layer (512 experts, top 10), ms, MMQ path -> fused at 2048 / 3584 / 8192
tokens: IQ2_S 10.5 -> 5.2, 12.5 -> 9.3, 21.4 -> 17.5; IQ2_XXS 8.8 -> 4.2, 10.7 -> 7.6, 17.9 -> 13.6; IQ3_S 10.6 -> 5.6,
12.3 -> 8.8, 19.8 -> 18.4; IQ3_XXS 10.2 -> 5.1, 11.9 -> 8.3, 19.1 -> 16.5.  MMQ's products alone (no gathers) at
3584 / 8192: IQ2_S 8.9 / 17.9, IQ3_S 8.1 / 15.6: the kernels are about even with MMQ's, the gain is the path around them.
(The test syncs the device after its uploads: a cudaMemcpy from pageable memory on the legacy stream can still be
queued on Windows while a non-blocking stream runs - the grouping then read zeroed ids, every pair on expert 0.
prefill_fused_moe_test's timing part has the same pattern.)

End to end, RTX 5070, speed_135.py's settings, greedy, A (MMQ) / B (STRATA_PF_FUSED=1) alternating, 2 pairs, prompt
tok/s: IQ2_XS 4K 1,228 -> 1,378 (+12%; a cold first A run of 590 left out), 32K 2,107 -> 2,170 (+3.0%); IQ3_XXS 4K
1,001 -> 1,020 (+1.8%), 32K 1,745 -> 1,739 (-0.4%); IQ3_S 4K 927 -> 925 (-0.2%), 32K 1,382 -> 1,362 (-1.4%).  The IQ3
packs' prompts here wait on the host copies of their unpinned experts (IQ3_XXS 4K: 5,062 of 22,573 streamed, ~3.0 s
of 3.4 s), so faster kernels change little.  Each arm repeats itself (256/256; IQ3_XXS 32K B and IQ3_S 32K A split at
162 / 223, the decode's timing); A vs B greedy agreement, identical prefix of 256: IQ2_XS 204 / 88, IQ3_XXS 182 / 89,
IQ3_S 31 / 256.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rb4SRxFuLpffQAAXBf4HxA
…(STRATA_PF_FUSED=0 off), the native IQ packs' fused kernels opt-in (=1), cluster decode kernels; the fused test waits for its uploads

The ring rule: 1024 slots for the Q2_0 pack's fused experts (P3), 512 for native packs with fused layers.
Quality (teacher-forced vs the FP16 path, Q2_0): fused as close as MMQ at 8K, closer at 32K (phaseA-tests s18-tf).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
device, expert_source, native_head, prefill gemm, prefill.cpp, generate.cpp
merged from dpct of aeb35be and d9ab843 with sycl/tools/merge_upstream.py (the
3-way merge restricted to files upstream changed, dpct kernel-name hashes
canonicalized to the port's). Version 0.1.35.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rb4SRxFuLpffQAAXBf4HxA
…ls) and their switches

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
The PCIe probe sets the share of the missed experts the GPU reads over
the link (--pcie-frac): the native default 0.55 from 20 GB/s up, less
on a slower link (Niko1221#44). On Windows HIP it timed its copies with events
that do not bracket them there: an RX 6800 read between 3,348 and
25,999 GB/s in 24 engine starts, so every link kept the x16 share. A
standalone copy of the probe on that card: its events measure 0.13 ms
for the 1 GiB, the host clock 43 ms (25.1 GB/s).

On Windows HIP the probe now times the same copies on the host, between
two synchronizes (1 GiB, tens of milliseconds): the same RX 6800 on
PCIe 4.0 x16 reads 27.2 GB/s in every run, which keeps 0.55 there. On
a PCIe 4.0 x8 or 3.0 x16 link the share now shrinks as Niko1221#44 intended.
CUDA and Linux keep the events.

Claude-Session: https://claude.ai/code/session_015Ld5YDzuKW1hgMVwwgRZDP
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…DGET)

On Windows, hipMemGetInfo (ROCclr's PAL backend) reports the card's size
minus this process's own allocations; what the desktop and other
programs hold on the card is not subtracted. On a card that also drives
the desktop the engine counts the ~930 MiB those hold as free, and
--expert-cache auto fills the card past what Windows lets the process
keep in VRAM: WDDM moves part of it out to system RAM. On an RX 6800
(HIP SDK 7.2) with setup's configuration: a 9.83 GiB cache, the card at
16132 of its 16368 MiB, 929 MiB more of the engine in system RAM than
with this change, 30.1-31.0 tok/s decode. On a 0.1.30 build,
--vram-reserve-mib 1500 left the card as full (16037 MiB), since the
reserve is taken from the same figure.

Windows gives every process a video memory budget that does account
for the others (DXGI QueryVideoMemoryInfo). On that card it is 799 MiB
below the card's size while the process is small and about 1.8 GiB
below once it holds 11 GiB; allocations past it still succeed.
cudaMemGetInfo, which the HIP shim maps to hipMemGetInfo, now lowers
hipMemGetInfo's free figure by what the budget withholds (the card's
size minus the budget), on Windows only. What is sized from it then
stays within the budget: --expert-cache auto sizes the cache from it,
and the existing check after the slots are written shrinks it ("only
8 MiB free once the slots are written ... shrinking"): an 8.05 GiB
cache, the card at 15230 MiB, 40.6-41.5 tok/s.
Lowering HIP's own figure, instead of taking the budget minus DXGI's
usage, keeps memory the HIP runtime has freed but still caches counted
as free, as HIP counts it.

dxgi.dll is loaded at run time, so nothing new is linked; the adapter
is found by the device's LUID. Without dxgi.dll, the adapter or a
budget below the card's size, hipMemGetInfo's figure stands. The first
query logs the budget once. STRATA_WDDM_BUDGET=0 turns it off. CUDA
and Linux builds are unchanged.

Claude-Session: https://claude.ai/code/session_015Ld5YDzuKW1hgMVwwgRZDP
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g forever (Niko1221#481)

A request whose engine prints nothing for engine_silence_s (default 300 s; while a prompt is read, a chunk may take
3x the previous one's time, the first up to its tokens at 50 tok/s more) ends with EngineSilent (an EngineDied): the
engine is killed and the next request starts it again. The STOP drain, under the request FIFO, is no longer an
untimed wait. "engine_silence_s": 0 keeps the old behaviour. Tests: unit (silence, PP chunks, first chunk, STOP
never acknowledged, 0, config) and over HTTP with a fake engine process (non-stream, stream, stop token).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
…0 log lines (Niko1221#496)

Every start failure, not only the draft-head case (Niko1221#474), now carries the engine's own reason: the last 20 lines
this start wrote to the engine log (never an earlier start's). The failed start also closes its pipes and its
handle on the log. Tests: the tail itself, and a fake engine that exits with a reason in its log.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
…p-experts (Niko1221#505)

The server window said "loading the experts into RAM (about N GB)" whatever the flags. Now: --mmap-experts maps them
from the model files (the OS file cache reads them, nothing is loaded up front; its "experts via mmap" line triggers
the message when the model has no PLE line), --resident-experts copies the ones the GPU does not hold, and
--resident-budget-gib N copies up to N GiB. setup's start banner says "maps" for the plain mapped mode too. Tests:
the words per flag set, and the narrator over a mapped and an arena log.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
…#495)

hf() takes the host from HF_ENDPOINT (default huggingface.co, a trailing slash dropped); the pinned revisions, the
fallback to main and the SHA-256 checks are unchanged. tools/mtp_fetch.py reads the MTP tensors from the same host.
Step 5 prints the folder setup expects the model files in and how to use files downloaded by hand (that folder with
their original names, or --gguf-dir), and the mirror when one is set. INSTALL.md says the same. Tests: the endpoint
in hf()/hf_unpinned()/mtp_fetch, and step 5's words over a mocked install. The golden configs are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
…ds it; no budget on a --gpus start (Niko1221#498)

The engine refuses --resident-budget-gib with a layer split, so setup always dropped UD-Q4_K_XL to one GPU. With RAM
>= the GGUF size + UNSLOTH_RAM_LEFT_GB (~135 GB) and 2+ usable cards, setup now asks (one GPU stays the default and
--yes keeps it; an explicit --gpus is honoured; an explicit --resident-budget-gib keeps one GPU) and the split writes
"gpu": [..], "layer_split": "auto" without the budget: all the experts loaded into RAM at start, the files through the
OS file cache (the config Niko1221#498 measured: 2x RTX 3090, 31 -> 64-78 tok/s). Latent bug: START-HERE --gpus 0,1 (or the
start-time offer, or a hand-edited config) on a UD-Q4 config kept the budget and the engine exited with code 2; the
split now drops it, or setup stops before saving when the RAM is too small. docs/UNSLOTH_Q4.md says so. Tests in
tools/test_setup_unsloth.py; the golden configs are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
The engine already leaves --vram-reserve-mib MiB of VRAM free (default 700). INSTALL.md now says what it does, its
default, and how to set it (the flag, or "--vram-reserve-mib", "2048" in the config's args). Setup's new flag writes
it into the config only when given (one value: it replaces the images' own 700, with a note when it is less than the
GPU image encoder needs); on a start of an installed model it is saved into that model's args, and a new copy set up
from an earlier install keeps it. The default config is unchanged (golden test). Tests in test_setup_choices.py.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
…are, the samples logged

The probe timed one 1 GiB burst; an x16 PCIe 4 laptop (RTX A3000) read 18.5, 6.9 and 5.8 GB/s
in three starts, and the low reading set pcie_frac (0.12-0.15 where calibrate.py found 0.35).
The same 1 GiB is now four bursts of 256 MiB, each timed on its own (events between them on
the stream; on Windows HIP the host clock, Niko1221#377), and the fastest is the link's figure: a
disturbance only ever slows a copy, while the median would still follow one lasting through
half the bursts. The probe copies no more than before.

The share was base above 20 GB/s and base * bw / 26 below (19.9 GB/s -> 0.42, 20.0 -> 0.55;
4.0 -> 0.08, 3.9 -> 0). It is now base * min(1, bw / 20): continuous, unchanged from 20 GB/s
up (an x16 PCIe 4/5 link reads ~26-28 GB/s, so the default stays 0.55 there), in proportion to
the link below, so the link's time on its share stays about what the x16 share costs. The rule
is one function for CUDA0 and the layer split's stages.

The log names every burst: "PCIe probe: 28.9 GB/s host->device (best of 28.9 28.9 28.9 28.9)"
(the RTX 5070 here; pcie_frac 0.55 as before).
Noted, not changed: a given --pcie-frac is every stage's share and skips the stages' probes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
Niko1221 and others added 10 commits October 4, 2026 10:12
# Conflicts:
#	src/core/expert_source.cpp
#	src/kernels/cuda/iq_kernels.cu
# Conflicts:
#	src/core/verify.cpp
#	src/program/generate.cpp
…l); UD-Q4_K_XL stays experimental

- "experimental" is per model now: MODELS["UD-Q4_K_XL"] keeps it, the unsloth family and UD-IQ4_XS drop it. The
  first menu lists the family without [experimental], with a one-line about (UD-IQ4_XS: a 94 GB download; under
  ~80 GB of RAM part of its experts are read from the SSD). Its size menu lists UD-IQ4_XS first (the default) and
  UD-Q4_K_XL second; an experimental size always sorts last. --model names are unchanged.
- UD-IQ4_XS: no EXPERIMENTAL warning, --check verdict without it. Images are asked (off by default), per model: the
  engine's image path has no restriction for this pack, which uses the original model's image encoder; not yet run
  with images. UD-Q4_K_XL keeps images off.
- MCP: the fallback tables mirror setup's (per-model experimental/vision), sizes in setup's order, per-size
  "experimental" and "images", the vision check per model.
- Docs: README and its six translations, MODELS, UNSLOTH_Q4, AI_SETUP, MCP_SERVER.
- Tests: the menus, the default, --model by name, images, --check, the pack/tokenizer paths; the golden configs
  are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJJwUv9g3cD2ztX6KWS8rk
…e-mib 1000 if a request stalls

With the image encoder on the GPU, the 700 MiB reserve can leave ~200 MiB free on a 12 GB card (UD-IQ4_XS
end-to-end check on an RTX 5070). Setup prints a one-line recommendation; the written config is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJJwUv9g3cD2ztX6KWS8rk
For the pipelined verify windows of a layer split (next commit). Nothing calls
it yet, and nothing changes unless set_force_capture is set before load.

- chain_launch: the catch-up round and its steps launched back to back with no
  host wait, an event after each output (chain_outputs_ready reads them as they
  land, chain_poll once the chain is done). Its first steps can take given
  tokens as their input instead of their own picks (teacher forcing: the drafts
  of a window already in flight), through a one-thread kernel at the end of the
  round and step graphs that does nothing while its mapped token is -1; the
  outputs stay the chain's own picks.
- prepare_chain / prepare_prefill: capture every graph and size the prefill's
  input records up front, so neither a later chain nor prefill(sync = false)
  waits for the device.
- set_source_R: the rows a round reads, copied into the bound buffer first (the
  pipelined loop has one stage-1 verifier per window parity).
- with forcing on, the drafter's stream gets the highest priority (CUDA;
  STRATA_MTP_PRIORITY=0: the default).

draft() resets the forced tokens and copies the source rows the same way, so
the serial loop's drafts are unchanged.
With a split the two cards take turns on every verify window: stage 0 runs its
layers and hands off, then waits while stage 1 runs the rest, the head and the
draft. The batch pipeline (--batch-groups) overlaps different conversations;
one conversation's decode stayed serial.

--pipeline-windows 2 runs window K+1 on stage 0 while stage 1 verifies window
K. The guess for K+1 comes from the MTP chain, launched as soon as K's verdict
is known and teacher-forced through K+1's drafts: if K is accepted as guessed,
K+1 is already half done; if not, stage 0 restores its GDN state from a
snapshot, replays K's commit with the real count, and K+1 is rebuilt from the
real tokens. --pipeline-windows 1 overlaps only the prompt's short reads
through the windows (--short-read). Default 0: nothing changes.

What it adds:
- Verifier: a non-blocking form of run()/commit() (capture_all, pl_launch,
  prestage, service, done, pl_finish, pl_commit_async), the batch pipeline's
  batch_launch/batch_poll pattern for one sequence. The window, its staging,
  the PLE gather at layer 0, the zero-doorbell graph's flag, the Niko1221#267 release
  on a timeout and the commit graph are run()'s. Two verifiers per stage (one
  per window parity) share one stream of their card (set_stream) and each
  pair has its own hand-off.
- set_always_publish: the pipelined verifiers copy the token rows to the host
  at every layer, because the adaptive tier now marks experts evicted on the
  host while windows are in flight, before the device residency table follows.
- MtpDrafter: chain_launch (the round and its steps back to back, an event
  after each output, the first steps fed the in-flight window's drafts by a
  one-thread forcing kernel), prefill without a host sync, 8 rows with 2, the
  drafter's stream at the highest priority (CUDA only).
- the adaptive tier beside the pipeline: evictions are marked first, then the
  refill copies wait (on the tier's thread) for every window in flight; the
  RAM side of a swap (exchange, release, unpin) waits for the windows that may
  still pull the incoming experts over PCIe.
- an agreement gate: a speculative window is launched only when the chain's
  estimate says it is likely kept (STRATA_PIPELINE_THETA, default 0.2); a
  window known to be wrong gets empty plans for its remaining layers; a lookup
  window's continuation becomes the speculative window behind it.
- VRAM: 160 MiB more kept out of each card's cache for the second verifier,
  and the two GDN snapshots on the first card.
- off, with one log line saying why, for anything it does not support: --batch
  slots, --peer-device, the helper caches, three or more stages, a split on
  one GPU, no drafter. Requests with repetition penalties or coupled draft
  sampling decode serially. Nothing is in flight outside the two pipelined
  loops, so checkpoints, parking and the serial paths are unchanged.

Measured: Swift 1.5 IQ3_XXS at 160K (q4_0 KV, the stock draft layer), RTX 4060
Ti (layers 0-19) + RTX 5080 (20-47), i9-14900KF, 32 GB of RAM, the resident
RAM mode on the split (Niko1221#848); greedy, 500 tokens, two interleaved pairs of
three rounds, decode tok/s, serial -> pipelined: Python code 90.4 -> 103.1,
C code 76.6 -> 81.6, English prose 63.0 -> 71.1, Italian prose 41.6 -> 46.3,
a copy-heavy edit (a 5 KB file back with a rename) 90.5 -> 117.9; mean
72.4 -> 84.0 (+16%). With the experts read through the OS file cache
(--mmap-experts on that PC) the file reads dominate and it measured no faster.

Correctness: with STRATA_IQ_MT_MIN=1 --pcie-frac 0 --adapt-every 0 the
greedy text is identical to the serial loop's when both have the same expert
caches (4/4 prompts, 500 tokens; the serial run given the pipeline's reserve
through --vram-reserve-mib). The same text with STRATA_PIPELINE_THETA=2 (no
speculative window), STRATA_PIPELINE_FORCE_MISS=1 (every speculative window
wrong, rolled back) with and without DOOM_SKIP, PRESTAGE=0 and
SNAP_OVERLAP=0. Against a run without the flag a few experts move from a card
to the CPU (the pipeline's VRAM), which rounds them differently, so a near-tie
can flip. With the adaptive tier on: 22 varied requests at temperature 0.6
(thinking on and off, five languages, code), and 11 more with
STRATA_PIPELINE_FORCE_MISS=2, gave no degenerate answer, no stall.

The watchdog prints the pipelined loop's state when it fires;
STRATA_PIPELINE_TRACE=<file> writes a timeline.
In the resident RAM mode (--resident-experts) an adaptive round runs on a
thread the decode loop joins after the draft: choose, copy the evicted slots
back with a stream sync, copy the new experts in from the RAM copy (through
the driver's staging when it is pageable). With ~90 swaps that was ~24 ms
every 4th window (measured on our fork). --adapt-async 1 splits a round into
four steps that advance between windows on a helper thread, so no window
waits for one:

  1. choose (adapt()'s rule, over snapshots of the counts and the residency)
     and copy the evicted slots back (D2H), each from the cache of the card
     that owns its layer
  2. stage the exchanges, mark the evicted experts non-resident (the CPU reads
     them from the exchange buffers), copy the new experts in: straight from
     a page-locked RAM copy, else through a pinned bounce buffer
  3. mark the new experts resident, move the evicted blobs into the RAM places
     the new ones left
  4. flip the copy's offsets

A slot is overwritten only after its old expert stopped being planned for the
GPU, and a RAM place is reused only once its expert is resident. The residency
tables on the devices are uploaded at steps 2 and 3, as apply_pending does
(the verify graph's doorbell reads them). A request starts with the round
drained, before its prompt lends any slot. --expert-profile-save counts the
routing at the start of each round, as the blocking tier does. Swaps that
need no exchange (the evicted expert is in the RAM copy already, or the new
one is not: a RAM budget) take the same steps without a copy back; a new
expert outside the RAM copy is read from the files on the helper thread.

Default off: which window first computes a swapped-in expert on the GPU
depends on when its copy lands, so the tier is not bit-exact from run to run.
Without the flag nothing changes. With it, the blocking tier still runs
outside --serve, without the resident RAM copy, and with --batch slots,
--peer-device or --remote-expert-opt (said once at startup). The exchange
buffers are twice as many with it (the bounce half): 96 more blob-sized
pinned buffers, ~130 MiB with 1.38 MB blobs.

Layer split (with the resident mode on a split, Niko1221#848): each swap's copies run
on the owning card's refill stream under that card's device; the homes are
looked up the way Niko1221#848's resident_stage_swaps does.

Checked: builds (MSVC + CUDA 13.3, sm_89/sm_120).
TODO numbers: blocking vs async decode tok/s (5080 alone with
--resident-experts; 4060 Ti + 5080 split with Niko1221#848), a stress run with swaps
on, and --adapt-async 0 against main byte for byte.
With --pipeline-windows 2 the decode loop kept the blocking tier (adapt() on a
thread of its own, its copies fenced on both stages' streams) even with
--adapt-async 1, so the two flags did not add up. Now the asynchronous tier's
four steps advance there too, one tick per verdict. Windows are in flight at
every tick (window K+1 on the first card while the last verifies or commits K),
and every layer of a window is planned from the residency table as it is when
that layer is served, so two steps also wait for every window both stages'
streams hold when the step starts:

  - step 2's copies into the evicted slots: the fence is recorded once the
    evicted experts are non-resident, and the job waits for it on the host
    before it copies (a window planned before may still read the old expert);
  - step 3's RAM moves (a new RamFence state): the fence is recorded once the
    new experts are resident, and the moves start at the first tick that finds
    it passed (a window planned before may still pull an incoming expert over
    PCIe from the RAM place its evicted one takes).

The CPU's own reads need no fence (the pool runs inside the loop's service
calls), the copies back (step 1) only read, the flip (step 4) is on the loop's
thread, and the pipelined verifiers already publish their rows whatever the
device's residency table says. The fences reuse the events of the blocking
tier, which does not run beside it. At the end of the loop, once pl_drain has
emptied both stages, the fences come off: the round in flight moves on at the
next tick, the next request's drain. An error that ends the loop makes a job
waiting for a fence give up.

STRATA_PIPELINE_ADAPT_ASYNC=0 keeps the blocking tier beside the pipelined
windows (--adapt-async 1 is then off for the run, said once at startup).

Measured on a 4060 Ti (layers 0-19) + 5080 (20-47) split, i9-14900KF, 32 GB
of RAM, the resident RAM mode (Niko1221#848), Swift 1.5 IQ3_XXS at 160K, a fine-tuned
draft layer, --spec-min-p 0.65, --trim-stage-weights, Windows power
throttling off; greedy, five prompts x two rounds, decode tok/s:
- --pipeline-windows 2 with the blocking tier: 111.2, 111.4
- --adapt-async 1, serial loop: 117.3, 109.6
- both (this): 121.7, 126.3, 120.9 (one more run at 105.4 had a cold first
  prompt at 64 tok/s)
About +10% over the pipeline alone and +8% over the async tier alone.
Stress with both on: 22 varied requests at temperature 0.6 (thinking on/off,
five languages, code; 48,387 experts swapped in over 658 rounds) and 11 more
with STRATA_PIPELINE_FORCE_MISS=2 STRATA_PIPELINE_THETA=0 gave no degenerate
answer, no stall. The async tier is not bit-exact by design (Niko1221#876), so there
is no exact comparison here.
@CC-David-CC

Copy link
Copy Markdown
Contributor

absolute king
what a banger

@CC-David-CC

CC-David-CC commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

got any other ideas, king? (cant sleep. up pushing for the roofline https://www.youtube.com/watch?v=xG_Z90EAZZs )

…s gap

Reported with --pipeline-windows 2 --adapt-async 1 (2x RTX 3090, Linux): after
a few requests one stops mid-answer with "verify: timed out at layer 44" (stage
1), its GPU not finishing once released, and the next request hangs ~300 s.
--adapt-async 0 is clean.

In the pipelined loop the asynchronous tier's job thread queued its copies (the
copies back at step 1, the copies in at step 2, after a host-side wait for a
fence) on both cards at any moment, and almost always while that card ran a
window spinning on this process's doorbell flags: the copies in are released
just as the next window leaves stage 0 for stage 1. A copy queued to a card
whose window spins on a host flag can block inside the driver until the window
ends (issue Niko1221#31, where the host waited forever and the copies moved into a GPU
kernel); a thread blocked there can hold up the loop's own CUDA calls, and the
window waits for the loop. On an error, die() and the end of the loop then
waited for a job that could be stuck in that copy, and a job waiting on a
fence behind a window that never finished.

Now, beside the pipelined windows:
  - the job thread does host work alone (the choice, the bounce copies, the
    RAM moves); the loop's thread queues each card's copies itself, at that
    card's next gap - a moment its stage has no window in flight: before a
    window is launched there, at the verdict's tick (now after the drafter's
    chain is launched) and at a tick every 0.5 ms while a round is in flight;
  - a gap after a step is the step's fence for that card (every window planned
    there before it has completed): step 2's copies into the evicted slots go
    at a gap after step 2, and step 3's RAM moves wait for a gap after step 3
    on every card whose layers exchanged. No events, no host waits;
  - the device residency tables are not uploaded meanwhile (a synchronous
    pageable copy; the pipelined verifiers publish every row whatever they
    say): once, when the loop has drained;
  - once drained, what still waits for a gap goes and the round continues
    unfenced; die() and the loop's end wait only for host work;
  - --adapt-async 1 is off beside --pipeline-windows 2 when the exchange
    buffers are not page-locked (said once).
The serial loop is unchanged.

Checked: builds (MSVC + CUDA 13.3, sm_89/sm_120).
TODO numbers: the reported repro (sequential 1500-token requests, temperature
0.6) on the 4060 Ti + 5080 split, and decode tok/s against 887a711.
…TRATA_MTP_KV for the draft layer


The split-K decode attention's merge (attn_merge_kernel) walks every chunk of
the capacity, and loads each chunk's l and acc behind its "m == -FLT_MAX"
branch. The drafter's capacity is its window (--mtp-window 32768: 512
chunks), so at a short context its merge reads ~510 empty chunks per head,
a few dependent L2 round trips each, at every draft step.

attn_merge_v2_kernel computes the same thing in the same order:
- it stops at ceil(step[kStepWidth] / 64) chunks: the chunk kernel writes
  m = -FLT_MAX, l = 0 for every chunk past the width and the merge skips
  exactly those (fmaxf with -FLT_MAX never moves M);
- it loads the m, l and acc of 16 chunks at a time, then folds them in chunk
  order with the same fmaf's; the skip is a select on the loaded m, so the
  loads cannot sink back behind a branch.

Every output bit is the old kernel's. src/kernels/attn_merge_parity.cpp (a
ctest) runs one chunk pass over random pools and merges it with both kernels:
fp16, int8, q4_0 and k8v4 pools, unresident pages (whole chunks masked
inside the width), widths 0..32768 across the 16-chunk block edges and above
the cap, 1-6 queries, caps 2112 and 32768, three selection shapes. 768 cases,
all bit-identical, on an RTX 5080 and an RTX 4060 Ti.

Its --bench (one merge per call, partials hot in L2, cap 32768, 200 calls):
- RTX 5080, 1 query: 21-25 -> 6-8 us at widths 64-2051, 97-98 -> 21 us at 32768;
  7 queries at 32768: 207 -> 105 us.
- RTX 4060 Ti, 1 query: 20 -> 6-8 us at widths 64-2051, 80-85 -> 21 us at
  32768; 7 queries at 31000-32768: 279-301 us both (within 2%).

STRATA_ATTN_MERGE_V2 picks the kernel for every decode attention call
(qsa_decode_attn_step and _batch: the drafter and the main layers). Default
1 on NVIDIA; on AMD it stays 0 until the test has run there (the kernel is
plain code; the HIP builds compile it). STRATA_ATTN_MERGE_V2=0 is the old
merge.

STRATA_MTP_KV=q4|int8|f16 (opt-in) gives the draft layer its own K/V format;
unset, it follows the main layers' as before. Under --kv q4_0 the drafter's
4-bit K/V costs draft acceptance. Only the drafts can change, never the
verified output: every drafter path reads the drafter state's own format
(its K/V append and attention, the prompt path's draft-layer batch, the
conversation snapshot's format check). It costs one layer of K/V on the
drafter's card, fully resident at --max-context 163840: q4_0 90 MiB, int8
165 MiB, f16 320 MiB, taken before --expert-cache auto sizes the slots. The
engine logs the drafter's format and state size when it is set.

Checked: builds (MSVC + CUDA 13.3, sm_89/sm_120); attn_merge_parity passes
on both cards.
Measured with the next/previous commit (all of them on against all off),
4060 Ti + 5080 split, resident RAM mode (Niko1221#848), --pipeline-windows 2
--adapt-async 1 (Niko1221#905), --trim-stage-weights, Swift 1.5 IQ3_XXS at 160K
(q4_0 KV), greedy, five prompts x two rounds, decode tok/s: off
(STRATA_ATTN_MERGE_V2=0, nothing else set) 117.5 / 117.5, on
(STRATA_PL_PLE_PREFETCH=1 STRATA_PL_PLE_LATE=1 STRATA_PL_EARLY_CHAIN=1
STRATA_MTP_KV=f16, merge v2 by default) 132.1 / 130.6, +12%. Not split per
switch.
attn_merge_parity: 768 cases (fp16 / int8 / q4_0 / k8v4 KV), the merge v2
bitwise equal to the old merge on the RTX 5080 and the RTX 4060 Ti.
…he chain before the verdict's bookkeeping (opt-in)


Three host-side switches for --pipeline-windows 2, all off by default. They
change only the pipelined loop and the pipelined verifier methods; run(), the
batch windows and the serial loop are as they were.

STRATA_PL_PLE_PREFETCH=1 (Direct PLE reads): the rows a stage-0 window will be
staged on are read into the reader's row cache as soon as each token is known,
instead of at the window's staging right before its launch:
- off the path, the next window's row 0 (the bonus token) at the verdict;
- each chain output as it lands (the fresh window's drafts, and past a forced
  window's own drafts the next window's guess);
- a lookup window B's tokens, once A has been launched.
PleReader::prefetch reads rows with no ticket, behind every ticket's reads;
an issue() that asks for a row whose read-ahead is still in flight attaches to
that read instead of reading the page again. Until the first prefetch the
reader behaves exactly as before. The bytes are the table's, so a wrong guess
costs SSD reads only. PleTable::prefetch_tokens hashes the rows the way a
window's staging does.

STRATA_PL_PLE_LATE=1: the stage-0 pipelined verifiers read a window's rows
through a handle of its own (PleTable::gather_issue_t / gather_ready /
gather_collect_t / gather_drop) instead of the table's shared prefetch slots,
where a window's gather at layer 0 could also wait for the reads of the other
window staged behind it. Verifier::service never blocks on them: when layer 0
has rung before they have landed, its CPU experts are served and its flag is
held until they have, while the same thread serves the other windows and the
other stage. The rows already gate layer 0's flag (layer 1 copies them after
it), so the captured graph is unchanged. A window staged ahead and never
launched drops its handle; the handle's buffer is reused once its reads have
landed.

STRATA_PL_EARLY_CHAIN=1: a verdict launches the next MTP chain before it
prints and flushes the tokens, feeds the suffix drafter, scores the gate's
calibration and the policy and ticks the adaptive tier. The chain needs none
of them, and the tier's uploads on the legacy stream do not wait for the
drafter's non-blocking stream. The end of the request is decided the same
way before the printing; a stop request that arrives in between ends the
request at the next verdict.

With STRATA_DECODE_TIMING and one of them on, the request's decode lines get
one more, "strata pipeline ple: ...": the rows read ahead, the share of the
windows' rows found in the row cache or attached to a read-ahead, the
tickets, the windows whose layer 0 waited for its rows, the early chains.

On our fork (the same switches on its own pipeline, plus STRATA_ATTN_MERGE_V2
and STRATA_MTP_KV=f16), 4060 Ti + 5080 split, Swift 1.5 IQ3_XXS at 160K: decode
131.4 / 129.3 tok/s against 125.4 / 124.2 without them.

Checked: builds (MSVC + CUDA 13.3, sm_89/sm_120); ple_reader_test --selftest
passes, with new cases for the read-ahead (tickets attached to read-aheads in
flight, ready() polled, 1/8/64 reads in flight, row cache on and off, worker
thread and caller thread).
Measured with the next/previous commit (all of them on against all off),
4060 Ti + 5080 split, resident RAM mode (Niko1221#848), --pipeline-windows 2
--adapt-async 1 (Niko1221#905), --trim-stage-weights, Swift 1.5 IQ3_XXS at 160K
(q4_0 KV), greedy, five prompts x two rounds, decode tok/s: off
(STRATA_ATTN_MERGE_V2=0, nothing else set) 117.5 / 117.5, on
(STRATA_PL_PLE_PREFETCH=1 STRATA_PL_PLE_LATE=1 STRATA_PL_EARLY_CHAIN=1
STRATA_MTP_KV=f16, merge v2 by default) 132.1 / 130.6, +12%. Not split per
switch.
The three pipelined switches give the same greedy text on as off (4/4
prompts, 500 tokens, STRATA_IQ_MT_MIN=1 --pcie-frac 0 --adapt-every 0, the
blocking tier). Stress with every switch on and
STRATA_PIPELINE_FORCE_MISS=2: 11 varied requests, no degenerate answer.
imanu86 added a commit to imanu86/Strata-2080Ti that referenced this pull request Oct 5, 2026
…rghezza

Porta Hardin22/Strata-DualGPU b1f03d3 (PR upstream Niko1221#910): attn_merge_v2_kernel
ferma il merge dell'attenzione del decode alla larghezza della selezione e
carica i parziali a blocchi di 16, con le stesse fmaf nello stesso ordine
(STRATA_ATTN_MERGE_V2, attivo di default su NVIDIA); STRATA_MTP_KV opt-in per
il formato K/V del drafter, spento lascia tutto com'era. Verifica sul 2080 Ti:
attn_merge_parity 768/768 casi identici bit per bit (merge del drafter a 131k
da 224 a 50 us per passo); teacher forcing a posizionamento fisso identico bit
per bit su code ed explain; a 131k la bozza scende di ~0,3 ms per finestra,
velocita' totale nel rumore (L01 48,5/46,7 contro 49,9 t/s).

Co-Authored-By: Hardin22 <Hardin22@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@justxiami

Copy link
Copy Markdown

sm_70 data on the merge commit alone (b1f03d333): bitwise confirmed, kernel faster, end to end flat to slightly worse on 4x V100.

We cherry-picked only that commit onto v0.1.39 (5 files, clean, no rejects) and built it for CMAKE_CUDA_ARCHITECTURES=70, CUDA 12.8. Rig: 4x Tesla V100-SXM2-16GB (sm_70), --layer-split 12,24,36 --split-device 1,2,3 = 4 stages, IQ3_XXS native pack, --kv int8, --spec 3 --spec-min-p 0.35.

The bitwise claim holds on Volta. attn_merge_parity on a V100:

attn_merge_parity on device 0 (Tesla V100-SXM2-16GB, 7.0)
[fp16] 192 cases, bit-identical (0 differ)   [int8] 192 (0)   [q4_0] 192 (0)   [k8v4] 192 (0)
attn_merge_parity: 768 cases, 0 differ, 15225012 nonzero output values in all -> PASS

The kernel is faster here too (--bench, 200 calls, CUDA events, cap 32768):

fmt n_q width merge, old merge, v2 saved
fp16 1 32768 217.8 us 51.7 us 166.0 us
fp16 3 32768 222.1 us 65.6 us 156.5 us
fp16 7 32768 276.4 us 121.3 us 155.1 us
fp16 3 2051 33.1 us 5.3 us 27.9 us
q4_0 1 64 26.9 us 2.8 us 24.1 us

End to end we cannot see it. Same binary, only the environment differs (STRATA_ATTN_MERGE_V2 1 vs 0), each arm with an engine restart, five fixed prompts at temperature 0 that produce identical window counts in both arms, plus a 32K / 120K / 65K / 8K decode ladder with 3 reps each:

prompt case windows verify ms/window V2=0 V2=1 delta
table 53 23.51 24.42 +0.91
json 62 23.14 24.09 +0.95
zh prose 75 22.09 22.93 +0.84
code 74 22.59 22.84 +0.25
tool JSON 37 22.39 22.40 +0.01
mean +0.59 ms = +2.6%

Decode ladder, per-rep pairing (same rung, same rep): +3.20 / +6.50 / -0.10 (8K), +0.80 / +0.10 / +3.40 (32K), -1.20 / -2.30 / -0.90 (65K), -1.40 / -0.10 / -3.60 (120K) tok/s - 5 faster, 7 slower, pooled +0.42%, which is inside our run-to-run spread. Shape: short prompts slightly positive, long prompts slightly negative - the opposite of the bench table, where wider selections save more.

Two questions:

  1. Does the merge win depend on something sm_70 does not have (async copy, L2 residency behaviour, occupancy at HD)? On our rig a window is 22-27 ms with 3-4 rows, and 156 us per call is under 1% of it if the merge runs only a few times per window. How many merge calls does one verify window make per stage on a 48-layer model? If it is once per stage, the arithmetic explains our result.
  2. Is the steps read (__ldg(steps + blockIdx.y * kStepCount + kStepWidth)) meant to keep the width on the same card, and does it hold when the chunk pass ran on a different stage? Our split hands off between cards each window.

We did not promote it; our production build is unchanged. We can re-run with more reps, at 2 stages, or with --spec 4 (more rows per window, where the bench says the saving grows) if that would help you decide.

The bounded merge gives the same bits as the old one (attn_merge_parity), but
its end-to-end effect was never measured apart from the other switches of this
PR: the +12% in Niko1221#910 is the bundle. On 4x V100 (4 stages) a reviewer measured
the merge alone flat to slightly slower end to end, though the kernel is faster
in the bench. The bench shape (cap 32768) is the drafter's; the main QSA
layers run at cap 2051, where the bound skips little. So it is off by default
until it is measured alone; STRATA_ATTN_MERGE_V2=1 turns it on.
@Hardin22 Hardin22 changed the title Two-GPU decode: bounded attention merge (bitwise, default on) and pipelined PLE read-ahead / early chain (stacked on #905) Two-GPU decode: bounded attention merge (bitwise, opt-in) and pipelined PLE read-ahead / early chain (stacked on #905) Oct 5, 2026
@Hardin22

Hardin22 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the careful numbers. Here's what the code says.

Where v2 runs. It runs in every decode attention call:

  • the main QSA layers in verify (verify.cpp:883);
  • the drafter (mtp.cpp:594);
  • plain decode and the prompt path (layer.cpp:980, prefill.cpp:2226).

Calls per window: three per stage.

  • The QSA layers are 3, 7, …, 47 (layout.hpp:63), so 12,24,36 puts three on each card.
  • Each does one merge launch over all T rows (--spec-split is opt-in, verify.cpp:566): 12 per window.
  • They run at cap 2051, i.e. 33 chunks (verify.cpp:355). Once the context passes 2051 tokens the width equals the cap, and only the load batching changes.
  • The 512-chunk shape your bench timed (attn_merge_parity is fixed at cap 32768, :254) is the drafter's. That is 1-2 calls per window at --spec 3, on the last card only, and it is timed as draft, not verify.

So your arithmetic holds: on the main layers the merge can't move a ~23 ms window much either way.

Why slower? I can't find added work.

  • Same single launch, same grid and block (qsa_decode_attn.cu:516-523). No pre-pass, no sync, no new graph node.
  • The only differences are one dependent 4-byte width load per block, and more registers for the 16-chunk staging.
  • Nothing needs sm_80.

+0.59 ms would need ~50 µs more on each of 12 small merges, so I suspect noise, but I haven't measured sm_70.

steps. It is always read on the same card. The merge reads the step record the chunk kernel just read, in the same call and on the same stream (:602-603), so a layer's two passes never span cards. Each stage computes the width from its own pos0 (verify.cpp:1346, :1560) into its own device buffer; only the residual crosses cards.

Default. You're right to question it. Our +12% was the bundle (merge v2 plus STRATA_MTP_KV=f16 and the three pipeline switches), never isolated, and our fork had v2 off. So it's now opt-in (9d27f7e, STRATA_ATTN_MERGE_V2=1). I'll post an isolated A/B here: same binary, interleaved, with the draft column from STRATA_DECODE_TIMING=1, which is where the bound should actually skip work. If it only pays in the drafter, I'll limit it to that call.

@Hardin22

Hardin22 commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

The isolated A/B I promised: same binary (the whole stack, pipelined plus async), STRATA_ATTN_MERGE_V2 0 vs 1, interleaved, five prompts × two rounds, 4060 Ti + 5080, Swift 1.5 IQ3_XXS at 160K. Decode tok/s:

pair 1 pair 2
v2 off 116.7 133.6
v2 on 129.5 126.0

The two pairs point opposite ways. The mean is +2% for v2, inside our run-to-run spread, so like your V100 result it's flat end to end. It stays opt-in. If I find a case where the drafter's wide merge shows up in the draft time, I'll come back with that alone.

imanu86 added a commit to imanu86/Strata-2080Ti that referenced this pull request Oct 5, 2026
Sorgente congelata 14433b0 (r2: Niko1221#904, Encapsulate, Niko1221#910, Q2_0 esatto, Niko1221#930, Niko1221#949, Niko1221#965), eseguibile shipping b0edf8455031; manifest 0.1.39 conservato come -0139, manifest del 5/10 storici, check_source legge prima source-manifest.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Niko1221 Niko1221 closed this Oct 6, 2026
@Niko1221

Niko1221 commented Oct 6, 2026

Copy link
Copy Markdown
Owner

Heads-up: today we cleaned up this repository's commit history, and GitHub closed this PR automatically when main was force-pushed (that is the "base branch was force-pushed" event above). It is not a rejection.

GitHub cannot reopen it, because its branch is built on the old history. If you want to keep it going: start a new branch from the new main, cherry-pick your own commits onto it (git fetch upstream && git switch -c my-fix upstream/main && git cherry-pick <your commits>), and open a new PR. Or simply leave it: when we take your change for a release we port it onto the new main ourselves and keep you as its author.

Sorry for the noise, and thank you for the contribution.

imanu86 added a commit to imanu86/Strata-2080Ti that referenced this pull request Oct 6, 2026
…na anticipata opzionale e latenza della catena nel log

Con STRATA_PIPELINE_ELASTIC=1 il prompt prende in prestito anche il core (nessuna finestra in volo durante la
lettura): con la coda da 1,5 GiB il prompt da 131k era passato da ~85 a 134 s. STRATA_PL_EARLY_CHAIN=1 (da
upstream Niko1221#910, Hardin22 730d6c8, opt-in): la catena MTP parte al verdetto prima di stampa, suffisso,
calibrazione e tier. Il riepilogo della pipeline scrive la latenza media dal lancio della catena al B pronto,
forzata e fresca, per misurare le attese dello stadio 0 ("chain late").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.