//! How big a task a match needs, from what is in it. //! //! A match task runs Suramadu, the host JVM with every Princess inside it, //! the watcher, and one MegaMek client JVM per connected human. The task //! definition carries the one-human bot-match shape; anything bigger asks //! for more at launch through RunTask's per-task override — the same //! mechanism the manifest URL rides in on, so nothing new touches the task //! definition. //! //! The demand model, in thousandths of a vCPU: //! //! demand = BASE the host, Suramadu, the watcher //! + HUMAN · humans one client JVM each; boots are //! simultaneous, so boot is the //! sizing case, not steady state //! + BOT_FIRST + BOT_EXTRA·(n−1) Princess thinks mostly one at a //! time; presence costs more than //! count //! + UNIT_BUMP if units > UNIT_THRESHOLD //! //! The coefficients are anchored to what production has shown, not to a //! fitted curve: one human plus one bot runs clean on the 2048/8192 task //! definition (many matches, no client stalls in their logs); two humans //! on the same shape stalled both boots for ten seconds in the first PvP //! match, so two clients take the next tier; and a host running two //! thinking Princesses saturates about one vCPU on its own. The unit bump //! is the one unanchored term, deliberately conservative. Each launch logs //! the size it chose, and the per-match stats artifacts are where these //! numbers get refined - from real matches, not a laptop stand-in. /// What RunTask is asked for. Strings because the ECS API takes strings. /// /// `memory` is owned rather than `&'static str`: the legal values are a /// range per tier, not a short list, and spelling them all out was already /// wrong once — 24576 MiB, which eight clients want, fell through a match /// arm to 20480. #[derive(Debug, PartialEq)] pub struct TaskSize { pub cpu: &'static str, pub memory: String, } /// One Fargate CPU tier: its size in Fargate units, the string RunTask /// wants, and the memory range and step it accepts. Fargate rejects any /// combination outside these, so the pick snaps into them. /// /// The step is per tier and is not decoration: 4 vCPU takes memory in 1 GiB /// increments, 8 vCPU in 4 GiB, and 16 vCPU in 8 GiB. Asking 8 vCPU for /// 18432 MiB is as invalid as asking it for 4096. struct Tier { units: u32, cpu: &'static str, memory_min: u32, memory_max: u32, memory_step: u32, } const TIERS: &[Tier] = &[ Tier { units: 2048, cpu: "2048", memory_min: 4096, memory_max: 16384, memory_step: 1024, }, Tier { units: 4096, cpu: "4096", memory_min: 8192, memory_max: 30720, memory_step: 1024, }, Tier { units: 8192, cpu: "8192", memory_min: 16384, memory_max: 61440, memory_step: 4096, }, // 16 vCPU, and the reason this tier exists here: eight human seats ask // for 8500 milli-vCPU, which is past what the 8192 tier covers. Without // it the pick fell off the end of the table and served an eight-client // match on eight vCPUs, silently. Tier { units: 16384, cpu: "16384", memory_min: 32768, memory_max: 122880, memory_step: 8192, }, ]; // Production-anchored coefficients, in milli-vCPU. See the module doc for // what each term pays for. const BASE_MILLI: u32 = 500; const HUMAN_MILLI: u32 = 1000; const BOT_FIRST_MILLI: u32 = 500; const BOT_EXTRA_MILLI: u32 = 250; const UNIT_THRESHOLD: u32 = 20; const UNIT_BUMP_MILLI: u32 = 1000; /// Client JVM heap plus its share of encode buffers, MiB. const HUMAN_MEMORY_MIB: u32 = 2560; /// Host and Suramadu heaps plus the OS and page cache, MiB. const BASE_MEMORY_MIB: u32 = 4096; /// What the task definition already grants; never ask below it. const FLOOR_MEMORY_MIB: u32 = 8192; /// The size a match of this shape wants. Never below the task definition's /// own two-vCPU shape: the definition is the floor, this only asks up. /// /// The top tier is a ceiling, not a promise: a shape that wants more than /// 16 vCPU gets 16 and runs slower, the same way the old top tier behaved. /// Nothing here refuses to launch — what stops a lobby growing past what /// Fargate can serve belongs in the lobby, not in the sizer. pub fn task_size(humans: u32, bots: u32, units: u32) -> TaskSize { let mut demand = BASE_MILLI + HUMAN_MILLI * humans; if bots > 0 { demand += BOT_FIRST_MILLI + BOT_EXTRA_MILLI * (bots - 1); } if units > UNIT_THRESHOLD { demand += UNIT_BUMP_MILLI; } // The task definition's own shape is the floor: this override only // ever asks up, so a match the old shape served keeps it byte for byte. let memory_wanted = (BASE_MEMORY_MIB + HUMAN_MEMORY_MIB * humans).max(FLOOR_MEMORY_MIB); // Smallest tier that covers the CPU demand (1024 Fargate units ≈ 1000 // milli-vCPU), with the memory snapped into what that tier accepts. let tier = TIERS .iter() .find(|tier| demand <= tier.units * 1000 / 1024) .unwrap_or(&TIERS[TIERS.len() - 1]); TaskSize { cpu: tier.cpu, memory: fit_memory(memory_wanted, tier).to_string(), } } /// The wanted memory as a figure this tier actually accepts: rounded *up* /// to its step, so nothing is quietly served less than it asked for, and /// held inside the tier's range. /// /// The ceiling is applied before the rounding rather than after. Rounding /// first is what a reader expects and it overflows on a large enough /// figure; capping first cannot, and costs nothing, because every tier's /// maximum is itself a multiple of its step — so a capped value is already /// on the step and the rounding leaves it alone. fn fit_memory(wanted: u32, tier: &Tier) -> u32 { let capped = wanted.min(tier.memory_max); let stepped = capped.div_ceil(tier.memory_step) * tier.memory_step; stepped.max(tier.memory_min) } #[cfg(test)] mod tests { use super::*; /// Today's bot match keeps today's shape: the override must not shrink /// what already works. #[test] fn a_bot_match_keeps_the_task_definition_shape() { let size = task_size(1, 1, 3); assert_eq!(size.cpu, "2048"); assert_eq!(size.memory, "8192"); } /// The case the first PvP match proved undersized: two client JVMs on /// two vCPUs stalled both players' boots for ten seconds. #[test] fn two_humans_get_four_vcpus() { let size = task_size(2, 0, 2); assert_eq!(size.cpu, "4096"); } /// Citadel Blitz full: one human, four bots, 29 units. #[test] fn a_big_scenario_bumps_the_tier() { let size = task_size(1, 4, 29); assert_eq!(size.cpu, "4096"); } /// Five humans is beyond anything the UI offers today; the point is the /// formula does not fall off a cliff or exceed Fargate's ceiling. #[test] fn many_humans_stay_within_fargate() { let size = task_size(5, 0, 29); assert_eq!(size.cpu, "8192"); assert!(size.memory.parse::().unwrap() >= 16384); } /// Eight human seats: 8500 milli-vCPU, past what the 8192 tier covers. /// Before the 16384 tier existed this fell off the end of the table and /// served eight client JVMs on eight vCPUs without saying so. #[test] fn eight_humans_take_the_sixteen_vcpu_tier() { let size = task_size(8, 0, 16); assert_eq!(size.cpu, "16384"); assert_eq!( size.memory, "32768", "eight client heaps want 24576 MiB, and the tier's floor is what \ it can actually be given" ); } /// The arithmetic bug the eight-seat case turned up: 24576 MiB is a /// legal 8192-tier value (that tier steps by 4096) and the old spelled- /// out table had no arm for it, so it fell through to 20480 — less than /// was asked for, which is the one direction rounding must never go. #[test] fn memory_rounds_up_into_the_tier_step_never_down() { let eight = &TIERS[2]; assert_eq!(eight.cpu, "8192"); assert_eq!(fit_memory(24576, eight), 24576, "an exact step is kept"); assert_eq!(fit_memory(20481, eight), 24576, "a part step rounds up"); assert_eq!(fit_memory(1, eight), eight.memory_min); assert_eq!(fit_memory(u32::MAX, eight), eight.memory_max); } /// Memory follows the client heaps and stays inside the chosen tier's /// legal range — and on its step — for every shape a lobby can now /// produce, eight seats included. #[test] fn memory_lands_in_the_tier_range() { for humans in 0..=8 { for bots in 0..=8 { let size = task_size(humans, bots, 29); let cpu: u32 = size.cpu.parse().unwrap(); let memory: u32 = size.memory.parse().unwrap(); let tier = TIERS.iter().find(|tier| tier.cpu == size.cpu).unwrap(); assert!( memory >= tier.memory_min && memory <= tier.memory_max, "humans={humans} bots={bots}: {cpu} cpu with {memory} MiB" ); assert_eq!( memory % tier.memory_step, 0, "humans={humans} bots={bots}: {memory} MiB is not on the \ {} MiB step {cpu} cpu takes", tier.memory_step ); } } } /// Every tier's own range has to be expressible on its own step, or /// `fit_memory`'s clamp could hand back a figure Fargate rejects. #[test] fn every_tier_range_sits_on_its_step() { for tier in TIERS { assert_eq!(tier.memory_min % tier.memory_step, 0, "{}", tier.cpu); assert_eq!(tier.memory_max % tier.memory_step, 0, "{}", tier.cpu); } } }