1use crate::{RuntimeConfigSnapshot, RuntimeConfigSource};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10pub struct DeviceMemorySnapshot {
11 pub capacity_bytes: u64,
12 pub available_bytes: u64,
13 pub source: String,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct StartupMemoryRequest {
18 pub device: DeviceMemorySnapshot,
19 pub usable_capacity_bytes: u64,
20 pub context_is_explicit: bool,
21 pub sequences_is_explicit: bool,
22 pub batch_is_explicit: bool,
23}
24
25impl StartupMemoryRequest {
26 pub fn from_snapshot(
27 device: DeviceMemorySnapshot,
28 utilization: f32,
29 requested: &RuntimeConfigSnapshot,
30 ) -> Result<Self, String> {
31 if device.capacity_bytes == 0 || device.available_bytes > device.capacity_bytes {
32 return Err("invalid device memory sample: expected available <= capacity > 0".into());
33 }
34 if !utilization.is_finite() || utilization <= 0.0 || utilization > 1.0 {
35 return Err("memory utilization must be in (0, 1]".into());
36 }
37 let explicit = |key: &str| {
38 requested.entries.iter().any(|entry| {
39 entry.key == key
40 && matches!(
41 entry.source,
42 RuntimeConfigSource::Cli
43 | RuntimeConfigSource::Env
44 | RuntimeConfigSource::ConfigFile
45 | RuntimeConfigSource::ScriptCase
46 )
47 })
48 };
49 let budget = requested
50 .entries
51 .iter()
52 .find(|entry| entry.key == "FERRUM_RUNTIME_MEMORY_BUDGET_BYTES")
53 .map(|entry| {
54 entry
55 .effective_value
56 .parse::<u64>()
57 .map_err(|_| "runtime memory budget must be a positive byte count".to_owned())
58 })
59 .transpose()?;
60 let usable_capacity_bytes = budget.unwrap_or_else(|| {
61 ((device.available_bytes as f64) * f64::from(utilization)).floor() as u64
62 });
63 if usable_capacity_bytes == 0 || usable_capacity_bytes > device.available_bytes {
64 return Err(format!(
65 "runtime memory budget {usable_capacity_bytes} bytes cannot fit the sampled {} available bytes (device ceiling {} bytes; {})",
66 device.available_bytes, device.capacity_bytes, device.source
67 ));
68 }
69 Ok(Self {
70 device,
71 usable_capacity_bytes,
72 context_is_explicit: explicit("FERRUM_MAX_MODEL_LEN") || explicit("FERRUM_KV_CAPACITY"),
73 sequences_is_explicit: explicit("FERRUM_PAGED_MAX_SEQS"),
74 batch_is_explicit: explicit("FERRUM_MAX_BATCHED_TOKENS"),
75 })
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80pub struct StartupResourceLimits {
81 pub context_tokens: usize,
82 pub max_sequences: usize,
83 pub max_batch_tokens: usize,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum StartupWorkload {
91 Prefill {
92 context_tokens: usize,
93 chunk_tokens: usize,
94 },
95 Decode {
96 context_tokens: usize,
99 active_sequences: usize,
100 },
101}
102
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct StartupMemoryPlan {
105 pub request: StartupMemoryRequest,
106 pub requested: StartupResourceLimits,
107 pub selected: StartupResourceLimits,
108 pub context_peak_bytes: u64,
109 pub decode_peak_bytes: u64,
110 pub reasons: Vec<String>,
111}
112
113impl StartupMemoryPlan {
114 pub fn apply_to_engine_config(&self, engine: &mut crate::EngineConfig) -> Result<(), String> {
115 engine.runtime.max_model_len = Some(self.selected.context_tokens);
116 engine.scheduler.max_running_requests = self.selected.max_sequences;
117 engine.batching.max_num_batched_tokens = self.selected.max_batch_tokens;
118 engine.memory.usable_capacity_bytes = Some(
119 usize::try_from(self.request.usable_capacity_bytes)
120 .map_err(|_| "runtime memory budget exceeds the process address space")?,
121 );
122 engine.runtime.startup_memory_plan = Some(self.clone());
123 Ok(())
124 }
125}
126
127pub fn fit_startup_resources(
131 request: &StartupMemoryRequest,
132 target: StartupResourceLimits,
133 mut peak_bytes: impl FnMut(StartupWorkload) -> Result<u64, String>,
134) -> Result<StartupMemoryPlan, String> {
135 if target.context_tokens == 0 || target.max_sequences == 0 || target.max_batch_tokens == 0 {
136 return Err("startup context, sequence and batch limits must be positive".into());
137 }
138 let budget = request.usable_capacity_bytes;
139 let prefill = |context_tokens: usize, chunk: usize| StartupWorkload::Prefill {
140 context_tokens,
141 chunk_tokens: chunk.min(context_tokens).max(1),
142 };
143 let batch_floor = if request.sequences_is_explicit {
144 target.max_sequences
145 } else {
146 1
147 };
148 let batch_target = if request.batch_is_explicit {
149 target.max_batch_tokens
150 } else {
151 target.max_batch_tokens.max(batch_floor)
152 };
153 if batch_target < batch_floor {
154 return Err("explicit batch token limit is smaller than explicit sequence limit".into());
155 }
156 let batch_cost =
157 |batch: usize, cost: &mut dyn FnMut(StartupWorkload) -> Result<u64, String>| {
158 let context = if request.context_is_explicit {
159 target.context_tokens
160 } else {
161 batch.min(target.context_tokens)
162 };
163 cost(prefill(context, batch))
164 };
165 let batch = if request.batch_is_explicit {
166 require_fit(
167 "explicit batch/context",
168 batch_cost(batch_target, &mut peak_bytes)?,
169 budget,
170 )?;
171 batch_target
172 } else {
173 largest_fit(batch_floor, batch_target, budget, |value| {
174 batch_cost(value, &mut peak_bytes)
175 })?
176 };
177 let context = if request.context_is_explicit {
178 require_fit(
179 "explicit context",
180 peak_bytes(prefill(target.context_tokens, batch))?,
181 budget,
182 )?;
183 target.context_tokens
184 } else {
185 largest_fit(1, target.context_tokens, budget, |tokens| {
186 let prefill_peak = peak_bytes(prefill(tokens, batch))?;
187 let decode_peak = peak_bytes(StartupWorkload::Decode {
188 context_tokens: tokens,
189 active_sequences: if request.sequences_is_explicit {
190 target.max_sequences
191 } else {
192 1
193 },
194 })?;
195 Ok(prefill_peak.max(decode_peak))
196 })?
197 };
198 let decode = |active_sequences| StartupWorkload::Decode {
199 context_tokens: context,
200 active_sequences,
201 };
202 let sequences = if request.sequences_is_explicit {
203 require_fit(
204 "explicit concurrency",
205 peak_bytes(decode(target.max_sequences))?,
206 budget,
207 )?;
208 target.max_sequences
209 } else {
210 largest_fit(1, target.max_sequences.min(batch), budget, |count| {
211 peak_bytes(decode(count))
212 })?
213 };
214 let selected = StartupResourceLimits {
215 context_tokens: context,
216 max_sequences: sequences,
217 max_batch_tokens: batch,
218 };
219 let mut reasons = Vec::new();
220 for (name, before, after) in [
221 ("context tokens", target.context_tokens, context),
222 ("concurrent sequences", target.max_sequences, sequences),
223 ("batch tokens", target.max_batch_tokens, batch),
224 ] {
225 if before != after {
226 reasons.push(format!("{name}: {before} -> {after}, compiled resource plan fitted to {budget} usable bytes"));
227 }
228 }
229 Ok(StartupMemoryPlan {
230 request: request.clone(),
231 requested: target,
232 selected,
233 context_peak_bytes: peak_bytes(prefill(context, batch))?,
234 decode_peak_bytes: peak_bytes(decode(sequences))?,
235 reasons,
236 })
237}
238
239fn require_fit(label: &str, required: u64, budget: u64) -> Result<(), String> {
240 if required > budget {
241 return Err(format!(
242 "{label} requires {required} bytes, exceeding the {budget}-byte runtime budget"
243 ));
244 }
245 Ok(())
246}
247
248fn largest_fit(
249 minimum: usize,
250 maximum: usize,
251 budget: u64,
252 mut cost: impl FnMut(usize) -> Result<u64, String>,
253) -> Result<usize, String> {
254 require_fit("minimum runnable configuration", cost(minimum)?, budget)?;
255 let mut low = minimum;
256 let mut high = maximum;
257 while low < high {
258 let middle = low + (high - low) / 2 + (high - low) % 2;
259 if cost(middle)? <= budget {
260 low = middle;
261 } else {
262 high = middle - 1;
263 }
264 }
265 Ok(low)
266}
267
268#[cfg(test)]
269mod tests;