Skip to content

feat(sched): bound the uninterruptible prefill step — 41.9 s to 23.2 s TTFT p50, and 4 users to 101 at K=128 [MEASURED] - #119

Closed
heydryft wants to merge 10 commits into
masterfrom
perf/prefill-admission-cap
Closed

heydryft wants to merge 10 commits into
masterfrom
perf/prefill-admission-cap

Conversation

@heydryft

Copy link
Copy Markdown
Contributor

Half the machine's time is decode stalled behind a prompt, and a user experiences
it as the server hanging. This is the first, cheap half of the fix, measured.

The mechanism, confirmed

A prompt step cannot yield, at three levels:

  1. scheduler/default_scheduler.rs::schedule runs exactly one bucket per
    engine iteration
    . A fresh prompt buckets at cache_bucket_len() == len()-1
    (no cache yet), so when the prompt bucket wins, completion is empty and
    no decode happens in that iteration at all.
  2. engine/mod.rs prompt branch calls pipeline.step(.., is_prompt = true, ..)
    once, holding the pipeline mutex for the whole prefill, then moves every
    scheduled sequence straight from RunningPrompt to RunningCompletion.
  3. There is no partially-prefilled state. Sequence::set_token_offset
    (sequence.rs:639) has zero callers, so token_offset() is always 0 and
    make_prompt_chunk's chunk_offset_toks / is_first_prompt_chunk are dead
    plumbing.

That plumbing is a vestige of the intra-step chunk loop upstream deleted in
863f33de7 (mistral.rs #1591, "Remove prompt chunksize apis"). It would not
have helped: every chunk ran inside ONE pipeline.step, so the scheduler never
regained control between them. Nothing in this tree, present or historical,
splits a prompt across engine iterations.

What this PR does

ARC_PREFILL_MAX_SEQS caps how many waiting sequences may be admitted to
prefill in one iteration. Unset or 0 reproduces the previous admission
expression for expression, in both arms.

This is the request-level half of chunked prefill, and it is the shape
SGLang runs by default — prefill and decode batches alternate, with mixing
behind an opt-in --enable-mixed-chunk. vLLM V1 goes further (decode first,
prefill fills the remaining max_num_batched_tokens, 8192 on an H200 server),
which needs machinery we do not have yet — see below.

Measured (H200, qtip2b, MTP d=3, PR #113's profiler, WARMUP_S=0 so the opening burst is inside the window)

K=32, 32 × 256-word prompts

cap off ARC_PREFILL_MAX_SEQS=4
TTFT p50 41.93 s 23.22 s (−45%)
TTFT p90 42.18 s 39.59 s (−6%)
TTFT max 42.19 s 43.80 s (+3.8%)
TTFT min 2.77 s 1.49 s
aggregate over the window 36.14 tok/s 47.95 tok/s
prompt steps / mean duration 4 / 21,680 ms 10 / 4,381 ms
decode steps / mean duration 56 / 709 ms 50 / 702 ms

K=128 — cap off, 4 tokens reached a client in 140 s, and only 4 of 128
users
ever saw a first token:

cap off ARC_PREFILL_MAX_SEQS=8
tokens delivered in 140 s 4 101 (25×)
users with a first token 4 / 128 101 / 128
prompt steps / mean duration 3 / 58,825 ms 18 / 9,517 ms
decode steps / mean duration 57 / 2,335 ms 42 / 2,320 ms
total prefill wall 176.5 s 171.3 s (same work)

Engagement is structural, not inferred: prompt steps 4→10 and 3→18, mean
prompt-step duration −80% and −84%, with decode step duration unchanged
(709→702 ms, 2,335→2,320 ms) — the cap chops the same prefill work into more,
smaller uninterruptible steps and costs the decode path nothing.

All four profiles: selftest PASS, unresolved_device_spans: 0,
violations: [], device÷wall 92–96%.

⚠️ The K=32 aggregate windows differ in length (130.1 s vs 80.1 s) because
the profile-write stop fires at different times. The shorter window contains
proportionally more prefill, so the comparison is biased against the
capped arm and it still wins — but this is an end-to-end number over a finite
window, not a steady-state throughput claim.

What this does NOT fix, and what would

The cap bounds when prefill blocks; it does not make prefill cheaper and it
does not let decode ride along on a prefill's GEMMs. At K=128 the capped arm
still delivers only ~1 token per user in 140 s, because prefill itself runs at
~281–405 tok/s
— 12,160 prompt tokens in 43.2 s. That is the real scandal, and
it is consistent with attention being 57% of the marginal decode cost (PR #113).

True token-level chunked prefill needs, in order:

Chunk-size guidance from Sarathi-Serve worth carrying into that work: 512 is the
practical floor (≤25% extra prefill runtime; prefill throughput saturates around
length 512), 2048 is near-free, and chunk size must be a multiple of the GEMM
tile — 257 costs 32% more than 256
.

heydryft and others added 10 commits August 17, 2026 13:41
Stacked on #95. Removes PR #92's second named blocker: dsv4_attention now
takes a per-row absolute query position, so a left-aligned ragged cohort
gets its dead prefix masked on the raw branch AND its start-anchored
compressed threshold rebuilt from each row's own position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extract resolve_ragged_rows and per_sequence_refusal so the dispatch and
the capability composition are testable directly — a mutation that routed
uniform batches down the ragged path was numerically invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#100 named

`step_full` keyed the whole fused step on one scalar `cache_len` (the BATCHED
buffer's width, which left-alignment makes `max_j L_j`). All four of PR #100's
defects close here:

* `uncached[i]` no longer saturates to 0 — `plan_step_window` takes each row's
  own length, so every ragged tail stays inside the window;
* `toks[cache_lens[i]..]` is in range by the same invariant, closing the panic
  site the saturation was hiding;
* `seqlen_offsets` routes per row through `inputs_processor` — which is what
  feeds `dsv4_attention`'s `row_q0`;
* the per-sequence commit strips the dead prefix (`drop_dead_prefix`, the
  inverse of `front_pad_kv_cache`) so a row's recorded length is its own.

Flag-off byte-identity is structural: `resolve_row_cache_lens` and
`resolve_row_offsets` return `None` for a uniform batch, and `None` runs the
pre-change scalar code verbatim.

`KvAdvance::PerSequence` is still refused. The refusal moved to the MTP DRAFT
chain, which keys a whole group on one absolute position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…102 named

The MTP draft KV is a second, independent time base from the target's, and it
was keyed on ONE absolute position in four places. All four now take the row's
own value, so a group whose rows sit at different positions drafts in one
batched MTP-block forward instead of B single-row ones.

* `MtpHiddenCapture::store_rows(seqlen_offsets, ..)` tags a capture with one
  start per row (`CaptureOffsets`) rather than `seqlen_offsets.first()`. The
  vector already existed; it was being collapsed.
* `propose_chain_batched` takes `chain_starts: &[usize]` and, when they differ,
  hands `starts[r] + i` to the new `MtpBlock::forward_step_rows` — a positions
  vector for RoPE and for `dsv4_attention`'s per-row `row_q0`.
* `front_align_draft_group` left-aligns a ragged group's draft KVs where
  `batch_draft_caches` refused them, and `drop_dead_prefix` takes the padding
  back out before any length is recorded.
* `draft_group_key` therefore drops the absolute chain start, restoring a
  partition bounded by `depth + 1` rather than by the batch size.

Flag-off byte-identity is structural: `resolve_capture_offsets`,
`resolve_ragged_chain_starts` and `resolve_draft_leads` all fold a uniform
batch back to the pre-change scalar representation, which runs the old code
verbatim down to RoPE's one-element fast branch.

`PerSequence` is still refused. The refusal moves to the batch admission
layer — `engine/mod.rs`'s `CacheInstruction::In` gate and the scheduler's
`cache_bucket_len` bucket key — both named by
`batch_admission_admits_ragged_cohorts`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… an MTP one

The batch admission layer assumed one batch has one cache length, in two
places. `scheduler/default_scheduler.rs` partitioned the running set by
`Sequence::cache_bucket_len` and ran exactly ONE bucket per step, so
sequences at different lengths could not batch together at all; and
`engine/mod.rs` issued `CacheInstruction::In` only when the completion-id
list changed, so a stable cohort's front-alignment was never recomputed.

Both now read one `RaggedAdmission`, decided once in `Engine::new` from the
pipeline's own declaration (`CacheManagerMixin::ragged_batch_admission`,
default a refusal carrying a reason). With admission refused the bucket key
and the `pre_op` predicate are the pre-change expressions exactly.

The cost of the old rule is a law, measured by a CPU scheduler simulation:
with B sequences over D distinct cache lengths far enough apart that the
coalescence override is refused, B/D of them run per step. At B=128 over
128 lengths 64 tokens apart that is 1.00, in steady state.

`KvAdvance::PerSequence` is reachable for a V4 target for the first time.
For every other architecture the declaration still refuses, from
`model_masks_ragged_batches` — no other model in this tree threads a
ragged-batch mask into its forward, and that is where the refusal now is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…c-profiler onto it, and instrument the front-alignment path

`feat/batch-admission-ragged` branched before PR #78 merged, so it carries no
profiler at all. The question it now has to answer — is the +155.6 ms per
forward at K=32 GPU-busy, host-blocked-on-GPU, or host-busy-while-the-GPU-idles
— is precisely the one a wall-clock timer cannot answer and the one #78's three
channels exist for.

Merging master in was tried first and rejected: it compiles to 7 errors because
master's scalar `XsRollingCache::trim_tail_to` and this branch's per-row
`base`/`tokens` are the same design decision made twice, and resolving that is
a change to someone else's workstream, not instrumentation. So the crate is
taken verbatim (`arc-profiler/`, unmodified) and the spans are re-placed by
hand at this branch's own call sites.

What is instrumented, and why each choice:

* `normal.rs` — `attach_device`/`maybe_selftest`/`set_meta`. Without
  `attach_device` every device span resolves to nothing, and a device column of
  zeros reads exactly like "the GPU was idle".
* `engine/mod.rs` — `step` root, `decode` and `prompt` as separate subtrees
  (a prefill step is orders of magnitude larger; averaging them hides the
  decode cost), `scheduler.lock`/`scheduler.schedule`, `pipeline.lock`,
  `pipeline.step`.
* `kv_cache/mod.rs` — `clone_in_cache`/`clone_out_cache`,
  `clone_in.alloc`/`clone_in.slice_set` (device), and the two that discriminate
  the candidate mechanisms: `cache.front_align` (ONE device span over the whole
  front-alignment region) and `kv.front_pad_grow` (a HOST span on
  `front_pad_single`'s padding branch only, so `calls` is literally how many
  row x slot x half buffers were re-materialised this step).
* `deepseek4.rs` — `model_forward`, one device span over the whole forward, as
  the denominator every host-side cost has to be read against.

`front_pad_grow` is a host span on purpose: it runs up to `rows*43*2` times per
step, and two `cudaEventRecord`s per call would be a device-side cost of the
same order as the thing being measured. The region's stream time is taken once,
at `front_align_batch`.

Off-state cost is unchanged at ~2.9 ns per call site; nothing here runs unless
`ARC_PROFILE=1`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t CUDA device

`device::timer_for` refused to attach a CUDA event timer whenever
`cu_stream()` returned NULL:

    let stream = dev.cuda_stream().cu_stream() as *mut c_void;
    if !stream.is_null() { return Box::new(CudaTimer::new(stream)); }

A NULL `CUstream` is **not** "no stream". It is CUDA's legacy default stream,
and cudarc says so in as many words at `CudaContext::default_stream` — *"the
default stream for this context (the null ptr stream)"* — constructed with
`cu_stream: std::ptr::null_mut()`. A candle device that was never handed an
explicit stream returns exactly that, which is the ordinary configuration.

So the guard rejected the common case, fell through to `NullTimer`, and every
device span in the run recorded nothing. Measured on an H200 tonight, on a V4
profile built `--features "cuda flash-attn"` against a device the report itself
names `Cuda(CudaDevice(DeviceId(1)))`:

    note: device selftest: NOT RUN (no CUDA event timer attached)
    unresolved_device_spans: 10200
    device_ns: 0.0 ms   sync_ns: 0.0 ms

⇒ **the three-channel separation PR #78 exists for has never produced a number
on this fork.** wall was real; device and sync were structurally unmeasurable.
The report was honest about it ("unmeasured, not zero", per D18) — which is why
this was findable at all — but it attributed the failure to a missing timer
rather than to a guard that refused a valid stream.

`cudaEventRecord(ev, 0)` is valid and records on the default stream, so the
handle needs no validation. The runtime does, so it is now asked rather than
assumed: probe once with a real `record()`, keep the timer if the event was
created and recorded, and only then fall back to `NullTimer`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…regated

Two device spans in `forward_4d` — `mla_attn` around `self.attn.forward` and
`moe` around `self.moe_or_mlp.forward` — aggregated across all layers.

Enough to answer the question actually being asked (what fraction of a decode
step grows with B); deliberately NOT the fourteen MLA sub-spans master carries,
which this branch predates and which would each fire 43x per step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ible step

A prompt step cannot yield. `Engine::run`'s prompt branch calls
`pipeline.step(.., is_prompt = true, ..)` once, holds the pipeline mutex for the
whole prefill, and moves every scheduled sequence straight from `RunningPrompt`
to `RunningCompletion`. There is no partially-prefilled state:
`Sequence::set_token_offset` has **zero callers** in this tree, so
`token_offset()` is always 0 and `make_prompt_chunk`'s `chunk_offset_toks` is
dead plumbing — a vestige of the intra-step chunk loop upstream deleted in
`863f33de7` (#1591, "Remove prompt chunksize apis"). That loop would not have
helped anyway: every chunk ran inside ONE `pipeline.step`, so the scheduler
never regained control between them.

Measured cost of that, on an H200 (2026-08-17, `qtip2b`, MTP depth 3, PR #113's
profiler): at K=32, **one prompt step took 43.2 s — 50.3% of the profiled
window**; at K=128 prefill ran ~120 s and the client received **zero tokens for
70 seconds**. Half the machine's time is decode stalled behind a prompt.

This is the **request-level** half of chunked prefill: it does not split an
individual prompt (that needs a per-sequence token cursor, ragged `cu_seqlens_q`
decoupled from per-row `seqused_k`, and bottom-right causal alignment — scoped
separately), but it bounds how many prompts may enter one uninterruptible step,
so decode gets a turn between groups. It is the shape SGLang runs by default:
prefill and decode batches **alternate** rather than mix, with mixing behind an
opt-in `--enable-mixed-chunk`.

`ARC_PREFILL_MAX_SEQS`, read once via `OnceLock`. Unset or `0` reproduces the
previous admission expression for expression, in both arms — the cold-start arm
where nothing is running, and the mixed arm where `sequence_fits` admits. A
sequence already mid-prompt is not counted against the cap, so a cohort cannot
be re-queued behind itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Code Metrics Report
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Language              Files        Lines         Code     Comments       Blanks
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 C Header                  5          305          210           52           43
 CSS                       2         1181         1036           34          111
 CUDA                     72        24328        17592         4018         2718
 Dockerfile                1           39           22            8            9
 JavaScript               16         3546         2676          482          388
 Jinja2                    7          694          656            5           33
 JSON                     74         4600         4597            0            3
 Makefile                  1            6            5            0            1
 Metal Shading Lan|       33        12224         9431         1142         1651
 PowerShell                1          300          227           30           43
 Python                  143        14830        12217          797         1816
 Shell                    23         5756         3964         1412          380
 Plain Text                4         3801            0         2479         1322
 TOML                     33         1485         1292           43          150
 YAML                      3           25           23            2            0
─────────────────────────────────────────────────────────────────────────────────
 HTML                      4         2687         2604           43           40
 |- CSS                    2          543          479           37           27
 |- JavaScript             1         1233         1215           12            6
 (Total)                             4463         4298           92           73
─────────────────────────────────────────────────────────────────────────────────
 Jupyter Notebooks         4          122           83           23           16
 |- Markdown               1           60           30           22            8
 |- Python                 1          122          113            1            8
 (Total)                              304          226           46           32
─────────────────────────────────────────────────────────────────────────────────
 Markdown                190        37932            0        29111         8821
 |- BASH                  71         1623         1192          315          116
 |- C                      2           12           12            0            0
 |- CUDA                   2           84           56           16           12
 |- JSON                  18          708          708            0            0
 |- PowerShell             1            1            1            0            0
 |- Python                23         1008          787          113          108
 |- Rust                  65         2048         1713           77          258
 |- TOML                   6          207          164            0           43
 |- YAML                   4           38           33            5            0
 (Total)                            43661         4666        29637         9358
─────────────────────────────────────────────────────────────────────────────────
 Rust                    662       309221       267775        14013        27433
 |- Markdown             477        23236          471        20016         2749
 (Total)                           332457       268246        34029        30182
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 Total                  1278       454005       331384        74308        48313
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

@heydryft

Copy link
Copy Markdown
Contributor Author

Closing as superseded. Naming what carries each piece, because a close without that is a discard.

The cap — ARC_PREFILL_MAX_SEQS — is reproduced in #123, scheduler/default_scheduler.rs::prefill_admission_cap(), same env var, same unset or 0 reproduces the previous admission exactly semantics. #123 additionally pairs it with the floor (ARC_PREFILL_FLOOR_STEPS) and with the engagement logging this branch does not have — the logging that exists because five published cells turned out to have been served by a scheduler file that never executed.

Both profiler commits are already on master, byte-identical. git diff origin/master origin/perf/prefill-admission-cap -- arc-profiler/src/device.rs is empty — 66a61259d ("the device and sync channels were dead on every default CUDA device", the NULL-CUstream-is-the-legacy-default-stream fix) landed independently. 30cdb6c3b (V4 decode split into attention and MoE) likewise.

The remaining unique content is other PRs, still open on their own. This branch is ab42c4508 (#104, ragged cohort admission) plus 583fda6ac (#113, the profiler port onto that branch). Both PRs are open. Nothing in this branch is lost by closing it.

State that made this unrecoverable as-is anyway: mergeable: DIRTY, 67 commits behind master, and 1 check — the comment bot. Zero CI lanes ever ran here.

Reopen if any of the above is wrong; the branch is not deleted.

@heydryft heydryft closed this Aug 18, 2026
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.

1 participant