Skip to main content

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}