spark_model/traits/model.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! `Model` trait — the interface the scheduler talks to.
4//!
5//! ## Dispatch contract
6//!
7//! Per request, the scheduler invokes:
8//!
9//! 1. [`Model::prefill`] (or [`Model::prefill_chunk`] for chunked prefill)
10//! once per sequence. Returns logits at the last prompt position;
11//! populates the sequence's KV cache and SSM state.
12//! 2. [`Model::decode`] once per emitted token. Returns next-token logits;
13//! extends KV/SSM state by one position. May be replaced by
14//! [`Model::decode_batch`] when multiple sequences are co-scheduled.
15//! 3. Optional speculative-decode verify path: [`Model::decode_verify_graphed`]
16//! (K=2), [`Model::decode_verify_graphed_k3`] (K=3),
17//! [`Model::decode_verify_graphed_k4`] (K=4), or
18//! [`Model::decode_verify_graphed_kgamma`] (DFlash γ-token).
19//! These take [last_token, draft0, ..] and return per-position logits;
20//! the scheduler picks accept/reject and rolls back state on reject.
21//! 4. [`Model::mixed_forward`] fuses one decode step + one prefill chunk
22//! through a single weight load; used by the scheduler to amortize
23//! weight-streaming cost when both phases are pending.
24//!
25//! Implementors live under `crates/spark-model/src/model/trait_impl/`,
26//! split per phase (prefill_a/b/c/d, decode_a/b, verify_a/b/c/d) per
27//! ADR-0006's multi-file module idiom.
28//!
29//! ## Concurrency
30//!
31//! `Model: Send + Sync` — a single instance handles all sequences
32//! concurrently. Per-sequence state lives in [`SequenceState`].
33
34use anyhow::{Result, bail};
35use spark_runtime::gpu::DevicePtr;
36
37use super::{MixedBatchResult, MixedForwardResult, PrefillSlice, SequenceState};
38
39/// One beam-search request for a translation model (NLLB). Carries the resolved
40/// per-request parameters the scheduler stamps onto the sequence; the model runs
41/// the whole beam search to completion and returns the winning hypothesis.
42#[derive(Debug, Clone)]
43pub struct BeamReq {
44 /// Raw source subword ids (the model adds `[src_lang] … </s>` itself).
45 pub prompt_tokens: Vec<u32>,
46 /// Per-request source/target language token ids (`0` = deployment default).
47 pub src_lang_id: u32,
48 pub tgt_lang_id: u32,
49 /// Per-request LoRA slot (`>=0` apply, `-1` base).
50 pub adapter_slot: i32,
51 pub num_beams: usize,
52 pub max_new: usize,
53 pub length_penalty: f32,
54 pub early_stopping: bool,
55}
56
57/// The multi-sequence batch padding ladder — the SSOT for `padded_n`.
58///
59/// Batched decode pads the live sequence count up to a small set of captured
60/// sizes so that (a) CUDA graphs (`ATLAS_DECODE_GRAPHS_MULTISEQ`) are keyed by
61/// a handful of stable shapes instead of one per exact n, and (b) the batched
62/// kernels see a bounded set of widths. Padding rows point at the dummy SSM
63/// slot / dummy KV block and cost one wasted lane each.
64///
65/// This expression used to be duplicated at FOUR call sites
66/// (`decode_a2.rs:168`, `decode_b.rs:52` and `:110`,
67/// `phase_continue_prefills.rs:142` — the last one in a different crate), which
68/// is exactly how the ladder would have drifted when a step was added. All four
69/// now call here.
70///
71/// `12` and `16` were added for the `C=[1,2,4,8,16]` concurrency work
72/// (2026-07-25): previously any n ≥ 9 fell through to `padded_n = n`, so at
73/// C=16 every distinct batch composition minted its OWN CUDA graph (n=9, 10,
74/// ... 16 each a separate capture) and the buffer-fit guards were computed on
75/// exact n.
76///
77/// `24` and `32` were added for native bs=32 (2026-07-30): n=17..32 now pads
78/// to two stable graph shapes instead of minting one graph per exact n.
79///
80/// `48`, `64`, `96` and `128` were added for native bs=64+ (2026-07-31,
81/// wave-14a): the decode-metadata layout is now DERIVED from the serve
82/// `max_batch_size` (`spark_runtime::buffers::DecodeMetaLayout`, rows =
83/// max(32, bs), ceiling `DECODE_META_MAX_ROWS`), and
84/// `upload_batch_metadata_fixed` ensures `padded_n <= rows`. Rungs above the
85/// boot's `max_batch_size` are unreachable (the scheduler admits at most
86/// `max_batch_size` active sequences), so every bs<=32 boot never pads past
87/// 32 — byte-identical by construction. Rungs <=32 unchanged.
88/// Above 128 the fall-through behaviour is unchanged (guarded downstream).
89#[inline]
90pub fn padded_batch_n(n: usize) -> usize {
91 [2usize, 4, 8, 12, 16, 24, 32, 48, 64, 96, 128]
92 .iter()
93 .copied()
94 .find(|&s| s >= n)
95 .unwrap_or(n)
96}
97
98pub trait Model: Send + Sync {
99 /// Release the device memory this model owns, in reverse construction
100 /// order.
101 ///
102 /// Called by the host when the model is being replaced, **after** the
103 /// scheduler has drained and the stream is synchronised — the only point at
104 /// which a device free is safe on GB10, where a free interleaved with other
105 /// allocation traffic corrupts neighbouring allocations. See
106 /// `atlas_core::scope` for why this is not `Drop`: `Drop` can express
107 /// neither the ordering nor the failure.
108 ///
109 /// Default: a no-op returning `Ok`, which is honest for the mock and
110 /// translation models that own no pooled device memory. A model that DOES
111 /// own pools and leaves this unimplemented leaks them — loudly, as the next
112 /// load failing to fit, never as wrong output.
113 fn teardown(&mut self) -> Result<()> {
114 Ok(())
115 }
116
117 /// Poll TQ+ InnerQ calibration for this model. Called once per prefill
118 /// chunk. Default: a no-op, which is every model without a driver — the
119 /// scheduler used to reach a process-wide `OnceLock` for this, which meant
120 /// the driver could outlive the model whose device symbols it writes.
121 fn poll_innerq(&self) {}
122
123 /// True when this model implements run-to-completion beam search
124 /// ([`Self::generate_beam_batch`]). Default `false` — only encoder-decoder
125 /// translation models (NLLB) override it.
126 fn supports_beam(&self) -> bool {
127 false
128 }
129
130 /// Run beam search to completion for each request, returning each one's
131 /// winning hypothesis token ids (EOS-terminated). Called from the prefill
132 /// path for `num_beams > 1` requests, bypassing the token-by-token decode
133 /// loop. Default: unsupported.
134 fn generate_beam_batch(&self, _reqs: &[BeamReq]) -> Result<Vec<Vec<u32>>> {
135 bail!("this model does not support beam search")
136 }
137
138 /// Run prefill: process all prompt tokens through the model.
139 ///
140 /// Returns logits DevicePtr for the last token position.
141 /// Updates KV cache and SSM states for the sequence.
142 fn prefill(&self, tokens: &[u32], seq: &mut SequenceState, stream: u64) -> Result<DevicePtr>;
143
144 /// Process `chunk_len` tokens starting at `chunk_start` in the prompt.
145 /// `is_last_chunk` runs final norm + LM head; intermediate chunks return
146 /// `DevicePtr::NULL`. KV blocks alloc incrementally; SSM state carries
147 /// across chunks; attention uses FA on chunk 0, paged decode after.
148 fn prefill_chunk(
149 &self,
150 tokens: &[u32],
151 seq: &mut SequenceState,
152 chunk_start: usize,
153 chunk_len: usize,
154 is_last_chunk: bool,
155 stream: u64,
156 ) -> Result<DevicePtr>;
157
158 /// Run one decode step: process a single new token.
159 ///
160 /// Returns logits DevicePtr for the new token.
161 /// Updates KV cache and SSM states.
162 fn decode(&self, token: u32, seq: &mut SequenceState, stream: u64) -> Result<DevicePtr>;
163
164 /// Run batched decode: process one token per sequence.
165 ///
166 /// Returns logits DevicePtr for [batch_size, vocab_size].
167 fn decode_batch(
168 &self,
169 tokens: &[u32],
170 seqs: &mut [&mut SequenceState],
171 stream: u64,
172 ) -> Result<DevicePtr>;
173
174 /// Process N decode tokens + an M-token prefill chunk in one pass through
175 /// the same weight loads. Returns decode logits `[N, vocab]` and prefill
176 /// logits `[1, vocab]` (when `is_last`). Default: serial decode + prefill.
177 fn mixed_forward(
178 &self,
179 decode_tokens: &[u32],
180 decode_seqs: &mut [&mut SequenceState],
181 prefill_tokens: &[u32],
182 prefill_seq: &mut SequenceState,
183 prefill_chunk_start: usize,
184 prefill_chunk_len: usize,
185 prefill_is_last: bool,
186 stream: u64,
187 ) -> Result<MixedForwardResult> {
188 // Default: serial execution (no weight sharing)
189 let decode_logits = if !decode_tokens.is_empty() {
190 self.decode_batch(decode_tokens, decode_seqs, stream)?
191 } else {
192 spark_runtime::gpu::DevicePtr::NULL
193 };
194 let prefill_logits = self.prefill_chunk(
195 prefill_tokens,
196 prefill_seq,
197 prefill_chunk_start,
198 prefill_chunk_len,
199 prefill_is_last,
200 stream,
201 )?;
202 Ok(MixedForwardResult {
203 decode_logits,
204 prefill_logits,
205 })
206 }
207
208 /// Process N concurrent prefill chunks in one forward pass (same weight
209 /// load amortised across N streams). The default implementation falls
210 /// back to a per-stream loop calling `prefill_chunk` — implementors that
211 /// support kernel-level batched prefill should override this.
212 ///
213 /// Returns a `Vec<DevicePtr>` parallel to `streams`: each entry is the
214 /// last-token logits pointer for that stream when its chunk is
215 /// `is_last_chunk`, or `DevicePtr::NULL` otherwise.
216 ///
217 /// Tracks issue Q12 in
218 /// `/workspace/atlas-internal/qwen-refactor/notes.md`.
219 fn prefill_batch_chunk(
220 &self,
221 streams: &mut [PrefillSlice<'_>],
222 stream: u64,
223 ) -> Result<Vec<DevicePtr>> {
224 // Default: serialized per-stream prefill_chunk. This preserves
225 // current behavior for any model that doesn't override; only the
226 // weight-streaming amortisation is lost vs a true batched path.
227 let mut out = Vec::with_capacity(streams.len());
228 for slice in streams.iter_mut() {
229 let logits = self.prefill_chunk(
230 slice.prompt_tokens,
231 slice.seq,
232 slice.chunk_start,
233 slice.chunk_len,
234 slice.is_last_chunk,
235 stream,
236 )?;
237 out.push(logits);
238 }
239 Ok(out)
240 }
241
242 /// Like `prefill_batch_chunk`, but each finishing stream's first-token
243 /// logits land in row `row_base + stream_idx` of the shared logits arena
244 /// instead of row `stream_idx`.
245 ///
246 /// CROSS-REQUEST CORRUPTION (the reason this exists). `decode_batch`
247 /// writes lane `i`'s logits to row `i` of `buffers.logits()`, and a
248 /// finishing prefill stream writes row `stream_idx` of the SAME arena —
249 /// byte-identical addresses. In a mixed step decode runs first and the
250 /// caller samples the decode rows AFTER the prefill sub-pass, so every
251 /// active decode lane whose index collides with a finishing prefill
252 /// stream samples THAT REQUEST'S first-token distribution instead of its
253 /// own. Symptoms are exactly what a foreign distribution looks like: a
254 /// stray `<tool_call>` opener at the head of a reply (the foreign stream
255 /// was tool-enabled), or a reply that veers onto another user's topic.
256 /// It cannot happen sequentially — a mixed step needs >=2 prefills and
257 /// >=1 active decode in the same tick.
258 ///
259 /// Giving prefill a disjoint row window is enough to fix it. The arena
260 /// holds `min(max_batch_tokens, 32)` rows against `n_decode + n_prefill
261 /// <= max_num_seqs`, so the shifted window fits with room to spare;
262 /// implementations MUST bounds-check and fall back to `row_base = 0`
263 /// rather than write past the arena.
264 ///
265 /// Default ignores `row_base` (models with no batched prefill of their
266 /// own can't alias, since the serial path returns one row).
267 fn prefill_batch_chunk_rows(
268 &self,
269 streams: &mut [PrefillSlice<'_>],
270 stream: u64,
271 _row_base: usize,
272 ) -> Result<Vec<DevicePtr>> {
273 self.prefill_batch_chunk(streams, stream)
274 }
275
276 /// Generalised mixed forward: M decode tokens + N concurrent prefill
277 /// chunks fused into one forward pass. Default: delegates to
278 /// `decode_batch` + `prefill_batch_chunk` serially. Models that
279 /// implement true mixed batching should override.
280 fn mixed_forward_batch(
281 &self,
282 decode_tokens: &[u32],
283 decode_seqs: &mut [&mut SequenceState],
284 prefill_streams: &mut [PrefillSlice<'_>],
285 stream: u64,
286 ) -> Result<MixedBatchResult> {
287 // Default: serial execution.
288 let decode_logits = if !decode_tokens.is_empty() {
289 let lg = self.decode_batch(decode_tokens, decode_seqs, stream)?;
290 // #110: decode_batch runs its whole forward on the DEFAULT stream
291 // — both `decode_batch_compute_main` (n>=2) and the n==1 graph path
292 // ignore the `stream` arg and hardcode `gpu.default_stream()`. The
293 // batched prefill below reuses the SAME shared arena buffers
294 // (hidden_states/residual/scratch/gdn) but submits on `stream`
295 // (prefill_stream). With no barrier the two sub-passes execute
296 // concurrently on two different streams and race over those
297 // buffers — corrupting the batched prefill's slot table into wild
298 // KV-cache indices and faulting with a CUDA illegal access
299 // (status 700). Synchronize the decode stream so its buffer use is
300 // fully retired before prefill overwrites them. This runs once per
301 // mixed step (active+prefilling), never in the hot decode loop.
302 self.synchronize(self.default_stream())?;
303 lg
304 } else {
305 spark_runtime::gpu::DevicePtr::NULL
306 };
307 // Prefill rows start ABOVE the decode lanes: decode owns rows
308 // 0..decode_tokens.len(), so a finishing prefill stream can no longer
309 // overwrite a lane whose logits the caller has not sampled yet. See
310 // `prefill_batch_chunk_rows`.
311 let prefill_logits =
312 self.prefill_batch_chunk_rows(prefill_streams, stream, decode_tokens.len())?;
313 Ok(MixedBatchResult {
314 decode_logits,
315 prefill_logits,
316 })
317 }
318
319 /// Normalize SSM h_state norms to prevent catastrophic state explosion
320 /// during long chunked prefill. Called between chunks by the scheduler.
321 /// Default: no-op (models without SSM layers don't need normalization).
322 fn normalize_ssm_states(&self, _seq: &SequenceState, _stream: u64) -> Result<()> {
323 Ok(())
324 }
325
326 /// Per-layer chunked prefill: SSM layers use three phases (proj →
327 /// single-launch GDN → post) so the recurrence sees the full sequence
328 /// in one launch; attention layers use standard chunked prefill.
329 /// Returns last-token logits. Default: single-chunk prefill (no SSM).
330 fn prefill_twophase(
331 &self,
332 tokens: &[u32],
333 seq: &mut SequenceState,
334 _chunk_size: usize,
335 stream: u64,
336 ) -> Result<DevicePtr> {
337 // Default: single-chunk prefill (no two-phase benefit without SSM)
338 self.prefill_chunk(tokens, seq, 0, tokens.len(), true, stream)
339 }
340
341 /// Vocab size (for sampler allocation).
342 fn vocab_size(&self) -> usize;
343
344 /// Runtime LoRA adapter rotation: select the resident adapter named `name`
345 /// as active (re-points the delta pool pointers). MUST be called at a
346 /// scheduler quiescent point (no in-flight decode). Graph-safety is via the
347 /// eager-on-rotate gate. Default: unsupported (non-LoRA or non-rotatable).
348 fn set_active_lora(&mut self, _name: &str) -> Result<()> {
349 bail!("this model does not support LoRA adapter rotation")
350 }
351
352 /// Task #24: stable adapter_id (KV/prefix-cache identity) for a per-request
353 /// pool-slot selector. `slot` follows `SequenceState.adapter_slot`: `>= 0`
354 /// picks that resident slot, `-1` defers to the installed active adapter.
355 /// The default (no LoRA) returns the base sentinel `0`, keeping the prefix
356 /// cache byte-identical to the pre-LoRA path.
357 fn adapter_id_for(&self, _slot: i32) -> u64 {
358 0
359 }
360
361 /// Task #25: acquire a per-slot ref when a sequence begins using its adapter
362 /// (at prefill), resolving `-1 -> active` like [`Self::adapter_id_for`].
363 /// Returns the RESOLVED pool index the ref was taken on (store it, release
364 /// EXACTLY that index at terminal free — immune to a rotate changing active).
365 /// Default (no LoRA) returns `-1` "nothing acquired" so the release guard
366 /// skips and the base path is byte-identical.
367 fn acquire_adapter_slot(&self, _slot: i32) -> i32 {
368 -1
369 }
370
371 /// Task #25: release a per-slot ref acquired by [`Self::acquire_adapter_slot`],
372 /// by the RESOLVED index it returned. `-1` is a no-op. Default: no-op.
373 fn release_adapter_slot(&self, _resolved: i32) {}
374
375 /// Runtime LoRA adapter dynamic-load: load the adapter at `dir` INTO pool
376 /// `slot` and make it resident there (pool-size-1 per-request weight change).
377 /// MUST be called at a scheduler quiescent point; needs rotation armed.
378 /// Default: unsupported (non-LoRA or non-rotatable).
379 fn swap_lora_from_disk(
380 &mut self,
381 _dir: &std::path::Path,
382 _name: &str,
383 _slot: usize,
384 ) -> Result<()> {
385 bail!("this model does not support LoRA disk swap")
386 }
387
388 /// Task #27 (demand-driven promotion): RDMA-promote the adapter `name`
389 /// (staged on `peer_addr` at `adapter_id`) from the peer into a cache pool
390 /// slot and make it active, returning `(slot, evicted_name)`. Runs at a
391 /// scheduler quiescent point. `peft` supplies the r/alpha/scaling the peer
392 /// manifest does not carry. Default: unsupported (non-LoRA / non-cuda).
393 fn promote_lora_from_peer(
394 &mut self,
395 _peer_addr: &str,
396 _adapter_id: &str,
397 _name: &str,
398 _peft: atlas_core::config::PeftAdapterConfig,
399 ) -> Result<(usize, Option<String>)> {
400 bail!("this model does not support LoRA peer promotion")
401 }
402
403 /// Demand-driven DISK promotion (no RDMA/peer): load the adapter `name` from
404 /// `adapter_dir` into a cache pool slot (LRU victim) and make it active,
405 /// returning `(slot, evicted_name)`. Local-disk sibling of
406 /// [`Self::promote_lora_from_peer`]; the swap re-parses the dir's
407 /// `adapter_config.json`, so no `peft` arg. Runs at a scheduler quiescent
408 /// point; needs rotation armed. Default: unsupported.
409 fn promote_lora_from_disk(
410 &mut self,
411 _adapter_dir: &std::path::Path,
412 _name: &str,
413 ) -> Result<(usize, Option<String>)> {
414 bail!("this model does not support LoRA disk promotion")
415 }
416
417 /// Dims for the `--high-speed-swap` orchestrator (installed thread-local
418 /// after `bind_gpu_to_thread`). `None` for legacy/non-attention models.
419 fn high_speed_swap_dims(&self) -> Option<spark_storage::ModelDims> {
420 None
421 }
422
423 /// Bind the GPU context to the current thread.
424 /// Must be called from any thread other than the one that created the model.
425 fn bind_gpu_to_thread(&self) -> Result<()>;
426
427 /// Allocate a new SequenceState with SSM states.
428 fn alloc_sequence(&self) -> Result<SequenceState>;
429
430 /// [`Self::alloc_sequence`] told what this request can actually reach
431 /// (`prompt_len + max_tokens`). Proposer state that scales with context is
432 /// sized to THAT instead of `--max-seq-len`; see
433 /// `DraftProposer::alloc_state_for`. Defaults to the unsized form.
434 fn alloc_sequence_for(&self, budget_tokens: usize) -> Result<SequenceState> {
435 let _ = budget_tokens;
436 self.alloc_sequence()
437 }
438
439 /// Copy logits from device to host buffer (for CPU-side sampling).
440 ///
441 /// `logits_ptr` points to `[vocab_size]` BF16 values on device.
442 /// `dst` must be at least `vocab_size * 2` bytes.
443 fn copy_logits_to_host(&self, logits_ptr: DevicePtr, dst: &mut [u8]) -> Result<()>;
444
445 /// FP32 logits flag (host buffer needs `vocab*4` bytes, reinterpret `&[f32]`).
446 /// True only for Gemma-4 dense single-token decode `lm_head`; default false.
447 fn logits_ptr_is_fp32(&self, _logits_ptr: DevicePtr) -> bool {
448 false
449 }
450
451 /// Base pointer of the on-device logits buffer (`[k, vocab]` BF16 after
452 /// `decode_verify_graphed`). Lets the scheduler read logits for temp
453 /// sampling even though graphs bake in argmax.
454 fn logits_buffer_ptr(&self) -> DevicePtr;
455
456 /// GPU argmax: 4-byte D2H copy vs 304KB BF16 D2H + CPU argmax.
457 fn argmax_on_device(&self, logits_ptr: DevicePtr, stream: u64) -> Result<u32>;
458
459 /// GPU batched argmax over `[N, vocab]` BF16; returns N token IDs.
460 fn argmax_batch(&self, logits_ptr: DevicePtr, n: usize, stream: u64) -> Result<Vec<u32>>;
461
462 /// Return the hidden state after final norm from the last decode step.
463 ///
464 /// Used by MTP speculative decoding: the MTP head takes the target model's
465 /// post-norm hidden states as input alongside the token embedding.
466 fn hidden_after_norm(&self) -> DevicePtr;
467
468 /// L2-resident multi-token verification: per-position argmax token IDs;
469 /// each token advances KV/SSM state. All tokens go through each layer
470 /// before moving on so weights stay in L2.
471 fn decode_verify(
472 &self,
473 tokens: &[u32],
474 seq: &mut SequenceState,
475 stream: u64,
476 ) -> Result<Vec<u32>>;
477
478 /// Checkpoint SSM states before speculative verification.
479 fn checkpoint_ssm_states(&self, seq: &mut SequenceState) -> Result<()>;
480
481 /// Rollback SSM states after partial acceptance.
482 fn rollback_ssm_states(&self, seq: &mut SequenceState, num_accepted: usize) -> Result<()>;
483
484 /// True when this model has recurrent SSM / Mamba layers whose
485 /// `h_state` + `conv_state` are advanced in-place every decoded
486 /// token.
487 ///
488 /// Pure-attention models return `false` (the default): their only
489 /// per-token state is the paged KV cache, which the Phase-C
490 /// boundary rollback rewinds by lowering `seq_len`. Hybrid models
491 /// (Qwen3.6-A3B, MiniMax, Nemotron-nano) return `true` — for those
492 /// the scheduler MUST also restore the SSM state from a decode-time
493 /// snapshot, because the recurrent state cannot be undone by
494 /// lowering a cursor.
495 fn has_ssm_layers(&self) -> bool {
496 false
497 }
498
499 /// Verify DRAFT capacity of the MTP state pools for a sequence
500 /// occupying SSM pool slot `slot_idx` — the deepest `num_drafts` a
501 /// speculative step may dispatch to it without overflowing its slot's
502 /// per-token H-intermediate allocation (tiered since 2026-08-16; SSOT
503 /// `ssm_reserve::verify_slot_h_intermediates`). The scheduler clamps
504 /// every spec step's draft count to the MINIMUM capacity across the
505 /// active slots. Default `usize::MAX`: no SSM verify pools to
506 /// constrain (pure-attention models, spec off).
507 fn mtp_slot_draft_capacity(&self, _slot_idx: usize) -> usize {
508 usize::MAX
509 }
510
511 /// Number of decode-rollback SSM snapshot slots reserved **per
512 /// active sequence** (Phase-C). The scheduler's per-sequence
513 /// snapshot ring is sized from this. `0` (the default) means the
514 /// model keeps no decode-rollback snapshots — appropriate for
515 /// pure-attention models and for SSM models when the snapshot pool
516 /// has no capacity reserved. SSM models with a populated pool
517 /// override to the depth `ssm_reserve::decode_rollback_ring_slots`
518 /// decided — 8 by default, or whatever `--ssm-decode-ring-slots` /
519 /// preflight's free-memory fit published (#915).
520 fn decode_rollback_ring_slots(&self) -> usize {
521 0
522 }
523
524 /// Save `seq`'s live SSM `h_state` + `conv_state` (all SSM layers)
525 /// into the decode-rollback snapshot slot `ring_slot`.
526 ///
527 /// `ring_slot` is a per-sequence ring index in
528 /// `[0, decode_rollback_ring_slots())`; the model maps it to a
529 /// concrete snapshot-pool slot keyed by `seq.slot_idx`. Reuses the
530 /// same `SsmSnapshotPool` D2D copy primitive as Marconi prefix
531 /// caching and MTP verify (SSOT — one snapshot mechanism).
532 ///
533 /// Default: no-op `Ok(())` for pure-attention models, which have no
534 /// SSM state to snapshot.
535 fn save_decode_ssm_snapshot(&self, _seq: &SequenceState, _ring_slot: usize) -> Result<()> {
536 Ok(())
537 }
538
539 /// Restore `seq`'s SSM `h_state` + `conv_state` (all SSM layers)
540 /// from the decode-rollback snapshot slot `ring_slot` previously
541 /// written by [`Self::save_decode_ssm_snapshot`].
542 ///
543 /// Default: no-op `Ok(())` for pure-attention models.
544 fn restore_decode_ssm_snapshot(&self, _seq: &SequenceState, _ring_slot: usize) -> Result<()> {
545 Ok(())
546 }
547
548 /// Speculative decoding via the model's internal MTP proposer; falls
549 /// back to regular decode when no proposer is wired up.
550 fn generate_speculative(
551 &self,
552 prompt_tokens: &[u32],
553 params: &spark_runtime::sampler::SamplingParams,
554 num_drafts: usize,
555 ) -> Result<crate::engine::GenerateResult>;
556
557 /// Check if speculative decoding is available (MTP or self-speculative).
558 fn has_proposer(&self) -> bool;
559 /// The installed DFlash drafter's block size γ, when one is installed.
560 /// The serve layer derives `num_drafts = γ - 1` from THIS (the head is
561 /// the SSOT — it resolved the drafter config's trained block size),
562 /// never from a CLI default that may not match the checkpoint.
563 fn dflash_gamma(&self) -> Option<usize> {
564 None
565 }
566
567 /// Check if self-speculative decoding is enabled.
568 fn has_self_speculative(&self) -> bool;
569
570 /// Eager decode skipping SSM layers. Used by self-speculative drafting.
571 /// Returns logits pointer for argmax. Advances seq_len by 1.
572 fn decode_draft(&self, token: u32, seq: &mut SequenceState, stream: u64) -> Result<DevicePtr>;
573
574 /// Insert the full token sequence (prompt + generated) into the prefix
575 /// cache. Call BEFORE `free_sequence()` (block indices must still be
576 /// valid). Benefits multi-turn agentic sessions that resend full history.
577 fn cache_sequence(&self, seq: &SequenceState);
578
579 /// #155 iter3: during decode, save a block-aligned Marconi SSM snapshot
580 /// at checkpoint-interval boundaries so the NEXT turn's warm prefix-cache
581 /// hit restores from decode-produced state near the conversation's end —
582 /// instead of replaying decode-produced tokens through the prefill kernel
583 /// (the warm-hit drift ratchet, issue #155). Called from the scheduler
584 /// after each decode step's live SSM state is canonical (post-commit on
585 /// the MTP path). Default no-op (non-hybrid models / caching disabled).
586 fn decode_marconi_checkpoint(&self, _seq: &mut SequenceState) {}
587
588 /// Free all GPU resources associated with a sequence.
589 ///
590 /// Releases KV cache blocks and returns SSM state pool slot.
591 /// Must be called when a sequence is no longer needed.
592 fn free_sequence(&self, seq: &mut SequenceState) -> Result<()>;
593
594 /// Move a sequence's SSM states to a different pool slot.
595 ///
596 /// Copies h_state and conv_state across all SSM layers from the current
597 /// slot to `new_slot`. Used by the scheduler for slot compaction after
598 /// swap_remove to keep active sequences at contiguous slots [0..N).
599 fn compact_sequence(&self, seq: &mut SequenceState, new_slot: usize) -> Result<()>;
600
601 /// Disown a retired sequence's SSM pool slot after `compact_sequence`
602 /// migrated it to a surviving sequence.
603 ///
604 /// Sets the `slot_idx` reuse sentinel AND neutralizes the sequence's
605 /// internal slot-release guard so the migrated slot is NOT released when
606 /// this sequence is later freed or dropped (the surviving sequence now owns
607 /// it). The scheduler MUST call this — instead of mutating `slot_idx`
608 /// directly — immediately after a `compact_sequence` that reuses this
609 /// sequence's slot, so a subsequent early-return/drop cannot double-release.
610 fn detach_slot_for_reuse(&self, seq: &mut SequenceState);
611
612 /// CUDA-graphed K=2 verify: 2 tokens, capture-then-replay. Returns
613 /// `[verified_0, verified_1]` argmax IDs. SSM intermediates saved for
614 /// partial rollback via `rollback_ssm_states`.
615 fn decode_verify_graphed(
616 &self,
617 tokens: &[u32; 2],
618 seq: &mut SequenceState,
619 stream: u64,
620 ) -> Result<[u32; 2]>;
621
622 /// CUDA-graphed K=3 verify (1 verified + 2 drafts). Returns 3 argmax IDs.
623 /// SSM intermediates `[0]` and `[1]` are saved for partial rollback.
624 fn decode_verify_graphed_k3(
625 &self,
626 tokens: &[u32; 3],
627 seq: &mut SequenceState,
628 stream: u64,
629 ) -> Result<[u32; 3]>;
630
631 /// CUDA-graphed K=4 verify (1 verified + 3 drafts). Returns 4 argmax IDs.
632 /// SSM intermediates [0..3] saved for partial rollback.
633 fn decode_verify_graphed_k4(
634 &self,
635 tokens: &[u32; 4],
636 seq: &mut SequenceState,
637 stream: u64,
638 ) -> Result<[u32; 4]>;
639
640 /// Whether [`Self::decode_verify_batched`] can run for `ks.len()`
641 /// sequences at `ks[i]` verify rows each (one more than that sequence's
642 /// draft count; the K-vs-batch ladder passes 2..=4, and D-Cut makes the
643 /// vector RAGGED — uniform is just the special case).
644 ///
645 /// Default `false`: the scheduler MUST fall back to the per-sequence
646 /// `decode_verify_graphed_k{2,3,4}` loop. There is deliberately NO
647 /// default loop impl of the batched form — a loop over the per-seq
648 /// verify would leave the shared logits buffer holding only the LAST
649 /// sequence's rows and silently poison row-based pipeline picks.
650 fn can_batch_verify(&self, _ks: &[usize]) -> bool {
651 false
652 }
653
654 /// Batched K-row verify: `ks.len()` sequences × `ks[i]` rows in ONE eager
655 /// forward (flat seq-major rows, `tokens.len() == Σ ks`). Weight matrices
656 /// are read once for all `Σ ks` rows. Sequence i occupies rows
657 /// `[off_i, off_i + ks[i])` where `off_i = Σ_{t<i} ks[t]`, holding
658 /// `[last_verified, d0, .., d_{ks[i]-2}]`. Returns the `Σ ks` argmax IDs
659 /// in the same flat order. On success each sequence's `tokens`/`seq_len`
660 /// advance by its own `ks[i]` (rewind is the caller's verdict arithmetic,
661 /// same as the per-seq path). On Err NO sequence state has been advanced.
662 ///
663 /// Callers must gate on [`Self::can_batch_verify`].
664 fn decode_verify_batched(
665 &self,
666 tokens: &[u32],
667 ks: &[usize],
668 seqs: &mut [&mut SequenceState],
669 stream: u64,
670 ) -> Result<Vec<u32>> {
671 let _ = (tokens, ks, seqs, stream);
672 bail!("decode_verify_batched: unsupported by this model")
673 }
674
675 /// Copy raw-hidden rows `rows[i]` of the just-run batched verify forward
676 /// into stash slot `i` (`verify_hidden_stash`), BEFORE any propose
677 /// clobbers the shared `hidden_states` buffer. Companion of
678 /// [`Self::decode_verify_batched`].
679 fn stash_verify_hidden_rows(&self, rows: &[usize], stream: u64) -> Result<()> {
680 let _ = (rows, stream);
681 bail!("stash_verify_hidden_rows: unsupported by this model")
682 }
683
684 /// Stashed-row variant of [`Self::save_hidden_for_mtp`]: copy stash slot
685 /// `idx` (written by [`Self::stash_verify_hidden_rows`]) into the MTP
686 /// input buffer. Used by the batched-verify verdict path, whose propose
687 /// calls have already overwritten the live verify rows.
688 fn save_hidden_for_mtp_from_stash(&self, idx: usize, stream: u64) -> Result<()> {
689 let _ = (idx, stream);
690 bail!("save_hidden_for_mtp_from_stash: unsupported by this model")
691 }
692
693 /// Batched cross-sequence MTP propose for the batched K=4 verify path:
694 /// `num_drafts` drafts for each of `tokens.len()` sequences, reading
695 /// every drafter weight once per draft position instead of once per
696 /// sequence. `stash_idx[i]` names the verify-stash slot holding sequence
697 /// i's accepted-position hidden (written by
698 /// [`Self::stash_verify_hidden_rows`]); `positions[i]` is the propose
699 /// position (post-rewind `seq_len`), matching the per-seq
700 /// [`Self::run_mtp_propose_multi`] contract. Grammarless sequences only.
701 ///
702 /// `out_conf`, when `Some`, receives each draft's top-1 LOG-probability
703 /// (`ln p`, same shape as the returned drafts) — the D-Cut ranking key.
704 /// It is filled with zeros (certainty) when the drafter cannot measure
705 /// confidence, so a caller ranking by prefix product never prunes on a
706 /// value nobody produced.
707 ///
708 /// `Ok(None)` = unsupported (caller falls back to the per-seq propose
709 /// loop, re-saving each stash slot first). Default: unsupported.
710 #[allow(clippy::too_many_arguments)]
711 fn run_mtp_propose_batched(
712 &self,
713 tokens: &[u32],
714 positions: &[usize],
715 stash_idx: &[usize],
716 num_drafts: usize,
717 seqs: &mut [&mut SequenceState],
718 stream: u64,
719 out_conf: Option<&mut Vec<Vec<f32>>>,
720 ) -> Result<Option<Vec<Vec<u32>>>> {
721 let _ = (
722 tokens, positions, stash_idx, num_drafts, seqs, stream, out_conf,
723 );
724 Ok(None)
725 }
726
727 /// Widest batch [`Self::run_mtp_propose_batched`] can carry in ONE
728 /// drafter forward per draft position. `1` = per-sequence only.
729 /// Schedulers chunk their propose groups by this — never by a constant.
730 fn mtp_propose_batch_max(&self) -> usize {
731 1
732 }
733
734 /// DFlash K=γ graphed verify (γ+1 tokens). Specialization of the K=2/3/4
735 /// pattern for arbitrary K. Default impl falls back to eager
736 /// `decode_verify`. Models can override for CUDA-graph speedup keyed by
737 /// `(slot_idx, K)`.
738 fn decode_verify_graphed_kgamma(
739 &self,
740 tokens: &[u32],
741 seq: &mut SequenceState,
742 stream: u64,
743 ) -> Result<Vec<u32>> {
744 self.decode_verify(tokens, seq, stream)
745 }
746
747 /// DFlash γ-token verification: 1 verified + γ drafts → per-position
748 /// argmax. Variable-length γ (vs fixed K=2/3/4) because it's a drafter
749 /// config field. CUDA-graph capture keyed by `(slot_idx, tokens.len())`.
750 /// Default routes to `decode_verify_graphed_kgamma`.
751 fn decode_verify_dflash(
752 &self,
753 tokens: &[u32],
754 seq: &mut SequenceState,
755 stream: u64,
756 ) -> Result<Vec<u32>> {
757 // Phase 2.5e: route to the K=γ graphed path. Models that don't
758 // override `decode_verify_graphed_kgamma` get the eager fallback
759 // for free (the trait default does that).
760 self.decode_verify_graphed_kgamma(tokens, seq, stream)
761 }
762
763 /// DFlash fused decode+verify: one M=(1+k) forward replacing separate
764 /// M=1 decode + M=k verify on the DFlash path.
765 ///
766 /// `tokens[0]` = accepted/decode token; `tokens[1..]` = draft block.
767 /// `try_dflash_capture` fires at row 0 so the DFlash drafter conditions
768 /// on the confirmed-accepted token's per-layer hidden, never on a
769 /// potentially-rejected draft's hidden.
770 ///
771 /// CUDA-graph cache keyed by `(slot_idx, tokens.len())`. Default falls
772 /// back to `decode_verify_graphed_kgamma` (which itself falls back to
773 /// eager `decode_verify`) for models that don't override.
774 fn decode_and_verify_fused(
775 &self,
776 tokens: &[u32],
777 seq: &mut SequenceState,
778 stream: u64,
779 ) -> Result<Vec<u32>> {
780 self.decode_verify_graphed_kgamma(tokens, seq, stream)
781 }
782
783 /// Save the post-norm hidden state at `token_idx` (0 or 1) to a
784 /// dedicated MTP input buffer. Must precede `run_mtp_propose` — MTP
785 /// overwrites shared buffers including `norm_output`.
786 fn save_hidden_for_mtp(&self, token_idx: usize, stream: u64) -> Result<()>;
787
788 /// ATLAS_MTP_CATCHUP: ring-capture a serially decoded token's final
789 /// hidden at `pos` for the drafter catch-up feed. Default no-op.
790 fn save_hidden_for_catchup(&self, _token_idx: usize, _pos: usize) -> Result<()> {
791 Ok(())
792 }
793
794 /// Capture `hidden_states[token_idx]` from every DFlash capture layer
795 /// into `dflash_hidden_save`. Called after gamma verify Phase 3 D2H
796 /// sync (bonus position known). No-op when DFlash is disabled.
797 fn save_dflash_hidden_for_propose(&self, _token_idx: usize, _stream: u64) -> Result<()> {
798 Ok(())
799 }
800
801 /// Append the accepted draft's hidden state (row 1 of dflash_hidden_save)
802 /// into the proposer context. Base primitive for both legacy and Eagle paths.
803 /// Default no-op for models without a DFlash drafter.
804 fn dflash_accept_append(&self, _seq: &mut SequenceState) -> Result<()> {
805 Ok(())
806 }
807
808 /// EAGLE-fix (K=2 accept): append row 0 @ N then row 1 @ N+1 BEFORE propose
809 /// so forward_block conditions on row 1 (the hidden that generated bonus).
810 /// Default no-op for models without a DFlash drafter.
811 fn dflash_eagle_accept_append(&self, _seq: &mut SequenceState) -> Result<()> {
812 Ok(())
813 }
814
815 /// EAGLE-fix (K=gamma): append rows 0..=num_accepted at positions
816 /// base_pos..=base_pos+num_accepted. Row num_accepted is appended LAST ->
817 /// freshest ctx slot = the hidden that generated the bonus (EAGLE).
818 /// Default no-op for models without a DFlash drafter.
819 fn dflash_eagle_kgamma_append(
820 &self,
821 _seq: &mut SequenceState,
822 _num_accepted: usize,
823 _base_pos: usize,
824 ) -> Result<()> {
825 Ok(())
826 }
827
828 /// Ctx-holes fix (serial decode): append the just-decoded token's
829 /// captured per-layer hidden (`dflash_hidden_save` row 0, filled by
830 /// `try_dflash_capture` inside the decode layer loop) into the seq's
831 /// DFlash ctx accumulator, stamped at its true position
832 /// (`seq.seq_len - 1`, matching propose.rs's decode-append convention).
833 ///
834 /// Called from the scheduler's serial bootstrap path when adaptive
835 /// speculation has SUSPENDED this seq — propose() never runs there, so
836 /// without this hook every serially-decoded token's target hidden is
837 /// overwritten (single-slot model capture) and permanently lost,
838 /// leaving holes in the drafter's ctx at spec re-entry (measured
839 /// -0.42 accepted/step on think-gated vs spec-through-think content).
840 ///
841 /// Sets `skip_next_decode_append` so a propose() firing later (re-probe)
842 /// does not double-append the same capture. Graceful no-op when DFlash
843 /// is disabled or the seq has a non-DFlash proposer state.
844 fn dflash_serial_ctx_append(&self, _seq: &mut SequenceState) -> Result<()> {
845 Ok(())
846 }
847
848 /// Unified DFlash ctx commit (ATLAS_DFLASH_UNIFIED_CTX=1). Copies
849 /// `num_committed` scratch rows (`dflash_hidden_save` rows
850 /// `scratch_row..scratch_row+num_committed`) into `ctx_hidden_acc` at the
851 /// CURRENT TAIL (`ctx_len`), stamping RoPE positions
852 /// `base_pos..base_pos+num_committed`, folding the watermark slide in
853 /// first. `base_pos` is the RoPE position, NOT the acc row index (they
854 /// diverge after a watermark slide — DDD §4.1 landmine). `scratch_row` is
855 /// 0 on every single-sequence path; batched decode (n>1) captures ALL
856 /// batch rows, so seq i commits from scratch row i. The single structural
857 /// replacement for the ~5 fragmented appends. Default no-op for models
858 /// without a DFlash drafter.
859 fn commit_ctx(
860 &self,
861 _seq: &mut SequenceState,
862 _num_committed: usize,
863 _base_pos: usize,
864 _scratch_row: usize,
865 ) -> Result<()> {
866 Ok(())
867 }
868
869 /// Rows per per-sequence capture BAND in the DFlash hidden scratch (γ+1).
870 /// Sequence `i` of a batched K=γ verify captures into band `i`, so its
871 /// `commit_ctx` `scratch_row` is `i * dflash_capture_band()`. Returning
872 /// the model's own stride keeps the capture and the commit from ever
873 /// disagreeing. `0` when there is no DFlash drafter.
874 fn dflash_capture_band(&self) -> usize {
875 0
876 }
877
878 /// Run the MTP proposer for one draft token off the saved hidden state.
879 /// `None` when no proposer is wired.
880 fn run_mtp_propose(
881 &self,
882 token: u32,
883 position: usize,
884 seq: &mut SequenceState,
885 stream: u64,
886 ) -> Result<Option<u32>>;
887
888 /// Run the MTP proposer to generate multiple draft tokens.
889 ///
890 /// Uses the hidden state previously saved via `save_hidden_for_mtp`.
891 /// Returns empty vec if no MTP proposer is available.
892 ///
893 /// `grammar_bitmask`: when `Some`, drafts are constrained to the allowed
894 /// token set of an XGrammar matcher at its current position. Format is
895 /// `ceil(vocab_size / 32)` i32 words; bit `tok` set ⇒ allowed. `None`
896 /// preserves the unconstrained GPU-argmax fast path.
897 fn run_mtp_propose_multi(
898 &self,
899 token: u32,
900 position: usize,
901 num_drafts: usize,
902 seq: &mut SequenceState,
903 stream: u64,
904 grammar_bitmask: Option<&[i32]>,
905 ) -> Result<Vec<u32>>;
906
907 /// Read the draft token ID stored on GPU by the last `run_mtp_propose_multi`
908 /// call (which used `embed_from_argmax` to write the draft embedding and
909 /// token ID directly on GPU). Returns 0 if no proposer is available.
910 fn read_deferred_draft_token(&self) -> Result<u32> {
911 Ok(0)
912 }
913
914 /// Encode images through the vision encoder and store embeddings for the next prefill.
915 ///
916 /// Each tuple is `(pixels: Vec<f32>, grid_h: usize, grid_w: usize)`.
917 /// Pixels are laid out [P, C×T×Hp×Wp] matching `vision_preprocess::preprocess_image`.
918 /// Must be called before `prefill_chunk` when the prompt contains `<|image_pad|>` tokens.
919 ///
920 /// Default: no-op (text-only models).
921 fn prepare_vision_embed(&self, _images: &[crate::VisionItem]) -> Result<()> {
922 Ok(())
923 }
924
925 /// Batched vision encode across N requests' images in ONE `forward_batched`
926 /// call (block GEMM weights read once over Σpatches). `per_request[i]` is
927 /// request i's images. Returns one `(patch_row_offset, grid_index_offset,
928 /// num_images, patch_row_count)` per request, in request order, locating
929 /// its slice of the shared packed `buf_out`. Default: no-op (text models).
930 fn prepare_vision_embed_batched(
931 &self,
932 _per_request: &[Vec<crate::VisionItem>],
933 ) -> Result<Vec<(usize, usize, usize, usize)>> {
934 Ok(Vec::new())
935 }
936
937 /// Set the co-dispatched batched-ViT slice base for the NEXT prefill_chunk
938 /// (row offset into buf_out, grid index offset, image count owned). Pass
939 /// (0,0,0) to reset to the legacy single-request behaviour. Default: no-op.
940 fn set_vision_slice_base(&self, _row_base: usize, _grid_base: usize, _owned_images: usize) {}
941
942 /// EP worker step: receive a (seq_id, cmd) preamble from rank 0 and
943 /// execute the command in the addressed slot.
944 ///
945 /// 🔴 An `Err` carrying [`EpCommandFailed`] means the command EXECUTED and failed —
946 /// a per-request fault the head raises identically and answers the client with. The
947 /// worker must STAY UP. Any other `Err` came from receiving the command, i.e. the link
948 /// to the head is gone, and the worker must exit. See [`EpCommandFailed`].
949 ///
950 /// Returns false when the worker should shut down.
951 /// Only valid on rank > 0 with EP enabled.
952 ///
953 /// `slots` must be sized to `args.max_batch_size` (same as the head's
954 /// scheduler `active` capacity); commands with `seq_id >= slots.len()`
955 /// fail loudly rather than corrupt unrelated state.
956 fn ep_worker_step(&self, _slots: &mut [Option<SequenceState>]) -> Result<bool> {
957 Ok(true) // no-op for non-EP models
958 }
959
960 /// Check whether expert parallelism (EP) is enabled (multi-GPU MoE).
961 ///
962 /// When true, the scheduler must use separate decode + prefill commands
963 /// with explicit EP broadcasts rather than mixed_forward (which has no
964 /// EP broadcast protocol defined).
965 fn is_ep(&self) -> bool {
966 false
967 }
968
969 /// True when single-token decode `lm_head` writes FP32 logits to a
970 /// dedicated FP32 scratch buffer (rather than the shared BF16 logits
971 /// buffer). Callers that consume those logits must read from
972 /// [`Self::decode_logits_ptr`] using 4 bytes/element. Defaults false;
973 /// only Gemma-4 dense overrides today (gated by
974 /// `ATLAS_GEMMA4_FP32_LMHEAD=1`).
975 fn decode_logits_fp32(&self) -> bool {
976 false
977 }
978
979 /// Buffer pointer the single-token decode `lm_head` last wrote to. The
980 /// returned dtype is FP32 when [`Self::decode_logits_fp32`] is true,
981 /// BF16 otherwise. The default impl returns the shared BF16 logits
982 /// buffer used by every existing model. Override on models that route
983 /// the lm_head output through an FP32 scratch (Gemma-4 + softcap).
984 fn decode_logits_ptr(&self) -> DevicePtr {
985 // Default: shared BF16 logits buffer. Models with FP32 lm_head
986 // override.
987 // NOTE: this default panics when the trait method is invoked on
988 // models that don't implement either accessor. TransformerModel
989 // overrides both. If a future model needs only one, it must
990 // override both for consistency.
991 unreachable!(
992 "Model::decode_logits_ptr() must be overridden alongside \
993 decode_logits_fp32() — default cannot return a valid pointer."
994 )
995 }
996
997 /// Multi-head Latent Attention guard. When true, chunked prefill MUST run
998 /// as a single chunk — Atlas has no paged-MLA prefill kernel and
999 /// multi-chunk MLA silently corrupts attention output (see Mistral-Small-4
1000 /// 2026-05-01 sweep: 8K collapses to "The\nThe…").
1001 fn is_mla(&self) -> bool {
1002 false
1003 }
1004
1005 /// mHC hyper-connection stream count (0 = no highway). Non-zero means
1006 /// the batched GDN decode paths are UNWIRED for this model (they carry
1007 /// their own residual, which the highway replaces — see
1008 /// `qwen3_ssm::hc::refuse_batched_under_hc`); the scheduler must clamp
1009 /// concurrency to 1 until the batched highway lands (Avarok #753 item B).
1010 fn hc_mult(&self) -> usize {
1011 0
1012 }
1013
1014 /// Tokens per paged-KV block, or `None` when the model has no paged KV.
1015 /// The scheduler uses this to land a prefill chunk boundary exactly on the
1016 /// block boundary a warm turn will match at (see
1017 /// `spark_runtime::ssm_tail_boundary`).
1018 fn kv_block_size(&self) -> Option<usize> {
1019 None
1020 }
1021
1022 /// EP broadcast: send a command (u32) to all worker ranks.
1023 ///
1024 /// Called by rank 0 before each model operation to synchronize workers.
1025 /// Only valid when EP is enabled.
1026 fn ep_broadcast_cmd(&self, _cmd: u32) -> Result<()> {
1027 Ok(()) // no-op for non-EP models
1028 }
1029
1030 /// EP broadcast: send a `(seq_id, cmd)` pair to all worker ranks.
1031 ///
1032 /// Use this at the *first* broadcast of a logical command sequence
1033 /// (e.g. the K=2 verify marker, prefill start, decode token, etc.).
1034 /// Follow-up broadcasts within the same command (chunk metadata, more
1035 /// tokens, accept/reject result) keep using [`Self::ep_broadcast_cmd`]
1036 /// — the worker consumes the preamble once per command and routes
1037 /// subsequent reads through the slot it identified.
1038 ///
1039 /// When [`Self::ep_protocol_v2`] returns false (the default), the
1040 /// `seq_id` is ignored on the wire and behaviour matches the legacy
1041 /// single-sequence broadcast.
1042 fn ep_broadcast_cmd_for_seq(&self, _seq_id: u32, _cmd: u32) -> Result<()> {
1043 Ok(()) // no-op for non-EP models
1044 }
1045
1046 /// Returns true if this model's EP comm path is using the v2 protocol
1047 /// (slot-aware seq_id preamble). Default false — pre-PR behaviour.
1048 fn ep_protocol_v2(&self) -> bool {
1049 false
1050 }
1051
1052 /// EP bulk broadcast: send an array of u32 tokens to all worker ranks.
1053 /// Uses a single NCCL broadcast instead of per-token broadcasts.
1054 fn ep_broadcast_tokens(&self, _tokens: &[u32]) -> Result<Vec<u32>> {
1055 Ok(Vec::new()) // no-op for non-EP models
1056 }
1057
1058 /// Trim the MTP proposer's KV cache after verification.
1059 ///
1060 /// Called on rejection to discard the rejected draft's MTP KV entry.
1061 fn trim_proposer_state(
1062 &self,
1063 seq: &mut SequenceState,
1064 num_accepted: usize,
1065 stream: u64,
1066 ) -> Result<()>;
1067
1068 /// Launch SSM state checkpoint D2D copies on a secondary CUDA stream.
1069 ///
1070 /// Non-blocking: returns immediately. The copies can overlap with MTP
1071 /// propose on the default stream since they access disjoint memory.
1072 /// Call `sync_secondary` before the next verify to ensure completion.
1073 fn start_checkpoint_async(&self, seq: &mut SequenceState) -> Result<()> {
1074 // Default: fall back to synchronous checkpoint.
1075 self.checkpoint_ssm_states(seq)
1076 }
1077
1078 /// Launch SSM state rollback + checkpoint on the secondary stream.
1079 ///
1080 /// Used on the reject path: rollback to `intermediate[0]`, then checkpoint
1081 /// the rolled-back state for the next verify iteration.
1082 fn start_rollback_and_checkpoint_async(
1083 &self,
1084 seq: &mut SequenceState,
1085 num_accepted: usize,
1086 ) -> Result<()> {
1087 // Default: fall back to synchronous operations.
1088 self.rollback_ssm_states(seq, num_accepted)?;
1089 self.checkpoint_ssm_states(seq)
1090 }
1091
1092 /// Wait for all work on the secondary stream to complete.
1093 fn sync_secondary(&self) -> Result<()> {
1094 Ok(()) // No-op if no secondary stream.
1095 }
1096
1097 /// Item #2 (STree-style in-place verify commit): commit the surviving
1098 /// prefix of a verify pass directly onto the canonical `h_state` /
1099 /// `conv_state`. Full accept (`num_accepted == k`) is a no-op (the
1100 /// kernel's final state is already live); partial accept is a single
1101 /// index-select of `h_state_intermediates[num_accepted-1]`. No-op
1102 /// default for backends without the dual-buffer SSM state.
1103 /// Runs on `secondary_stream`; pair with `sync_secondary`.
1104 fn commit_accepted_prefix(
1105 &self,
1106 _seq: &mut SequenceState,
1107 _num_accepted: usize,
1108 _k: usize,
1109 ) -> Result<()> {
1110 Ok(())
1111 }
1112
1113 /// Save KV blocks + SSM state to writer. Does NOT free resources.
1114 ///
1115 /// Format: `[KV layers × blocks × (K + V)]` then `[SSM layers × (h + conv)]`.
1116 /// The model owns the serialization format.
1117 fn save_sequence_state(
1118 &self,
1119 _seq: &SequenceState,
1120 _writer: &mut dyn std::io::Write,
1121 ) -> Result<()> {
1122 bail!("swap not supported by this model")
1123 }
1124
1125 /// Restore KV blocks + SSM state from reader into an allocated sequence.
1126 ///
1127 /// Allocates `num_blocks` new KV blocks, fills from reader, restores SSM.
1128 fn restore_sequence_state(
1129 &self,
1130 _seq: &mut SequenceState,
1131 _num_blocks: usize,
1132 _reader: &mut dyn std::io::Read,
1133 ) -> Result<()> {
1134 bail!("swap not supported by this model")
1135 }
1136
1137 /// Whether `tokens` contains a vision pad token for this model — i.e.
1138 /// the KV at those positions came from image/video EMBEDDINGS that a
1139 /// plain token re-prefill cannot reproduce. Decode-time preemption uses
1140 /// this to exclude vision sequences from the requeue-with-re-prefill
1141 /// path (the spill path, which saves KV verbatim, stays eligible).
1142 /// Default false: pure-text models are always re-prefillable.
1143 fn tokens_contain_vision_pad(&self, _tokens: &[u32]) -> bool {
1144 false
1145 }
1146
1147 /// Number of free KV cache blocks available for allocation.
1148 fn num_free_blocks(&self) -> usize {
1149 0
1150 }
1151
1152 /// Total KV blocks in the paged cache (denominator for occupancy
1153 /// gauges). Default 0 for backends without a paged cache.
1154 fn num_total_blocks(&self) -> usize {
1155 0
1156 }
1157
1158 /// Reclaim up to `num_blocks` blocks from the prefix cache, returning how
1159 /// many actually became free.
1160 ///
1161 /// The prefill/decode allocators reclaim implicitly (`try_alloc` → evict →
1162 /// retry), but swap-in cannot: it gates on `num_free_blocks()` BEFORE
1163 /// attempting a restore, so cached-but-evictable capacity is invisible to
1164 /// it and a swapped-out sequence waits for blocks that are never
1165 /// volunteered. Cached blocks are legitimately held (the cache owns one ref
1166 /// per radix node), so nothing frees them on its own — the swap-in path has
1167 /// to ask. Returns 0 when nothing is evictable, which the caller must treat
1168 /// as "no progress possible" rather than retrying forever.
1169 fn reclaim_prefix_blocks(&self, _num_blocks: usize) -> usize {
1170 0
1171 }
1172
1173 /// Return the default CUDA stream handle.
1174 fn default_stream(&self) -> u64 {
1175 0
1176 }
1177
1178 /// Create a new CUDA stream (for overlapping prefill with decode).
1179 fn create_stream(&self) -> Result<u64> {
1180 Ok(0)
1181 }
1182
1183 /// Create a CUDA event (for inter-stream synchronization).
1184 fn create_event(&self) -> Result<u64> {
1185 Ok(0)
1186 }
1187
1188 /// Record an event on a stream (marks a point in the stream's work).
1189 fn record_event(&self, _event: u64, _stream: u64) -> Result<()> {
1190 Ok(())
1191 }
1192
1193 /// Make a stream wait for an event (GPU-side sync, CPU does not block).
1194 fn stream_wait_event(&self, _stream: u64, _event: u64) -> Result<()> {
1195 Ok(())
1196 }
1197
1198 /// Block the host until all work submitted to `stream` has completed.
1199 /// Used by `mixed_forward_batch` to retire the decode pass (which runs on
1200 /// the default stream) before the batched prefill reuses the shared arena
1201 /// buffers on another stream (#110). Default no-op for non-CUDA mocks.
1202 fn synchronize(&self, _stream: u64) -> Result<()> {
1203 Ok(())
1204 }
1205}
1206
1207#[cfg(test)]
1208mod padded_batch_n_tests {
1209 use super::padded_batch_n;
1210
1211 /// Rungs <= 32 must be UNCHANGED by the wave-14a widening (byte-identity
1212 /// for every bs <= 32 boot), and the new rungs must cover n=33..128.
1213 #[test]
1214 fn ladder_rungs() {
1215 // Legacy rungs (aacd29cb and earlier) — must not move.
1216 for (n, want) in [
1217 (1usize, 2usize),
1218 (2, 2),
1219 (3, 4),
1220 (5, 8),
1221 (9, 12),
1222 (13, 16),
1223 (16, 16),
1224 (17, 24),
1225 (25, 32),
1226 (32, 32),
1227 ] {
1228 assert_eq!(padded_batch_n(n), want, "n={n}");
1229 }
1230 // Wave-14a rungs (only reachable when the boot's max_batch_size
1231 // admits that many active sequences).
1232 for (n, want) in [
1233 (33usize, 48usize),
1234 (48, 48),
1235 (49, 64),
1236 (64, 64),
1237 (65, 96),
1238 (96, 96),
1239 (97, 128),
1240 (128, 128),
1241 ] {
1242 assert_eq!(padded_batch_n(n), want, "n={n}");
1243 }
1244 // Above the ladder: fall-through unchanged.
1245 assert_eq!(padded_batch_n(129), 129);
1246 }
1247}
1248
1249/// A worker command that was received and then FAILED TO EXECUTE.
1250///
1251/// 🔴 Why this distinction is load-bearing. The EP worker loop used to `break` on any
1252/// error, so a per-request fault — a prefill chunk the model legitimately refuses — killed
1253/// the worker, which then exited with status **0** while the head stayed up. The head's very
1254/// next request issued a collective against a peer that no longer existed and spun in NCCL
1255/// forever at 100 % CPU, with `/v1/models`, `/health` and `/health/live` all still answering
1256/// 200. Measured 2026-08-30: rank 1 logged this exact refusal and stopped 4 s later; rank 0
1257/// accepted a 13-token request 10 minutes on and never produced a single further log line.
1258/// ANOMALIES A60 (the wedge) and A62 (the refusal that triggered it).
1259///
1260/// The head raises the SAME error for the SAME command and turns it into an HTTP 500, so the
1261/// two ranks disagreeing about whether it is fatal is the defect. A receive failure stays
1262/// fatal: the link is gone, and the next iteration's receive would fail again anyway.
1263#[derive(Debug)]
1264pub struct EpCommandFailed(pub anyhow::Error);
1265
1266impl std::fmt::Display for EpCommandFailed {
1267 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1268 write!(f, "{:#}", self.0)
1269 }
1270}
1271
1272impl std::error::Error for EpCommandFailed {}
1273
1274#[cfg(test)]
1275mod ep_command_failed_tests {
1276 use super::EpCommandFailed;
1277
1278 /// The worker loop classifies by downcast, so the tag must survive being boxed into an
1279 /// `anyhow::Error` — and the original message must survive with it, or the operator
1280 /// loses the only line that says WHY the command failed.
1281 #[test]
1282 fn the_tag_and_its_message_survive_anyhow() {
1283 let inner = anyhow::anyhow!("Prefill chunk layer 3 failed: DSA indexer cache: 16385");
1284 let tagged = anyhow::Error::new(EpCommandFailed(inner));
1285 assert!(
1286 tagged.downcast_ref::<EpCommandFailed>().is_some(),
1287 "the worker loop cannot tell a command failure from a dead link without this"
1288 );
1289 assert!(format!("{tagged:#}").contains("DSA indexer cache: 16385"));
1290 }
1291
1292 /// A receive failure must NOT be mistaken for a command failure: the link is gone and
1293 /// the worker has to exit rather than spin re-reading a dead socket.
1294 #[test]
1295 fn an_untagged_error_stays_fatal() {
1296 let recv = anyhow::anyhow!("ep_recv_seq_and_cmd: peer closed");
1297 assert!(recv.downcast_ref::<EpCommandFailed>().is_none());
1298 }
1299}