Skip to content

[Engine] dsv41: batched serving (M1, M4-M6, M2 step) - one pass for the whole batch, 2x aggregate at 16 slots - #4199

Closed
suifei wants to merge 117 commits into
Niko1221:mainfrom
suifei:feat/dsv41-batch-m1
Closed

suifei wants to merge 117 commits into
Niko1221:mainfrom
suifei:feat/dsv41-batch-m1

Conversation

@suifei

@suifei suifei commented Oct 26, 2026 •

Copy link
Copy Markdown

dsv41 batching: M1-M6, with the measurements the PR needs

Ready for review. M1, M2's foundations, M4, M5 and M6 are in, tested and measured; the single-launch decode step is
not, and section 5 says what that costs and what it still needs. The two test sections the owner asked for - concurrency
against the engine without batch slots, and how many concurrent requests the L20 holds at the production 1M context with
256K max_tokens - then the same two questions on Qwen3.8-Flash-Next (256K context, 128K max_tokens).

1. Concurrency against the engine without it (real 1M context)

Box: 4 x NVIDIA L20 (46 GB each), the IST-DAS 2.25 bpw pack, the production configuration (--max-context 1048576,
--experts 6, --tp 2, DSpark on). Every request asks max_tokens: 262144 (256K) and leaves after it has read its
share; thinking is off, so the tokens counted are the answer's.

clients engine wall first token per client handovers aggregate
3 stock (no --batch): the server's FIFO 14.55 s 0.50 / 5.81 / 10.84 s 2 / 900 61.9 tok/s
3 this branch, --batch 3 14.43 s 0.31 / 0.76 / 1.04 s 167 / 900 62.4 tok/s
16 stock (no --batch) 27.35 s within 25.28 s (last: 25.93 s) 15 / 1600 58.5 tok/s
16 this branch, --batch 16 31.08 s within 4.15 s 219 / 1600 51.5 tok/s

The branch changes when a user sees their tokens: the stock engine runs the answers one after the other, so the last
client's first token waits for every answer before it (10.84 s at three clients, 25.93 s at sixteen), while the branch
admits them all at once (0.31-0.76 s at three, 4.15 s at worst at sixteen) and their tokens interleave. At three
clients the aggregate is identical (62.4 against 61.9 tok/s); at sixteen it is 12 % lower, because the slots' KV comes
out of the experts' VRAM. The aggregate itself needs the single-launch step (M2's remainder) and the grouped MoE (M3),
which the routing measurements bound at 1.15-1.28x on this box.

2. How many concurrent requests the L20 holds at 1M context (256K max_tokens)

Every run loaded the model at --max-context 1048576 and every client was answered.

slots (sequences) wall first tokens within handovers aggregate each client
3 (4) 14.43 s 0.74 s 167 / 900 62.4 tok/s 20.8-21.1 tok/s
6 (7) 20.46 s 1.44 s 181 / 1200 58.7 tok/s ~9.8 tok/s
8 (9) 14.74 s 1.96 s 119 / 800 54.3 tok/s ~7.0 tok/s
10 (11) 18.50 s 2.50 s 133 / 1000 54.1 tok/s ~5.5 tok/s
12 (13) 22.12 s 2.95 s 167 / 1200 54.3 tok/s ~4.6 tok/s
16 (17) 31.08 s 4.15 s 219 / 1600 51.5 tok/s ~3.3 tok/s

Sixteen concurrent requests at the 1M context load and are all answered (17 sequences: the sixteen slots and the
solo one), with 0.59 GB a GPU left. The price is expert residency: a sequence's compressed KV is ~0.54 GB a GPU
(~2.2 GB in all), taken from the experts - 4224 of 4224 experts stay in VRAM with one sequence, 3662 at 13 sequences and
3441 at 17 - and the aggregate drifts from ~62 to ~51 tok/s as the displaced experts are read from RAM. At a 32K
context the same sixteen slots keep every expert (4224 of 4224, the tables 0.30 GB against 9.14 at 1M) and score 57.2
tok/s, so about half of the drift is the displacement and half is what sixteen sequences' passes and working sets cost
by themselves. Capping what one slot may hold (the design's 32K a slot) is the lever that would raise both.

Why sixteen concurrent requests are 13.6 % slower in wall than the stock engine (31.08 against 27.35 s): the engine
says it at load - with one sequence "the experts VRAM leaves over (~0.0 GB) are read in place from the model's files",
with the sixteen slots' seventeen sequences "~13.8 GB". The 1M-context KV is 9.14 GB a GPU for them against 0.54 GB for
one, and the experts it displaces are read per token. The 32K control (all 4224 experts resident) takes 28.0 s for the
same sixteen clients - +2.4 % is the interleaving, ~+11 % the spill. Measured against the 31.08 s baseline:

16 slots, 1M context wall aggregate note
--slot-burst 32 31.14 s 51.4 tok/s no gain, first token 9.45 s against 4.15
STRATA_DS4_BATCH_NOSPEC=1 36.52 s 43.8 tok/s the drafter is worth 5.4 s here - keep it on
--experts 5 28.67 s 55.8 tok/s 17 % less expert traffic a token

The three levers, in order: the single-launch step (amortise the weights - M2's remainder), a per-slot context cap (the
tables are what displace the experts), and --experts 5 where the last expert is not worth it. Warming helps only the
first tokens (the pack's cold reads and the empty RAM store after a load), not the steady state.

3. Qwen3.8-Flash-Next: the same two questions (256K context, 128K max_tokens)

The Qwen path (strata, ISTA-DASLab IQ2_XS, 69 GB) is untouched by this branch; it already decodes several requests in
one window ("parallel": N). Same box, same client, --max-context 262144 and every request asks
max_tokens: 131072.

parallel clients wall first tokens within handovers aggregate expert cache
1 3 4.11 s 2.87 s 2 / 300 73.0 tok/s -
4 4 7.78 s 1.87 s 364 / 400 51.4 tok/s 96 %
8 8 20.69 s 6.11 s 769 / 800 38.7 tok/s 84 %

It runs normally on this tree, and at 256K four requests are the sweet spot (all admitted at once, first tokens
within 1.87 s, 364 handovers) while one at a time keeps the highest aggregate (73.0 tok/s) at the price of the queue
(the third client waits 2.87 s) - and eight slots cost both (38.7 tok/s, 6.11 s) because the extra KV comes out of the
expert cache. The controls across contexts say the same thing from both sides: at 32K the same batching is a real
aggregate win (25.0 -> 45.6 tok/s, 1.8x, 377 handovers against 2) - what this branch's single-launch step (M2) and
grouped MoE (M3) would buy; at 1M the engine needs --kv q4_0 (with int8 the 1M-token KV leaves no VRAM for its
expert tier and it refuses), where parallel 1 took 5.99 s for three clients (50.1 tok/s) and parallel 4 12.92 s for
four (31.0 tok/s, cache 68-78 %). The first case after each load pays the pack's cold reads, so its single-request rates
are the low ones.

4. What else the branch carries: the monitor's parallel view (M6)

The built-in API monitor (/api-monitor) shows the batch as it runs, in the page's own vocabulary: a row of slot cells
that light while a slot holds a request, an area chart of requests in flight over four minutes with the session's peak
marked, and four facts (in flight, slots busy, waiting, peak). It draws itself only where the server serves more than one
request at a time, so a single-request deployment looks exactly as it did.

The data is the engine's own - /v1/status's concurrency gained slots (a slot's busy flag), busy and waiting
(requests admitted but holding no slot). Measured with two slots and three clients at 8K: in_flight=3 slots=BB busy=2 waiting=1, then B. and .. as they finish; the page carries the panel's seven elements and the three clients answered
in 5.16 s with 164 handovers.

5. Status: what is in this branch, and what is not

In, tested, and measured:

  • M1 - per-sequence state (rings, compressed tables, the compressors' partial groups, positions and tokens), the live
    slots (STRATA_DS4_BATCH_LIVE), and the scheduler that takes the model a slot a turn.
  • M2 - the per-sequence regions and the per-row pass: absolute selection rows, AttnRows in its explicit form, the
    per-row chunk path (StepRows through layer_chunk), proved bit-identical to the contiguous path in process
    (the same chunk through both paths on the same state, 300 tokens in 38 chunks through every layer, no difference in the rows, the compressed KV or the index keys). Re-running that A/B on the final tree showed it had never reached the per-row branch: --prefill-chunk 8 is clamped to 256 at load and chunk_rows takes the per-row path only at T <= 16, so both sides ran the same code. Driven at T = 16 the per-row path is ~270x slower than the contiguous one (a 128-token prompt: 857 ms against not finishing in 233 s); one cause is fixed here - the indexer's weight block was rebuilt once a row - and the rest is open. Two faults found on the way are fixed too: a pos_rows that pointed at pageable host memory, and a per-row ring write that read the block after its own. The
    decode side's plumbing is in too: Run
    carries the descriptor, forward_chunks builds one chunk of T rows from it, the Engram rows are read per row, and
    forward_rows is the entry.
  • M4 - the BGEN/BADM/BT/BDONE/BSTOP protocol, --batch N, and the server's batched streaming path: three clients over
    HTTP, every one answered.
  • M5 - the numbers above, and the parameters computed rather than written: INFO batch_* (a sequence's tables, the
    room left, the count it allows, the experts the cards hold, from the pack's geometry and cudaMemGetInfo), the burst
    measured per turn from the switch cost, and a slot's context computed at load, written beside the pack and applied by
    the next load (STRATA_DS4_AUTO=1): at 17 sequences 1M a slot became 373K, the tables 964 -> 344 MB a sequence, the
    experts 13190 -> 14809 of 15360 in VRAM.
  • M6 - the monitor's parallel view (section 4).

Not in yet: the single-launch decode step itself - the serve loop calling forward_rows once for all the active slots
and sampling each row (the pieces are in: the descriptor through the chunk path, the per-row Engram reads, the per-row
sampler). What it still needs is measured rather than assumed now: on this box every multi-device run is layer-split
(--tp 2 and --tp 4 both collapse to one member a group), so a layer's compressed rows are written on another device
and the per-row path refuses without a cross-group hand-off (the design note's item 2: one copy of the step's T latent
rows to the owning group, ~32 KB a layer a step); and the per-row chunk path is ~270x slower than the contiguous one at T = 16 (one cause fixed), so it needs a profile pass before the step can rest on it. Until the step lands the engine keeps one pass a sequence, so this branch buys waiting time and
fairness
(first tokens within 4.15 s against 25.93 s at sixteen clients) rather than aggregate throughput. That step is also M3's
prerequisite: the grouped GEMM over the step's T routed rows is what would turn the 1.15-1.28x bound (route_overlap)
into the 2-3x the milestone promises.


dsv41: one pass for the whole batch

M2's batched step now runs on the decode path's kernels instead of the chunked prompt path: one pass over T rows, a
token of a sequence each, the same single launch a decode token takes. The chunk path paid ~200 ms of fixed cost a pass
(a device sync after the routing, the bounds over the bus, ~40 kernels a layer); the window path pays ~16 ms.

What changed

  • forward_rows -> forward_step -> the window path's forward_par/forward_serial and layer(), with every
    per-position piece taken from the row itself: finish's pos_rows rope, the row's own ring block in the window
    buffer, its own compressor group, its own rows of the compressed tables (one finish a row, at the row's own position
    and destination), and the indexer a row at a time - its own sequence's region of the index keys, and its own picks
    made absolute in that region (rows_add_i with kv_base(sr, seq)).
  • the cross-group hand-off for a pass's rows: the rows a pass completes are staged in row order (a pass a chunk), the
    readers place them in their own copies, and every device's work is finished before the next pass stages.
  • finish's pos_rows indexes the query, not the row (pos_rows[v / vpp]). A step's q carries vpp = NHL rows
    a query, so the old indexing read another row's position - and the chunk path's step wrote its compressed rows
    unroped (rd = 0), which is the drift it showed from the third token on.
  • --slot-context N / STRATA_DS4_SLOT_CONTEXT: the tokens a conversation may hold. The pack's compressed tables are
    sized a sequence by it - 965 MB a sequence at 1M, 30 MB at 32K - which is what keeps the experts in VRAM: at 16
    slots and 1M they cost 15.4 GB a GPU and 2170 of 4224 experts go to the host lane (a pass then reads ~27 GB); at 32K,
    none do.
  • two races on buffers reused within a pass, both fixed: the window path's staging had no acknowledgement (a reader's
    copy could still be in flight when the next pass staged), and the serial path staged every row through one pinned
    buffer (replicate); a pass's device is now synced at the end of its work, and a row stages in its own slice.

Numbers

Four L20s, 1M max-context, greedy, DSpark off, 64-token prompts, 96 tokens a slot, --slot-context 32768, each step run
against the bursts of its own slot count (batch_step_bench.py --ref):

slots bursts, tok/s step, tok/s gain tokens
2 43.0 59.9 +39 % the reference's, bit for bit
4 43.2 77.6 +80 % the reference's, bit for bit
8 43.3 88.6 +105 % the reference's, bit for bit
16 43.4 86.7 +100 % differs - see below

The step's first tokens all arrive at one instant (0.88 s at 2 slots, 3.40 s at 8, 6.75 s at 16 - the last prompt's
read) against the bursts' 0.41 s spread, and at 16 slots nothing goes to the host expert lane.

At 1M with no slot cap (a shorter run, 32 tokens a slot): 2 slots 31.3 -> 39.4 (+26 %, 125 host experts) and 8 slots
31.0 -> 49.1 (+58 %, 984 host experts, 2.6x a slot). The flow streams a step's experts on this path at no cost
(39.4 with it on, 39.5 with DS4_FLOW=0, the same tokens), which the chunk path's flow could never do - it deadlocked
on a step's rows.

Exactness

Bit-identical to the burst path at 2, 4 and 8 slots. Above 8 rows the pass is deterministic and self-consistent but
rounds differently from a one-token decode: in the first pass, layers 0-12 agree to 1e-7 and layer 13 jumps to 1e-2 (an
indexer top-k tie flipping on a 1-ulp difference), which greedy decode then amplifies. Not a race, not a per-row
defect - the harness is tools/dsv41/same_probe.py (one prompt for every slot, or two alternating) plus
cmp_hash.py/cmp_num.py over the per-layer hashes of row 0 (DS4_STEPCHECK=2).

Tests

  • serve.test_server: Ran 348 tests, OK
  • tools/dsv41/batch_probe.py (two slots): all checks passed
  • tools/dsv41/batch_step_bench.py, same_probe.py, steprows_check.py
  • docs/DSV41_HANDOFF.md: the whole path - measurements, the dead ends, and the two remaining questions.

Debug switches the work left behind, all off by default: DS4_STEP_CHUNK=1 (the chunk path's step, for an A/B),
DS4_STEP_SERIAL=1 (a pass on one thread, replicate, no staging), DS4_STEP_SYNCROUTE=1 (the route on the device,
no mailbox), DS4_STEPCHECK=N (pass shape, per-layer hashes, row-0-vs-row-2 check).

suifei added 30 commits October 26, 2026 16:15
The engine serves one sequence at a time; docs/DSV41_BATCHING.md scopes
continuous batching in four milestones.  This is M1's first piece: the step
scheduler as a header-only, GPU-free component (src/dsv41/ds4_sched.hpp) that
owns the order and the token budget the engine will follow:

* running sequences take the batch first, round robin: a sequence still reading
  its prompt gets its next chunk, a read one decodes one token;
* waiting sequences fill the rest, a chat question jumping a queued
  40K-token agent prompt (STRATA_ADMIT_SHORT_FIRST's rule, FIFO otherwise);
* the per-step token budget caps prompt tokens, so prefill never starves decode;
* cancel/retire, and max_new retirement.

No behaviour change: nothing calls it yet, and the engine still runs its
single-sequence path.  M1 wires it into ds4_main.cpp (one kernel call per
entry), M2/M3 replace those calls with varlen attention and a batched MoE.

tests/dsv41/sched_test.cpp covers the admission order (short first and FIFO),
chunking a 5000-token prompt, the budget across three prompts, round-robin
decode, max_batch, cancel and retirement; ds4_sched_test is the CMake target.
M2 of docs/DSV41_BATCHING.md needs per-sequence KV: the single-sequence engine keeps one ring per layer, so a
batch cannot address two sequences' tokens.  This adds the index arithmetic as a GPU-free header:

* KvSeq: a sequence's block table over an arena of block_tokens-sized blocks; slot(pos) for the varlen kernel,
  grow() that appends blocks from a free list, release() back to it;
* KvBatch: the arena and its sequences in LRU order; touch() grows a sequence's table, evicting the oldest other
  sequence when the arena is full (the conversation slots' and expert cache's order), close()/open().

No behaviour change: nothing includes it yet; M1's wiring and M2's kernels come next.
tests/dsv41/batch_kv_test.cpp covers slot arithmetic (offsets inside a block, block crossing), two sequences not
sharing a slot, arena exhaustion with LRU eviction, a lone sequence never evicting itself, release/reopen and the
LRU order; ds4_batch_kv_test is the CMake target.
The serve loop took the next stdin line and ran it to completion, so a queued chat question waited behind
the agent prompt already in front of it.  The line reader gains try_next()/push_front(), and before a
GEN/GENI runs the loop looks at what is already waiting: a shorter prompt goes first
(STRATA_DS4_ADMIT_SHORT_FIRST, default 2048 tokens, 0 turns it off) - the server rule, now at the engine
where M4's queue needs it.

One request at a time is unchanged, and nothing calls the batch scheduler yet; verified by building and
--pack-info only, the behavioural test needs the model (a restart window).
The batch needs one thing between the scheduler and the kernels: for every entry of a step, the arena blocks
that run's tokens live in and the offset inside the first one.  This adds it as a GPU-free component:

* KvBatch/KvSeq gain advance() and blocks(), so the caller can move a sequence on and read its table;
* BatchPlanner::build() walks a StepPlan, grows each sequence's table through KvBatch::touch (evicting the
  oldest other sequence when the arena is full, reported to the caller), and returns SeqRun entries:
  position, token count, blocks, offset - what a varlen attention launch and a batched MoE take;
* an entry for a sequence the arena does not hold is an error, not a crash.

Nothing calls it yet; M2's kernels and the serve loop's queue are the callers.  tests/dsv41/batch_plan_test.cpp
covers a decode run beside a 6-token prefill (two blocks), advancing positions across a block boundary, the
eviction report and the unopened-sequence error; ds4_batch_plan_test builds and passes in CMake.
admit_short_first() looked at the lines queued at that moment and swapped a single shorter one in front; it
now collects everything waiting, orders requests shortest first (stable, non-requests after them) and takes the
first - one pass, no starvation change beyond arrival order among equals.

--admit-order reads GEN/GENI lines from stdin and prints the order the serve loop would take, without loading
the model; the window test showed the reorder only fires for requests already queued when a step starts (the
second request arrived 0.2 s after the first was taken, so the long prompt ran first).
The low-cost compromise in docs/DSV41_BATCHING.md lets two requests take turns on the one model: a burst of
decoded tokens, then slot_save() into host memory, then the next request's slot_load().  This adds the memory
side as a GPU-free component:

* StateBank::put/get/drop over byte vectors, bounded by bytes and count;
* a get() refreshes the entry, eviction takes the oldest first (the same order the KV block tables and the
  expert cache use); evictions and their bytes are counted;
* a state larger than the whole budget is refused rather than silently parked.

Nothing calls it yet: the serve loop's burst integration is the next step and needs a model run to verify.
tests/dsv41/state_bank_test.cpp covers put/get/drop, replace, LRU order after a get, the byte budget and the
refusal; ds4_state_bank_test builds and passes in CMake.
The serve loop still runs one request at a time; the flag is parsed and recorded so the split of the decode
loop (tok/got/produced/counters parked in a GenState, slot_save/slot_load between turns) can follow without
another CLI change. Nothing reads it yet: decoding is byte-for-byte what it was.
The engine runs one request at a time; a request can still be split into short generations whose continuation
reuses the conversation (the engine skips the shared prefix), so another request takes the engine between two
chunks. This adds the bookkeeping, with no engine and no HTTP in it:

* split(max_new, burst) -> the chunk sizes;
* BurstCursor: the growing id list for the continuation, tokens produced, the EOS cut inside a chunk and the
  stop when max_tokens is reached.

serve/server.py does not call it yet; wiring it into the generation path and testing it with three clients is
the next step, and it needs a server restart (about 90 s of model load).
tests: serve/test_burst.py (4 tests).
Proves the bookkeeping of the chunked path without a model: three requests, one burst each per round, strict
interleave; a short request leaves the rotation when it finishes; the continuation carries the prompt plus the
tokens generated so far.  serve/server.py still sends one generate() per request; wiring this into run() is the
next step and needs a server restart to test.
A pass may now be capped at STRATA_BURST tokens; when it hits the cap and the model did not stop, the reply
continues with prompt = ids + generated (the engine reuses the shared prefix), and between passes the FIFO is
free for another request - the interleaving the batching design calls B2.  Off unless STRATA_BURST > 0; the
continuation is gated by STRATA_BURST_CONTINUE=1 so a capped pass without it still ends the reply (what the
server did before).

Validated against the mock engine (no GPU): the same 40-token request returns 8 tokens with
STRATA_BURST=8 STRATA_BURST_CONTINUE=0 and the full 40 with STRATA_BURST_CONTINUE=1, the production port 8080
untouched (health 200).
serve/server.py turns its batched path on from one INFO line (self.batch = int(self.info.get("batch_slots"))), and it
drives the engine with BGEN/BGENI, BADM, BT, BDONE, BSTOP and BYIELD - not the "BGEN behaves like GEN" shape the handoff
sketched.  The dsv41 engine speaks all of them now:

  BGEN[I] <slot> <max_new> [keys] <ids>  the prompt is read and its first token sampled as a GEN 1 (PP / T / DONE on the
                                         control lines), then BADM <slot> <1: the slot decodes the rest | 0: done>
  BT <slot> <id> ... BDONE <slot> <produced> <finish> <ms>  the slot's tokens; <produced> counts the admission's token
  BSTOP <slot>                           that slot ends at its next token (BDONE ... cancel)
  BYIELD <slot>                          accepted and dropped: this engine reads a prompt whole

--batch N is what prints INFO batch_slots=N; without it the engine is exactly the one-at-a-time engine the server ran
before, so the production config is untouched.  One slot at a time until per-sequence KV (M2): the model holds one
conversation, so a slot's answer runs to its end before the next request is read.  The win is the protocol, not
throughput.

Tests: ds4_batch_proto_test (the line split and when an admission goes on; no GPU), and tools/dsv41/batch_probe.py
against a live engine on the box - BGEN's tokens equal GEN's for the same prompt (12/12), BADM 0 for max_new 1, BSTOP
ends a slot with BDONE ... cancel, a slot after a cancelled one still works, a bad slot gets ERR BGEN.  Through the
server on port 8099 (--batch 1): one request alone answers exactly as production (0.66 s vs 0.65 s), three clients at
once all answer with the reference prompt's byte-identical answer through BGEN, a max_tokens 1 request completes, and a
client that leaves mid-stream (BSTOP) does not disturb the next request.
With --batch N > 1 the serve loop runs the admitted requests in turn: the model holds one conversation, so a slot that
is not decoding is set aside in host memory (Model::slot_save) and loaded back for its next burst of --slot-burst tokens
(16 by default); its tokens stream as BT <slot> <id> and BDONE ends it as before.  A prompt read, an admission and a
solo GEN all set the current slot aside first; a finished slot's conversation stays in the model and the next admission
parks it into the conversation folder, so a later turn still reuses its prefix.

What it buys is the latency half of docs/DSV41_BATCHING.md: three clients' answer tokens interleave instead of queueing
(8 handovers over 47 answer tokens, against 2 with one slot) while the aggregate stays put (4.13 s with one slot, 4.23 s
with two, 120/60/40 tokens) - the same tokens go through the same kernels, so M2/M3 are still what raises throughput.
A request with pictures cannot be parked and runs to its end; a prompt read still blocks the other slots (BYIELD is
dropped).

tools/dsv41/batch_probe.py grew the two-slot check: two admissions sent at once, each slot's tokens equal its GEN
reference, their BT lines interleave (0001110000111100001111), and each slot ends with its own BDONE - all green on the
box.  Over HTTP with --batch 2, three clients interleave, the reference answer admitted through BGEN behind them is
byte-identical to production, and a client that leaves mid-stream (BSTOP) does not disturb the next request.
…1#656)

The server reserves a slot and sends BYIELD <slot> when a much shorter prompt waits for the control lines; the engine
ignored it, so a 4k-token agent prompt held the engine for its whole read.  With --batch N the prompt is now fed in
1024-token slices, and between two of them a pending BYIELD ends the read: the part read stays in the model (its tokens
= where it stopped), the engine answers YIELDED <slot> <tokens> + DONE (and BADM 0 for an admission), and the server
sends the same prompt again later - the read goes on from there, never from 0.  Without --batch the one-at-a-time engine
is untouched (one feed, no slices).

Measured on the box (4,031-token prompt, a short request 1.2 s behind it, --batch 2 --slot-burst 8): the short request's
first token at 0.77 s instead of 5.47 s; the engine's own line "the prompt read gave way after 1024 of 4031 tokens".
The HTTP check with the sliced read still gives the reference answer byte-identical to production, three clients
interleaving (8 handovers), and a client that leaves mid-stream handled.

Still one thing at a time: between its slices the read holds the model, so the short request's later tokens wait for the
long read to finish.  Interleaving the read slices with the slots' bursts (the scheduler of the design's section 3) is
the next step.
A read gives way to a shorter request (BYIELD), but between its 1024-token slices it still held the model: the short
request got its first token early and its answer waited for the whole read.  Between two slices the read's state is now
set aside (Model::slot_save), the active slots get bursts for about half the slice's time, and the state is loaded back
- so a 4k-token prompt is read while the other slot's answer finishes.

Measured (4,031-token prompt, a short request 1.2 s behind it, --batch 2 --slot-burst 8): the short request's first
token 0.77 s and its whole answer 2.59 s, against 5.70 s with neither mechanism and 5.98 s with the yield alone.  The
HTTP check stays green: the reference answer byte-identical to production, three clients interleaving, a client leaving
mid-stream handled.

A read whose state is over 32k tokens is not interleaved (the copies per slice would cost more than they give), nor a
read with pictures: both stay on the straight read.
STRATA_DS4_SLOT_TRACE=1 now prints per burst the tokens decoded, the burst's ms and the switch's ms (park + load), and
per read slice its size in MB, its park and its load - the numbers the tuning needs.  --read-slice N sets how much of a
prompt one feed reads (default 1024; a BYIELD lands between two slices).

Measured on the box (three clients, --batch 2, short conversations): a switch costs ~4.4-4.5 ms (the park and the load
of the state, a few hundred tokens here), so a burst of 8 tokens (~120 ms of decode) pays ~3.7% and the default 16
~1.9%; the wall time does not move with the burst (4.1-5.1 s across runs, the scheduling of three short clients is
noisy), while the interleaving drops (8 handovers at burst 8, 2 at burst 32).  The switch's cost follows the state's
size, so the context - not the kernels - is what caps the parked-state approach at chat-sized conversations.
The two-slot check is now general: with --slots N the probe sends N admissions at once, each against its own prompt,
compares every slot's tokens with the GEN the same prompt gives, checks each BADM <slot> 1 and each BDONE <slot> <n>, and
that the BT lines take turns.  --slots 3 on the box is all green (INFO batch_slots=3, three admissions, the order
012012..., three BDONEs).

Through the server with --batch 3 the three-client check is green too: the reference answer byte-identical to
production, the engine's trace shows slots 0, 1 and 2 decoding, mean switch 4.99 ms.
…admissions at once and the server's

three-client check is green with --batch 3 (engine trace: slots 0, 1 and 2 decoding, mean switch 4.99 ms).
batch_soak.py drives the test server with rounds of concurrent streaming requests and checks that every one finishes
with an answer and that the server stays healthy.  On the box (--batch 2 --slot-burst 8): 8 rounds, 32 requests, 0
failed, 4.1-6.1 s a round, health 200 after, both slots decoding, mean switch 3.07 ms, no engine ERR line and no server
traceback.

serve.test_server over the same tree: 352 tests, OK (275 s).
…xt turn

A slot that finished an answer sets its state aside in its own bytes (up to 64k tokens), and a later admission whose
prompt shares at least 16 tokens with it loads it back before the prefix logic runs - so the server's next turn of the
same chat stops at one of that conversation's checkpoints and reads only the new tokens.  The match is made across every
slot's kept conversation: a re-rendered turn drops the thinking the engine produced, so the kept state can be longer
than the new prompt and the server's token-level slot_held matches nothing.

Seen on the box (three-client check, --batch 2): the engine takes back slot 0's conversation with 101 of the prompt's
118 tokens shared, slot 1's with 28 of 43; the turn and HTTP checks stay green (the reference answers byte-identical to
production).  A single client still runs through the solo path (the server keeps it there while it is alone), where the
engine continues its state in place as before - so a two-turn single-client test shows no slot reuse, by design.
… a canary a round)

batch_fuzz.py sends rounds of 2-4 concurrent requests with random max_tokens (1..80) and, one in three, a client that
goes away mid-stream, then a canary request: a slot nobody released, or a stream that never ends, shows up as a canary
that stalls.

On the box (--batch 2 --slot-burst 8): 12 rounds, every round answered, the canary came back in 0.29-0.46 s, both slots
decoded, no engine ERR line and no server traceback.
A slot's turn switch copied its whole state to the host and back (slot_save/slot_load): ~4.5 ms on the box's short
conversations.  With STRATA_DS4_PARK_DEVICE=1 the state is parked in per-device arenas instead (Model::park_save /
park_load: the same segments slot_save walks - every window ring, the compressors' partial groups and each source's
compressed KV and index keys, per device and per tensor-parallel member - copied device to device, with the tokens and the
checkpoints kept beside them).  The host path stays the default and is untouched.

On the box (--batch 2 --slot-burst 8): the two-slot probe passes every check with the device park, the three-client HTTP
check is green (reference answer byte-identical to production), 0 host fallbacks and 0 load failures, and the engine's
own trace goes from ~4.5 ms to 2.62 ms a switch.  What is left in the 2.6 ms is the stream sync plus the checkpoints'
host copies, which a move-based park would halve.
The design's open question was whether DSpark drafting should stay on when several sequences share the model ("likely
off").  The switch to test it is now there: STRATA_DS4_BATCH_NOSPEC=1 decodes a batch slot without the drafter (a solo
request keeps its drafts).

Measured on the box (--batch 2 --slot-burst 8, the three-client workload, two runs each): drafting on 4.27 s and 4.39 s,
drafting off 5.09 s and 5.31 s - 15-20% slower, with the same interleaving (8 handovers).  So the answer is the opposite
of the guess: keep the drafter on in a batch at this size.  The flag stays for the next measurement (more slots, longer
answers).
Measured on the box (batched engine, greedy, one request at a time): decode is 79 tok/s at a 216-token context, 90 at 3.5K,
90 at 10K and 77 at 20K - a few percent across a 100x context change; prompt reads settle at 585-590 tok/s from 3.5K on.
The split-KV attention kernel's own header documents ~125 us a query, ~1% of a 12.5 ms step, so even at 20K the attention
path is a few percent of the token.

That re-orders the remaining work honestly: M2 (varlen attention) is what a *batched* step needs - each token's attention
with its own KV and positions - but it is not the speedup; M3 (batched MoE) is where the 2-3x lives.  The numbers and the
engine log lines are in docs/DSV41_BATCHING.md's new section and the handoff.
…t 2-3x

The engine already writes its routing (DS4_TRACE=file: a record a route request, u8 layer, u8 T, T x k_exp int16 expert
ids).  tools/dsv41/route_overlap.py parses it, groups the decode records into tokens by their layer cycle and compares the
two slots' expert sets step by step.

On the box (--batch 2 --slot-burst 1, two unrelated prompts): the two sequences share 25.5% of their six experts (1.53 of
6); consecutive tokens of one sequence share 22.1%.  Batching two tokens therefore reads 10.5 expert rows instead of 12
(1.15x) and three read 14.9 instead of 18 (1.20x) - the design's 2-3x assumption does not hold on this workload, which
re-bases M3: the throughput is in the reads, so it only pays when the overlap is much higher (closer prompts), and that
is a scheduling question before it is a kernel one.
Strata agent added 9 commits October 27, 2026 19:18
…nish before the next pass stages

The step drifted from the third token on at 4 slots and more, and only sometimes: the same 4-slot run with
alternating prompts came out clean in 1 of 3, and a run with a per-layer sync (a debug check) came out clean while
the unsynced ones drifted.  Two buffers were reused before their readers were done with them, both inside a pass:

* the window path's cross-group staging has no acknowledgements (the chunk path's x_ack is what the chunk path
  relies on), so a reader's copy into its table could still be in flight when the next pass's writer staged into the
  same slot: some rows then landed from another pass.  run_dev now syncs a step's device at the end of its work - a
  pass a chunk, its copies done before the next pass stages - which is what the head device's sync already gave the
  logits.
* the serial path (!wp.par, replicate) staged every row through the same pinned buffer (sr.h_ckv), so the next
  row's device-to-host copy could overtake the previous row's host-to-device copies; a row now stages in its own
  slice of it (h_ckv + t * ckv_rb(), h_ik + t * ik_rb()).

After: the alternating-prompt probes are clean in 3 of 3 runs at 4 slots and 1 of 1 at 16, and 2 slots are exact
against the bursts (96 tokens, 59.9 tok/s against 43.0).  16 slots with a different prompt a slot still drift (12 of
16 slots, from token 1-8 on) - that is a second, T-dependent bug, not this race; docs/DSV41_HANDOFF.md has the two
7/8-row boundaries it can be (the attention's staged kernel gives way to the packed one, and the routing mailbox's
async limit) and the probe that separates them.

Also: tools/dsv41/same_probe.py grew --alt (two prompts alternating, every even slot must answer slot 0 and every odd
slot slot 1 - the probe that exposed the race) and --out; DS4_STEP_SERIAL=1 and DS4_STEPCHECK=1 are the debug
switches the hunt used.
…ot a row region; the per-layer dump is the next probe
…a sync-route switch, and the comparison tool
…ug - layer 0..12 agree to 1e-7, layer 13 flips on a 1-ulp tie
…er the tokens nor the cost; exactness holds to 8 rows, rounding differs above
…st and the grouped routing pays +58% at 8 slots
…d 8 slots 88.6 tok/s, both bit-identical to their burst references
@suifei suifei changed the title [Engine] dsv41: batched serving (M1, M4-M6, M2 foundations) [Engine] dsv41: batched serving (M1, M4-M6, M2 step) - one pass for the whole batch, 2x aggregate at 16 slots Oct 27, 2026
Strata agent added 9 commits October 27, 2026 21:51
…prompts and their prefill blew the 300 s idle timeout
…ounts data events (an empty delta every 5 s), the live-mode read no longer stops the other slots past 32K, and the slot wait beats
…- the 300 s silence was in all three dialects, not just chat/completions
…oted into a slot keeps its conversation instead of re-reading the prompt (184 s -> 92 s on a 40K prompt)
… None put it back in the validation buffer) and a seat keeps its half-read state at a yield
…en: 572/634/1307 tok/s at read-slice 1K/8K/32K against 1649 for one stream
…alternating turn 35 s -> 1.9 s; a 233K cold read is 122 s at 1906 tok/s; the read-slice cost is the cards pipeline
Strata agent added 2 commits October 28, 2026 01:38
…ue too

Problem: without --batch the single turn is strictly first come, first served, so a short request (a chat question, a
health check, a tool's side request) waits behind every agent prompt that arrived before it - including the decode of a
long answer - although STRATA_ADMIT_SHORT_FIRST already gives it priority in the batched queue.

Result: with the same opt-in setting (unset or 0 keeps the old behaviour exactly), the requests waiting for the single
turn are ordered the way StrataEngine._admit_next orders them: a prompt of at most STRATA_ADMIT_SHORT_FIRST tokens
first, arrival order within the short and within the long group, and a longer one that has waited
STRATA_ADMIT_MAX_WAIT_S counts as short, so a long request is never held back for good.  The request that holds the turn
is never interrupted - only the waiters' order changes - and a waiting request still sends its keep-alive every
QUEUE_BEAT_S.

Verification:
* passed: serve.test_queue_keepalive, 6 tests (the 3 existing ones and 3 new: a late short request takes the turn first
  over three rounds, the age-out restores arrival order, and an ordered waiter still beats and drops out when its client
  leaves).  The 3 new ones fail cleanly on main (no ordering).
* failed, unrelated: the full serve suite has two failures - test_lifeline's watcher fails on unmodified code in this
  container, and test_responses.NoLeakedSampler fails 4 of 6 runs on unmodified main when it follows
  test_queue_keepalive (a leftover sampler thread).
* not run: a real engine or a GPU; and the change with --batch, which uses its own ordering already.

(cherry picked from commit d9569fd)
…ver.py sets SLOT_KEEP=1 for the engine); what changed is the 2-day expiry and the 0700 folder
@suifei
suifei marked this pull request as draft October 27, 2026 18:11
@gdevenyi

Copy link
Copy Markdown

Why was this closed?

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