Repository navigation
Conversation
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>
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 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ |
|
Closing as superseded. Naming what carries each piece, because a close without that is a discard. The cap — Both profiler commits are already on master, byte-identical. The remaining unique content is other PRs, still open on their own. This branch is State that made this unrecoverable as-is anyway: Reopen if any of the above is wrong; the branch is not deleted. |
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:
scheduler/default_scheduler.rs::scheduleruns exactly one bucket perengine iteration. A fresh prompt buckets at
cache_bucket_len() == len()-1(no cache yet), so when the prompt bucket wins,
completionis empty andno decode happens in that iteration at all.
engine/mod.rsprompt branch callspipeline.step(.., is_prompt = true, ..)once, holding the pipeline mutex for the whole prefill, then moves every
scheduled sequence straight from
RunningPrompttoRunningCompletion.Sequence::set_token_offset(
sequence.rs:639) has zero callers, sotoken_offset()is always 0 andmake_prompt_chunk'schunk_offset_toks/is_first_prompt_chunkare deadplumbing.
That plumbing is a vestige of the intra-step chunk loop upstream deleted in
863f33de7(mistral.rs #1591, "Remove prompt chunksize apis"). It would nothave helped: every chunk ran inside ONE
pipeline.step, so the scheduler neverregained control between them. Nothing in this tree, present or historical,
splits a prompt across engine iterations.
What this PR does
ARC_PREFILL_MAX_SEQScaps how many waiting sequences may be admitted toprefill in one iteration. Unset or
0reproduces the previous admissionexpression 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=0so the opening burst is inside the window)K=32, 32 × 256-word prompts
ARC_PREFILL_MAX_SEQS=4K=128 — cap off, 4 tokens reached a client in 140 s, and only 4 of 128
users ever saw a first token:
ARC_PREFILL_MAX_SEQS=8Engagement 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 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:
sequence.rs(set_token_offsetalreadyexists, unused) and
inputs_processor.rs::get_prompt_input(slicetoksto[offset, offset+C);chunk_offset_toksalready threads into positions,RoPE offsets,
seqlens_kand slot mappings);pipeline/mod.rs, theDefaultInstructionsarm;ragged
cu_seqlens_qdecoupled from per-rowseqused_k, plus bottom-rightcausal alignment (FlashAttention ≥2.1 semantics — a top-left-aligned mask
silently produces wrong results for every chunk after the first). That is
PR feat(v4): per-row
q0indsv4_attention— the ragged-mask blocker #92 named #100's per-rowq0territory inmodels/dsv4_attention.rs, on top ofPR feat(scheduler): admit ragged cohorts — the admission layer #103 named, and a general throughput bug #104's ragged cache lengths.
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.