1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3
4const SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
5const OS_RESERVE: f64 = 2.0 * 1024.0 * 1024.0 * 1024.0;
6pub const HEADROOM: f64 = 0.15;
7
8#[derive(Debug, Error)]
9#[error("{0}")]
10pub struct ValidationError(pub String);
11
12#[derive(Debug, Clone, Deserialize, Serialize)]
13#[serde(rename_all = "lowercase")]
14pub enum Architecture {
15 Dense,
16 Moe,
17}
18
19#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
20#[serde(rename_all = "lowercase")]
21pub enum CpuArch {
22 X64,
23 Arm64,
24}
25
26#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
27#[serde(rename_all = "lowercase")]
28pub enum Platform {
29 Darwin,
30 Linux,
31 Win32,
32}
33
34#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
35#[serde(rename_all = "lowercase")]
36pub enum GpuVendor {
37 Apple,
38 Nvidia,
39 Amd,
40 Intel,
41 None,
42}
43
44#[derive(Debug, Clone, Deserialize, Serialize)]
45#[serde(rename_all = "camelCase", deny_unknown_fields)]
46pub struct Gpu {
47 pub vendor: GpuVendor,
48 pub vram_bytes: f64,
49}
50
51#[derive(Debug, Clone, Deserialize, Serialize)]
52#[serde(rename_all = "camelCase", deny_unknown_fields)]
53pub struct Hardware {
54 pub arch: CpuArch,
55 pub platform: Platform,
56 pub total_ram_bytes: f64,
57 pub free_ram_bytes: f64,
58 pub free_disk_bytes: f64,
59 pub gpu: Vec<Gpu>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub unified_memory: Option<bool>,
63}
64
65impl Hardware {
66 pub fn is_unified(&self) -> bool {
68 self.unified_memory
69 .unwrap_or(self.arch == CpuArch::Arm64 && self.platform == Platform::Darwin)
70 }
71}
72
73#[derive(Debug, Clone, Deserialize, Serialize)]
74#[serde(rename_all = "camelCase", deny_unknown_fields)]
75pub struct Quantization {
76 pub name: String,
77 pub disk_bytes: f64,
78 pub min_ram_bytes: f64,
79 pub min_vram_bytes: f64,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub sha256: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub digest_verified: Option<bool>,
84}
85
86#[derive(Debug, Deserialize)]
87#[serde(rename_all = "camelCase", deny_unknown_fields)]
88pub struct Model {
89 pub id: String,
90 pub params: String,
91 pub architecture: Architecture,
92 pub context_length: f64,
93 pub kv_bytes_per_token: Option<f64>,
94 pub quantizations: Vec<Quantization>,
95}
96
97#[derive(Debug, Deserialize)]
98#[serde(rename_all = "camelCase", deny_unknown_fields)]
99pub struct SizingRequest {
100 pub model: Model,
101 pub hardware: Hardware,
102 pub context: Option<f64>,
103}
104
105#[derive(Debug, Serialize)]
106#[serde(rename_all = "camelCase")]
107pub struct FitResult {
108 pub fits: bool,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub quant: Option<Quantization>,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub reason: Option<&'static str>,
113 pub usable_bytes: f64,
114 pub required_bytes: Option<f64>,
115}
116
117#[derive(Debug, Serialize)]
118#[serde(rename_all = "camelCase")]
119pub struct SizingResult {
120 pub fit: FitResult,
121 pub memory_kind: &'static str,
122 pub usable_bytes: f64,
123 pub weights: Vec<f64>,
124 pub required: Vec<f64>,
125 pub at_context: Vec<Option<f64>>,
126 pub max_context: Vec<Option<f64>>,
127}
128
129fn invalid(message: &str) -> ValidationError {
130 ValidationError(message.into())
131}
132
133fn safe_number(value: f64, minimum: f64, label: &str) -> Result<(), ValidationError> {
134 if !value.is_finite() || value < minimum || value > SAFE_INTEGER || value.fract() != 0.0 {
135 return Err(invalid(&format!(
136 "{label} must be a safe integer >= {minimum}"
137 )));
138 }
139 Ok(())
140}
141
142pub fn parse_param_count(label: &str) -> Result<f64, ValidationError> {
143 let split = label
144 .len()
145 .checked_sub(1)
146 .ok_or_else(|| invalid("invalid parameter label"))?;
147 if !label.is_ascii() {
148 return Err(invalid("invalid parameter label"));
149 }
150 let (number, unit) = label.split_at(split);
151 let mut parts = number.split('.');
152 let integer = parts.next().unwrap_or("");
153 let fraction = parts.next();
154 if integer.is_empty()
155 || !integer.bytes().all(|byte| byte.is_ascii_digit())
156 || fraction.is_some_and(|value| {
157 value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit())
158 })
159 || parts.next().is_some()
160 {
161 return Err(invalid("invalid parameter label"));
162 }
163 let multiplier = match unit {
164 "M" => 1e6,
165 "B" => 1e9,
166 "T" => 1e12,
167 _ => return Err(invalid("invalid parameter unit")),
168 };
169 let count = number
170 .parse::<f64>()
171 .map_err(|_| invalid("invalid parameter label"))?
172 * multiplier;
173 if !count.is_finite() || count <= 0.0 || count > SAFE_INTEGER {
174 return Err(invalid("parameter count outside safe range"));
175 }
176 Ok(count)
177}
178
179pub fn quant_bits(name: &str) -> Option<f64> {
180 let name = name.to_ascii_lowercase();
181 [
182 ("iq1", 1.9),
183 ("iq2", 2.4),
184 ("iq3", 3.4),
185 ("iq4", 4.3),
186 ("q2", 2.8),
187 ("q3", 3.5),
188 ("q4", 4.7),
189 ("q5", 5.6),
190 ("q6", 6.6),
191 ("q8", 8.5),
192 ("f16", 16.5),
193 ("fp16", 16.5),
194 ("bf16", 16.5),
195 ("f32", 32.5),
196 ("fp32", 32.5),
197 ]
198 .into_iter()
199 .find_map(|(prefix, bits)| name.starts_with(prefix).then_some(bits))
200}
201
202pub fn kv_cache_bytes(per_token: u64, tokens: u64) -> Result<u64, ValidationError> {
203 if per_token == 0 {
204 return Err(invalid("KV bytes per token must be positive"));
205 }
206 let bytes = per_token
207 .checked_mul(tokens)
208 .ok_or_else(|| invalid("KV size overflow"))?;
209 if bytes > SAFE_INTEGER as u64 {
210 return Err(invalid("KV size exceeds safe-integer range"));
211 }
212 Ok(bytes)
213}
214
215#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Deserialize, Serialize)]
217pub enum KvCacheType {
218 #[default]
219 #[serde(rename = "f16")]
220 F16,
221 #[serde(rename = "q8_0")]
222 Q8_0,
223 #[serde(rename = "q4_0")]
224 Q4_0,
225}
226
227impl KvCacheType {
228 pub fn name(self) -> &'static str {
229 match self {
230 Self::F16 => "f16",
231 Self::Q8_0 => "q8_0",
232 Self::Q4_0 => "q4_0",
233 }
234 }
235 pub fn parse(name: &str) -> Option<Self> {
236 [Self::F16, Self::Q8_0, Self::Q4_0]
237 .into_iter()
238 .find(|kind| kind.name() == name)
239 }
240 pub fn label(kind: Option<Self>) -> &'static str {
242 match kind {
243 None | Some(Self::F16) => "fp16",
244 Some(kind) => kind.name(),
245 }
246 }
247 fn block(self) -> (u64, u64) {
249 match self {
250 Self::F16 => (2, 1),
251 Self::Q8_0 => (34, 32),
252 Self::Q4_0 => (18, 32),
253 }
254 }
255 fn bytes_for(self, elements: u64) -> Option<u64> {
256 let (bytes, per_block) = self.block();
257 elements.div_ceil(per_block).checked_mul(bytes)
258 }
259}
260
261pub fn typed_kv_bytes_per_token(
263 f16_per_token: u64,
264 k: KvCacheType,
265 v: KvCacheType,
266) -> Result<u64, ValidationError> {
267 if f16_per_token == 0 || !f16_per_token.is_multiple_of(4) {
268 return Err(invalid(
269 "KV bytes per token must split into f16 K and V halves",
270 ));
271 }
272 let elements = f16_per_token / 4;
273 k.bytes_for(elements)
274 .zip(v.bytes_for(elements))
275 .and_then(|(k, v)| k.checked_add(v))
276 .filter(|bytes| *bytes <= SAFE_INTEGER as u64)
277 .ok_or_else(|| invalid("KV size exceeds safe-integer range"))
278}
279
280fn validate(request: &SizingRequest) -> Result<(), ValidationError> {
281 let model = &request.model;
282 if model.id.is_empty() || model.id.len() > 256 {
283 return Err(invalid("invalid model id length"));
284 }
285 parse_param_count(&model.params)?;
286 safe_number(model.context_length, 1.0, "contextLength")?;
287 if let Some(context) = request.context {
288 safe_number(context, 1.0, "context")?;
289 if context > 10_000_000.0 {
290 return Err(invalid("context exceeds ceiling"));
291 }
292 }
293 if let Some(rate) = model.kv_bytes_per_token {
294 safe_number(rate, 1.0, "kvBytesPerToken")?;
295 }
296 for (name, value) in [
297 ("totalRamBytes", request.hardware.total_ram_bytes),
298 ("freeRamBytes", request.hardware.free_ram_bytes),
299 ("freeDiskBytes", request.hardware.free_disk_bytes),
300 ] {
301 safe_number(value, 0.0, name)?;
302 }
303 if request.hardware.gpu.len() > 128 || model.quantizations.len() > 1024 {
304 return Err(invalid("input collection too large"));
305 }
306 for gpu in &request.hardware.gpu {
307 safe_number(gpu.vram_bytes, 0.0, "vramBytes")?;
308 }
309 for quant in &model.quantizations {
310 if quant.name.is_empty() || quant.name.len() > 100 {
311 return Err(invalid("invalid quantization name"));
312 }
313 safe_number(quant.disk_bytes, 1.0, "diskBytes")?;
314 safe_number(quant.min_ram_bytes, 0.0, "minRamBytes")?;
315 safe_number(quant.min_vram_bytes, 0.0, "minVramBytes")?;
316 if quant
317 .sha256
318 .as_ref()
319 .is_some_and(|sha| sha.len() != 64 || !sha.bytes().all(|byte| byte.is_ascii_hexdigit()))
320 {
321 return Err(invalid("invalid sha256"));
322 }
323 }
324 Ok(())
325}
326
327pub fn memory_capacity(hardware: &Hardware) -> (&'static str, f64) {
328 let unified = hardware.is_unified();
329 let vram = hardware
330 .gpu
331 .iter()
332 .map(|gpu| gpu.vram_bytes)
333 .fold(0.0_f64, f64::max);
334 let memory_kind = if !unified && vram > 0.0 {
335 "vram"
336 } else {
337 "ram"
338 };
339 let usable_bytes = if memory_kind == "vram" {
340 vram
341 } else {
342 ((if unified {
343 hardware.total_ram_bytes
344 } else {
345 hardware.free_ram_bytes
346 }) - OS_RESERVE)
347 .max(0.0)
348 };
349 (memory_kind, usable_bytes)
350}
351pub fn evaluate(request: &SizingRequest) -> Result<SizingResult, ValidationError> {
352 validate(request)?;
353 let model = &request.model;
354 let hardware = &request.hardware;
355 let (memory_kind, usable_bytes) = memory_capacity(hardware);
356 let budget = usable_bytes * (1.0 - HEADROOM);
357 let params = parse_param_count(&model.params)?;
358 let mut weights = Vec::new();
359 let mut required = Vec::new();
360 let mut at_context = Vec::new();
361 let mut max_context = Vec::new();
362 let mut best: Option<(usize, f64)> = None;
363 let mut smallest: Option<(f64, bool)> = None;
364 for (index, quant) in model.quantizations.iter().enumerate() {
365 let bits = quant_bits(&quant.name);
366 if bits.is_none() && matches!(model.architecture, Architecture::Moe) {
367 return Err(invalid("cannot size MoE with unknown quantization"));
368 }
369 let weight = quant
370 .disk_bytes
371 .max(bits.map_or(0.0, |bits| (params * bits / 8.0).ceil()));
372 let legacy = weight + (weight * 0.15).ceil();
373 safe_number(legacy, 1.0, "required memory")?;
374 let slack = (weight * 0.05).ceil();
375 let context_bytes = match (model.kv_bytes_per_token, request.context) {
376 (Some(rate), Some(context)) => {
377 let explicit = weight + kv_cache_bytes(rate as u64, context as u64)? as f64 + slack;
378 safe_number(explicit, 1.0, "context memory")?;
379 Some(legacy.max(explicit))
380 }
381 _ => None,
382 };
383 let maximum = model.kv_bytes_per_token.map(|rate| {
384 if legacy > budget {
385 0.0
386 } else {
387 ((budget - weight - slack) / rate).floor()
388 }
389 });
390 let footprint = context_bytes.unwrap_or(legacy);
391 let memory_fits = footprint <= budget;
392 if memory_fits && quant.disk_bytes <= hardware.free_disk_bytes {
393 let better = match best {
394 None => true,
395 Some((best_index, best_bytes)) => {
396 footprint > best_bytes
397 || (footprint == best_bytes
398 && bits
399 .zip(quant_bits(&model.quantizations[best_index].name))
400 .is_some_and(|(current, prior)| current > prior))
401 }
402 };
403 if better {
404 best = Some((index, footprint));
405 }
406 }
407 if smallest.is_none_or(|(bytes, _)| footprint < bytes) {
408 smallest = Some((footprint, memory_fits));
409 }
410 weights.push(weight);
411 required.push(legacy);
412 at_context.push(context_bytes);
413 max_context.push(maximum);
414 }
415 let fit = if request
416 .context
417 .is_some_and(|context| context > model.context_length)
418 {
419 FitResult {
420 fits: false,
421 quant: None,
422 reason: Some("context-bound"),
423 usable_bytes,
424 required_bytes: None,
425 }
426 } else if let Some((index, bytes)) = best {
427 FitResult {
428 fits: true,
429 quant: Some(model.quantizations[index].clone()),
430 reason: None,
431 usable_bytes,
432 required_bytes: Some(bytes),
433 }
434 } else {
435 let reason = if smallest.is_some_and(|(_, fits)| fits) {
436 "disk-bound"
437 } else if memory_kind == "vram" {
438 "vram-bound"
439 } else {
440 "ram-bound"
441 };
442 FitResult {
443 fits: false,
444 quant: None,
445 reason: Some(reason),
446 usable_bytes,
447 required_bytes: smallest.map(|(bytes, _)| bytes),
448 }
449 };
450 Ok(SizingResult {
451 fit,
452 memory_kind,
453 usable_bytes,
454 weights,
455 required,
456 at_context,
457 max_context,
458 })
459}