spark_model/ssm_reserve/decode_ring.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! SSOT for the Phase-C decode-rollback ring DEPTH.
4//!
5//! A sibling of `ssm_reserve.rs` (which sits over the 500-line cap),
6//! following the `per_sequence_state.rs` precedent: the depth decision, its
7//! publication cell and the #915 auto-fit live here.
8//!
9//! Two call sites MUST agree on this number or a serve either under-reserves
10//! (runtime CUDA alloc failure after weights load) or over-reserves
11//! (preflight refuses batch sizes the runtime could fund):
12//!
13//! * `spark-server` `preflight_reserve` — sizes the SSM-snapshot GPU
14//! reservation before weights load;
15//! * `TransformerModel::new` (`impl_a1.rs`) — allocates the actual ring.
16//!
17//! The ring's ONLY writer (scheduler `snapshot_boundary_if_ssm`) and reader
18//! (content-loop `rollback_to_boundary`) live on the PLAIN decode path — the
19//! speculative path does its rejection rollback through the verify snapshot,
20//! never this ring. Under `--speculative` the ring is unreachable, and it is
21//! NOT cheap: 8 slots × max_batch × the full SSM blob (27B: 158.9 MB) is
22//! ~19 GB at batch 16 and ~38 GB at batch 32. Reserving it unconditionally
23//! while the runtime skipped it capped the native batch at ~20 on GB10
24//! (SSM reserve 75.2 GB vs an 85.2 GB budget at util 0.70).
25//!
26//! ## Why the depth is now sized from free memory (#915)
27//!
28//! The depth was a CONSTANT (8) multiplied by `--max-batch-size`, so it grew
29//! with a flag that says nothing about what the card can hold. Serving
30//! Qwen3.8-27B-FP8 on one 80 GB H100 with the hopper recipe
31//! (`--max-batch-size 32`, 48 GDN layers, 151.5 MiB per-seq state blob) asked
32//! for 8 × 32 × 151.5 MiB = 37.88 GiB of ring alone — an inference reserve of
33//! 45,823 MiB against a 71.3 GiB budget with 57.2 GiB of weights — and the
34//! serve REFUSED to boot (rental H100, 2026-09-05). The recipe workaround was
35//! `--max-batch-size 4`, i.e. paying for rollback depth with four fifths of
36//! the serve's concurrency.
37//!
38//! The ring degrades gracefully with depth (fewer retained boundaries = fewer
39//! reachable re-steer anchors, never wrong output), so preflight now SHRINKS
40//! it down the [`DECODE_RING_FIT_LADDER`] until the reserve fits and publishes
41//! the chosen depth through [`set_decode_ring_slots`], instead of refusing.
42//! Refusal is kept only for the case where even depth 0 does not fit.
43//!
44//! Env contract (read HERE and nowhere else):
45//!
46//! * `ATLAS_SSM_DECODE_RING=1` force-allocates the ring even under spec
47//! (mixed workloads whose grammar-bound sequences fall to plain decode and
48//! should keep loop re-steer); `=0` force-disables it even without spec.
49//! * `ATLAS_DISABLE_WATCHDOGS=1|true` (trimmed, case-insensitive — mirrors
50//! spark-server's `parse_disable_watchdogs`): the ring's only reader can
51//! never fire, so the ring is skipped.
52
53/// Outcome of the ring-depth decision.
54///
55/// `skip_reason` is `Some` only for the IMPLICIT skip (speculative decode /
56/// watchdogs off) — never for an explicit `ATLAS_SSM_DECODE_RING=0`
57/// override, a published `--ssm-decode-ring-slots N` or the #915 auto-fit —
58/// so the allocating call site can log the savings once.
59pub struct DecodeRingDecision {
60 pub slots: usize,
61 pub skip_reason: Option<&'static str>,
62}
63
64/// Depths preflight's #915 auto-fit may choose from, LARGEST FIRST.
65///
66/// Halving rather than decrementing: each rung halves the ring's share of
67/// the reserve, so at most four steps separate "the default" from "no ring",
68/// and every rung is a power of two — the ring's slot assignment is
69/// `(next_slot + 1) % capacity` (`SsmDecodeRing::record`), which is exact at
70/// any capacity but wraps most evenly at these.
71///
72/// 8 is [`atlas_kernels::DECODE_ROLLBACK_RING_SLOTS`] and covers the
73/// 3-repeat fuzzy loop detector with margin; 1 still anchors a single clean
74/// boundary; 0 means the sequence hard-stops instead of re-steering
75/// (`RollbackFallback::NoSsmSnapshot` — an honest decline, never a partial
76/// rewind).
77pub const DECODE_RING_FIT_LADDER: [usize; 5] = [8, 4, 2, 1, 0];
78
79/// The depth published by the serve command line (`--ssm-decode-ring-slots
80/// N`) or by preflight's auto-fit. Written once, read by BOTH sizing call
81/// sites — same first-write-wins cell pattern as
82/// [`super::set_ssm_rollback_mode`].
83///
84/// Absent is NOT a value: `--ssm-decode-ring-slots auto` publishes nothing,
85/// so the documented `ATLAS_SSM_DECODE_RING` fallback stays reachable and
86/// preflight can publish the auto-fit depth later in the same boot.
87static DECODE_RING_SLOTS: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
88
89/// Publish the decode-ring depth. Returns the value in force (first write
90/// wins, matching `gdn_flags::set_from_cli`): an explicit
91/// `--ssm-decode-ring-slots N` is published before preflight runs, so the
92/// auto-fit's later write is a no-op and an operator's explicit depth is
93/// never silently shrunk.
94pub fn set_decode_ring_slots(slots: usize) -> usize {
95 let _ = DECODE_RING_SLOTS.set(slots);
96 *DECODE_RING_SLOTS.get().expect("just set")
97}
98
99/// The published depth, or `None` when nothing has been published.
100///
101/// Does NOT initialize the cell — preflight asks this to tell an EXPLICIT
102/// depth (auto-fit disabled, refuse as before) from `auto` (auto-fit
103/// allowed), and asking must not itself seal the decision.
104pub fn published_decode_ring_slots() -> Option<usize> {
105 DECODE_RING_SLOTS.get().copied()
106}
107
108/// SSOT parse for the `--ssm-decode-ring-slots` value: `auto` (`None` — size
109/// it from free memory at preflight) or an explicit `0..=8`.
110///
111/// `validate_serve_args` and `publish_kernel_flags` both go through THIS, so
112/// what the CLI accepts and what it publishes cannot drift.
113pub fn parse_decode_ring_slots(s: &str) -> Result<Option<usize>, String> {
114 if s == "auto" {
115 return Ok(None);
116 }
117 let n: usize = s
118 .parse()
119 .map_err(|_| format!("unknown ssm-decode-ring-slots '{s}' (valid: auto, 0..=8)"))?;
120 if n > atlas_kernels::DECODE_ROLLBACK_RING_SLOTS {
121 return Err(format!(
122 "ssm-decode-ring-slots {n} exceeds the {} the ring is sized for",
123 atlas_kernels::DECODE_ROLLBACK_RING_SLOTS
124 ));
125 }
126 Ok(Some(n))
127}
128
129/// Decide the per-sequence decode-rollback ring depth.
130///
131/// `use_speculative` MUST be the same flag `factory::build_model` receives
132/// (`--speculative || --dflash` as plumbed by spark-server) at every call
133/// site, or preflight and allocation diverge.
134pub fn decode_rollback_ring_slots(
135 num_ssm_layers: usize,
136 use_speculative: bool,
137) -> DecodeRingDecision {
138 let watchdogs_value = std::env::var("ATLAS_DISABLE_WATCHDOGS").ok();
139 let watchdogs_disabled = watchdogs_disabled_from_value(watchdogs_value.as_deref());
140 let ring_override = std::env::var("ATLAS_SSM_DECODE_RING").ok();
141 decode_rollback_ring_slots_with(
142 num_ssm_layers,
143 use_speculative,
144 published_decode_ring_slots(),
145 ring_override.as_deref(),
146 watchdogs_disabled,
147 )
148}
149
150/// `ATLAS_DISABLE_WATCHDOGS` truthiness — trimmed, case-insensitive, `1` or
151/// `true` only (mirrors spark-server's `parse_disable_watchdogs`).
152pub fn watchdogs_disabled_from_value(value: Option<&str>) -> bool {
153 value
154 .map(|v| {
155 let v = v.trim().to_ascii_lowercase();
156 v == "1" || v == "true"
157 })
158 .unwrap_or(false)
159}
160
161/// Pure core of [`decode_rollback_ring_slots`] (env-free, unit-testable).
162///
163/// Precedence, highest first:
164///
165/// 1. no SSM layers — there is no recurrent state to snapshot;
166/// 2. `published` — `--ssm-decode-ring-slots N`, or the depth preflight's
167/// auto-fit chose. It outranks the env override AND the implicit skips for
168/// the same reason `ATLAS_SSM_DECODE_RING=1` always has: an operator (or a
169/// reserve that has already been sized against free memory) asking for a
170/// specific depth must get that depth on BOTH sides, or the two diverge;
171/// 3. `ATLAS_SSM_DECODE_RING=1|0` — the legacy spelling of "8" and "0";
172/// 4. the implicit skips (speculative decode, watchdogs off);
173/// 5. the default depth.
174pub fn decode_rollback_ring_slots_with(
175 num_ssm_layers: usize,
176 use_speculative: bool,
177 published: Option<usize>,
178 ring_override: Option<&str>,
179 watchdogs_disabled: bool,
180) -> DecodeRingDecision {
181 if num_ssm_layers == 0 {
182 return DecodeRingDecision {
183 slots: 0,
184 skip_reason: None,
185 };
186 }
187 if let Some(slots) = published {
188 return DecodeRingDecision {
189 slots,
190 skip_reason: None,
191 };
192 }
193 match ring_override {
194 Some("1") => DecodeRingDecision {
195 slots: atlas_kernels::DECODE_ROLLBACK_RING_SLOTS,
196 skip_reason: None,
197 },
198 Some("0") => DecodeRingDecision {
199 slots: 0,
200 skip_reason: None,
201 },
202 _ if use_speculative || watchdogs_disabled => DecodeRingDecision {
203 slots: 0,
204 skip_reason: Some(if use_speculative {
205 "speculative decode active"
206 } else {
207 "watchdogs disabled"
208 }),
209 },
210 _ => DecodeRingDecision {
211 slots: atlas_kernels::DECODE_ROLLBACK_RING_SLOTS,
212 skip_reason: None,
213 },
214 }
215}
216
217/// Largest [`DECODE_RING_FIT_LADDER`] depth `<= start_slots` whose ring term
218/// still fits in `free_mem` alongside everything else the reserve needs
219/// (#915). Pure — preflight owns the bytes, this owns the ladder.
220///
221/// `bytes_per_ring_slot` is ONE depth unit: `max_batch_size × the per-seq SSM
222/// state blob`, the same product `ssm_snapshot_bytes` multiplies the depth by.
223///
224/// Returns 0 when nothing fits — including the case where the rest of the
225/// reserve ALONE exceeds `free_mem`, which is the caller's cue to refuse with
226/// the existing suggested-batch text rather than to boot a ringless serve.
227pub fn fit_decode_ring_slots(
228 start_slots: usize,
229 reserve_without_ring: usize,
230 bytes_per_ring_slot: usize,
231 free_mem: usize,
232) -> usize {
233 DECODE_RING_FIT_LADDER
234 .iter()
235 .copied()
236 .filter(|&slots| slots <= start_slots)
237 .find(|&slots| {
238 reserve_without_ring.saturating_add(slots.saturating_mul(bytes_per_ring_slot))
239 <= free_mem
240 })
241 .unwrap_or(0)
242}
243
244#[cfg(test)]
245#[path = "decode_ring_tests.rs"]
246mod decode_ring_tests;