metrale_model_layers/
ssm_reserve.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: GPU reserve terms for SSM (linear-attention) state: verify-pool
4//! slot counts and capacities, pool bytes, Marconi snapshot slots, and the
5//! decode-rollback ring depth (`decode_ring`).
6//!
7//! Owner: model-layers (SSM reserve).
8//! Invariants: none beyond the types.
9//!
10//! The server's `preflight_reserve`, which runs before the weights load, and the
11//! allocating code (`SsmStatePool::new`, `TransformerModel::new`) call the same
12//! functions here. If the two computed different sizes, a serve would fail a
13//! CUDA allocation after load, or refuse a configuration it could run. 2026-09-30:
14//! the pool itself is one plan both read ([`PoolPlan`]).
15
16mod decode_ring;
17pub use decode_ring::{
18    DECODE_RING_FIT_LADDER, DecodeRingDecision, decode_rollback_ring_slots,
19    decode_rollback_ring_slots_with, fit_decode_ring_slots, parse_decode_ring_slots,
20    published_decode_ring_slots, set_decode_ring_slots, watchdogs_disabled_from_value,
21};
22
23mod copy_tier;
24pub use copy_tier::{CopyTier, copy_tier, set_copy_tier, tier_h, tier_rows};
25mod pool_plan;
26pub use pool_plan::{
27    PoolCounts, PoolPlan, PoolShape, PoolState, UnitSource, VerifyCounts, pool_counts,
28    pool_counts_tiered, pool_counts_with, recurrent_units, state_dims,
29};
30
31mod rollback;
32pub use rollback::{
33    SsmRollbackMode, set_ssm_rollback_mode, ssm_replay_ring_bytes, ssm_replay_row_bytes,
34    ssm_rollback_mode,
35};
36
37/// 2026-09-25: Number of SSM-pool slots the verify pools (per-token intermediates and
38/// the pre-verify checkpoint) cover.
39///
40/// Three callers use it: the server's `preflight_reserve` sizes the pre-load
41/// reserve with it, `SsmStatePool::new` allocates that many verify slots plus a
42/// dummy, and for a model with SSM layers the scheduler takes the plain-decode
43/// branch while any active slot is at or above it (`spec_slot_cap`,
44/// `lane_decode.rs`). Retirement compacts survivors onto low slots, except
45/// under EP protocol v2 (`retire_finished_sequences`).
46///
47/// It is `max_batch_size` when that is at most 32 ([`MTP_STATE_SLOTS_FLOOR`],
48/// the floor) or when [`mtp_pool_full_width`] holds; otherwise
49/// `max(mtp_max_seqs(), 32)`, capped at `max_batch_size`. The scheduler never
50/// speculates with more than `mtp_max_seqs()` active sequences.
51pub fn mtp_state_slots(max_batch_size: usize) -> usize {
52    mtp_state_slots_with(
53        max_batch_size,
54        crate::speculative::mtp_max_seqs(),
55        mtp_pool_full_width(),
56    )
57}
58
59/// 2026-09-25: True when `METRALE_MTP_POOL_FULL_WIDTH` is present (any value, `0`
60/// included) or `METRALE_EP_PROTOCOL` is `v2`. Then [`mtp_state_slots`] is
61/// `max_batch_size` and [`verify_slot_drafts`] is `num_drafts` for every slot.
62pub fn mtp_pool_full_width() -> bool {
63    std::env::var_os("METRALE_MTP_POOL_FULL_WIDTH").is_some()
64        || matches!(std::env::var("METRALE_EP_PROTOCOL").as_deref(), Ok("v2"))
65}
66
67/// 2026-09-29: The fewest slots the verify pools cover (when `max_batch_size` allows): the
68/// verify table width before 2026-09-29, kept apart from it so a wider verify table does not
69/// grow every serve's pools (137.6 MB per slot on Qwen3.6-35B-A3B).
70pub const MTP_STATE_SLOTS_FLOOR: usize = 32;
71
72/// 2026-09-25: Env-free core of [`mtp_state_slots`]. The `MTP_STATE_SLOTS_FLOOR`
73/// (32) covers every slot when `max_batch_size` is at most 32, also when
74/// `METRALE_NO_MTP_K_LADDER` lowers the dispatch cap to 4.
75pub fn mtp_state_slots_with(
76    max_batch_size: usize,
77    spec_dispatch_cap: usize,
78    full_width: bool,
79) -> usize {
80    if full_width {
81        return max_batch_size;
82    }
83    max_batch_size.min(spec_dispatch_cap.max(MTP_STATE_SLOTS_FLOOR))
84}
85
86/// 2026-09-25: Verify draft capacity of pool slot `slot_idx`: the largest count
87/// `drafts_at(n)` gives for a width `n` in `slot_idx + 1..=max(dispatch_cap,
88/// slot_idx + 1)`, clamped to `1..=num_drafts`; 0 when `num_drafts` is 0.
89///
90/// Retirement compacts active sequences onto slots `0..n`
91/// (`retire_finished_sequences`), so a sequence on slot `slot_idx` usually runs
92/// with at least `slot_idx + 1` active, hence the lower bound. When it does not,
93/// the scheduler clamps the step's draft count to the smallest capacity among
94/// the active slots (`spec_capacity::clamp_drafts_to_slot_capacity`).
95///
96/// With the default ladder (`4:3,8:3,16:1,32:1`), `--num-drafts 3` and a
97/// dispatch cap of 32, slots 0..8 get 3 drafts and slots from 8 on get 1. So
98/// `adaptive_rung`'s two-draft lift at widths 9..=16 is clamped back to one
99/// draft at every such width: nine or more active sequences hold as many
100/// distinct slots, one of them at index 8 or above. [`mtp_pool_full_width`]
101/// gives every slot `num_drafts`, which keeps the lift.
102pub fn verify_slot_drafts_with(
103    slot_idx: usize,
104    dispatch_cap: usize,
105    num_drafts: usize,
106    drafts_at: impl Fn(usize) -> usize,
107) -> usize {
108    if num_drafts == 0 {
109        return 0;
110    }
111    let hi = dispatch_cap.max(slot_idx + 1);
112    ((slot_idx + 1)..=hi)
113        .map(&drafts_at)
114        .max()
115        .unwrap_or(num_drafts)
116        .clamp(1, num_drafts)
117}
118
119/// 2026-09-25: [`verify_slot_drafts_with`] with the dispatch cap `mtp_max_seqs()` and
120/// the ladder `mtp_ladder_drafts`. Every slot gets `num_drafts` when
121/// [`mtp_pool_full_width`] holds or the ladder is disabled.
122pub fn verify_slot_drafts(slot_idx: usize, num_drafts: usize) -> usize {
123    if mtp_pool_full_width() {
124        return num_drafts;
125    }
126    verify_slot_drafts_with(
127        slot_idx,
128        crate::speculative::mtp_max_seqs(),
129        num_drafts,
130        |n| crate::speculative::mtp_ladder_drafts(n, num_drafts),
131    )
132}
133
134/// 2026-09-25: H-state intermediates the verify pools hold for pool slot `slot_idx`:
135/// the slot's draft capacity, which is K-1 for a K-row verify. With
136/// `uniform_verify` (set by the server's preflight for DFlash) every slot gets
137/// `num_drafts`.
138///
139/// K-1 suffices because the state after the last verified row stays in
140/// `h_state`: `commit_accepted_prefix` returns early on a full accept and
141/// otherwise reads index `num_accepted - 1 <= K - 2` (`async_chkpt.rs`), and
142/// the batched WY path requires only k-1 h intermediates
143/// (`trait_decode_batched_conv_gdn_multi.rs`).
144///
145/// Only the H side is tiered. The conv intermediates stay at `num_drafts + 1`
146/// per slot: `gdn_verify_fused_conv_kn_batched` writes all K, and the batched
147/// path declines unless every sequence holds k conv intermediates and the
148/// per-sequence regions are evenly spaced (`trait_decode_batched_conv_gdn_multi.rs`).
149pub fn verify_slot_h_intermediates(
150    slot_idx: usize,
151    num_drafts: usize,
152    uniform_verify: bool,
153) -> usize {
154    if uniform_verify {
155        return num_drafts;
156    }
157    verify_slot_drafts(slot_idx, num_drafts)
158}
159
160/// 2026-09-25: Bytes of one stored h-state blob: half of `h_f32_bytes` when `f16_pool`
161/// (FP16 elements), else `h_f32_bytes`. `SsmStatePool::new`, the preflight
162/// reserve ([`PoolPlan`]) and the SSM layer's slot stride
163/// (`ssm_h_fp16.rs`) all take the stored width from here.
164///
165/// `f16_pool` is `ssm_h_f16_pool_enabled()` (`--ssm-h-dtype f16-pool`) at the
166/// production call sites; it is a parameter so pool geometry is testable
167/// without the process-global flag.
168///
169/// Panics when `h_f32_bytes` is not a multiple of 4.
170pub fn ssm_h_stored_bytes(h_f32_bytes: usize, f16_pool: bool) -> usize {
171    assert!(
172        h_f32_bytes.is_multiple_of(4),
173        "h-state blobs are FP32-element sized"
174    );
175    if f16_pool {
176        h_f32_bytes / 2
177    } else {
178        h_f32_bytes
179    }
180}
181
182/// 2026-09-25: Bytes of the FP32 h-state prefill staging arena: `slots *
183/// h_layer_f32_bytes` when `f16_pool`, else 0.
184///
185/// On an f16-sized pool the layer widens a slot's h-state into its FP32
186/// staging blob before the GDN prefill kernels run and narrows it back after
187/// (`ssm_h_fp16::prefill_h_begin` / `prefill_h_end`). There is one blob per
188/// slot, shared by all layers, so `h_layer_f32_bytes` is one layer's FP32 h
189/// blob, not the across-layers total the other reserve terms use.
190///
191/// `SsmStatePool::new` passes its slot count including the dummy slot
192/// (`max_slots + 1`); the server's preflight passes `max_batch_size`.
193pub fn ssm_h_prefill_stage_bytes(slots: usize, h_layer_f32_bytes: usize, f16_pool: bool) -> usize {
194    if f16_pool {
195        slots * h_layer_f32_bytes
196    } else {
197        0
198    }
199}
200
201/// 2026-09-25: Outcome of the Marconi snapshot-slot decision. `skip_reason` is `Some`
202/// only when the slots are dropped because prefix caching is inactive, so the
203/// allocating call site can log the saving.
204pub struct MarconiSlotDecision {
205    pub slots: usize,
206    pub skip_reason: Option<&'static str>,
207}
208
209/// 2026-09-25: Number of Marconi SSM-snapshot slots to reserve and allocate:
210/// `requested` (`--ssm-cache-slots`, default 16), or 0 when prefix caching is
211/// inactive.
212///
213/// The server's `preflight_reserve` and `TransformerModel::new` (`impl_a1.rs`)
214/// both call this. A slot is restored from a prefix-cache match
215/// (`PrefixMatch::ssm_snapshot`); with prefix caching inactive the server
216/// installs `NoPrefixCaching` (`build_prefix_cache`), whose lookup returns an
217/// empty match.
218///
219/// `METRALE_SSM_MARCONI_FULL`, present with any value (`0` included), keeps
220/// `requested` regardless.
221pub fn marconi_snapshot_slots(
222    requested: usize,
223    prefix_caching_active: bool,
224) -> MarconiSlotDecision {
225    marconi_snapshot_slots_with(requested, prefix_caching_active, marconi_reserve_full())
226}
227
228/// 2026-09-25: Whether `METRALE_SSM_MARCONI_FULL` is present, with any value.
229pub fn marconi_reserve_full() -> bool {
230    std::env::var_os("METRALE_SSM_MARCONI_FULL").is_some()
231}
232
233/// 2026-09-25: Env-free core of [`marconi_snapshot_slots`].
234pub fn marconi_snapshot_slots_with(
235    requested: usize,
236    prefix_caching_active: bool,
237    full_reserve: bool,
238) -> MarconiSlotDecision {
239    if requested == 0 || prefix_caching_active || full_reserve {
240        return MarconiSlotDecision {
241            slots: requested,
242            skip_reason: None,
243        };
244    }
245    MarconiSlotDecision {
246        slots: 0,
247        skip_reason: Some("prefix caching inactive — Marconi snapshot slots are unreachable"),
248    }
249}
250
251/// 2026-09-25: Whether the prefix cache the server will install is a real cache: the
252/// flag and `ModelConfig::kv_only_prefix_cache_is_safe`, the predicate
253/// `build_prefix_cache` uses. Preflight calls this before the cache exists;
254/// `TransformerModel::new` asks the constructed cache (`PrefixCache::is_active`).
255pub fn prefix_caching_active(enable_flag: bool, kv_only_prefix_cache_is_safe: bool) -> bool {
256    enable_flag && kv_only_prefix_cache_is_safe
257}
258
259#[cfg(test)]
260#[path = "ssm_reserve_tests.rs"]
261mod mtp_state_slot_tests;