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;