metrale_model_engine/factory/build/slots.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-10-01: How many sequence slots a model is built with. `--max-batch-size N` asks for N;
4//! `--max-batch-size auto` asks for the count that admits the most sequences at the full
5//! `--max-seq-len`, sizing the per-slot state (the SSM pools and every other slot-scaled reserve
6//! term) and the KV pool together: each slot of state costs KV blocks, so past some count more
7//! slots admit fewer sequences, not more.
8//!
9//! Owner: metrale-model-engine.
10//! Invariants:
11//! - [`balance_slots`] is pure: the reserve and the pool size come in as functions of the slot
12//! count, so the boot and the tests evaluate the same arithmetic.
13//! - An explicit count is never changed; `auto` is the only request this module resolves.
14
15use anyhow::{Result, ensure};
16
17/// 2026-10-01: The `--max-batch-size` request.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum SlotRequest {
20 /// 2026-10-01: Exactly this many slots.
21 Count(usize),
22 /// 2026-10-01: The count [`balance_slots`] chooses, at most [`AUTO_MAX_SLOTS`].
23 Auto,
24}
25
26/// 2026-10-01: The most slots `auto` considers: the widest rung of the decode batch ladder
27/// (`traits::DECODE_BATCH_LADDER`), the widest batch a decode step is padded to and captured at.
28pub const AUTO_MAX_SLOTS: usize =
29 crate::traits::DECODE_BATCH_LADDER[crate::traits::DECODE_BATCH_LADDER.len() - 1];
30
31impl SlotRequest {
32 /// 2026-10-01: The slots sized before the KV pool is (the pre-load reserve, the buffer
33 /// arena): the count, or for `auto` its ceiling [`AUTO_MAX_SLOTS`].
34 pub fn ceiling(self) -> usize {
35 match self {
36 SlotRequest::Count(n) => n,
37 SlotRequest::Auto => AUTO_MAX_SLOTS,
38 }
39 }
40}
41
42impl std::str::FromStr for SlotRequest {
43 type Err = String;
44
45 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
46 if s == "auto" {
47 return Ok(SlotRequest::Auto);
48 }
49 s.parse::<usize>()
50 .map(SlotRequest::Count)
51 .map_err(|_| format!("`{s}` is neither a slot count nor `auto`"))
52 }
53}
54
55impl std::fmt::Display for SlotRequest {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 match self {
58 SlotRequest::Count(n) => write!(f, "{n}"),
59 SlotRequest::Auto => f.write_str("auto"),
60 }
61 }
62}
63
64/// 2026-10-01: The slot request and the pre-load reserve as a function of the slot count (the
65/// preflight's plan, `inference_reserve(slots)`), which KV sizing evaluates at the count it
66/// builds.
67pub struct SlotPlan<'a> {
68 pub request: SlotRequest,
69 pub reserve_for: &'a dyn Fn(usize) -> Result<usize>,
70}
71
72/// 2026-10-01: A built model and the slot count it was built with (the request, or what `auto`
73/// resolved to), which the scheduler and the serve's disclosure take from here.
74pub struct BuiltModel {
75 pub model: Box<dyn crate::traits::Model>,
76 pub max_batch_size: usize,
77 /// 2026-10-04: sha256 over the drafter's stored weights (`WeightStore::drafter_weights_sha256`),
78 /// `None` for a checkpoint with no `mtp.*` tensors. The `--spec-cost-model measured` drafter
79 /// key.
80 pub drafter_weights_sha256: Option<String>,
81}
82
83/// 2026-10-01: One candidate slot count's outcome.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct Balance {
86 /// 2026-10-01: The slot count.
87 pub slots: usize,
88 /// 2026-10-01: KV blocks the pool gets with that many slots.
89 pub kv_blocks: usize,
90 /// 2026-10-01: Sequences admitted at the full `--max-seq-len`: the slots, or the full-length
91 /// sequences the pool holds, whichever is fewer.
92 pub admitted: usize,
93}
94
95/// 2026-10-01: The slot count in `1..=max_slots` that admits the most sequences at
96/// `blocks_per_seq` blocks each, `kv_blocks_at(slots)` giving the pool size for a count. On a tie
97/// the smaller count wins, so the chosen count never exceeds what its pool holds at full length
98/// (a count above it ties with the count one below, whose pool is at least as large) and the boot's
99/// overcommit check passes. An error when `max_slots` is 0, when `kv_blocks_at` fails for every
100/// count, or when no count's pool holds one full-length sequence.
101pub fn balance_slots(
102 max_slots: usize,
103 blocks_per_seq: usize,
104 kv_blocks_at: impl Fn(usize) -> Result<usize>,
105) -> Result<Balance> {
106 ensure!(max_slots > 0, "no slot count to balance (ceiling 0)");
107 let per_seq = blocks_per_seq.max(1);
108 let mut best: Option<Balance> = None;
109 for slots in 1..=max_slots {
110 let Ok(kv_blocks) = kv_blocks_at(slots) else {
111 continue;
112 };
113 let b = Balance {
114 slots,
115 kv_blocks,
116 admitted: slots.min(kv_blocks / per_seq),
117 };
118 if best.is_none_or(|x| b.admitted > x.admitted) {
119 best = Some(b);
120 }
121 }
122 let best = best.ok_or_else(|| {
123 anyhow::anyhow!("no slot count in 1..={max_slots} leaves memory for the KV pool")
124 })?;
125 ensure!(
126 best.admitted > 0,
127 "no slot count in 1..={max_slots} leaves a KV pool that holds one sequence at the full \
128 --max-seq-len ({per_seq} blocks; {} at 1 slot): lower --max-seq-len",
129 best.kv_blocks
130 );
131 Ok(best)
132}
133
134#[cfg(test)]
135#[path = "slots_tests.rs"]
136mod slots_tests;