microvms_core/sizing.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Size classes: what `minimumMemoryInMiB` actually buys, and what it bills.
3//!
4//! `minimumMemoryInMiB` does not size a VM. It selects a *class*, and the class has
5//! two numbers that differ by 4x — the baseline you are billed for every running
6//! second, and the peak the guest reports in `/proc/meminfo`. Measured 2026-08-07,
7//! us-east-1: requesting 512 produced a guest reporting ~2 GB `MemTotal`; requesting
8//! 2048 produced ~8 GB. Both match AWS's documented sizing table exactly, so the
9//! number the caller writes down is neither the number they get nor, on its own,
10//! enough to predict the bill (`docs/PLATFORM.md`, "`minimumMemoryInMiB` selects a
11//! *baseline*, and the guest reports the *peak*").
12//!
13//! # Why the table is data (TRAP-13)
14//!
15//! Every documented peak is exactly four times its baseline, which makes
16//! `baseline * 4` look like the obvious simplification. It is the one thing this
17//! module must not do. That regularity is AWS's and not ours: a sixth row, or a
18//! revision to an existing one, that breaks the pattern would silently get the
19//! pattern applied to it, and the report would name a burst ceiling the service does
20//! not offer. So [`SIZE_CLASSES`] is the only place any of these numbers appears, and
21//! every accessor reads a row out of it through one lookup — which is also what makes
22//! the guard testable: the test below drives the accessors over a table whose peak is
23//! *not* 4x, and a computed implementation cannot answer it.
24//!
25//! # Why an off-table request is refused (TRAP-10)
26//!
27//! Only the five table values are accepted. What the service does with an off-table
28//! request such as 1500 is undocumented and unmeasured, and the two plausible
29//! behaviours — round up to the enclosing class, or take the literal as a baseline —
30//! differ in both the memory the guest gets and the rate it is billed at. Rejecting
31//! locally costs the caller a second; guessing costs them a wrong bill they have no
32//! way to notice.
33
34use std::fmt;
35
36use crate::error::Error;
37
38/// MiB per GB as AWS's pricing and sizing tables use it.
39///
40/// The guest's `MemTotal` reads in KiB and lands slightly under (2 GB shows as
41/// 2037648 kB), so any comparison against a guest-reported figure is approximate by
42/// nature.
43pub const MIB_PER_GB: u32 = 1024;
44
45/// One row of the documented sizing table: a baseline and its burst ceiling.
46///
47/// Plain data with public fields, because that is what it is — a transcription of
48/// someone else's published table, not settings. The accessors on [`SizeClass`] are
49/// where a caller normally reads it; the row is public so the drift gate and the
50/// guard test can compare against the table itself.
51#[derive(Clone, Copy, Debug, PartialEq)]
52pub struct SizeRow {
53 /// What `minimumMemoryInMiB` must be set to in order to select this class.
54 pub baseline_mib: u32,
55 /// Billed per running second, alongside `baseline_vcpu`. Not what the guest
56 /// reports.
57 pub baseline_vcpu: f64,
58 /// What the guest reports as `MemTotal`, and the ceiling a burst can reach.
59 /// Charged only for the seconds above baseline that are actually consumed.
60 pub peak_mib: u32,
61 pub peak_vcpu: f64,
62}
63
64/// The documented table (`microvms-images.html`), smallest first.
65///
66/// The single definition of all twenty numbers. See the module docs for why nothing
67/// derives one of them from another.
68pub const SIZE_CLASSES: [SizeRow; 5] = [
69 SizeRow {
70 baseline_mib: 512,
71 baseline_vcpu: 0.25,
72 peak_mib: 2048,
73 peak_vcpu: 1.0,
74 },
75 SizeRow {
76 baseline_mib: 1024,
77 baseline_vcpu: 0.5,
78 peak_mib: 4096,
79 peak_vcpu: 2.0,
80 },
81 SizeRow {
82 baseline_mib: 2048,
83 baseline_vcpu: 1.0,
84 peak_mib: 8192,
85 peak_vcpu: 4.0,
86 },
87 SizeRow {
88 baseline_mib: 4096,
89 baseline_vcpu: 2.0,
90 peak_mib: 16384,
91 peak_vcpu: 8.0,
92 },
93 SizeRow {
94 baseline_mib: 8192,
95 baseline_vcpu: 4.0,
96 peak_mib: 32768,
97 peak_vcpu: 16.0,
98 },
99];
100
101/// One of the five documented size classes.
102///
103/// Closed, so an off-table memory figure is not a value that exists — the Python
104/// client's S2 runtime check (`sizing.py:95` `size_class_for`) becomes S1 for every
105/// caller who has a `SizeClass` in hand. [`SizeClass::from_baseline_mib`] is the one
106/// boundary where a bare integer still has to be judged, which is where TRAP-10
107/// lives.
108///
109/// The variants are named for their baseline because that is the number the caller
110/// writes into `minimumMemoryInMiB`; the peak is deliberately not in any name, since
111/// naming both would suggest a caller gets to pick them independently.
112#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
113pub enum SizeClass {
114 Mib512,
115 Mib1024,
116 Mib2048,
117 Mib4096,
118 Mib8192,
119}
120
121impl SizeClass {
122 /// The platform's own default, and ours.
123 ///
124 /// Deliberately not the smallest class: the baseline is also the floor of the
125 /// burst range, and a 0.5 GB default hands someone a sandbox that OOM-kills a
126 /// real test suite to save about three cents an hour. Guest swap is absent
127 /// (`SwapTotal: 0 kB`), so there is no paging phase to absorb the mistake —
128 /// pressure goes straight to the OOM killer.
129 pub const DEFAULT: SizeClass = SizeClass::Mib2048;
130
131 /// Every class, smallest first, in [`SIZE_CLASSES`] order.
132 pub const ALL: [SizeClass; 5] = [
133 SizeClass::Mib512,
134 SizeClass::Mib1024,
135 SizeClass::Mib2048,
136 SizeClass::Mib4096,
137 SizeClass::Mib8192,
138 ];
139
140 /// The class `minimumMemoryInMiB = mib` selects (TRAP-10).
141 ///
142 /// Rejects anything not in the table rather than snapping to a neighbour: an
143 /// off-table request has two plausible readings that differ in both memory and
144 /// rate, and we have measured neither. This is the boundary a figure from a CLI
145 /// flag or a config file crosses.
146 pub fn from_baseline_mib(mib: u32) -> Result<SizeClass, Error> {
147 if let Some(class) = class_for_baseline_in(&SIZE_CLASSES, mib) {
148 return Ok(class);
149 }
150 let offered = SIZE_CLASSES
151 .iter()
152 .map(|row| row.baseline_mib.to_string())
153 .collect::<Vec<_>>()
154 .join(", ");
155 Err(Error::invalid_arg(format!(
156 "minimumMemoryInMiB={mib} is not a documented size class baseline. The field \
157 selects a class, it does not size a VM; pass one of {offered} MiB. See \
158 docs/PLATFORM.md, '`minimumMemoryInMiB` selects a *baseline*, and the guest \
159 reports the *peak*'."
160 )))
161 }
162
163 /// This class's row of the documented table.
164 ///
165 /// The one reader of [`SIZE_CLASSES`] for a known class, which is what keeps
166 /// every accessor below reading data rather than deriving it.
167 pub fn row(self) -> &'static SizeRow {
168 row_in(&SIZE_CLASSES, self)
169 }
170
171 /// The value to send as `minimumMemoryInMiB`. Billed per running second.
172 pub fn baseline_mib(self) -> u32 {
173 self.row().baseline_mib
174 }
175
176 /// What the guest reports as `MemTotal`, and the burst ceiling.
177 ///
178 /// Read from the table. Never `baseline_mib() * 4` — see the module docs.
179 pub fn peak_mib(self) -> u32 {
180 self.row().peak_mib
181 }
182
183 /// Billed per running second, alongside [`SizeClass::baseline_mib`].
184 pub fn baseline_vcpu(self) -> f64 {
185 self.row().baseline_vcpu
186 }
187
188 /// The vCPU ceiling a burst can reach. Read from the table.
189 pub fn peak_vcpu(self) -> f64 {
190 self.row().peak_vcpu
191 }
192
193 /// The figure a GB-second rate multiplies. Always the baseline, never the peak
194 /// (COST-5).
195 pub fn baseline_gb(self) -> f64 {
196 f64::from(self.baseline_mib()) / f64::from(MIB_PER_GB)
197 }
198
199 /// The peak in GB, for a report that shows the caller what the guest will say.
200 pub fn peak_gb(self) -> f64 {
201 f64::from(self.peak_mib()) / f64::from(MIB_PER_GB)
202 }
203
204 /// The position of this class in [`SIZE_CLASSES`].
205 ///
206 /// Private and `const`: it is the identity mapping between the enum and the
207 /// table, and the invariant test below pins it to the baselines so a reordered
208 /// table cannot quietly re-point every variant.
209 const fn index(self) -> usize {
210 match self {
211 SizeClass::Mib512 => 0,
212 SizeClass::Mib1024 => 1,
213 SizeClass::Mib2048 => 2,
214 SizeClass::Mib4096 => 3,
215 SizeClass::Mib8192 => 4,
216 }
217 }
218}
219
220/// One line naming both numbers, for an error message or a CLI.
221///
222/// Both numbers or neither: naming only the peak invites someone to budget for
223/// memory they will not be billed for, and naming only the baseline invites a
224/// memory-pressure test written against a ceiling four times lower than the one the
225/// guest enforces.
226impl fmt::Display for SizeClass {
227 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228 let row = self.row();
229 write!(
230 f,
231 "{} GB / {} vCPU baseline (billed while running), bursting to {} GB / {} vCPU \
232 (what the guest reports)",
233 self.baseline_gb(),
234 row.baseline_vcpu,
235 self.peak_gb(),
236 row.peak_vcpu,
237 )
238 }
239}
240
241/// The row a class occupies in `table`.
242///
243/// Takes the table as a parameter for one reason: it is what makes TRAP-13's guard
244/// falsifiable. Every accessor above goes through here, so a test can hand this
245/// function a table whose peak is not four times its baseline and see whether the
246/// answer follows the data or the pattern.
247fn row_in(table: &[SizeRow], class: SizeClass) -> &SizeRow {
248 &table[class.index()]
249}
250
251/// The class whose row in `table` carries `baseline_mib`, if any.
252///
253/// Table-parameterized for the same reason as [`row_in`], and a linear scan because
254/// five rows is five rows.
255fn class_for_baseline_in(table: &[SizeRow], baseline_mib: u32) -> Option<SizeClass> {
256 SizeClass::ALL
257 .into_iter()
258 .find(|class| table[class.index()].baseline_mib == baseline_mib)
259}
260
261#[cfg(test)]
262mod tests {
263 use proptest::prelude::*;
264
265 use super::*;
266 use crate::error::ErrorKind;
267
268 /// The measured table, written out as literals. This is the test a table edit
269 /// must fail: the twenty numbers are transcriptions of AWS's published table and
270 /// two of the rows were measured directly, so changing one is a claim about the
271 /// service that needs its own evidence.
272 #[test]
273 fn the_documented_table_carries_the_measured_rows() {
274 let rows: Vec<(u32, f64, u32, f64)> = SIZE_CLASSES
275 .iter()
276 .map(|r| (r.baseline_mib, r.baseline_vcpu, r.peak_mib, r.peak_vcpu))
277 .collect();
278 assert_eq!(
279 rows,
280 [
281 (512, 0.25, 2048, 1.0),
282 (1024, 0.5, 4096, 2.0),
283 (2048, 1.0, 8192, 4.0),
284 (4096, 2.0, 16384, 8.0),
285 (8192, 4.0, 32768, 16.0),
286 ]
287 );
288 }
289
290 /// TRAP-13. The lookup answers from the data, so a table whose peak breaks the
291 /// 4x pattern is reported as written rather than as the pattern predicts.
292 ///
293 /// This is the falsification the spec asks for: replace [`SizeClass::peak_mib`]'s
294 /// body with `self.baseline_mib() * 4` and this row's peak comes back 8192
295 /// instead of 9000. A test that only checked the shipped table could not tell the
296 /// two implementations apart, because every shipped peak *is* 4x its baseline.
297 #[test]
298 fn a_peak_that_is_not_four_times_its_baseline_is_read_not_computed() {
299 let mut table = SIZE_CLASSES;
300 table[SizeClass::Mib2048.index()].peak_mib = 9000;
301 table[SizeClass::Mib2048.index()].peak_vcpu = 3.5;
302
303 let row = row_in(&table, SizeClass::Mib2048);
304 assert_eq!(
305 row.peak_mib, 9000,
306 "the peak must come from the table, not from baseline * 4"
307 );
308 assert_eq!(row.peak_vcpu, 3.5);
309 // The baseline is untouched by the edit, which is what makes the assertion
310 // above about the peak rather than about the row being found at all.
311 assert_eq!(row.baseline_mib, 2048);
312 assert_eq!(row.peak_mib * 4 / 4, 9000);
313 assert_ne!(row.peak_mib, row.baseline_mib * 4);
314 }
315
316 /// The public accessors are that lookup applied to the shipped table, and
317 /// nothing else. Under a computed implementation this passes today and goes red
318 /// the moment a table row stops being 4x — which is exactly the regression
319 /// TRAP-13 names.
320 #[test]
321 fn every_accessor_returns_its_own_row_from_the_shipped_table() {
322 for class in SizeClass::ALL {
323 let row = &SIZE_CLASSES[class.index()];
324 assert_eq!(class.baseline_mib(), row.baseline_mib, "{class:?}");
325 assert_eq!(class.peak_mib(), row.peak_mib, "{class:?}");
326 assert_eq!(class.baseline_vcpu(), row.baseline_vcpu, "{class:?}");
327 assert_eq!(class.peak_vcpu(), row.peak_vcpu, "{class:?}");
328 }
329 }
330
331 /// The enum-to-row mapping is pinned to the baselines rather than to positions,
332 /// so a reordered table fails here instead of quietly re-pointing every variant
333 /// at its neighbour's numbers.
334 #[test]
335 fn each_variant_is_pinned_to_the_baseline_its_name_states() {
336 assert_eq!(SizeClass::Mib512.baseline_mib(), 512);
337 assert_eq!(SizeClass::Mib1024.baseline_mib(), 1024);
338 assert_eq!(SizeClass::Mib2048.baseline_mib(), 2048);
339 assert_eq!(SizeClass::Mib4096.baseline_mib(), 4096);
340 assert_eq!(SizeClass::Mib8192.baseline_mib(), 8192);
341 }
342
343 /// TRAP-10: the five documented baselines are accepted and answer with their own
344 /// class.
345 #[test]
346 fn the_five_documented_baselines_select_their_own_class() {
347 for class in SizeClass::ALL {
348 let selected = SizeClass::from_baseline_mib(class.baseline_mib())
349 .expect("a documented baseline is accepted");
350 assert_eq!(selected, class);
351 }
352 }
353
354 /// TRAP-10's falsification, at the values a caller actually reaches for: a round
355 /// number between two classes, the peak of a class mistaken for a request, and
356 /// zero. 1500 is the case in `sizing.py`'s own docstring — the reading is
357 /// ambiguous in a way that changes the bill, so it is refused rather than
358 /// snapped.
359 #[test]
360 fn an_off_table_baseline_is_refused_naming_the_finding() {
361 for mib in [0, 1, 511, 513, 1500, 3072, 8191, 16384, u32::MAX] {
362 let err = SizeClass::from_baseline_mib(mib)
363 .expect_err("only the five documented baselines are accepted");
364 assert_eq!(err.kind(), ErrorKind::InvalidArg, "{mib}");
365 let message = err.to_string();
366 assert!(
367 message.contains("not a documented size class baseline"),
368 "{message}"
369 );
370 assert!(
371 message.contains("selects a class, it does not size a VM"),
372 "must name the finding: {message}"
373 );
374 }
375 }
376
377 /// A peak is not a baseline. 2048 is both — the peak of the smallest class and
378 /// the baseline of the default — and 8192 is both the peak of the default and the
379 /// largest baseline, so a caller who read a `MemTotal` and passed it back gets a
380 /// silently different class rather than an error. Nothing can be done about that
381 /// (both are legal requests), which is why `Display` always names both numbers.
382 /// 16384 and 32768 are peaks that are *not* baselines and must be refused.
383 #[test]
384 fn a_peak_that_is_not_also_a_baseline_is_refused() {
385 assert_eq!(
386 SizeClass::from_baseline_mib(2048).expect("2048 is a baseline too"),
387 SizeClass::Mib2048
388 );
389 for peak_only in [16384, 32768] {
390 assert!(
391 SizeClass::from_baseline_mib(peak_only).is_err(),
392 "{peak_only}"
393 );
394 }
395 }
396
397 /// The default is the platform's, not the cheapest. A default of `Mib512` would
398 /// hand someone a 2 GB ceiling and no swap.
399 #[test]
400 fn the_default_class_is_two_gigabytes_of_baseline() {
401 assert_eq!(SizeClass::DEFAULT, SizeClass::Mib2048);
402 assert_eq!(SizeClass::DEFAULT.baseline_mib(), 2048);
403 assert_eq!(SizeClass::DEFAULT.baseline_gb(), 2.0);
404 assert_eq!(SizeClass::DEFAULT.peak_gb(), 8.0);
405 }
406
407 /// Both numbers or neither, so nobody budgets for memory they are not billed for
408 /// and nobody writes a pressure test against the wrong ceiling.
409 #[test]
410 fn display_names_the_baseline_and_the_peak_together() {
411 let described = SizeClass::DEFAULT.to_string();
412 assert!(described.contains("2 GB / 1 vCPU baseline"), "{described}");
413 assert!(described.contains("8 GB / 4 vCPU"), "{described}");
414 assert!(described.contains("billed while running"), "{described}");
415 assert!(described.contains("what the guest reports"), "{described}");
416 }
417
418 /// The band where an off-table figure is *plausible*, sampled deliberately.
419 ///
420 /// A uniform `u32` draw is the wrong generator for TRAP-10 and measuring it
421 /// proved so: with the whole domain to pick from, essentially every case lands
422 /// above 8192, where the only reachable verdict is "refused" and a
423 /// round-up-to-the-enclosing-class implementation is indistinguishable from a
424 /// refusing one. Snapping can only happen *between* the smallest and largest
425 /// baseline, so the property has to be told to look there. The band is what
426 /// a caller actually types.
427 fn plausible_baseline_mib() -> impl Strategy<Value = u32> {
428 prop_oneof![
429 // The whole domain, so nothing outside the band goes unchecked.
430 2 => any::<u32>(),
431 // The band where an enclosing class exists to snap to.
432 5 => 1u32..=8192,
433 // The neighbourhoods of each documented baseline, where an off-by-one or
434 // a nearest-neighbour rule would hide.
435 3 => prop::sample::select(SIZE_CLASSES.to_vec())
436 .prop_flat_map(|row| (row.baseline_mib.saturating_sub(2))..=(row.baseline_mib + 2)),
437 ]
438 }
439
440 proptest! {
441 /// The verdict, over the whole `u32` domain plus the band where an off-table
442 /// figure is plausible: accepted exactly when the figure is one of the five,
443 /// refused as `ERR_INVALID_ARG` otherwise. Asserting the verdict rather than a
444 /// side effect is the lesson from
445 /// `.erpaval/solutions/test-failures/proptest-and-dst-tiers-need-verdict-assertions.md`
446 /// — a property that only checked "no panic" would pass against a function
447 /// that accepted everything.
448 ///
449 /// The `Ok` arm's second assertion is the one that catches snapping: a
450 /// rounding implementation returns a class whose baseline is *not* the figure
451 /// it was handed, so `class.baseline_mib() == mib` is what says the answer is
452 /// the caller's request rather than a nearby one chosen for them.
453 #[test]
454 fn from_baseline_mib_accepts_exactly_the_five_documented_baselines(
455 mib in plausible_baseline_mib(),
456 ) {
457 let documented = SIZE_CLASSES.iter().any(|row| row.baseline_mib == mib);
458 match SizeClass::from_baseline_mib(mib) {
459 Ok(class) => {
460 prop_assert!(documented, "{mib} MiB is not a documented baseline but was accepted");
461 prop_assert_eq!(class.baseline_mib(), mib);
462 }
463 Err(err) => {
464 prop_assert!(!documented, "{} MiB is documented but was refused", mib);
465 prop_assert_eq!(err.kind(), ErrorKind::InvalidArg);
466 prop_assert_eq!(err.code(), "ERR_INVALID_ARG");
467 }
468 }
469 }
470
471 /// Whatever a table says, the lookup says. Generated peaks are deliberately
472 /// unrelated to their baselines, so a computed implementation disagrees with
473 /// the table on almost every input.
474 #[test]
475 fn the_lookup_reports_whatever_peak_the_table_carries(
476 peak_mib in 1u32..100_000,
477 peak_vcpu in 0.1f64..64.0,
478 ) {
479 let mut table = SIZE_CLASSES;
480 for class in SizeClass::ALL {
481 table[class.index()].peak_mib = peak_mib;
482 table[class.index()].peak_vcpu = peak_vcpu;
483 }
484 for class in SizeClass::ALL {
485 let row = row_in(&table, class);
486 prop_assert_eq!(row.peak_mib, peak_mib);
487 prop_assert_eq!(row.peak_vcpu, peak_vcpu);
488 // The baselines are untouched, so the row is still the right row.
489 prop_assert_eq!(row.baseline_mib, class.baseline_mib());
490 prop_assert_eq!(
491 class_for_baseline_in(&table, class.baseline_mib()),
492 Some(class)
493 );
494 }
495 }
496 }
497}