Skip to content

Doubled throughput - #245

Closed
eddoursul wants to merge 94 commits into
Niko1221:mainfrom
eddoursul:custom
Closed

eddoursul wants to merge 94 commits into
Niko1221:mainfrom
eddoursul:custom

Conversation

@eddoursul

@eddoursul eddoursul commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

After a week of profiling and benchmarking I doubled throughput on 3090 + 5070 Ti.

See the comparison tables.

Use JSON configurations to run https://github.com/eddoursul/Strata/blob/custom/examples/README.md

A request continues from the longest prefix of its prompt the engine still
holds: the live sequence, checkpoints of the fixed-size sequence state (GDN
recurrence and conv history, QSA indexer tail, PLE history) in host memory,
or a conversation another request replaced, stashed in RAM with its K/V
cells. Checkpoints are taken before a prompt's last <|im_start|>, at the
prompt's end and every --cache-every cells of a long prompt.

Short prompt remainders go through verify windows instead of the batched
prompt path. The decode loop no longer verifies past the output limit or a
drafted end-of-turn token, so the live state ends where the emitted text does.

The server reports reused tokens (OpenAI prompt_tokens_details.cached_tokens,
Anthropic cache_read_input_tokens) and drains the engine to DONE inside the
request lock.

New engine options: --prompt-cache N, --cache-ram MIB, --cache-every N,
--feed-max N.
The engine now runs unsloth's Qwen3.8-Flash-Next quants (UD-IQ1_S up to
UD-Q6_K_XL and Q8_0) as native packs, besides the ISTA-DASLab files.

- Split models: --native takes the first shard and finds the others by
  name (gguf_split_paths, GgufModel). The expert arena is filled from the
  shards by tensor name and checked against native_experts.txt, whose
  lines no longer carry shard-1 offsets. The native head and embedding are
  found in any shard, and --ple-gguf defaults to the shard that holds
  per_layer_token_embd.
- GPU kernels: expert dot products and dequantizers for Q4_K, Q5_K, Q6_K,
  Q5_1, Q8_0, IQ1_S and IQ4_XS (llama.cpp's), one dispatch list for all
  formats, and a startup check that every layer's formats have kernels.
  The Q5_1 min and IQ1_S delta terms use the quantized activation sums, as
  ggml-cpu does: 1.1% instead of 1.9% relative error per expert.
- PLE: the table may be IQ4_NL or Q8_0 and may share its shard with other
  tensors; the native PLE key may be any native MMVQ type (Q8_0 here).
- tools/iq_pack.py: all shards of a split model, and every float tensor in
  the form the engine takes. The files' F32 router, SSM gate and injection
  tensors hold BF16 values (exact); their Q8_0 hyper-connection and PLE
  value weights are re-rounded to BF16.
- native_expert_parity: split models, a --synthetic mode for formats with
  no model file at hand, and a check of the GPU dequantizers.
- README: the unsloth files, their RAM needs and how to pack them.

UD-Q4_K_XL on an RTX 3090 (160 GB RAM): prompts 529-594 tok/s, generation
49-57 tok/s. Greedy tokens equal llama.cpp's argmax on the same GGUF at
98.2% (code) and 95.6% (thinking) of positions.
A batched prompt call costs a fixed 4.7-6.6 s on an RTX 3090 behind a PCIe
4.0 x4 link: it lends and refills expert-cache slots and streams every
expert its tokens use, however few tokens it holds. Verify windows cost
~15 ms (IQ3_XXS) and ~21 ms (UD-Q4_K_XL) per token, so the two break even
at ~480 and ~530 tokens.

- --feed-max defaults to 512 (was 256).
- A last prompt chunk of up to --feed-max tokens goes through verify
  windows as well. A prompt just past a multiple of --prefill used to run
  its last tokens as a chunk of their own (3.6 s on UD-Q4_K_XL).
- Chunks and checkpoint segments count from the request's first new
  token, so a follow-up that starts inside a segment no longer gets a
  short first chunk. --cache-every is rounded down to whole --prefill
  chunks.
- Image requests always take the batched path, the only one that reads
  image rows. A request of up to --feed-max tokens with an image used to
  lose the image.

Prompt speed with the prompt cache off, 16K-131K tokens: IQ3_XXS 703-766
tok/s (was 674-747), UD-Q4_K_XL 570-603 tok/s (was 529-594).
- MSVC: _CRT_SECURE_NO_WARNINGS (fopen, sscanf and getenv are standard C),
  /wd4211 for the host stubs nvcc generates for kernels with internal
  linkage, and C4324 off around ExpertPool, whose alignas padding is the
  point.
- Unused locals, device functions, a kernel and a constant removed;
  shadowed locals renamed; the router clamp bound is an if constexpr.
- qsa_layer's prologue, a single 917-character line, split into lines.
The draft head copies rows of the model's output head, and the copy took
rows in 16-byte units only.  Q6_K rows of 2,560 values are 2,100 bytes, so
MTP exited at startup with the Q6_K heads of unsloth's UD-IQ3_XXS, UD-IQ4_XS
and UD-Q3_K_XL.  Rows that are a multiple of 4 bytes are now copied in
4-byte units.
With --spec-follow, a generate run emits a given continuation instead of
the window's argmax and accepts the drafts that match it.  Runs with
different --spec, --spec-min-p or draft-layer settings then process the
same tokens and route the same experts, so their speeds compare within
~1% instead of the ~4% two identical free runs differ by once their text
diverges at a near tie.  The usage text also lists the MTP flags.
…n --stats

- The serve loop and the speculative loop each carried the same adaptive
  expert tier (ranking, refill copies, admission once landed); it is now
  strata::core::AdaptiveTier.
- --stats prints the share of the routed entries the N most-routed experts
  of the run take, which sizes what a larger expert cache could serve.
The GPU that holds the expert cache is one per-layer bottleneck and the
CPU pool, which computes the missed experts, the other.  With --second-gpu
N, device N holds more experts (the profile's pairs the first cache lacks,
then empty slots its own adaptive tier fills with the conversation's) and
computes its share of each verify-window layer while the CPU pool computes
the rest.  It never spins on host memory: per layer the host launches one
captured graph (the plan copied in by a kernel, the activations read from
mapped memory, the grouped expert kernels of the first GPU's hit path, the
rows written to mapped memory) before the pool and collects the rows after
it, so a GPU that also drives the display keeps its latency.  A layer whose
misses are below --second-gpu-min-mb (4) stays on the CPU, which is then
quicker than the ~100 us round trip.

UD-Q4_K_XL, RTX 3090 + RTX 5070 Ti (12.6 GiB of experts): generation +9%
on a 3000-token thinking answer, +14-19% on code; IQ3_XXS, whose rounds
wait on the first GPU, gains nothing.

The expert arena is now one cudaHostAlloc block per layer instead of a
registered reservation.  With a second CUDA context in the process,
Windows pins at most ~42 GiB through cudaHostRegister, and past that every
CUDA allocation fails; allocated blocks reach ~75 GiB with one GPU or two.
The layers past the budget share one locked, pageable range as before.
…refetched and double-buffered down tiles, two up rows per warp (bitwise the single-token read)
…shes

RoPE (native_rope_apply_tokens), the int8 K/V append (kv_append_q8_steps),
the indexer append (native_qsa_indexer_append_multi), the q/gate split, the
q/k norms and the gate now run once per window instead of once per token.
Each token's arithmetic is unchanged: verify_parity checks the new kernels
bitwise against the per-token ones, and --window-hashes writes a hash of
each window's final residual rows so two builds can be compared end to end.
…nk; --prefill-profile

The PLE block ran once per prompt token (a 52 MB key GEMV each); its key and
value projections are now GEMMs over the chunk, followed by the decode path's
per-token arithmetic in one pass (native_ple_postops_tokens, bitwise n_tok
native_ple_postops + ple_history_advance calls; verify_parity checks it).
The indexer appends of a chunk are one native_qsa_indexer_append_multi launch.
16K-token prompt, IQ3_XXS: 22.8 -> 18.3 s.

--prefill-profile prints the GPU time of each section of the prompt path;
--window-hashes lines carry the window's first position.
…tion in one pass per query

qsa_block_scores launched a warp for every block of the full context
(max_cells / 4, 50K blocks at 200K) for every query, nearly all of them
idle.  Its warps now go round over the blocks: the verify window and the
token path use a fixed grid of 1024 per query, the prompt path the chunk's
largest n_bid + 1.  Each score is computed as before (bitwise).

qsa_prefill_attn: the prompt path's attention, a block per (KV head, query)
walking the query's selected cells in tiles of 64 with an online softmax,
instead of the decode kernel's split-K partial sums through memory.  FP32;
int8 codes are converted without I2F.  verify_parity checks it against the
decode kernel (relative difference 3.9e-6).

16K-token prompt: IQ3_XXS 18.3 -> 15.9 s, UD-Q4_K_XL 24.8 -> 22.4 s.
…rrence

The conv + L2 norm walked the chunk one token at a time per channel (320
warps); the prompt path now uses the verify window's gdn_conv_l2_multi over
all T tokens plus a small history update.  The recurrence keeps the decode
kernels' arithmetic (four 32-row partial sums per column, added in order)
but puts a column's row groups in adjacent lanes, so the sums are shuffles;
each thread holds two columns, a head is split over four blocks, and each
token's inputs arrive through a cp.async ring 7 tokens ahead: one barrier per
token.  The output norm is a separate pass.  verify_parity checks the prompt
path's GDN bitwise against the verify window's kernels (1-37 tokens).

16K-token prompt: recurrence 1.36 -> 0.46 s; IQ3_XXS 15.9 -> 15.2 s,
UD-Q4_K_XL 22.4 -> 21.7 s.
… GPU

A prompt chunk uses nearly every expert, and the 3090 streamed all it did
not hold over its PCIe 4.0 x4 link.  With --second-gpu the prompt path now
hands them to the second GPU (x16): per layer the 3090 copies the chunk's
MoE input (FP16) to pinned host memory while its router and shared expert
run; the second GPU computes its experts from its own cache or streamed
from the pinned arena, and returns each token's weighted sum (FP32), which
the 3090's combine adds to its cached experts' rows (prefill/offload.cpp).

Experts go through in batches of consecutive ids (up to 16 experts and 4096
rows): one staging copy per run of adjacent arena blobs, one dequant launch
(iq_dequant_experts_f16), one gather, per-expert GEMMs (cuBLAS does not group
FP16 -> FP32), one SwiGLU and one ordered per-token add (moe_gather_add,
the same sums as one moe_scatter_add per expert).  --no-prompt-offload
streams them to the main GPU as before.

16K-token prompt, UD-Q4_K_XL: 21.6 -> 11.9 s.
…ots too; one SlotLoan for both GPUs and both modes
… a layer's weights dequantized once; the routed experts of both GPUs through one batched runner; buffers sized to each prompt (16K: 1.4 GiB of cache slots lent instead of 10.9)
… steps run; per-expert GEMMs over side streams; the second GPU's sums return as FP16; pinned uploads
…verify windows at ~185 (IQ3_XXS) and ~45 tokens (UD-Q4_K_XL)
… RAM is idle (--second-gpu-prefetch 4), its rows out in full PCIe transactions, the second tier's moves paced behind the prefetch

Each layer's router also runs on the previous layer's FFN input on a side branch of the verify window's graph; once
the CPU's rows of a layer are in, the host copies the next layer's likeliest experts that no GPU holds into prefetch
slots on the second GPU (~0.3 ms of idle RAM per layer), and that layer's share takes them from there. The second
GPU's down kernel wrote its rows one float at a time into mapped host memory: 0.25 ms a layer, and the CPU pool read
RAM at 38 instead of 48 GB/s. UD-Q4_K_XL code, fixed text: 71.6 -> 77.4 t/s (rows) -> 86.1 (prefetch 4); thinking
70.2 -> 73.6.
…arted on the CPU's (its launch took the host ~15 us before them); UD-Q4_K_XL code +0.5%
…nched and waited for alone, 0.67 ms a round); UD-Q4_K_XL code +0.9%
… (28.8 GB in ~7 s), direct reads until then; a 16K-token prompt of new text 0.5 s faster
… idle RAM window holds 4 UD-Q4_K_XL or ~7 IQ3_XXS experts (IQ3_XXS code 98.9 -> 108.3 t/s with the second GPU)
…lone (it failed to start); its budget always fills, the rest as empty slots (without the adaptive tier IQ3_XXS had nothing there and lost the prompt offload)
…rupt core, copies in batches

Windows sends each GPU's interrupts to one logical processor (here the 5070 Ti's to the first, the 3090's to
the second, one MSI each), and the host thread ran on the first: generating IQ3_XXS thinking text the 5070 Ti
raised ~12,000 interrupts a second, each ~13 us in dxgkrnl's ISR and ~3 us in DPCs, a fifth of that processor.
A copy raises one when it lands, so the prefetch's copies, one per expert, raised most of them.  The host saw
a layer's ring up to 30-110 us late (p99), its call submitting the 5070 Ti's share took 18-24 us (14-17 on
another core), and in the pool's gate/up phase it finished last in 42% of the layers with a share.

- The host thread on the last physical core, the pool's workers on the others; with a second GPU the first
  core stays with the OS and its interrupts (6 workers here, from 7).  core_plan() decides; the startup log
  says where the threads run.
- The 5070 Ti's prefetch copies and each adaptive-tier call's copies in one cudaMemcpyBatchAsync (copy_blobs):
  the first processor's interrupt time 10% -> 4% on IQ3_XXS thinking text.

Window hashes identical to 7105fcc's (--adapt-every 0, both models); pool_test --stress passes.  Fixed text,
4 runs each in both orders, t/s: both GPUs IQ3_XXS code +1.2%, thinking +0.5%, after 16K +1.3%, edit -0.7%;
UD-Q4_K_XL +5.9 / +0.7 / +1.7 / -0.1%; the 3090 alone IQ3_XXS code +4.9%, thinking +0.9%, UD-Q4_K_XL +3.2 /
+2.1%.  16K prompt times unchanged (IQ3_XXS 8.11 -> 7.93 s, UD-Q4_K_XL 8.53 -> 8.48 s).

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
Windows moves device interrupts between processors as the load shifts (the power plan's interrupt steering,
"Default" here): the GPUs' went to logical processors 0 and 1 one morning and to 1 and 2 that night, beside a
network card's ~26,000 a second on 0, so the 3090's landed on a pool worker (up to ~45% of its processor in
ISRs and DPCs).  Moving that worker off its processor mid-run left the interrupts where they were (two traced
runs: to its core's other processor, and to another core).

So CorePlacement starts the threads where 5137b33 put them and, once a second between windows, reads each
processor's interrupt and DPC time: a thread whose processor spent over 5% there two seconds in a row moves
to a quiet processor, a free core's, else its own core's other one.  Each move is logged; --stats counts them.

Window hashes identical to 5137b33's (--adapt-every 0, both models); pool_test --stress passes.  Fixed text,
2 runs each in both orders against 5137b33: no thread had to move (the interrupts stayed on the first core),
speeds within the runs' spread (IQ3_XXS +0.2 / +0.2 / +0.2% on code / thinking / edit, UD-Q4_K_XL +7.8 /
-0.4 / -0.4%).  With the threshold lowered for a test, a worker on the first core moved to its other
processor on the 3090 alone.

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
…server fixes

Upstream's 236 commits since 6da1f66, with this branch's engine kept wherever both sides do the same
thing (the prompt cache, prompt lookup, the verify window's batched kernels, the second GPU tier, the
prompt path, the CPU pool's phase protocol).

Taken:
- serve/: upstream's server (web app with chat, monitor and MCP tools, Claude Code's /v1/messages?beta=true,
  llama.cpp's discovery endpoints and timings, --fit-max-tokens, LAN access with an API key, an engine that
  dies is restarted by the next request); the wait loop sleeps so Ctrl+C stops it on Windows.
- Sampling: temperature, top_p, top_k, min_p, seed and the penalties per request, on the GEN line.  The
  verify window samples its head after the graph (Philox(seed, position)), with one penalty-history row per
  window row; windows that read a prompt skip it.  spec_min_p sets a request's draft floor.
- The serve protocol: INFO before READY, PP lines per prompt chunk, DONE with the drafts accepted and
  offered, the reused tokens and the expert-cache hits of either GPU.
- CPU experts: the AVX-2 multi-token i-quant kernels for CPUs without AVX-512, IQ4_NL down rows of 2+
  tokens decoded once for all of them (STRATA_NO_IQ4NL), the i-quant rows prefetched 2 KB ahead
  (STRATA_IQ_PREFETCH).
- --prefill auto (setup writes it): 16384-token chunks, halved until their buffers fit the lendable slots.
- setup.py and its tools, tools/needle_bench.py, draft_vocab.py, make_profile.py, calibrate.py; docs.

Kept from this branch: data/expert-profile.bin (the 8,000-slot cap; upstream's full ranking would give
IQ3_XXS's 3090 cache ~1.3 GiB more, measured no faster).  Not taken: upstream's conversation cache and
prompt lookup, the helper-GPU caches and layer split, KV streaming and the q4/k8v4 KV, control vectors,
the low-RAM mode, pruned expert counts, the HIP and Turing builds (their files are in the tree, not built).

Window hashes on fixed text (--adapt-every 0) equal the previous build's on both models (IQ3_XXS with
STRATA_NO_IQ4NL=1: the IQ4_NL kernel sums in another order).

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
data/expert-profile.bin is upstream's ranking of all 24,576 (layer, expert) pairs (it ranked 8,000).  The 3090's
cache takes the pairs it fits, the 5070 Ti's tier the next ones: with 8,000 pairs that tier started with 523 of IQ3_S's
6,619 slots filled (UD-Q4_K_XL: 3,463 of ~4,300) and its adaptive tier filled the rest over thousands of tokens.

Fixed text, both GPUs, 4 runs each in both orders, t/s against the 8,000-pair profile:
  IQ3_S       code 173.5 (+15.8%), thinking 130.2 (+4.6%), after 16K 138.6 (+6.3%), edit 236.4 (+9.6%)
  UD-Q4_K_XL  code 115.4 (+4.8%), thinking 102.3 (-1.1%), after 16K 119.8 (-0.2%), edit 161.8 (-1.1%)

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
The verify window's head ended with sampler_greedy_kernel, one 1024-thread block a token over the 248,320 logits,
and each draft pass with it and row_top_prob over the 40,525-token draft head, one block each.  argmax_rows scans a
row in up to 128 blocks (4,096 logits each), the row's last block merging their picks (the larger value, on equality
the lower index: the one-block kernel's order); row_top_prob_split computes row_top_prob's 32 warps' sums in 8 blocks,
the row's last block adding them in order.  Both keep their counters and partials in per-row slots of one scratch,
whatever a launch's row count.  Bitwise: verify_parity (ties, NaN, -inf, 1-8 rows on one scratch), and the window
hashes on fixed text equal the previous build's (IQ3_S, UD-Q4_K_XL; thinking and code text).

CUPTI, IQ3_S thinking text: the head's argmax 84-86 -> 9.7 us a window, a draft pass's argmax and top probability
15.1 + 10.1 -> 5.6 + 4.3 us; ~105 us a round (~0.7%).

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
MtpDrafter::capture_round ran the draft layer's front (the embedding, the fc projections, the attention
hyper-connection read, the K/V projections and appends) over the window's T cells, then the whole layer again for row
a, repeating row a's front.  record_forward is now record_front (T rows) and record_rest (one row: the attention from
the q projection, the MLP, the final mixer, the head); the round graph runs the front once, copies row a's residual,
injection and mixed input to row 0 (copy_row_to_first, the row read on the device) and runs the rest there.  The
multi-row front is per row the one-row front's arithmetic: the window hashes on fixed text equal the previous build's
(IQ3_S, UD-Q4_K_XL; thinking and code text).

CUPTI, IQ3_S thinking text (--adapt-every 0): the round graph 721 / 747 / 789 / 784 / 898 -> 655 / 662 / 689 / 692 /
812 us after windows of 1 / 2 / 3 / 4 / 8 tokens.

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
A one-token window always keeps its token, so its graph now advances the sequence state itself: the GDN step kernel
writes the state (n_keep = 1, the arithmetic the commit's replay used), the conv kernel writes the history it read
(gdn_conv_l2_multi's commit flag), and its indexer append and PLE history were final already.  Verifier::commit
launches no commit graph after it: the draft round graph then runs alone instead of beside the commit's 98 kernels,
which read and write each GDN layer's 3 MB state.  Window hashes on fixed text equal the previous build's (IQ3_S,
UD-Q4_K_XL; thinking and code text); cache_test.py's regenerated turn and stash round trip reproduce their output.

CUPTI, IQ3_S thinking text (--adapt-every 0), the 35% of rounds after one-token windows: the window 11,270 -> 11,380
us, the time from its end to the next window's start 3,706 -> 3,325 us (the round graph 655 -> 450 us, the draft
starting 150 us sooner): -270 us a round there, ~-95 us over all rounds.

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
- quantize_q8_0_scaled runs a warp a 32-value block (it ran a thread a block: 80 threads for the draft layer's input):
  the block's |max| over the same values, then each value's bytes as before.  6.2 -> 2.4 us and 9.2 -> 1.6 us for the
  Q2_0 draft experts' two quantizations (CUPTI).
- The draft graphs' inputs from mapped memory in one copy kernel (copy_from_mapped_multi): 7 launches in the round
  graph, 2 in a step, became one each.
- The router in one kernel (verify_router: the logits, the top 10, and the native draft experts' q8_1 rows), bitwise
  the gemv, top 10 and quantizer it replaces; as fast as the gemv and top 10 were.
- The shared expert on a branch of the draft graphs beside the router and the routed experts, joined before the combine.

Window hashes on fixed text equal the previous build's (IQ3_S, UD-Q4_K_XL; thinking and code text; IQ3_S with the
IQ4_NL draft experts too).  CUPTI, IQ3_S thinking text (--adapt-every 0): the round graph 450 / 661 / 687 / 693 / 824 ->
411 / 592 / 620 / 627 / 760 us after windows of 1 / 2 / 3 / 4 / 8 tokens, each step 435 -> 405-410 us.

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
After the last layer the head streamed the whole output matrix on the 3090 (IQ3_S's Q6_K 521 MB, UD-Q4_K_XL's Q8_0
675 MB).  With --second-gpu the 5070 Ti now holds its last rows (--head-split, default 0.45 of them: 234 / 290 MiB,
which its expert tier gives up and the 3090's cache gets back): the window's graph writes the head's input rows to
mapped memory and raises a count, the host launches the 5070 Ti's part once it sees it (the same quantization,
projection and argmax kernels, so every logit is the one-GPU head's), and keeps each token's larger logit, on equality
the 3090's.  argmax_rows can return the pick's logit.  A sampled request or --window-logits gets whole rows: the 5070
Ti's part then copies its logits to mapped memory and the 3090 joins them to its own (join_rows).  The drafter gathers
its draft head's rows before the 3090 keeps only its part (MtpDrafter::bind_head); without a draft head the head
stays whole.  split_head.hpp/.cpp.

With both GPUs' caches held at one size (--expert-cache 3000 --second-gpu-gib 10) the window hashes and the
--window-logits output equal the previous build's (IQ3_S); with the configs the caches' sizes change with the head's.
Fixed text, 2 runs each in both orders, t/s against d3d48b1 (at 0.5 / 0.45 of the rows on the 5070 Ti):
  IQ3_S       code +5.3 / +5.8%, thinking +2.9 / +2.5%
  UD-Q4_K_XL  code -6.3 / -0.4% (the 0.5 build ran second of three in both rounds, where UD's code runs are slowest:
              106 against 110-117 t/s), thinking +1.4 / +2.7%
The 5070 Ti's part starts ~30 us after the 3090's (the host sees the count, launches under WDDM): balanced near 0.46.

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
The hyper-connection read before the head ran gr_read's kernels once per token of the window (lm_head_mix), and once
per draft pass in the draft layer.  Both now go through fused_gr_read_multi, the layers' read (no injection weight: the
final mixer), the window's tokens in one launch.  Its sums are gr_read's: window hashes on fixed text and the
distributions along llama.cpp's texts (scratch/parity_new.sh: IQ3_S and UD-Q4_K_XL, code, thinking, after 16K) equal
the previous build's.

CUPTI, IQ3_S thinking text (--adapt-every 0): the time between windows 3,679 -> 3,578 us a round, each draft step 6-9
us shorter.

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
…mixer's write with the FFN norm

Upstream's F-1 and F-2 (882bb6d, b046845) in this prompt path.  A read normalized its rows into an FP32 copy for the
mix and a BF16 one for the projections: gr_norm_rs now writes the row scales and the BF16 image only, and gr_mix_r
recomputes each normalized value from the residual rows, in gr_norm's order.  The mixer half's write runs with the
FFN half's norm (gr_write_norm_rs: one pass over the rows, the reduction as gr_norm's), so the FFN read starts at its
projections.  The FFN half's write covers the whole chunk after the experts while the next layer's reads run per
2048-token sub-chunk, so it stays apart; the draft layer's prompt pass keeps the FP32 rows (its up projection
overwrites its source rows).  Window hashes after the 16K prompt equal the previous build's (both models).

16K-token prompt (bench strata_gen.sh, alternating builds): IQ3_S with both GPUs 7,816 -> 7,638 ms (-2.3%, 3 runs
each), on the 3090 alone 11,691 -> 11,633 ms (-0.5%: there the dense steps overlap the expert copies); UD-Q4_K_XL
with both GPUs 8,399 -> 8,163 ms (-2.8%, 2 runs each).

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
…ayer's FFN write deferred to the next layer's read

A layer's expert phase ended with the second GPU's FP16 sums for the whole chunk crossing the 3090's x4 link (80 MiB
for 16K tokens, ~13 ms a layer) and one write over the chunk, with the 3090 idle meanwhile.  The sums now come back in
2048-token pieces on a stream of their own, and each sub-chunk's FFN write runs at the start of that sub-chunk in the
next layer, after its piece has landed and with the next read's norm in the same pass (gr_write_norm_rs takes the
partial sums): the next layer starts once the first piece is in, and the rest land beside its sub-chunks.  Before the
PLE block at layer 1 and after the last layer the writes run without the norm.  The sums buffer leaves the buffers'
union for the chunk's (the next layer's steps would overwrite it): the prompt borrows 80 MiB more of the caches for a
16K chunk.

Window hashes after the 16K prompt equal the previous build's on the 3090 alone, and with both GPUs against it with the
sums buffer moved the same way (IQ3_S, UD-Q4_K_XL); against the previous build as it was, the borrowed slots differ,
so which GPU computes some experts, and with it the rounding (1 of 100 argmaxes differs on IQ3_S, a near-tie).

16K-token prompt (bench strata_gen.sh, alternating builds): both GPUs, IQ3_S 7,623 -> 7,104 ms (-6.8%, 3 runs each),
UD-Q4_K_XL 8,237 -> 7,656 ms (-7.1%, 2 runs each); the 3090 alone 11,755 -> 11,638 ms (-1.0%: the FFN write with the
next norm).

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
…step 2b) on both GPUs

The prompt path dequantized each batch of experts to FP16 (iq_dequant_experts_f16) and multiplied them with cuBLAS.
Layers whose expert types llama.cpp's MMQ covers now keep the weights quantized: a batch's experts' GGUF blocks are
gathered side by side in the dequantization buffers' place (mmq::gather_native, a zeroed tail after them), its rows
are gathered in FP32 (gather_rows16_f32) and rounded to q8_1, and gate/up and down run as one MMQ launch each over the
batch (int8 tensor cores), with SwiGLU between; the weighted scatter-add is as before.  Upstream's moe_mmq.cu, and its
host shim for ggml-cuda, are built into strata_mmq with the instances of every expert type of ISTA-DASLab's and
unsloth's files (the K-quants, Q5_1 and IQ1_S added to its switch; IQ1_M keeps FP16).  A single expert with more rows
than a batch keeps the FP16 path in pieces; STRATA_PREFILL_MMQ=0 takes it for every layer.  The identity row map MMQ
writes through is filled with each layer's uploads: the 3090's runner shares the prompt buffers' union with the dense
steps, which overwrite it.

The activations are rounded to 8 bits, as the decode path's expert kernels already take them.  Along llama.cpp's text
after the 16K prompt (--adapt-every 0), against llama.cpp: IQ3_S 99.33% / KL 1.11e-3 -> 99.67% / 1.05e-3,
UD-Q4_K_XL 98.00% / 3.80e-3 -> 98.67% / 3.95e-3; MMQ against FP16 99.00% / 9.2e-4 and 99.33% / 1.3e-3, the size of
llama.cpp's own CPU-against-CUDA difference.

16K-token prompt (bench strata_gen.sh, 2 runs each, alternating): IQ3_S with both GPUs 7,100 -> 6,759 ms (-4.8%), on
the 3090 alone 11,626 -> 11,047 (-5.0%); UD-Q4_K_XL with both GPUs 7,772-8,500 -> 7,178 ms (-7.6% or more), alone
17,662 -> 16,828 (-4.7%).

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
…ry, its sums back in pieces

The second GPU copied a chunk's MoE input in (80 MiB for 16K tokens) once the first GPU's last sub-chunk had reached
host memory and the host had grouped the layer's experts, and started computing ~5.6 ms after the dense steps' end
(CUPTI).  Each sub-chunk's rows now go on to it as they arrive (ExpertRunner::stage_input, queued behind the previous
layer's sums on its stream, which pass through the same buffer), so the input is there when its plan is.  Its sums
leave in the same pieces (converted to FP16 and copied to host memory piece by piece, piece_done(k)), so the first
piece starts over the 3090's link before the last one leaves the 5070 Ti.  Window hashes after the 16K prompt equal
the previous build's (IQ3_S, UD-Q4_K_XL).

16K-token prompt, both GPUs (bench strata_gen.sh, alternating builds): IQ3_S 6,849 -> 6,686 ms (-2.4%, 3 runs each),
UD-Q4_K_XL 7,233 -> 7,044 ms (-2.6%, 2 runs each).

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
…ile the one before runs

Each prompt chunk began with its PLE rows read from the SSD (one batched request, ~0.4 s for 16K tokens) and
dequantized on the host, both GPUs idle meanwhile (CUPTI: two stalls of 0.36-0.52 s in a 16K prompt).  The read now
runs on a thread into one of two pinned buffers: the first chunk's from the prompt's start while its embeddings and
layer 0 run, each next chunk's from the layer 1 of the chunk before; the PLE block at layer 1 waits for it and the
rows go up asynchronously.  The rows' token history for the reads runs ahead of the one the prompt cache keeps, which
still advances chunk by chunk.  Window hashes after the 16K prompt equal the previous build's (IQ3_S, UD-Q4_K_XL).

IQ3_S with both GPUs (bench strata_gen.sh, alternating builds, 2 runs each): 16K prompt 6,785 -> 6,357 ms (-6.3%),
64K 25,391 -> 24,372 ms (-4.0%).  UD-Q4_K_XL 16K 6,723 ms (7,044 before).  The two buffers pin 336 MB more (UD's
71.7 GiB arena stays pinned whole).

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
…+ RTX 5070 Ti (docs/COMPARISON.md)

docs/COMPARISON.md holds the current numbers for IQ3_S and UD-Q4_K_XL: speed through the API (prompts 16K-128K,
generation on code, thinking, an edit and after each prompt, a follow-up turn, the time to the API, VRAM and RAM),
generation on fixed text, agreement with llama.cpp's next-token distributions with their rounding baselines,
llama.cpp's perplexity of the files, and the three engines' features.  llama.cpp's and upstream's columns were
measured once; this fork's are kept current.  The README's fork note and DETAILS' second-GPU and unsloth sections
point to it instead of carrying numbers of their own.

data/draft_vocab.bin is this fork's 40,525-token draft subset again (the merge took upstream's 106,299-token one with
the Chinese, Japanese and Korean tokens: a 2.6x larger draft head, English text 1-2% slower by upstream's measure).

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
… setup's layout

The engine arguments of the configs COMPARISON.md measures, with paths relative to the Strata folder: the engine
in CMake's build/ (setup's engine/ holds upstream's ready-made engine), the model files, packs and MTP draft layer
in setup's data folder ../Strata-data. The README says how to build and start them. Both start verbatim from such
a layout and answer (checked through junctions to this PC's files).

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
… the MTP layer come from

run-iq3_s.bat (port 8081) and run-ud-q4_k_xl.bat (8082) start the server from the Strata folder with their config,
as this fork's own launchers do. Both start and answer from a setup-style layout (checked as copies on another port
beside them). The README: a pack is tools/iq_pack.py's output from the model's GGUF files (setup makes IQ3_S's, step
3 UD-Q4_K_XL's), the MTP draft layer setup's conversion of the original checkpoint's MTP tensors.

Claude-Session: https://claude.ai/code/session_019XxRxQQNyk7Ycdr4ComSs1
@Niko1221

Copy link
Copy Markdown
Owner

Thank you for opening this - your fork is the reference we're porting UD-Q4_K_XL from. As one PR it's too big to review or gate (94 commits, 121 files, many default-path changes). We're taking the Unsloth pieces (8029fa9's shard discovery, per-role expert sources, Q4_K/Q5_K/Q5_1/Q8_0 kernels, the packer forms; later 7706c1b's MMQ dispatch) into upstream under your name, gated so existing models stay byte-identical. The other speedups (second-GPU tier, verify-window rewrites) are welcome as focused PRs, one change each with an A/B. Would you be up for comparing our port against your fork on your 160 GB machine once it runs? Closing this one.

@Niko1221 Niko1221 closed this Sep 30, 2026
This was referenced Sep 30, 2026
Niko1221 added a commit that referenced this pull request Oct 1, 2026
… the mapped mode without experts.bin, --resident-budget-gib, the tier counters

- docs/UNSLOTH_Q4.md: what UD-Q4_K_XL is (Hugging Face revision 38bb39e, the four shards with sizes and SHA-256),
  disk/RAM/GPU needs (111.3 GB download, 1.4 GB pack, no experts.bin), the iq_pack.py command and why
  --compat-bf16, the numerical policy (native experts/embedding/head/projections/PLE; the converted small tensors
  listed in conversions.json), the MTP draft layer, a server config (--native shard 1, --ple-gguf found by name,
  --resident-budget-gib N with N = RAM minus 20-24 GB), measured speed on an RTX 5070 / 64 GB PC (7-8.5 tok/s at
  40 GiB; ~60 s to the first answer), the switches (STRATA_LOOKAHEAD=0, STRATA_KQ256=1, STRATA_PARTIAL_PIN=1,
  STRATA_FETCH_THREADS), scope and validation, credits (eddoursul #245, gopinath87607 #255, jagsan-cyber #247).
  Experimental, not in setup.
- docs/DETAILS.md: the mapped low-RAM mode reads a native pack's experts from the GGUF when it has no experts.bin
  (checked, identical answers on the Coder); --resident-budget-gib and the routing-aware prefetch; where the tier
  counters are (--stats, the server log, /metrics' ram_blobs / file_blobs / file_mb); a pointer from "Which model?".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
Niko1221 added a commit that referenced this pull request Oct 1, 2026
…) - the four shards pinned at 38bb39e with their sizes and SHA-256, a RAM budget of the PC's RAM less 24 GB, packed with --compat-bf16, engine 0.1.32 or newer

- FAMILIES["unsloth"] (marked [experimental], not the default) and MODELS["UD-Q4_K_XL"] ("budget": True,
  48 GB of RAM or more); HF_REVISIONS pins unsloth/Qwen3.8-Flash-Next-GGUF; families may have more than two
  shards ("shards": 4).
- The download resumes as before; then verify_sha256 checks each shard's size and SHA-256 once (remembered in its
  finish mark; a wrong file is deleted so the next run downloads it again).
- resident_budget_gib: round(RAM) - 24 GiB (40 on 64 GB, the measured setting), at most the 71 GiB of experts, at
  least 8; a KV cache streamed to RAM (64K+) comes out of it. --resident-budget-gib replaces the low-RAM mode for
  this model (no experts.bin; --low-ram is ignored with a warning); one GPU, no images, no speed projection; 8K
  context recommended on GPUs under 14 GB; no --ple-gguf for a 4-shard file (the engine finds the PLE shard).
- The engine version is checked right after the engine step, before the 111 GB download.
- choices_from_config reads a size with a dash (strata-unsloth-ud-q4_k_xl.json).
- tools/test_setup_unsloth.py: pins, budget, SHA-256 handling, config round trip, and setup.main() end to end with
  every outside effect mocked (URLs, pack flags, engine args, old engine, too little RAM, two GPUs + images).
- README and docs/UNSLOTH_Q4.md / DETAILS.md describe it.

The model support itself follows eddoursul/Strata (PR #245).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
Niko1221 added a commit that referenced this pull request Oct 6, 2026
… the mapped mode without experts.bin, --resident-budget-gib, the tier counters

- docs/UNSLOTH_Q4.md: what UD-Q4_K_XL is (Hugging Face revision 38bb39e, the four shards with sizes and SHA-256),
  disk/RAM/GPU needs (111.3 GB download, 1.4 GB pack, no experts.bin), the iq_pack.py command and why
  --compat-bf16, the numerical policy (native experts/embedding/head/projections/PLE; the converted small tensors
  listed in conversions.json), the MTP draft layer, a server config (--native shard 1, --ple-gguf found by name,
  --resident-budget-gib N with N = RAM minus 20-24 GB), measured speed on an RTX 5070 / 64 GB PC (7-8.5 tok/s at
  40 GiB; ~60 s to the first answer), the switches (STRATA_LOOKAHEAD=0, STRATA_KQ256=1, STRATA_PARTIAL_PIN=1,
  STRATA_FETCH_THREADS), scope and validation, credits (eddoursul #245, gopinath87607 #255, jagsan-cyber #247).
  Experimental, not in setup.
- docs/DETAILS.md: the mapped low-RAM mode reads a native pack's experts from the GGUF when it has no experts.bin
  (checked, identical answers on the Coder); --resident-budget-gib and the routing-aware prefetch; where the tier
  counters are (--stats, the server log, /metrics' ram_blobs / file_blobs / file_mb); a pointer from "Which model?".
Niko1221 added a commit that referenced this pull request Oct 6, 2026
…) - the four shards pinned at 38bb39e with their sizes and SHA-256, a RAM budget of the PC's RAM less 24 GB, packed with --compat-bf16, engine 0.1.32 or newer

- FAMILIES["unsloth"] (marked [experimental], not the default) and MODELS["UD-Q4_K_XL"] ("budget": True,
  48 GB of RAM or more); HF_REVISIONS pins unsloth/Qwen3.8-Flash-Next-GGUF; families may have more than two
  shards ("shards": 4).
- The download resumes as before; then verify_sha256 checks each shard's size and SHA-256 once (remembered in its
  finish mark; a wrong file is deleted so the next run downloads it again).
- resident_budget_gib: round(RAM) - 24 GiB (40 on 64 GB, the measured setting), at most the 71 GiB of experts, at
  least 8; a KV cache streamed to RAM (64K+) comes out of it. --resident-budget-gib replaces the low-RAM mode for
  this model (no experts.bin; --low-ram is ignored with a warning); one GPU, no images, no speed projection; 8K
  context recommended on GPUs under 14 GB; no --ple-gguf for a 4-shard file (the engine finds the PLE shard).
- The engine version is checked right after the engine step, before the 111 GB download.
- choices_from_config reads a size with a dash (strata-unsloth-ud-q4_k_xl.json).
- tools/test_setup_unsloth.py: pins, budget, SHA-256 handling, config round trip, and setup.main() end to end with
  every outside effect mocked (URLs, pack flags, engine args, old engine, too little RAM, two GPUs + images).
- README and docs/UNSLOTH_Q4.md / DETAILS.md describe it.

The model support itself follows eddoursul/Strata (PR #245).
anon761 pushed a commit to anon761/Strata-upstream that referenced this pull request Oct 6, 2026
… the mapped mode without experts.bin, --resident-budget-gib, the tier counters

- docs/UNSLOTH_Q4.md: what UD-Q4_K_XL is (Hugging Face revision 38bb39e, the four shards with sizes and SHA-256),
  disk/RAM/GPU needs (111.3 GB download, 1.4 GB pack, no experts.bin), the iq_pack.py command and why
  --compat-bf16, the numerical policy (native experts/embedding/head/projections/PLE; the converted small tensors
  listed in conversions.json), the MTP draft layer, a server config (--native shard 1, --ple-gguf found by name,
  --resident-budget-gib N with N = RAM minus 20-24 GB), measured speed on an RTX 5070 / 64 GB PC (7-8.5 tok/s at
  40 GiB; ~60 s to the first answer), the switches (STRATA_LOOKAHEAD=0, STRATA_KQ256=1, STRATA_PARTIAL_PIN=1,
  STRATA_FETCH_THREADS), scope and validation, credits (eddoursul Niko1221#245, gopinath87607 Niko1221#255, jagsan-cyber Niko1221#247).
  Experimental, not in setup.
- docs/DETAILS.md: the mapped low-RAM mode reads a native pack's experts from the GGUF when it has no experts.bin
  (checked, identical answers on the Coder); --resident-budget-gib and the routing-aware prefetch; where the tier
  counters are (--stats, the server log, /metrics' ram_blobs / file_blobs / file_mb); a pointer from "Which model?".
anon761 pushed a commit to anon761/Strata-upstream that referenced this pull request Oct 6, 2026
…) - the four shards pinned at 38bb39e with their sizes and SHA-256, a RAM budget of the PC's RAM less 24 GB, packed with --compat-bf16, engine 0.1.32 or newer

- FAMILIES["unsloth"] (marked [experimental], not the default) and MODELS["UD-Q4_K_XL"] ("budget": True,
  48 GB of RAM or more); HF_REVISIONS pins unsloth/Qwen3.8-Flash-Next-GGUF; families may have more than two
  shards ("shards": 4).
- The download resumes as before; then verify_sha256 checks each shard's size and SHA-256 once (remembered in its
  finish mark; a wrong file is deleted so the next run downloads it again).
- resident_budget_gib: round(RAM) - 24 GiB (40 on 64 GB, the measured setting), at most the 71 GiB of experts, at
  least 8; a KV cache streamed to RAM (64K+) comes out of it. --resident-budget-gib replaces the low-RAM mode for
  this model (no experts.bin; --low-ram is ignored with a warning); one GPU, no images, no speed projection; 8K
  context recommended on GPUs under 14 GB; no --ple-gguf for a 4-shard file (the engine finds the PLE shard).
- The engine version is checked right after the engine step, before the 111 GB download.
- choices_from_config reads a size with a dash (strata-unsloth-ud-q4_k_xl.json).
- tools/test_setup_unsloth.py: pins, budget, SHA-256 handling, config round trip, and setup.main() end to end with
  every outside effect mocked (URLs, pack flags, engine args, old engine, too little RAM, two GPUs + images).
- README and docs/UNSLOTH_Q4.md / DETAILS.md describe it.

The model support itself follows eddoursul/Strata (PR Niko1221#245).
anon761 pushed a commit to anon761/Strata-upstream that referenced this pull request Oct 6, 2026
… the mapped mode without experts.bin, --resident-budget-gib, the tier counters

- docs/UNSLOTH_Q4.md: what UD-Q4_K_XL is (Hugging Face revision 38bb39e, the four shards with sizes and SHA-256),
  disk/RAM/GPU needs (111.3 GB download, 1.4 GB pack, no experts.bin), the iq_pack.py command and why
  --compat-bf16, the numerical policy (native experts/embedding/head/projections/PLE; the converted small tensors
  listed in conversions.json), the MTP draft layer, a server config (--native shard 1, --ple-gguf found by name,
  --resident-budget-gib N with N = RAM minus 20-24 GB), measured speed on an RTX 5070 / 64 GB PC (7-8.5 tok/s at
  40 GiB; ~60 s to the first answer), the switches (STRATA_LOOKAHEAD=0, STRATA_KQ256=1, STRATA_PARTIAL_PIN=1,
  STRATA_FETCH_THREADS), scope and validation, credits (eddoursul Niko1221#245, gopinath87607 Niko1221#255, jagsan-cyber Niko1221#247).
  Experimental, not in setup.
- docs/DETAILS.md: the mapped low-RAM mode reads a native pack's experts from the GGUF when it has no experts.bin
  (checked, identical answers on the Coder); --resident-budget-gib and the routing-aware prefetch; where the tier
  counters are (--stats, the server log, /metrics' ram_blobs / file_blobs / file_mb); a pointer from "Which model?".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
anon761 pushed a commit to anon761/Strata-upstream that referenced this pull request Oct 6, 2026
…) - the four shards pinned at 38bb39e with their sizes and SHA-256, a RAM budget of the PC's RAM less 24 GB, packed with --compat-bf16, engine 0.1.32 or newer

- FAMILIES["unsloth"] (marked [experimental], not the default) and MODELS["UD-Q4_K_XL"] ("budget": True,
  48 GB of RAM or more); HF_REVISIONS pins unsloth/Qwen3.8-Flash-Next-GGUF; families may have more than two
  shards ("shards": 4).
- The download resumes as before; then verify_sha256 checks each shard's size and SHA-256 once (remembered in its
  finish mark; a wrong file is deleted so the next run downloads it again).
- resident_budget_gib: round(RAM) - 24 GiB (40 on 64 GB, the measured setting), at most the 71 GiB of experts, at
  least 8; a KV cache streamed to RAM (64K+) comes out of it. --resident-budget-gib replaces the low-RAM mode for
  this model (no experts.bin; --low-ram is ignored with a warning); one GPU, no images, no speed projection; 8K
  context recommended on GPUs under 14 GB; no --ple-gguf for a 4-shard file (the engine finds the PLE shard).
- The engine version is checked right after the engine step, before the 111 GB download.
- choices_from_config reads a size with a dash (strata-unsloth-ud-q4_k_xl.json).
- tools/test_setup_unsloth.py: pins, budget, SHA-256 handling, config round trip, and setup.main() end to end with
  every outside effect mocked (URLs, pack flags, engine args, old engine, too little RAM, two GPUs + images).
- README and docs/UNSLOTH_Q4.md / DETAILS.md describe it.

The model support itself follows eddoursul/Strata (PR Niko1221#245).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154bwiad8gY7wRehCXy27pN
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