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 `ROLLBACK_RESTEER_CAP + 1`.
518 fn decode_rollback_ring_slots(&self) -> usize {
519 0
520 }
521
522 /// Save `seq`'s live SSM `h_state` + `conv_state` (all SSM layers)
523 /// into the decode-rollback snapshot slot `ring_slot`.
524 ///
525 /// `ring_slot` is a per-sequence ring index in
526 /// `[0, decode_rollback_ring_slots())`; the model maps it to a
527 /// concrete snapshot-pool slot keyed by `seq.slot_idx`. Reuses the
528 /// same `SsmSnapshotPool` D2D copy primitive as Marconi prefix
529 /// caching and MTP verify (SSOT — one snapshot mechanism).
530 ///
531 /// Default: no-op `Ok(())` for pure-attention models, which have no
532 /// SSM state to snapshot.
533 fn save_decode_ssm_snapshot(&self, _seq: &SequenceState, _ring_slot: usize) -> Result<()> {
534 Ok(())
535 }
536
537 /// Restore `seq`'s SSM `h_state` + `conv_state` (all SSM layers)
538 /// from the decode-rollback snapshot slot `ring_slot` previously
539 /// written by [`Self::save_decode_ssm_snapshot`].
540 ///
541 /// Default: no-op `Ok(())` for pure-attention models.
542 fn restore_decode_ssm_snapshot(&self, _seq: &SequenceState, _ring_slot: usize) -> Result<()> {
543 Ok(())
544 }
545
546 /// Speculative decoding via the model's internal MTP proposer; falls
547 /// back to regular decode when no proposer is wired up.
548 fn generate_speculative(
549 &self,
550 prompt_tokens: &[u32],
551 params: &spark_runtime::sampler::SamplingParams,
552 num_drafts: usize,
553 ) -> Result<crate::engine::GenerateResult>;
554
555 /// Check if speculative decoding is available (MTP or self-speculative).
556 fn has_proposer(&self) -> bool;
557 /// The installed DFlash drafter's block size γ, when one is installed.
558 /// The serve layer derives `num_drafts = γ - 1` from THIS (the head is
559 /// the SSOT — it resolved the drafter config's trained block size),
560 /// never from a CLI default that may not match the checkpoint.
561 fn dflash_gamma(&self) -> Option<usize> {
562 None
563 }
564
565 /// Check if self-speculative decoding is enabled.
566 fn has_self_speculative(&self) -> bool;
567
568 /// Eager decode skipping SSM layers. Used by self-speculative drafting.
569 /// Returns logits pointer for argmax. Advances seq_len by 1.
570 fn decode_draft(&self, token: u32, seq: &mut SequenceState, stream: u64) -> Result<DevicePtr>;
571
572 /// Insert the full token sequence (prompt + generated) into the prefix
573 /// cache. Call BEFORE `free_sequence()` (block indices must still be
574 /// valid). Benefits multi-turn agentic sessions that resend full history.
575 fn cache_sequence(&self, seq: &SequenceState);
576
577 /// #155 iter3: during decode, save a block-aligned Marconi SSM snapshot
578 /// at checkpoint-interval boundaries so the NEXT turn's warm prefix-cache
579 /// hit restores from decode-produced state near the conversation's end —
580 /// instead of replaying decode-produced tokens through the prefill kernel
581 /// (the warm-hit drift ratchet, issue #155). Called from the scheduler
582 /// after each decode step's live SSM state is canonical (post-commit on
583 /// the MTP path). Default no-op (non-hybrid models / caching disabled).
584 fn decode_marconi_checkpoint(&self, _seq: &mut SequenceState) {}
585
586 /// Free all GPU resources associated with a sequence.
587 ///
588 /// Releases KV cache blocks and returns SSM state pool slot.
589 /// Must be called when a sequence is no longer needed.
590 fn free_sequence(&self, seq: &mut SequenceState) -> Result<()>;
591
592 /// Move a sequence's SSM states to a different pool slot.
593 ///
594 /// Copies h_state and conv_state across all SSM layers from the current
595 /// slot to `new_slot`. Used by the scheduler for slot compaction after
596 /// swap_remove to keep active sequences at contiguous slots [0..N).
597 fn compact_sequence(&self, seq: &mut SequenceState, new_slot: usize) -> Result<()>;
598
599 /// Disown a retired sequence's SSM pool slot after `compact_sequence`
600 /// migrated it to a surviving sequence.
601 ///
602 /// Sets the `slot_idx` reuse sentinel AND neutralizes the sequence's
603 /// internal slot-release guard so the migrated slot is NOT released when
604 /// this sequence is later freed or dropped (the surviving sequence now owns
605 /// it). The scheduler MUST call this — instead of mutating `slot_idx`
606 /// directly — immediately after a `compact_sequence` that reuses this
607 /// sequence's slot, so a subsequent early-return/drop cannot double-release.
608 fn detach_slot_for_reuse(&self, seq: &mut SequenceState);
609
610 /// CUDA-graphed K=2 verify: 2 tokens, capture-then-replay. Returns
611 /// `[verified_0, verified_1]` argmax IDs. SSM intermediates saved for
612 /// partial rollback via `rollback_ssm_states`.
613 fn decode_verify_graphed(
614 &self,
615 tokens: &[u32; 2],
616 seq: &mut SequenceState,
617 stream: u64,
618 ) -> Result<[u32; 2]>;
619
620 /// CUDA-graphed K=3 verify (1 verified + 2 drafts). Returns 3 argmax IDs.
621 /// SSM intermediates `[0]` and `[1]` are saved for partial rollback.
622 fn decode_verify_graphed_k3(
623 &self,
624 tokens: &[u32; 3],
625 seq: &mut SequenceState,
626 stream: u64,
627 ) -> Result<[u32; 3]>;
628
629 /// CUDA-graphed K=4 verify (1 verified + 3 drafts). Returns 4 argmax IDs.
630 /// SSM intermediates [0..3] saved for partial rollback.
631 fn decode_verify_graphed_k4(
632 &self,
633 tokens: &[u32; 4],
634 seq: &mut SequenceState,
635 stream: u64,
636 ) -> Result<[u32; 4]>;
637
638 /// Whether [`Self::decode_verify_batched`] can run for `ks.len()`
639 /// sequences at `ks[i]` verify rows each (one more than that sequence's
640 /// draft count; the K-vs-batch ladder passes 2..=4, and D-Cut makes the
641 /// vector RAGGED — uniform is just the special case).
642 ///
643 /// Default `false`: the scheduler MUST fall back to the per-sequence
644 /// `decode_verify_graphed_k{2,3,4}` loop. There is deliberately NO
645 /// default loop impl of the batched form — a loop over the per-seq
646 /// verify would leave the shared logits buffer holding only the LAST
647 /// sequence's rows and silently poison row-based pipeline picks.
648 fn can_batch_verify(&self, _ks: &[usize]) -> bool {
649 false
650 }
651
652 /// Batched K-row verify: `ks.len()` sequences × `ks[i]` rows in ONE eager
653 /// forward (flat seq-major rows, `tokens.len() == Σ ks`). Weight matrices
654 /// are read once for all `Σ ks` rows. Sequence i occupies rows
655 /// `[off_i, off_i + ks[i])` where `off_i = Σ_{t<i} ks[t]`, holding
656 /// `[last_verified, d0, .., d_{ks[i]-2}]`. Returns the `Σ ks` argmax IDs
657 /// in the same flat order. On success each sequence's `tokens`/`seq_len`
658 /// advance by its own `ks[i]` (rewind is the caller's verdict arithmetic,
659 /// same as the per-seq path). On Err NO sequence state has been advanced.
660 ///
661 /// Callers must gate on [`Self::can_batch_verify`].
662 fn decode_verify_batched(
663 &self,
664 tokens: &[u32],
665 ks: &[usize],
666 seqs: &mut [&mut SequenceState],
667 stream: u64,
668 ) -> Result<Vec<u32>> {
669 let _ = (tokens, ks, seqs, stream);
670 bail!("decode_verify_batched: unsupported by this model")
671 }
672
673 /// Copy raw-hidden rows `rows[i]` of the just-run batched verify forward
674 /// into stash slot `i` (`verify_hidden_stash`), BEFORE any propose
675 /// clobbers the shared `hidden_states` buffer. Companion of
676 /// [`Self::decode_verify_batched`].
677 fn stash_verify_hidden_rows(&self, rows: &[usize], stream: u64) -> Result<()> {
678 let _ = (rows, stream);
679 bail!("stash_verify_hidden_rows: unsupported by this model")
680 }
681
682 /// Stashed-row variant of [`Self::save_hidden_for_mtp`]: copy stash slot
683 /// `idx` (written by [`Self::stash_verify_hidden_rows`]) into the MTP
684 /// input buffer. Used by the batched-verify verdict path, whose propose
685 /// calls have already overwritten the live verify rows.
686 fn save_hidden_for_mtp_from_stash(&self, idx: usize, stream: u64) -> Result<()> {
687 let _ = (idx, stream);
688 bail!("save_hidden_for_mtp_from_stash: unsupported by this model")
689 }
690
691 /// Batched cross-sequence MTP propose for the batched K=4 verify path:
692 /// `num_drafts` drafts for each of `tokens.len()` sequences, reading
693 /// every drafter weight once per draft position instead of once per
694 /// sequence. `stash_idx[i]` names the verify-stash slot holding sequence
695 /// i's accepted-position hidden (written by
696 /// [`Self::stash_verify_hidden_rows`]); `positions[i]` is the propose
697 /// position (post-rewind `seq_len`), matching the per-seq
698 /// [`Self::run_mtp_propose_multi`] contract. Grammarless sequences only.
699 ///
700 /// `out_conf`, when `Some`, receives each draft's top-1 LOG-probability
701 /// (`ln p`, same shape as the returned drafts) — the D-Cut ranking key.
702 /// It is filled with zeros (certainty) when the drafter cannot measure
703 /// confidence, so a caller ranking by prefix product never prunes on a
704 /// value nobody produced.
705 ///
706 /// `Ok(None)` = unsupported (caller falls back to the per-seq propose
707 /// loop, re-saving each stash slot first). Default: unsupported.
708 #[allow(clippy::too_many_arguments)]
709 fn run_mtp_propose_batched(
710 &self,
711 tokens: &[u32],
712 positions: &[usize],
713 stash_idx: &[usize],
714 num_drafts: usize,
715 seqs: &mut [&mut SequenceState],
716 stream: u64,
717 out_conf: Option<&mut Vec<Vec<f32>>>,
718 ) -> Result<Option<Vec<Vec<u32>>>> {
719 let _ = (
720 tokens, positions, stash_idx, num_drafts, seqs, stream, out_conf,
721 );
722 Ok(None)
723 }
724
725 /// Widest batch [`Self::run_mtp_propose_batched`] can carry in ONE
726 /// drafter forward per draft position. `1` = per-sequence only.
727 /// Schedulers chunk their propose groups by this — never by a constant.
728 fn mtp_propose_batch_max(&self) -> usize {
729 1
730 }
731
732 /// DFlash K=γ graphed verify (γ+1 tokens). Specialization of the K=2/3/4
733 /// pattern for arbitrary K. Default impl falls back to eager
734 /// `decode_verify`. Models can override for CUDA-graph speedup keyed by
735 /// `(slot_idx, K)`.
736 fn decode_verify_graphed_kgamma(
737 &self,
738 tokens: &[u32],
739 seq: &mut SequenceState,
740 stream: u64,
741 ) -> Result<Vec<u32>> {
742 self.decode_verify(tokens, seq, stream)
743 }
744
745 /// DFlash γ-token verification: 1 verified + γ drafts → per-position
746 /// argmax. Variable-length γ (vs fixed K=2/3/4) because it's a drafter
747 /// config field. CUDA-graph capture keyed by `(slot_idx, tokens.len())`.
748 /// Default routes to `decode_verify_graphed_kgamma`.
749 fn decode_verify_dflash(
750 &self,
751 tokens: &[u32],
752 seq: &mut SequenceState,
753 stream: u64,
754 ) -> Result<Vec<u32>> {
755 // Phase 2.5e: route to the K=γ graphed path. Models that don't
756 // override `decode_verify_graphed_kgamma` get the eager fallback
757 // for free (the trait default does that).
758 self.decode_verify_graphed_kgamma(tokens, seq, stream)
759 }
760
761 /// DFlash fused decode+verify: one M=(1+k) forward replacing separate
762 /// M=1 decode + M=k verify on the DFlash path.
763 ///
764 /// `tokens[0]` = accepted/decode token; `tokens[1..]` = draft block.
765 /// `try_dflash_capture` fires at row 0 so the DFlash drafter conditions
766 /// on the confirmed-accepted token's per-layer hidden, never on a
767 /// potentially-rejected draft's hidden.
768 ///
769 /// CUDA-graph cache keyed by `(slot_idx, tokens.len())`. Default falls
770 /// back to `decode_verify_graphed_kgamma` (which itself falls back to
771 /// eager `decode_verify`) for models that don't override.
772 fn decode_and_verify_fused(
773 &self,
774 tokens: &[u32],
775 seq: &mut SequenceState,
776 stream: u64,
777 ) -> Result<Vec<u32>> {
778 self.decode_verify_graphed_kgamma(tokens, seq, stream)
779 }
780
781 /// Save the post-norm hidden state at `token_idx` (0 or 1) to a
782 /// dedicated MTP input buffer. Must precede `run_mtp_propose` — MTP
783 /// overwrites shared buffers including `norm_output`.
784 fn save_hidden_for_mtp(&self, token_idx: usize, stream: u64) -> Result<()>;
785
786 /// ATLAS_MTP_CATCHUP: ring-capture a serially decoded token's final
787 /// hidden at `pos` for the drafter catch-up feed. Default no-op.
788 fn save_hidden_for_catchup(&self, _token_idx: usize, _pos: usize) -> Result<()> {
789 Ok(())
790 }
791
792 /// Capture `hidden_states[token_idx]` from every DFlash capture layer
793 /// into `dflash_hidden_save`. Called after gamma verify Phase 3 D2H
794 /// sync (bonus position known). No-op when DFlash is disabled.
795 fn save_dflash_hidden_for_propose(&self, _token_idx: usize, _stream: u64) -> Result<()> {
796 Ok(())
797 }
798
799 /// Append the accepted draft's hidden state (row 1 of dflash_hidden_save)
800 /// into the proposer context. Base primitive for both legacy and Eagle paths.
801 /// Default no-op for models without a DFlash drafter.
802 fn dflash_accept_append(&self, _seq: &mut SequenceState) -> Result<()> {
803 Ok(())
804 }
805
806 /// EAGLE-fix (K=2 accept): append row 0 @ N then row 1 @ N+1 BEFORE propose
807 /// so forward_block conditions on row 1 (the hidden that generated bonus).
808 /// Default no-op for models without a DFlash drafter.
809 fn dflash_eagle_accept_append(&self, _seq: &mut SequenceState) -> Result<()> {
810 Ok(())
811 }
812
813 /// EAGLE-fix (K=gamma): append rows 0..=num_accepted at positions
814 /// base_pos..=base_pos+num_accepted. Row num_accepted is appended LAST ->
815 /// freshest ctx slot = the hidden that generated the bonus (EAGLE).
816 /// Default no-op for models without a DFlash drafter.
817 fn dflash_eagle_kgamma_append(
818 &self,
819 _seq: &mut SequenceState,
820 _num_accepted: usize,
821 _base_pos: usize,
822 ) -> Result<()> {
823 Ok(())
824 }
825
826 /// Ctx-holes fix (serial decode): append the just-decoded token's
827 /// captured per-layer hidden (`dflash_hidden_save` row 0, filled by
828 /// `try_dflash_capture` inside the decode layer loop) into the seq's
829 /// DFlash ctx accumulator, stamped at its true position
830 /// (`seq.seq_len - 1`, matching propose.rs's decode-append convention).
831 ///
832 /// Called from the scheduler's serial bootstrap path when adaptive
833 /// speculation has SUSPENDED this seq — propose() never runs there, so
834 /// without this hook every serially-decoded token's target hidden is
835 /// overwritten (single-slot model capture) and permanently lost,
836 /// leaving holes in the drafter's ctx at spec re-entry (measured
837 /// -0.42 accepted/step on think-gated vs spec-through-think content).
838 ///
839 /// Sets `skip_next_decode_append` so a propose() firing later (re-probe)
840 /// does not double-append the same capture. Graceful no-op when DFlash
841 /// is disabled or the seq has a non-DFlash proposer state.
842 fn dflash_serial_ctx_append(&self, _seq: &mut SequenceState) -> Result<()> {
843 Ok(())
844 }
845
846 /// Unified DFlash ctx commit (ATLAS_DFLASH_UNIFIED_CTX=1). Copies
847 /// `num_committed` scratch rows (`dflash_hidden_save` rows
848 /// `scratch_row..scratch_row+num_committed`) into `ctx_hidden_acc` at the
849 /// CURRENT TAIL (`ctx_len`), stamping RoPE positions
850 /// `base_pos..base_pos+num_committed`, folding the watermark slide in
851 /// first. `base_pos` is the RoPE position, NOT the acc row index (they
852 /// diverge after a watermark slide — DDD §4.1 landmine). `scratch_row` is
853 /// 0 on every single-sequence path; batched decode (n>1) captures ALL
854 /// batch rows, so seq i commits from scratch row i. The single structural
855 /// replacement for the ~5 fragmented appends. Default no-op for models
856 /// without a DFlash drafter.
857 fn commit_ctx(
858 &self,
859 _seq: &mut SequenceState,
860 _num_committed: usize,
861 _base_pos: usize,
862 _scratch_row: usize,
863 ) -> Result<()> {
864 Ok(())
865 }
866
867 /// Rows per per-sequence capture BAND in the DFlash hidden scratch (γ+1).
868 /// Sequence `i` of a batched K=γ verify captures into band `i`, so its
869 /// `commit_ctx` `scratch_row` is `i * dflash_capture_band()`. Returning
870 /// the model's own stride keeps the capture and the commit from ever
871 /// disagreeing. `0` when there is no DFlash drafter.
872 fn dflash_capture_band(&self) -> usize {
873 0
874 }
875
876 /// Run the MTP proposer for one draft token off the saved hidden state.
877 /// `None` when no proposer is wired.
878 fn run_mtp_propose(
879 &self,
880 token: u32,
881 position: usize,
882 seq: &mut SequenceState,
883 stream: u64,
884 ) -> Result<Option<u32>>;
885
886 /// Run the MTP proposer to generate multiple draft tokens.
887 ///
888 /// Uses the hidden state previously saved via `save_hidden_for_mtp`.
889 /// Returns empty vec if no MTP proposer is available.
890 ///
891 /// `grammar_bitmask`: when `Some`, drafts are constrained to the allowed
892 /// token set of an XGrammar matcher at its current position. Format is
893 /// `ceil(vocab_size / 32)` i32 words; bit `tok` set ⇒ allowed. `None`
894 /// preserves the unconstrained GPU-argmax fast path.
895 fn run_mtp_propose_multi(
896 &self,
897 token: u32,
898 position: usize,
899 num_drafts: usize,
900 seq: &mut SequenceState,
901 stream: u64,
902 grammar_bitmask: Option<&[i32]>,
903 ) -> Result<Vec<u32>>;
904
905 /// Read the draft token ID stored on GPU by the last `run_mtp_propose_multi`
906 /// call (which used `embed_from_argmax` to write the draft embedding and
907 /// token ID directly on GPU). Returns 0 if no proposer is available.
908 fn read_deferred_draft_token(&self) -> Result<u32> {
909 Ok(0)
910 }
911
912 /// Encode images through the vision encoder and store embeddings for the next prefill.
913 ///
914 /// Each tuple is `(pixels: Vec<f32>, grid_h: usize, grid_w: usize)`.
915 /// Pixels are laid out [P, C×T×Hp×Wp] matching `vision_preprocess::preprocess_image`.
916 /// Must be called before `prefill_chunk` when the prompt contains `<|image_pad|>` tokens.
917 ///
918 /// Default: no-op (text-only models).
919 fn prepare_vision_embed(&self, _images: &[crate::VisionItem]) -> Result<()> {
920 Ok(())
921 }
922
923 /// Batched vision encode across N requests' images in ONE `forward_batched`
924 /// call (block GEMM weights read once over Σpatches). `per_request[i]` is
925 /// request i's images. Returns one `(patch_row_offset, grid_index_offset,
926 /// num_images, patch_row_count)` per request, in request order, locating
927 /// its slice of the shared packed `buf_out`. Default: no-op (text models).
928 fn prepare_vision_embed_batched(
929 &self,
930 _per_request: &[Vec<crate::VisionItem>],
931 ) -> Result<Vec<(usize, usize, usize, usize)>> {
932 Ok(Vec::new())
933 }
934
935 /// Set the co-dispatched batched-ViT slice base for the NEXT prefill_chunk
936 /// (row offset into buf_out, grid index offset, image count owned). Pass
937 /// (0,0,0) to reset to the legacy single-request behaviour. Default: no-op.
938 fn set_vision_slice_base(&self, _row_base: usize, _grid_base: usize, _owned_images: usize) {}
939
940 /// EP worker step: receive a (seq_id, cmd) preamble from rank 0 and
941 /// execute the command in the addressed slot.
942 ///
943 /// 🔴 An `Err` carrying [`EpCommandFailed`] means the command EXECUTED and failed —
944 /// a per-request fault the head raises identically and answers the client with. The
945 /// worker must STAY UP. Any other `Err` came from receiving the command, i.e. the link
946 /// to the head is gone, and the worker must exit. See [`EpCommandFailed`].
947 ///
948 /// Returns false when the worker should shut down.
949 /// Only valid on rank > 0 with EP enabled.
950 ///
951 /// `slots` must be sized to `args.max_batch_size` (same as the head's
952 /// scheduler `active` capacity); commands with `seq_id >= slots.len()`
953 /// fail loudly rather than corrupt unrelated state.
954 fn ep_worker_step(&self, _slots: &mut [Option<SequenceState>]) -> Result<bool> {
955 Ok(true) // no-op for non-EP models
956 }
957
958 /// Check whether expert parallelism (EP) is enabled (multi-GPU MoE).
959 ///
960 /// When true, the scheduler must use separate decode + prefill commands
961 /// with explicit EP broadcasts rather than mixed_forward (which has no
962 /// EP broadcast protocol defined).
963 fn is_ep(&self) -> bool {
964 false
965 }
966
967 /// True when single-token decode `lm_head` writes FP32 logits to a
968 /// dedicated FP32 scratch buffer (rather than the shared BF16 logits
969 /// buffer). Callers that consume those logits must read from
970 /// [`Self::decode_logits_ptr`] using 4 bytes/element. Defaults false;
971 /// only Gemma-4 dense overrides today (gated by
972 /// `ATLAS_GEMMA4_FP32_LMHEAD=1`).
973 fn decode_logits_fp32(&self) -> bool {
974 false
975 }
976
977 /// Buffer pointer the single-token decode `lm_head` last wrote to. The
978 /// returned dtype is FP32 when [`Self::decode_logits_fp32`] is true,
979 /// BF16 otherwise. The default impl returns the shared BF16 logits
980 /// buffer used by every existing model. Override on models that route
981 /// the lm_head output through an FP32 scratch (Gemma-4 + softcap).
982 fn decode_logits_ptr(&self) -> DevicePtr {
983 // Default: shared BF16 logits buffer. Models with FP32 lm_head
984 // override.
985 // NOTE: this default panics when the trait method is invoked on
986 // models that don't implement either accessor. TransformerModel
987 // overrides both. If a future model needs only one, it must
988 // override both for consistency.
989 unreachable!(
990 "Model::decode_logits_ptr() must be overridden alongside \
991 decode_logits_fp32() — default cannot return a valid pointer."
992 )
993 }
994
995 /// Multi-head Latent Attention guard. When true, chunked prefill MUST run
996 /// as a single chunk — Atlas has no paged-MLA prefill kernel and
997 /// multi-chunk MLA silently corrupts attention output (see Mistral-Small-4
998 /// 2026-05-01 sweep: 8K collapses to "The\nThe…").
999 fn is_mla(&self) -> bool {
1000 false
1001 }
1002
1003 /// mHC hyper-connection stream count (0 = no highway). Non-zero means
1004 /// the batched GDN decode paths are UNWIRED for this model (they carry
1005 /// their own residual, which the highway replaces — see
1006 /// `qwen3_ssm::hc::refuse_batched_under_hc`); the scheduler must clamp
1007 /// concurrency to 1 until the batched highway lands (Avarok #753 item B).
1008 fn hc_mult(&self) -> usize {
1009 0
1010 }
1011
1012 /// Tokens per paged-KV block, or `None` when the model has no paged KV.
1013 /// The scheduler uses this to land a prefill chunk boundary exactly on the
1014 /// block boundary a warm turn will match at (see
1015 /// `spark_runtime::ssm_tail_boundary`).
1016 fn kv_block_size(&self) -> Option<usize> {
1017 None
1018 }
1019
1020 /// EP broadcast: send a command (u32) to all worker ranks.
1021 ///
1022 /// Called by rank 0 before each model operation to synchronize workers.
1023 /// Only valid when EP is enabled.
1024 fn ep_broadcast_cmd(&self, _cmd: u32) -> Result<()> {
1025 Ok(()) // no-op for non-EP models
1026 }
1027
1028 /// EP broadcast: send a `(seq_id, cmd)` pair to all worker ranks.
1029 ///
1030 /// Use this at the *first* broadcast of a logical command sequence
1031 /// (e.g. the K=2 verify marker, prefill start, decode token, etc.).
1032 /// Follow-up broadcasts within the same command (chunk metadata, more
1033 /// tokens, accept/reject result) keep using [`Self::ep_broadcast_cmd`]
1034 /// — the worker consumes the preamble once per command and routes
1035 /// subsequent reads through the slot it identified.
1036 ///
1037 /// When [`Self::ep_protocol_v2`] returns false (the default), the
1038 /// `seq_id` is ignored on the wire and behaviour matches the legacy
1039 /// single-sequence broadcast.
1040 fn ep_broadcast_cmd_for_seq(&self, _seq_id: u32, _cmd: u32) -> Result<()> {
1041 Ok(()) // no-op for non-EP models
1042 }
1043
1044 /// Returns true if this model's EP comm path is using the v2 protocol
1045 /// (slot-aware seq_id preamble). Default false — pre-PR behaviour.
1046 fn ep_protocol_v2(&self) -> bool {
1047 false
1048 }
1049
1050 /// EP bulk broadcast: send an array of u32 tokens to all worker ranks.
1051 /// Uses a single NCCL broadcast instead of per-token broadcasts.
1052 fn ep_broadcast_tokens(&self, _tokens: &[u32]) -> Result<Vec<u32>> {
1053 Ok(Vec::new()) // no-op for non-EP models
1054 }
1055
1056 /// Trim the MTP proposer's KV cache after verification.
1057 ///
1058 /// Called on rejection to discard the rejected draft's MTP KV entry.
1059 fn trim_proposer_state(
1060 &self,
1061 seq: &mut SequenceState,
1062 num_accepted: usize,
1063 stream: u64,
1064 ) -> Result<()>;
1065
1066 /// Launch SSM state checkpoint D2D copies on a secondary CUDA stream.
1067 ///
1068 /// Non-blocking: returns immediately. The copies can overlap with MTP
1069 /// propose on the default stream since they access disjoint memory.
1070 /// Call `sync_secondary` before the next verify to ensure completion.
1071 fn start_checkpoint_async(&self, seq: &mut SequenceState) -> Result<()> {
1072 // Default: fall back to synchronous checkpoint.
1073 self.checkpoint_ssm_states(seq)
1074 }
1075
1076 /// Launch SSM state rollback + checkpoint on the secondary stream.
1077 ///
1078 /// Used on the reject path: rollback to `intermediate[0]`, then checkpoint
1079 /// the rolled-back state for the next verify iteration.
1080 fn start_rollback_and_checkpoint_async(
1081 &self,
1082 seq: &mut SequenceState,
1083 num_accepted: usize,
1084 ) -> Result<()> {
1085 // Default: fall back to synchronous operations.
1086 self.rollback_ssm_states(seq, num_accepted)?;
1087 self.checkpoint_ssm_states(seq)
1088 }
1089
1090 /// Wait for all work on the secondary stream to complete.
1091 fn sync_secondary(&self) -> Result<()> {
1092 Ok(()) // No-op if no secondary stream.
1093 }
1094
1095 /// Item #2 (STree-style in-place verify commit): commit the surviving
1096 /// prefix of a verify pass directly onto the canonical `h_state` /
1097 /// `conv_state`. Full accept (`num_accepted == k`) is a no-op (the
1098 /// kernel's final state is already live); partial accept is a single
1099 /// index-select of `h_state_intermediates[num_accepted-1]`. No-op
1100 /// default for backends without the dual-buffer SSM state.
1101 /// Runs on `secondary_stream`; pair with `sync_secondary`.
1102 fn commit_accepted_prefix(
1103 &self,
1104 _seq: &mut SequenceState,
1105 _num_accepted: usize,
1106 _k: usize,
1107 ) -> Result<()> {
1108 Ok(())
1109 }
1110
1111 /// Save KV blocks + SSM state to writer. Does NOT free resources.
1112 ///
1113 /// Format: `[KV layers × blocks × (K + V)]` then `[SSM layers × (h + conv)]`.
1114 /// The model owns the serialization format.
1115 fn save_sequence_state(
1116 &self,
1117 _seq: &SequenceState,
1118 _writer: &mut dyn std::io::Write,
1119 ) -> Result<()> {
1120 bail!("swap not supported by this model")
1121 }
1122
1123 /// Restore KV blocks + SSM state from reader into an allocated sequence.
1124 ///
1125 /// Allocates `num_blocks` new KV blocks, fills from reader, restores SSM.
1126 fn restore_sequence_state(
1127 &self,
1128 _seq: &mut SequenceState,
1129 _num_blocks: usize,
1130 _reader: &mut dyn std::io::Read,
1131 ) -> Result<()> {
1132 bail!("swap not supported by this model")
1133 }
1134
1135 /// Whether `tokens` contains a vision pad token for this model — i.e.
1136 /// the KV at those positions came from image/video EMBEDDINGS that a
1137 /// plain token re-prefill cannot reproduce. Decode-time preemption uses
1138 /// this to exclude vision sequences from the requeue-with-re-prefill
1139 /// path (the spill path, which saves KV verbatim, stays eligible).
1140 /// Default false: pure-text models are always re-prefillable.
1141 fn tokens_contain_vision_pad(&self, _tokens: &[u32]) -> bool {
1142 false
1143 }
1144
1145 /// Number of free KV cache blocks available for allocation.
1146 fn num_free_blocks(&self) -> usize {
1147 0
1148 }
1149
1150 /// Total KV blocks in the paged cache (denominator for occupancy
1151 /// gauges). Default 0 for backends without a paged cache.
1152 fn num_total_blocks(&self) -> usize {
1153 0
1154 }
1155
1156 /// Reclaim up to `num_blocks` blocks from the prefix cache, returning how
1157 /// many actually became free.
1158 ///
1159 /// The prefill/decode allocators reclaim implicitly (`try_alloc` → evict →
1160 /// retry), but swap-in cannot: it gates on `num_free_blocks()` BEFORE
1161 /// attempting a restore, so cached-but-evictable capacity is invisible to
1162 /// it and a swapped-out sequence waits for blocks that are never
1163 /// volunteered. Cached blocks are legitimately held (the cache owns one ref
1164 /// per radix node), so nothing frees them on its own — the swap-in path has
1165 /// to ask. Returns 0 when nothing is evictable, which the caller must treat
1166 /// as "no progress possible" rather than retrying forever.
1167 fn reclaim_prefix_blocks(&self, _num_blocks: usize) -> usize {
1168 0
1169 }
1170
1171 /// Return the default CUDA stream handle.
1172 fn default_stream(&self) -> u64 {
1173 0
1174 }
1175
1176 /// Create a new CUDA stream (for overlapping prefill with decode).
1177 fn create_stream(&self) -> Result<u64> {
1178 Ok(0)
1179 }
1180
1181 /// Create a CUDA event (for inter-stream synchronization).
1182 fn create_event(&self) -> Result<u64> {
1183 Ok(0)
1184 }
1185
1186 /// Record an event on a stream (marks a point in the stream's work).
1187 fn record_event(&self, _event: u64, _stream: u64) -> Result<()> {
1188 Ok(())
1189 }
1190
1191 /// Make a stream wait for an event (GPU-side sync, CPU does not block).
1192 fn stream_wait_event(&self, _stream: u64, _event: u64) -> Result<()> {
1193 Ok(())
1194 }
1195
1196 /// Block the host until all work submitted to `stream` has completed.
1197 /// Used by `mixed_forward_batch` to retire the decode pass (which runs on
1198 /// the default stream) before the batched prefill reuses the shared arena
1199 /// buffers on another stream (#110). Default no-op for non-CUDA mocks.
1200 fn synchronize(&self, _stream: u64) -> Result<()> {
1201 Ok(())
1202 }
1203}
1204
1205#[cfg(test)]
1206mod padded_batch_n_tests {
1207 use super::padded_batch_n;
1208
1209 /// Rungs <= 32 must be UNCHANGED by the wave-14a widening (byte-identity
1210 /// for every bs <= 32 boot), and the new rungs must cover n=33..128.
1211 #[test]
1212 fn ladder_rungs() {
1213 // Legacy rungs (aacd29cb and earlier) — must not move.
1214 for (n, want) in [
1215 (1usize, 2usize),
1216 (2, 2),
1217 (3, 4),
1218 (5, 8),
1219 (9, 12),
1220 (13, 16),
1221 (16, 16),
1222 (17, 24),
1223 (25, 32),
1224 (32, 32),
1225 ] {
1226 assert_eq!(padded_batch_n(n), want, "n={n}");
1227 }
1228 // Wave-14a rungs (only reachable when the boot's max_batch_size
1229 // admits that many active sequences).
1230 for (n, want) in [
1231 (33usize, 48usize),
1232 (48, 48),
1233 (49, 64),
1234 (64, 64),
1235 (65, 96),
1236 (96, 96),
1237 (97, 128),
1238 (128, 128),
1239 ] {
1240 assert_eq!(padded_batch_n(n), want, "n={n}");
1241 }
1242 // Above the ladder: fall-through unchanged.
1243 assert_eq!(padded_batch_n(129), 129);
1244 }
1245}
1246
1247/// A worker command that was received and then FAILED TO EXECUTE.
1248///
1249/// 🔴 Why this distinction is load-bearing. The EP worker loop used to `break` on any
1250/// error, so a per-request fault — a prefill chunk the model legitimately refuses — killed
1251/// the worker, which then exited with status **0** while the head stayed up. The head's very
1252/// next request issued a collective against a peer that no longer existed and spun in NCCL
1253/// forever at 100 % CPU, with `/v1/models`, `/health` and `/health/live` all still answering
1254/// 200. Measured 2026-08-30: rank 1 logged this exact refusal and stopped 4 s later; rank 0
1255/// accepted a 13-token request 10 minutes on and never produced a single further log line.
1256/// ANOMALIES A60 (the wedge) and A62 (the refusal that triggered it).
1257///
1258/// The head raises the SAME error for the SAME command and turns it into an HTTP 500, so the
1259/// two ranks disagreeing about whether it is fatal is the defect. A receive failure stays
1260/// fatal: the link is gone, and the next iteration's receive would fail again anyway.
1261#[derive(Debug)]
1262pub struct EpCommandFailed(pub anyhow::Error);
1263
1264impl std::fmt::Display for EpCommandFailed {
1265 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1266 write!(f, "{:#}", self.0)
1267 }
1268}
1269
1270impl std::error::Error for EpCommandFailed {}
1271
1272#[cfg(test)]
1273mod ep_command_failed_tests {
1274 use super::EpCommandFailed;
1275
1276 /// The worker loop classifies by downcast, so the tag must survive being boxed into an
1277 /// `anyhow::Error` — and the original message must survive with it, or the operator
1278 /// loses the only line that says WHY the command failed.
1279 #[test]
1280 fn the_tag_and_its_message_survive_anyhow() {
1281 let inner = anyhow::anyhow!("Prefill chunk layer 3 failed: DSA indexer cache: 16385");
1282 let tagged = anyhow::Error::new(EpCommandFailed(inner));
1283 assert!(
1284 tagged.downcast_ref::<EpCommandFailed>().is_some(),
1285 "the worker loop cannot tell a command failure from a dead link without this"
1286 );
1287 assert!(format!("{tagged:#}").contains("DSA indexer cache: 16385"));
1288 }
1289
1290 /// A receive failure must NOT be mistaken for a command failure: the link is gone and
1291 /// the worker has to exit rather than spin re-reading a dead socket.
1292 #[test]
1293 fn an_untagged_error_stays_fatal() {
1294 let recv = anyhow::anyhow!("ep_recv_seq_and_cmd: peer closed");
1295 assert!(recv.downcast_ref::<EpCommandFailed>().is_none());
1296 }
1297}