Skip to content

batch-groups: read a cold prompt beside the decoding slots, stall 11.9 -> 0.8 s, same work 6.4% sooner (opt-in) - #2326

Closed
SuperDistance wants to merge 7 commits into
Niko1221:mainfrom
SuperDistance:pr/piped-concurrent-read
Closed

SuperDistance wants to merge 7 commits into
Niko1221:mainfrom
SuperDistance:pr/piped-concurrent-read

Conversation

@SuperDistance

@SuperDistance SuperDistance commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Title

Issue: Related: #2275, #1906 and #1603 (on the pipelined groups every decoding stream stops for a whole prompt read),
and #2050 (the other approach, reads in pieces; this PR does not depend on it).

Motivation

I run Strata as the backend for coding agents on 2x RTX 5090. Every new agent starts with a long cold prompt, and with
--batch-groups > 1 every agent that is already decoding stops until that prompt is read.

vLLM and SGLang avoid this with chunked prefill: a new prompt's chunks share batches with the running requests' decode
steps. #2050 brought the time-sliced form of that here (the read in pieces, taking turns with decode windows), but
every turn drains the pipeline, so the new prompt waits longer, and with a coarse chunk one piece is still about 21 s
of reading (superniker on #2050). Strata's prompt path and its decode windows use different kernels and buffers, so
they cannot share one forward pass, but they can run at the same time on the same GPUs. That is this PR.

Summary

With STRATA_PIPED_CONCURRENT=1 (opt-in, experimental) a cold prompt is read on a thread of its own while the slot
groups keep decoding. With the variable unset nothing changes: main's tokens (SHA-256 equal) at main's speed.

On 2x RTX 5090, four streams decoding while a 64K and a 32K cold prompt join:

main this mode
longest pause of a stream 11.9 s 0.8 s
the streams' rate while the prompts are read 2-3 tok/s 85-92 tok/s
both GPUs busy while the prompts are read 80% 88%
the same work done in 26.1-27.1 s 24.6-25.3 s, 6.4% sooner
the joining prompts' first token 6.8 / 5.8-5.9 s 8.1-8.2 / 8.2-8.3 s
a one-line question 1 s into a cold 45K read: its first token 5.7-5.9 s 1.9 s

The whole workload finishes sooner because the GPUs stay busier during a read; on main they sit partly idle while every
stream waits. The price is the joining prompt's own first token, which comes 20-47% later.

Four decoding streams while two cold prompts join: when each stream was paused, for main, #2050 and this mode

The same work, done 6.4% sooner: where the time goes for main and this mode, and how busy the GPUs were while the prompts were read

How it works

  • The read runs on a worker thread. The main thread keeps the groups decoding through the pipeline (pump) and takes
    BSTOP and BYIELD meanwhile.
  • Two rules keep it from hanging: the slots' doorbells are served by a thread that never calls CUDA, and nothing is
    allocated or freed on the read's threads (everything is sized before the read starts).
  • The windows' streams get the device's highest priority on CUDA.
  • A BYIELD ends the read at its next chunk, and the part read waits in a slot, as serve: cooperative prefill preemption at safe chunk boundaries (--prefill-preempt) #656's give-way does.
  • The stall watchdog stays on for the whole read.
  • On Linux the read's threads run off the host thread's core and the pool workers' CPUs (a new thread there inherits
    the host thread's one-core mask; Windows' CPU Sets are not inherited).
Where the two rules come from, and what an independent review found

Two hangs turned up while building this, both read from the engine's stall dumps against its PDB. Both had #31's
shape: the main thread waited for the CUDA driver's lock in cudaSetDevice while the lock's holder waited for the GPU
(a cudaFree in the drafter's prompt pass, then cuBLASLt's lazy load of a GEMM module), and a window on that GPU
waited for its doorbell. A doorbell served by a thread that never calls CUDA cannot be held up that way. An independent
review then found five more things, fixed in the third commit: configurations where serving a doorbell does call CUDA
(the mode is now off there), #1996's pinned upload rings growing mid-read (now reserved first), a window that would
never finish going unnoticed, the watchdog switching itself off when the last slot finished mid-read, and a rare race
that could serve a new window with the previous one's row count.

How to review

  1. src/program/generate.cpp, read_part: the block under if (piped && pr_conc && req_imgs.empty()), then pump's
    two changes (the watchdog during a read, the launch that wakes the doorbell thread).
  2. src/core/verify.cpp: batch_serve is batch_poll's per-layer service moved out unchanged.
  3. Everything else enforces the two rules above. concurrent_read_off (batch_read.hpp, tested in batch_read_test)
    lists the configurations where the mode stays off.

Results

Measured on 2x RTX 5090 (Swift 1.5 IQ3_S, split 25, --batch 6 --batch-groups 2), engines built from main a0116b27.

Tables and method

Against #2050 (a cold 59.6K prompt at 6 s and a 33.7K one at 12 s, the same prompts in every arm):

main #2050, 32K pieces, 400 ms this mode
longest pause of a stream 12.7 s 4.6 s 0.97 s
the streams' tokens while the prompts are read 293 509 1,377
the prompts' first token 7.3 / 6.6 s 8.5 / 8.9 s 9.0 / 9.7 s

With a second prompt under half the first one's length (72.4K and 28.1K), the long read gives way at its next chunk by
#656's rule, here at 57,344 of 72,368 tokens: the short prompt gained little (7.4 against main's 7.1 s) and the long
one waited for it (17.2 against 8.3 s). A rule that compares what is left to read, as @blange48 carries on #2170, would
skip a give-way that late; that belongs in its own PR.

The same work, sooner. The four streams send the same prompts in every run, greedy, and their answers came out the
same length (3,828-3,899 tokens on main, 3,831-3,865 here). Finishing time is the last stream's last token. Order main,
this mode, this mode, main. Both arms decode at about 330 tok/s before the joins; main then stops the streams for about
12.8 s, while this mode keeps them near 90 tok/s through a 15.7 s read, which puts it 1.5-2 s ahead. GPU busy is
nvidia-smi's utilization every 100 ms during the read period. The gain depends on how much of the time is spent
reading; with no reads there is none.

Host contention (the cold 64K prompt read alone, then beside the decoding streams; read speeds from the engine's
line for the whole request):

full config, four streams of 40K main #2050, 32K pieces this mode
the read alone 8,581 tok/s 7,442 tok/s 8,731 tok/s
the read beside the streams 8,606 tok/s (they stop) 7,418 tok/s (they wait) 7,331 tok/s (-16%)
the streams before / during the read 345 / 4 tok/s 348 / 18 tok/s 303 / 67 tok/s
longest pause of a stream 7.7 s 4.7 s 0.48 s
the engine's CPU during the read 6.3 cores 4.7 cores 16.6 cores
CPU-bound host (20 GB of each card reserved, experts not resident, AVX2, 6 pool workers), one stream main this mode
the read alone 5,226 tok/s 5,307 tok/s
the read beside the stream 5,180 tok/s (it stops) 4,508 tok/s (-15%)
the stream before / during the read 92 / 1.3 tok/s 93 / 27 tok/s
the stream's longest pause 12.6 s 0.31 s

16 GB cards (--vram-reserve-mib 16600 --kv-resident 16384): the streams' rate during the reads 0.9 -> 52.9 tok/s,
all four done 70.6 -> 63.8 s, GPUs busy 80% -> 92%, the joining prompts' first token 9.5 / 10.1 -> 15.8 / 21.1 s.
A weaker host (16 GB cards, experts from the mapped file, AVX2, 6 pool workers, --batch 2): a stream's longest
stop 11.1 -> 0.41 s beside a 64K read and 20.3 -> 0.64 s beside a 127K one, the prompt's first token +4-7%.

Linux (WSL2, Ubuntu 24.04, the same machine and engine, all builds on main ed4773eb with #2050's commits applied
for its arm; piped_read_join_bench.py's cold32k, followup and two_joiners; the n-gram table in RAM in every arm, since
WSL's disk reads it about 7x slower; 2-3 runs each):

main #2050, 16K pieces this mode, first version this mode
cold32k: longest stream stop 6.1-6.5 s 2.9-3.1 s 0.8-0.9 s 0.7 s
cold32k: the joining prompt's first token 5.8-6.1 s 9.6-10.0 s 8.2-8.6 s 7.0-7.3 s
cold32k: stream tokens during the join 222-229 814-860 454-484 925-1,065
followup: a short prompt 1 s into a cold 45K read, its first token 6.4-6.6 s 2.7 s 1.7-1.8 s 1.6-2.0 s
two_joiners: longest stream stop 15.6-15.9 s 5.9-6.0 s 1.7-2.0 s 1.2-1.6 s
two_joiners: stream tokens during the joins 303-313 1,460-1,559 664-718 1,796-2,046

The slots decoded 24-37 rows/s during a 30K+ read with the first version and 95-127 with the fix.

Exactness. With the usual settings near-tie tokens flip a little more often than on main (the prompt path borrows
some of the decode's GPU expert slots, and those experts run on the CPU kernels, which round differently). With
--pcie-frac 0 --adapt-swaps 0 --prompt-cache 0 --no-prefill-borrow this mode's two runs were byte-identical for all
three answers; release's two runs with the same settings still differed. In batch_interleave_test.py --long 40000
every request equaled its solo tokens in two runs of three; the third had a near tie that flips on main as well. With
the variable unset, six greedy answers' SHA-256 equal main's in two engine starts each.

Load. The hour: four streams, cold joins of 4-128K every 4-15 s, short questions, follow-ups, clients leaving
mid-read; GPU0's VRAM grew 82 MiB over 80 minutes (in my local testing an engine grew 44 MiB in its first hours and
then held). My local testing ran v0.1.43 with #2050 and this ported; against the 20 hours before it on #2050's pieces,
the slowest tenth of long reads fell from 15.8 to 7.5 s, but the workload differed, so that is not a controlled A/B.

Costs and limits

  • The joining prompt's first token comes 20-47% later (67-109% on 16 GB cards).
  • The read is about 15% slower beside decoding streams, and the engine uses more of the host (16.6 cores against 6.3).
  • A request that comes during a read still waits for it, unless its prompt is under half as long (BYIELD).
  • The mode stays off, and says why once, with --pcie-mode dma, the helper caches (--expert-cache-device1..3), an
    expert source that recycles its buffers (the GGUF in place, unbuffered, STRATA_IO_PREFETCH), STRATA_FS_SLOTS,
    STRATA_PREFILL_HELP and STRATA_SPLIT_MTP_BATCH (--remote-expert-opt without a helper cache does not count).
    Prompts with pictures are read the usual way.

What changed

  • src/program/generate.cpp: the concurrent read, the doorbell thread and the log lines; pump keeps the watchdog on
    during a read and wakes the doorbell thread at a launch.
  • src/core/verify.cpp, include/strata/core/verify.hpp: batch_serve (shared with batch_poll) and
    set_external_serve; the window state as atomics; the windows' streams at high priority under the opt-in.
  • src/prefill/prefill.cpp, include/strata/prefill/prefill.hpp: stop_at_chunk, reserve_uploads.
  • src/core/mtp.cpp, include/strata/core/mtp.hpp: reserve_prefill. include/strata/core/pinned_ring.hpp:
    PinnedRing::reserve.
  • src/kernels/ngram.cpp: a lock in PleTable. src/core/expert_source.cpp and its header: atomic read counters.
  • include/strata/program/batch_read.hpp, tests/program/batch_read_test.cpp: concurrent_read_off and its test.
  • include/strata/platform/aux_cpus.hpp: place_off_host and release_host_inheritors (Linux), the read's threads
    off the host thread's core.
  • docs/BATCHING.md: the section and the tables. tools/piped_read_join_bench.py: batch-groups: a cold prompt read no longer pauses every stream for its whole length, longest pause 12.2 -> 4.2 s on 2x RTX 5090 (opt-in) #2050's measuring client,
    unchanged.

Extra Notes

@blange48

Copy link
Copy Markdown
Contributor

Measured on a 4-stage split: 4x RTX 5080 16 GB (PCIe Gen3), i9-10920X, 123 GiB RAM, Linux, CUDA sm_120. Qwen3.8-Flash-Next GSQ-RCO IQ3_S, --layer-split 12,24,36 --trim-stage-weights --batch 8 --batch-groups 4, MTP --spec 4, 262K, int8 KV (--kv-resident 32768), vision on.

The build is v0.1.44 + this PR + #2050 + a few of our patches. Only one of the two read modes is set per run. All five commits applied on top of #2050; I resolved the overlap in the read loop by keeping your give_way lambda and the concurrent block first, then #2050's piece path. Each variant runs on a fresh engine on an idle machine.

neither (0.1.44) #2050, PIECE=16384 this PR, CONCURRENT=1
cold32k: longest stream stop 10.69 s 5.44 s 5.06 s
cold32k: joiner TTFT (36.6K) 10.5 s 19.7 s 11.4 s
cold32k: stream tokens during the join 183 654 278
cold32k: aggregate tok/s 141.3 137.2 140.5
followup: short prompt TTFT behind 40.5K 10.75 s 5.77 s 10.28 s
followup: the long read's TTFT 10.74 s 16.64 s 19.67 s
our agent follow-up during a fresh 45K read (2 rounds) 9.80-9.84 s 4.05-4.06 s 5.52-5.55 s
that 45K read's own TTFT 10.7-10.8 s 16.8 s 14.9 s
two_joiners: longest stop 2.71 s
decode 1 / 4 / 8 clients (2nd pass) 153 / 270 / 397 155 / 270 / 395

With CONCURRENT=1 the tool calls (3/3) and a 16-agent context-isolation test (0 leaks) pass.

What I see on this rig: the joiner's own TTFT stays close to main (11.4 s against 10.5 s), which is the big win over pieces (19.7 s). But the streams barely decode during the read. The engine log says "the slots decoded 36 rows meanwhile (6.1 rows/s; stage 1: 36 windows, 1.4 ms host each; ...)" over a ~5.9 s read, while you saw 85-92 tok/s on 2x 5090. Two of the three reads in our follow-up test also logged "decoded 0 rows" with no slot decoding, which is expected. My guess is that with 4 stages on Gen3 x16 and 16 GB cards (most experts host-side), the prompt's expert traffic and the windows' compete on the same links and the CPU pool. Happy to run a per-stage timing if that helps.

For our agent workload we stay on #2050's pieces for now: the follow-up latency matters most to us. Thanks for the work!

SuperDistance added a commit to SuperDistance/Strata that referenced this pull request Oct 14, 2026
…ead's core on Linux

On Linux a thread inherits its creator's CPU mask, and the host thread is pinned to the one core the pool leaves it
(aux_cpus.hpp). The read's thread and the doorbell thread were created from it, so they shared that core with the host
thread that pumps the windows, and every window waited there for the doorbell of each layer: 6.1 rows/s during a read
on 4x RTX 5080, 4 stages (blange48 on Niko1221#2326). Windows places the host with CPU Sets, which new threads do not inherit,
so it did not show there. Both threads now move off the host's core and the pool workers' CPUs (on an SMT machine with
the default pool: the idle sibling threads; without such CPUs, every CPU but the host's), and the threads that already
run on the host's CPU (the prompt path's copy threads, started by the first prompt) move the same way at a read.
--aux-cpus places them all when it is on. STRATA_PIPED_CONCURRENT_UNPIN=0 keeps the inherited CPUs (an A/B).

Measured on Linux (WSL2, Ubuntu 24.04, 2x RTX 5090, 4 streams, a cold 32K prompt; same binary, UNPIN=0 / default): the
slots decoded 25.9 and 23.7 rows/s during the read with the threads kept on the host's CPU (76-81 threads there), and
75.6 and 62.8 with this change; with the n-gram table in RAM (WSL's disk reads it 7x slower than Windows) 110.7 rows/s,
as on Windows (107.0). piped_read_join_bench.py (cold32k, followup, two_joiners; table in RAM, 2-3 runs each): 24-37
rows/s during the 30K+ reads kept on the host's CPU, 95-127 with this change, and 925-1,065 stream tokens during the
cold 32K join against 454-484. On Windows, STRATA_TEST_CONC_ON_HOST_CPU=1 (a test) puts both threads on the host
thread's CPU set first: 18.6 rows/s kept there, 105.4 with this change.
@SuperDistance

SuperDistance commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

@blange48 thank you for testing this so carefully. Your report found a real bug on Linux: a new thread inherits its
creator's CPU mask there, and the read's threads were started from the host thread, which is pinned to one core, so
they all shared it. Windows doesn't pass this on, so I never saw it. The last commit on this PR moves them off that core.

Your bench on Linux here (WSL2, 2x RTX 5090, 2 stages, all on main ed4773e, 2-3 runs each):

neither (main) #2050, PIECE=16384 this PR, CONCURRENT=1, before the fix this PR, with the fix
cold32k: longest stream stop 6.14-6.45 s 2.92-3.11 s 0.77-0.94 s 0.67-0.70 s
cold32k: joiner TTFT (36.1K) 5.8-6.1 s 9.6-10.0 s 8.2-8.6 s 7.0-7.3 s
cold32k: stream tokens during the join 222-229 814-860 454-484 925-1,065
cold32k: aggregate tok/s 194.8-209.5 183.3-198.3 179.1-184.3 183.9-213.1
followup: short prompt TTFT behind 39.8K 6.44-6.64 s 2.67-2.69 s 1.69-1.84 s 1.64-1.98 s
followup: the long read's TTFT 6.25-6.36 s 8.38-8.39 s 7.64-8.03 s 6.80-7.27 s
two_joiners: longest stop 15.63-15.94 s 5.86-5.98 s 1.72-1.95 s 1.22-1.64 s

I can't say yet that this is all of what you saw: here the slots fell to 24-37 rows/s during a read, not 6.1, and I
can't run four stages. A quick check on your current build: --aux-cpus auto --pool-workers 10 moves those threads off
the host's core. If the 6.1 rows/s goes up a lot, it was this; if not, the per-stage timing you offered would help. A
rerun with the fix would be great too.

For the follow-up, the long read paying ~9 s more (with #2050 too) looks like a re-read from the start after the
give-way; here it resumes. The gives way at line and the long prompt's next read line would show it.

…table

The prompt path gathers a chunk's PLE rows on its own thread and the batch windows gather theirs on the main thread,
from the same table (ss.ple.table). Today they never overlap - a prompt run's read-ahead ends with the run, and the
windows run between runs - but a prompt read beside running windows would race on the reader's tickets and the
prefetch slots. Every public method that touches them now holds a recursive mutex (gather holds it across its issue
and collect). Uncontended it is a few nanoseconds per call; nothing else changes.
…its own (opt-in, experimental STRATA_PIPED_CONCURRENT)

With a layer split and --batch-groups > 1 a prompt read is one sp.run, so every decoding slot stops for the whole read
(Niko1221#1906); Niko1221#2050's pieces bound the stop but drain the pipeline at each piece. With STRATA_PIPED_CONCURRENT=1 the read
runs on a worker thread while the main thread keeps the groups going through the pipeline (pump) and takes BSTOP and
BYIELD meanwhile. The prompt's K/V goes to the main sessions and the slots' to their own; the windows' streams get the
device's highest priority on CUDA (STRATA_PIPED_CONCURRENT_PRIO=0 keeps the default).

- The slots' doorbells are served on a thread of their own that makes no CUDA call (Verifier::batch_serve, now
  batch_poll's per-layer service too, and set_external_serve). Two hangs found while building this, read from the
  stall dumps against the PDB, had Niko1221#31's shape: the main thread waited for the driver's lock in cudaSetDevice while its
  holder waited for the GPU (a cudaFree in the drafter's prompt pass; cuBLASLt's lazy load of a GEMM module), and a
  window on that GPU waited for its doorbell.
- MtpDrafter::reserve_prefill captures the drafter's prompt graphs and sizes its records before the read.
- Prefill::stop_at_chunk: a BYIELD ends the run at its next chunk, also with no slot decoding; the pipeline is drained
  there and the part read gives way as Niko1221#656 does. A read nobody waits for is not cut.
- The reader is joined on every way out (an exception too). Off beside STRATA_PREFILL_HELP and STRATA_SPLIT_MTP_BATCH,
  which allocate during a read.
…eeps its checks and the watchdog, and waits on a launch

From a review of the concurrent read:
- With external serving batch_poll still finds a window that will not finish (its graph ended or failed with a layer
  to ring, or no layer rang for 20 s): the served count is read before the stream query and the ring count after it,
  at most one query a millisecond; the last ring's time is an atomic. batch_serve reads a window's row count after its
  served count, so a window launched while the doorbell thread was descheduled is not served with the last one's rows.
- Niko1221#1996's pinned upload rings (0.1.43.1) grew a slot at its first larger upload, a pinned free and allocation on the
  read's thread: the drafter's records and the prompt path's routing tables reserve their slots before the read
  (PinnedRing::reserve, Prefill::reserve_uploads).
- Off, said once, where serving a doorbell makes a CUDA call (--pcie-mode dma, STRATA_FS_SLOTS, the helper caches) or
  the expert source recycles the buffers it hands out (the GGUF in place, unbuffered, STRATA_IO_PREFETCH); a prompt
  with pictures is read the usual way. The expert sources' read counters are atomics.
  The check is a pure function, concurrent_read_off (batch_read.hpp), with a test in batch_read_test.
- pump's all-idle branch turned the stall watchdog off when the last slot finished mid-read; it stays on while a prompt
  is read until the request ends.
- Between windows the doorbell thread slept 50 us, a whole timer tick on Windows (up to 15.6 ms): it now waits on a
  condition each launch notifies (STRATA_TEST_BELL_SLEEP_US=N, a test, restores the sleep for an A/B). With nothing
  decoding the main thread waits for the read's end or a line instead of sleeping. A pool failure the doorbell thread
  set is reported before pump clears the flag for the next window.
- The read's log line ends with the slots' rate and, per stage, its windows and the host time that set each up. The
  priority stream's cudaGetLastError runs only under the opt-in.
…ured

The opt-in's section: how the read and the windows share the GPUs, the doorbell thread, the give-way at a chunk, where
the mode is off. Measured against main (a0116b2) on 2x RTX 5090 in one window (main, this, this, main): the stops,
the streams' rate during the reads with the same answers every run, the joining prompts' TTFT, a question during a
read, the same randomised load on both, the doorbell thread's first wait as an A/B; 16 GB cards on each side; a weaker
host as well in Niko1221#1906's shape; the default path's check; text equality.

tools/piped_read_join_bench.py, the client that measured it, is Niko1221#2050's, unchanged (the same file as there).
…p the concurrent read off

The flag works only through a helper cache (--expert-cache-device1..3), and on a plain layer split it is inert
(MULTI_GPU.md, Niko1221#1447), so the check looks at the helper caches themselves. Niko1221#2275's rig runs a plain split with the
flag set, and the mode would have stayed off there for a cache it does not have.
…ead's core on Linux

On Linux a thread inherits its creator's CPU mask, and the host thread is pinned to the one core the pool leaves it
(aux_cpus.hpp). The read's thread and the doorbell thread were created from it, so they shared that core with the host
thread that pumps the windows, and every window waited there for the doorbell of each layer: 6.1 rows/s during a read
on 4x RTX 5080, 4 stages (blange48 on Niko1221#2326). Windows places the host with CPU Sets, which new threads do not inherit,
so it did not show there. Both threads now move off the host's core and the pool workers' CPUs (on an SMT machine with
the default pool: the idle sibling threads; without such CPUs, every CPU but the host's), and the threads that already
run on the host's CPU (the prompt path's copy threads, started by the first prompt) move the same way at a read.
--aux-cpus places them all when it is on. STRATA_PIPED_CONCURRENT_UNPIN=0 keeps the inherited CPUs (an A/B).

Measured on Linux (WSL2, Ubuntu 24.04, 2x RTX 5090, 4 streams, a cold 32K prompt; same binary, UNPIN=0 / default): the
slots decoded 25.9 and 23.7 rows/s during the read with the threads kept on the host's CPU (76-81 threads there), and
75.6 and 62.8 with this change; with the n-gram table in RAM (WSL's disk reads it 7x slower than Windows) 110.7 rows/s,
as on Windows (107.0). piped_read_join_bench.py (cold32k, followup, two_joiners; table in RAM, 2-3 runs each): 24-37
rows/s during the 30K+ reads kept on the host's CPU, 95-127 with this change, and 925-1,065 stream tokens during the
cold 32K join against 454-484. On Windows, STRATA_TEST_CONC_ON_HOST_CPU=1 (a test) puts both threads on the host
thread's CPU set first: 18.6 rows/s kept there, 105.4 with this change.
@SuperDistance
SuperDistance force-pushed the pr/piped-concurrent-read branch from 6c9238b to 67bc65c Compare October 14, 2026 04:09
@blange48

Copy link
Copy Markdown
Contributor

Re-ran with the fix. The build is v0.1.44.1 + this PR as rebased (6 commits, with 67bc65c) + #2050 + a few of our patches. Same rig: 4x RTX 5080 16 GB (PCIe Gen3), i9-10920X, --layer-split 12,24,36 --batch 8 --batch-groups 4, MTP spec 4, int8 KV --kv-resident 32768. Each column is a fresh engine on an idle machine.

#2050, PIECE=16384 CONCURRENT=1, UNPIN=0 (no fix) CONCURRENT=1 (with the fix)
cold32k: longest stream stop 5.61 s 5.21 s 4.45 s
cold32k: joiner TTFT (36.1K) 19.8 s 11.4 s 12.1 s
cold32k: stream tokens during the join 643 280 535
cold32k: aggregate tok/s 124.0 134.9 137.4
followup: short prompt TTFT behind 40.2K 6.01 s 4.41 s 4.04 s
followup: the long read's TTFT 16.8 s 13.8 s 13.3 s
two_joiners: longest stop 6.21 s 2.52 s
two_joiners: joiner TTFTs (76.6K / 30.6K) 35.3 / 46.3 s 19.5 / 24.6 s
two_joiners: aggregate tok/s 75.7 112.1
our agent follow-up during a fresh 45K read 4.01-4.03 s 5.49-5.54 s 5.37-5.41 s
that 45K read's own TTFT 16.8 s 14.7-14.8 s 14.3 s
decode 1 / 4 / 8 clients (2nd pass) 147 / 270 / 397

With the fix, tool calls (3/3), the 16-agent isolation test (0 leaks) and 16 parked conversations re-opened warm (0.26 s) all pass.

The fix shows: stream tokens during the join go from 280 to 535, and the stop and the follow-up both shorten. But the engine log still reports about 7 rows/s for the slots during a read ("read 18037 tokens on a thread of its own in 5434 ms (concurrent, to 42613 of 42620); the slots decoded 37 rows meanwhile (6.8 rows/s; stage 1: 37 windows, 0.9 ms host each; ...)"), against 24-37 on your 2-stage rig. Our follow-up test still prefers pieces (4.0 against 5.4 s), because the follow-up waits for the next chunk boundary of the concurrent read; followup in your bench sees the opposite (4.0 against 6.0 s).

Overall it is a clear win on our rig: two cold joiners finish about twice as fast and the run's throughput is 48% higher. We will likely move production to it. I can run a per-stage timing if you want to dig into the 7 rows/s; tell me which variables to set.

@qiuzan2001

Copy link
Copy Markdown

Found by reading (no Mac here to build): the new make_stream lambda in src/core/verify.cpp (around line 755) calls cudaDeviceGetStreamPriorityRange and cudaStreamCreateWithPriority under #if !defined(STRATA_USE_HIP) && !defined(STRATA_HIP_GFX906). The Mac build compiles this file with STRATA_GPU_METAL (metal/CMakeLists.txt:60 and :94), and metal/include/metal_compat/cuda_runtime.h declares neither call, so I expect an undeclared-identifier error there. The same calls in src/core/mtp.cpp:437 are guarded with && !defined(STRATA_GPU_METAL) // (HIP, Metal: the default priority); adding that term here would match.

…calls in its CUDA shim)

STRATA_PIPED_CONCURRENT's window streams ask for the device's highest priority through
cudaDeviceGetStreamPriorityRange / cudaStreamCreateWithPriority. The Mac engine compiles src/core/verify.cpp too
(metal/CMakeLists.txt) with STRATA_GPU_METAL, and metal_compat/cuda_runtime.h declares neither, so the Mac build would
not compile. Same guard as the drafter's streams in mtp.cpp: HIP and Metal keep the default priority. CUDA and HIP
preprocess to the same code as before. Not built on a Mac (none here). Reported by @qiuzan2001 on Niko1221#2326.
@SuperDistance

Copy link
Copy Markdown
Contributor Author

Found by reading (no Mac here to build): the new make_stream lambda in src/core/verify.cpp (around line 755) calls cudaDeviceGetStreamPriorityRange and cudaStreamCreateWithPriority under #if !defined(STRATA_USE_HIP) && !defined(STRATA_HIP_GFX906). The Mac build compiles this file with STRATA_GPU_METAL (metal/CMakeLists.txt:60 and :94), and metal/include/metal_compat/cuda_runtime.h declares neither call, so I expect an undeclared-identifier error there. The same calls in src/core/mtp.cpp:437 are guarded with && !defined(STRATA_GPU_METAL) // (HIP, Metal: the default priority); adding that term here would match.

@qiuzan2001 Nice catch! Thanks! Let me get my changes compiled on mac to verify

@superniker

Copy link
Copy Markdown

2x RTX 2080 Ti (sm_75, 22 GB, layer split) — the mode pays with one stream decoding, costs a lot with three

Summary, first: on this machine the mode does exactly what it says for a decoding stream (a 52 s freeze becomes a 3 s gap), and with one stream decoding the whole run costs 5% more wall clock while the small request is answered 44% sooner — that is the case it is for. With three streams decoding the same run costs 73% more, because the read's rate decays as more slots decode. It does not change aggregate decode throughput in either direction, and the four-stream long-context case that made us open #2275 is untouched by it.

Hardware the maintainers don't have: 2x RTX 2080 Ti 22 GB (modded, sm_75), layer split 24, a Broadwell-era Xeon without AVX-512 (expert kernels on AVX2), 123 GiB RAM, CUDA 13.0, driver 595.99.02. Built from this PR's head (63335d4, engine 0.1.44.1): Release, CMAKE_CUDA_ARCHITECTURES=75, STRATA_MMQ_KQUANTS=ON, STRATA_Q6K_EXPERTS=ON. Same binary in all four runs; the only difference is the environment variable.

Common settings: --batch 4 --batch-mtp --batch-groups auto --pipeline-windows 2 --kv int8 --kv-resident 65536 --remote-expert-opt --prefill auto:32768 --max-context 262144. Each run: a cold 78,641-token prompt joins 12 s after the other stream(s) have been decoding, and a 75-token question arrives 2 s later; every prompt carries a unique tag (nothing cached); ignore_eos, 2,048 tokens per stream.

A. One stream decoding (the "two users: one chatting, one loading a long prompt" case)

main STRATA_PIPED_CONCURRENT=1
longest pause of the decoding stream 52.16 s 2.96 s
its rate during the read 0.2 tok/s 12.8 tok/s
its rate before the read 98.1 tok/s 99.1 tok/s
joining prompt's TTFT (78.6K cold) 50.6 s 76.5 s (+51%)
its read rate (engine's own lines) 1,564 tok/s in one shot 1,032 then 1,206 tok/s
75-token question's first token 50.2 s (waited for the read) 28.1 s (−44%)
whole run's wall clock 73.5 s 77.4 s (+5.2%)

The engine's own lines for that arm:

strata batch: read 26624 tokens on a thread of its own in 25789 ms (concurrent, to 26624 of 78641); the slots decoded 449 rows meanwhile (17.4 rows/s; ...)
strata batch: read 70 tokens on a thread of its own in 802 ms (concurrent, to 70 of 75); the slots decoded 35 rows meanwhile (43.7 rows/s; ...)
strata batch: read 52012 tokens on a thread of its own in 43132 ms (concurrent, to 78636 of 78641); the slots decoded 489 rows meanwhile (11.3 rows/s; ...)

That is the case this PR is for: a stream that would be frozen for 52 s keeps moving, the small question is answered during the read instead of after it, and the run as a whole costs only 5% more wall clock.

B. Three streams decoding (same join, same binary)

main STRATA_PIPED_CONCURRENT=1
longest pause of a decoding stream 51.08 s 4.06 s
each stream's rate during the read 0.1 tok/s 7.1 tok/s
joining prompt's TTFT 50.4 s 136.3 s (+170%)
its read rate ~1,560 tok/s in one shot 830 → 653 → 443 tok/s (3 chunks)
aggregate the engine printed (strata batch, admissions included) 6,162 rows / 127.6 s 6,127 rows / 220.8 s
whole run's wall clock 128.8 s 222.9 s (+73%)

So the same mechanism costs 2.7x on the read and 73% on the run once three slots are decoding — the read's rate decays as more slots decode, which is more than the decode side gains.

What I think this means (offered as a data point, not a theory)

If it helps, I can re-run either arm with --batch-groups 1 / STRATA_BATCH_MTP_SERIAL=1, with the two build options off (our production combination), or with 2 streams — each pair of arms is about 15 minutes on this machine.

Niko1221 pushed a commit that referenced this pull request Oct 17, 2026
…ead's core on Linux

On Linux a thread inherits its creator's CPU mask, and the host thread is pinned to the one core the pool leaves it
(aux_cpus.hpp). The read's thread and the doorbell thread were created from it, so they shared that core with the host
thread that pumps the windows, and every window waited there for the doorbell of each layer: 6.1 rows/s during a read
on 4x RTX 5080, 4 stages (blange48 on #2326). Windows places the host with CPU Sets, which new threads do not inherit,
so it did not show there. Both threads now move off the host's core and the pool workers' CPUs (on an SMT machine with
the default pool: the idle sibling threads; without such CPUs, every CPU but the host's), and the threads that already
run on the host's CPU (the prompt path's copy threads, started by the first prompt) move the same way at a read.
--aux-cpus places them all when it is on. STRATA_PIPED_CONCURRENT_UNPIN=0 keeps the inherited CPUs (an A/B).

Measured on Linux (WSL2, Ubuntu 24.04, 2x RTX 5090, 4 streams, a cold 32K prompt; same binary, UNPIN=0 / default): the
slots decoded 25.9 and 23.7 rows/s during the read with the threads kept on the host's CPU (76-81 threads there), and
75.6 and 62.8 with this change; with the n-gram table in RAM (WSL's disk reads it 7x slower than Windows) 110.7 rows/s,
as on Windows (107.0). piped_read_join_bench.py (cold32k, followup, two_joiners; table in RAM, 2-3 runs each): 24-37
rows/s during the 30K+ reads kept on the host's CPU, 95-127 with this change, and 925-1,065 stream tokens during the
cold 32K join against 454-484. On Windows, STRATA_TEST_CONC_ON_HOST_CPU=1 (a test) puts both threads on the host
thread's CPU set first: 18.6 rows/s kept there, 105.4 with this change.
Niko1221 added a commit that referenced this pull request Oct 17, 2026
…#2050, opt-in STRATA_PIPED_READ_PIECE), on top of #2326's concurrent read

Squash of SuperDistance's three commits of PR #2050 (decode between the pieces of a read on the pipelined groups, the
piece lines counting the rows the slots decoded meanwhile, the stall watchdog staying on for a read in pieces),
applied after #2326 here: the BYIELD give-way is #2326's give_way lambda, the concurrent block runs first and the
piece path serves a read that STRATA_PIPED_CONCURRENT leaves to it (pictures, configurations where it is off).

Co-authored-by: SuperDistance <43229543+SuperDistance@users.noreply.github.com>
@Niko1221

Copy link
Copy Markdown
Owner

Thank you, this is a good piece of work. We ported all six commits with your authorship and ran it on 2x RTX A4000 (Q2_0, layer split, 4 decoding streams, pipelined groups, 3 interleaved rounds): the longest stall of a stream during a cold 32K join goes from 17.7 s to 1.7 s, with two joiners from 26 s to 1.5 s, and a short follow-up asked during a 40K read gets its first token after 5.9 s instead of 18.6 s. The joining prompt pays for it (its first token comes about 25% later, aggregate tok/s is a little higher). Eight repeats of the three scenarios with the mode on gave no hang, stall report or error. With the variable unset everything is identical to main (identity gate on sm_86, sm_60 and gfx1201). It stays opt-in and experimental in 0.1.45; we would like more reports from 3-4 stage splits before we change that.

@havok2-htwo

Copy link
Copy Markdown
Contributor

A 4-stage report on stock 0.1.45 (477ac13c, setup.py release flags, CUDA 13.0): 4x RTX 3090 24 GB, Qwen3.8-Flash-Next UD-IQ4_XS native pack (--native GGUF), --layer-split 12,25,37 --trim-stage-weights --resident-experts, 16 slots (4 groups of 4), --spec 4, --kv int8 --kv-resident 28672, greedy, STRATA_IQ_MT_MIN=1.

STRATA_PIPED_CONCURRENT=1 never ran here. Every start logged STRATA_PIPED_CONCURRENT is off beside an expert source that stages its blobs (the GGUF in place, unbuffered, STRATA_IO_PREFETCH), and a run with STRATA_SPLIT_MTP_BATCH=1 logged ... off beside STRATA_SPLIT_MTP_BATCH. Its numbers match unset, so its 8 repeats with 0 hangs say nothing about the mode. That comes from our flags and pack: --resident-experts implies --mmap-experts, and our pack has no experts.bin, so the experts come from the GGUF shards in place, which BATCHING.md lists as an off case. Here every expert is in VRAM (layer split: 100% of the experts resident), the decode windows never fetch one (zero-doorbell graph on all 4 stages), the prompt path does not borrow (too few cache slots to borrow), and after the load the source handed out 0 blobs. Is the gate needed in that case, or is the RAM arena (no --resident-experts) or an iq_pack.py --experts-bin pack the way to run it on 4 stages? I can rerun the same arms either way.

tools/piped_read_join_bench.py, 3 interleaved rounds, min-max per cell:

unset PIECE=16384
cold32k: longest stream stop 8.36-8.40 s 4.49-4.54 s
cold32k: joiner TTFT (30.8K) 8.16-8.20 s 10.72-10.83 s
cold32k: stream tokens during the join 205-220 786-838
two_joiners: longest stop 25.92-26.02 s 9.16-9.28 s
two_joiners: joiner TTFTs (65.5K / 29.3K) 16.67-16.71 / 19.81-19.90 s 38.02-38.45 / 15.24-15.49 s
two_joiners: aggregate tok/s 121.8-122.7 111.0-119.9
followup: short prompt (42 tokens) TTFT behind 44.3K 11.67-11.72 s 4.55-4.61 s
followup: the 44.3K read's TTFT 11.37-11.41 s 13.34-13.46 s

Determinism prompts (sequential) were 6/6 identical, unset vs PIECE and unset vs CONCURRENT=1. Our agent mix, one run per arm, took 206.0 s unset, 207.4 s with CONCURRENT=1 and 276.2 s with PIECE. Replies differ between runs even between unset and CONCURRENT=1, which ran the same code path (bench_multiturn 32/48 replies identical; cause not checked). So the PIECE run did different work: 5,577 vs 8,041 output tokens, and 4 vs 10 chat requests (the bench sends them at random intervals, ~25 s mean).

For context, our fork's own overlapped read (0.1.44.1, not upstream code; in the builds measured here it never gave way; our own bench, --kv-resident 32768) on this rig: beside a cold 51.6K join the worst stop fell from 7.7 s to 0.56 s, and the joiner's TTFT rose from 7.7 s to 10.7 s.

Not tested: PIPED_CONCURRENT itself, the RAM arena (without --resident-experts), an iq_pack.py --experts-bin pack, HIP/SYCL/Metal, sampled output, other models.

@Vop3p

Vop3p commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

A HIP data point for STRATA_PIPED_CONCURRENT=1, since the docs say it has not been run on an AMD GPU yet.

Rig: 2x RX 6900 XT (gfx1030), ROCm 10.0, Linux, Ryzen 5 5600X, 128 GB DDR4. Flash-Next GSQ-RCO IQ3_S, 262144 context, --kv int8 --kv-resident 32768, layer split with part of the experts in RAM (not all resident), --spec 3 --spec-min-p 0.7, "parallel": 2, --batch-groups 2 --batch-mtp. Engine 0.1.45 (477ac13), some arms with local opt-in patches on top. A fresh engine per arm.

Stability: 6 arms with the variable on, 136 concurrent reads in total (8 of them with a slot decoding beside the read), up to 57K tokens per read: 0 stalls, 0 hangs, 0 stall reports. Each arm also read a 64K prompt with two codes in it while a short request arrived; both codes were answered correctly in every arm.

A long read and a short request (64K prompt, a one-line request sent while it is read):

short request TTFT long request TTFT
0.1.45 without the variable 36.8-37.4 s 35.1-35.9 s
STRATA_PIPED_CONCURRENT=1 8.1-8.2 s 41.1-43.6 s

Cost on a single request: none that I can measure. With the variable on and off (both with STRATA_SPLIT_MTP_BATCH=0), the outputs were identical token for token (6 of 6 requests at 4K / 32K / 128K), decode within 0.6%, prompt reads 0.6-2.1% faster with it on.

Two things worth knowing on this kind of rig:

  1. It is off beside STRATA_SPLIT_MTP_BATCH=1 (STRATA_PIPED_CONCURRENT is off beside STRATA_SPLIT_MTP_BATCH). Turning that off costs us 1.0-4.0% of prompt speed here (same binary, 4K / 32K / 128K). If the batched draft K/V could be sized before the read, as mtp.reserve_prefill does for the drafter's prompt buffers, the two might be able to run together. I have not tried it.
  2. Beside a long read, a decoding slot is slow here: 1.8 rows/s during a 57K read (15 rows/s during a 4K read). I assume that is the borrowed VRAM experts computed on the CPU, which costs more when part of the experts already live in RAM.

Developed with an AI coding assistant; every number above was measured on 2x RX 6900 XT (gfx1030), ROCm 10.0, Linux.

@havok2-htwo

Copy link
Copy Markdown
Contributor

Thanks, this is useful, especially the 64K case with a short request beside it.

Our data is on the other side of the gate. On 4x RTX 3090 with the native GGUF pack (no experts.bin), the engine turned the switch off at every start ("off beside an expert source that stages its blobs"). So the 0.1.45 rows above are unset vs PIECE=16384, not CONCURRENT vs unset.

On your point 1: we ran STRATA_SPLIT_MTP_BATCH=1 with the switch on in the batched arms, and the engine still turned it off beside that switch. Your suggestion to size the batched draft K/V before the read sounds like the way to get both. Do you know whether anyone has tried that?

Your point 2 (decode slow beside a long read) matches our numbers: during a cold 32K read, the worst stream stopped for 8.4 s without PIECE. With PIECE=16384 it stopped for 4.5 s, but the joiner's TTFT went from 8.2 to 10.8 s.

Not tested by us: HIP, the batched draft path, a pack with experts.bin.

@Vop3p

Vop3p commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

@havok2-htwo I tried it: #3103 (opt-in STRATA_PIPED_CONCURRENT_SMB=1).

Why the two were exclusive. In Prefill::draft_kv the row buffers are carved from the prompt path's scratch region and its two uploads fit what reserve_uploads already holds, so neither allocates. What can is the Q8_0 MMQ: its context is made at first use, and llama.cpp takes the stream-k fixup buffer from the context's pool, which grows on the first larger request. That only happens where the MMQ configs use stream-k: NVIDIA (Volta and newer) and CDNA. On our RDNA2 cards every config has stream_k = false, so there the batched draft K/V never allocated on the read's thread in the first place.

The change. Before each concurrent read (next to mtp.reserve_prefill and reserve_uploads), it creates the MMQ context and grows the pool once to the largest fixup, one 128 x 128 float tile per SM. Then concurrent_read_off no longer names STRATA_SPLIT_MTP_BATCH.

On 2x RX 6900 XT: 9 arms, 0 stalls; long read + short request, two streams, hand-off and a needle across the yield point all fine (short TTFT 8.0-8.1 s). Prompt read +4.0% at 4K, +1.8% at 32K, +0.9% at 128K (3 interleaved pairs, identical outputs run to run). The catch is STRATA_SPLIT_MTP_BATCH itself: its MMQ draft K/V move the drafts, and acceptance fell about 2 points here (87.5 -> 85.3% on the same requests), so decode at 4K/32K was about 3% lower. On our rig that cancels out and we leave it off. Details in the PR.

What I cannot test, and where your rig would help: the stream-k path that the reservation is actually for. If you can, a run on your 4x 3090 with STRATA_PIPED_CONCURRENT=1 STRATA_SPLIT_MTP_BATCH=1 STRATA_PIPED_CONCURRENT_SMB=1 would tell whether it holds on CUDA: a few cold long reads beside a decoding slot, no stall, and whether the prompt read gains more than on ours. Your note that the native GGUF source keeps the concurrent read off would need a pack with experts.bin for this, I think. Reply here or on #3103, whichever is easier.


Developed with an AI coding assistant; every number above was measured on 2x RX 6900 XT (gfx1030), ROCm 10.0, Linux.

@Niko1221

Copy link
Copy Markdown
Owner

Thanks for the pull request.

Your change is already in main for 0.2.0-beta, with your credit, so there is nothing left for you to do here. We're closing it because we're resetting the tracker for 0.2.0-beta: there are more than 1,300 open issues and pull requests, and we're closing everything that isn't being worked on right now.

If part of your work did not make it in and you want it considered, open a new pull request on top of main that follows CONTRIBUTING.md, or comment /reopen after fixing the title area prefix and the template.

@Niko1221 Niko1221 closed this Oct 19, 2026
atmJava pushed a commit to atmJava/Strata that referenced this pull request Oct 24, 2026
…osted, and the handover gains the corrections

Draft C was written from L-011 and L-012 and all three comments were posted on 2026-10-24 02:08Z: A to Niko1221#2275
(the gate predicate question), B and C to Niko1221#2197 (the seventh sm_75 box, then the mode-8 numbers). No pull
request was opened - A asks the maintainers whether the predicate should change before one is offered.
Before posting, the threads were checked: Niko1221#2326 is closed (its change is already in main for 0.2.0-beta),
Niko1221#2197's last comment is rafatxf questioning which draft path the DFlash2 row actually ran, Niko1221#3724 is still
open with no comments. Draft B no longer says EXP_MODE was unmeasured, and Draft A now states plainly that
its two arms were the same binary with the variable set or unset, in the gate config rather than the
production shape.

Handover corrections a reader would otherwise act on wrongly:

  - S1 is not "measured but not in production": those two lines are in the engine binary that is running
    now, and concurrent reads happen every day (L-013). The open question is the production-shape trade and
    whether to turn the variable off, not whether to ship the change.
  - the sampler's hit-rate column is thinner than the service log's per-request lines; hit rate is judged
    from the service log from now on (L-012).
  - MainPID is the python server, not the engine: its environ holds no STRATA_* at all, so reading it reads
    "the environment did not apply". Read the engine child instead.
  - mtimes alone cannot pin which source a binary came from (cp -p preserves them); reverse-patch and
    compare hashes.
  - the sudo password question was asked together with a new GitHub token and the password itself was not
    answered, so it stays open. mode 8 was approved for production and the old goal is superseded.
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.

7 participants