From: Dominik Rusovac <d.rusovac@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [RFC proxmox 3/3] resource-scheduling: evaluate static scheduler variants
Date: Fri, 6 Mar 2026 09:20:46 +0100 [thread overview]
Message-ID: <20260306082046.34311-4-d.rusovac@proxmox.com> (raw)
In-Reply-To: <20260306082046.34311-1-d.rusovac@proxmox.com>
This hacky diff was used to evaluate several variants of the static
scheduler. In particular, results of proptests were pretty printed and
compared in the command line.
Might come in handy for future experiments/evaluations.
Signed-off-by: Dominik Rusovac <d.rusovac@proxmox.com>
---
proxmox-resource-scheduling/src/pve_static.rs | 60 ++-
.../tests/pve_static.rs | 495 ++++++++++++++++++
2 files changed, 542 insertions(+), 13 deletions(-)
create mode 100644 proxmox-resource-scheduling/tests/pve_static.rs
diff --git a/proxmox-resource-scheduling/src/pve_static.rs b/proxmox-resource-scheduling/src/pve_static.rs
index 23e49656..f0c772c7 100644
--- a/proxmox-resource-scheduling/src/pve_static.rs
+++ b/proxmox-resource-scheduling/src/pve_static.rs
@@ -5,7 +5,7 @@ use crate::topsis;
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
-#[cfg_attr(feature = "lab", derive(Debug))]
+#[cfg_attr(feature = "lab", derive(Debug, PartialEq))]
/// Static usage information of a node.
pub struct StaticNodeUsage {
/// Hostname of the node.
@@ -249,6 +249,37 @@ pub mod evaluate {
}
}
+ pub fn imbalance(
+ target_index: usize,
+ nodes: &[StaticNodeUsage],
+ service: &StaticServiceUsage,
+ ) -> f64 {
+ let len = nodes.len();
+ let mut xs = vec![];
+
+ for (index, node) in nodes.iter().enumerate() {
+ let new_cpu = if index == target_index {
+ add_cpu_usage(node.cpu, node.maxcpu as f64, service.maxcpu)
+ } else {
+ node.cpu
+ } / (node.maxcpu as f64);
+
+ let new_mem = if index == target_index {
+ node.mem + service.maxmem
+ } else {
+ node.mem
+ } as f64
+ / node.maxmem as f64;
+
+ xs.push((new_cpu + new_mem) / 2.0);
+ }
+
+ let mean = xs.iter().sum::<f64>() / len as f64;
+ let sd = (xs.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / len as f64).sqrt();
+
+ sd / mean
+ }
+
#[cfg(test)]
mod base_variant_scores_like_present_rollout {
use crate::{
@@ -270,23 +301,26 @@ pub mod evaluate {
const MAX_CPU: usize = 32;
const MAX_MEM: usize = 406;
- fn node() -> impl Strategy<Value = StaticNodeUsage> {
- (".*", 0.0..(MAX_CPU as f64), 0..MAX_MEM).prop_map(|(name, cpu, mem)| StaticNodeUsage {
- name,
- cpu,
- maxcpu: MAX_CPU,
- mem,
- maxmem: MAX_MEM,
- })
- }
-
fn service() -> impl Strategy<Value = StaticServiceUsage> {
- (0.0..(MAX_CPU as f64), 0..MAX_MEM)
+ (1.0..(MAX_CPU as f64), 1..MAX_MEM)
.prop_map(|(maxcpu, maxmem)| StaticServiceUsage { maxcpu, maxmem })
}
fn nodes() -> impl Strategy<Value = Vec<StaticNodeUsage>> {
- prop::collection::vec(node(), MIN_NODES..MAX_NODES)
+ prop::collection::vec((0.0..(MAX_CPU as f64), 0..MAX_MEM), MIN_NODES..MAX_NODES)
+ .prop_map(move |resources| {
+ resources
+ .into_iter()
+ .enumerate()
+ .map(|(i, (cpu, mem))| StaticNodeUsage {
+ name: format!("node{i}"),
+ cpu,
+ maxcpu: MAX_CPU,
+ mem,
+ maxmem: MAX_MEM,
+ })
+ .collect()
+ })
}
fn test_case_err(err: impl ToString) -> TestCaseError {
diff --git a/proxmox-resource-scheduling/tests/pve_static.rs b/proxmox-resource-scheduling/tests/pve_static.rs
new file mode 100644
index 00000000..92f6d17b
--- /dev/null
+++ b/proxmox-resource-scheduling/tests/pve_static.rs
@@ -0,0 +1,495 @@
+#[cfg(feature = "lab")]
+mod skewed {
+ //! This test compares several variants on skewed node stats
+ use proxmox_resource_scheduling::{
+ pve_static::{
+ evaluate::{
+ imbalance, score_nodes_to_start_service_with_variant, StaticResourceScheduling,
+ },
+ StaticNodeUsage, StaticServiceUsage,
+ },
+ topsis::evaluate::Topsis,
+ };
+ use std::{fmt::Debug, sync::LazyLock};
+
+ use proptest::prelude::*;
+
+ fn print_table(
+ input: &[impl Evaluate],
+ nodes: &[StaticNodeUsage],
+ service: &StaticServiceUsage,
+ ) {
+ let delim = " ";
+
+ let (result_names, results) = (
+ input.iter().map(|x| x.name()).collect::<Vec<_>>(),
+ input
+ .iter()
+ .map(|x| x.run(nodes, service))
+ .collect::<Vec<_>>(),
+ );
+
+ let (service_cpu, service_mem) = (service.maxcpu, service.maxmem);
+
+ eprintln!();
+ eprint!(
+ "{:<15}{delim}{:>10}{delim}{:>10}{delim}{:>10}{delim}{:>10}{delim}{:>10}",
+ "name",
+ format!("cpu+{service_cpu:.2}"),
+ "maxcpu",
+ format!("mem+{service_mem}"),
+ "maxmem",
+ "imbalance"
+ );
+ for x in &result_names {
+ eprint!("{delim}{:>10}", x.chars().take(10).collect::<String>());
+ }
+ eprintln!();
+
+ eprint!(
+ "{:-<15}{delim}{:->10}{delim}{:->10}{delim}{:->10}{delim}{:->10}{delim}{:->10}",
+ "", "", "", "", "", ""
+ );
+ for _ in result_names {
+ eprint!("{delim}{:->10}", "");
+ }
+ eprintln!();
+
+ let sorted_results = results
+ .iter()
+ .map(|result| {
+ let mut xs = result.to_vec();
+ xs.sort_by(|(_, a), (_, b)| b.total_cmp(a));
+ xs
+ })
+ .collect::<Vec<_>>();
+
+ let k = sorted_results.len();
+
+ nodes.iter().enumerate().for_each(|(i, n)| {
+ let pivot = &n.name;
+ let scores = sorted_results
+ .iter()
+ .filter_map(|result| result.iter().find(|(n, _)| n == pivot).map(|x| x.1))
+ .collect::<Vec<_>>();
+ let ranks = sorted_results
+ .iter()
+ .filter_map(|result| result.iter().position(|(n, _)| n == pivot).map(|x| x + 1))
+ .collect::<Vec<_>>();
+
+ if let Some(node) = nodes.iter().find(|n| n.name == *pivot) {
+ eprint!(
+ "{:<15}{delim}{:>10.2}{delim}{:>10}{delim}{:>10}{delim}{:>10}{delim}{:>10.2}",
+ node.name,
+ node.cpu,
+ node.maxcpu,
+ node.mem,
+ node.maxmem,
+ imbalance(i, nodes, service)
+ );
+ for j in 0..k {
+ let s = match ranks[j] {
+ 1 => format!("\x1B[30m\x1B[1;{}m{:.3}:1\x1B[0m", (41 + j) % 47, scores[j]),
+ n => format!("\x1B[37m\x1B[0;{}m{:.3}:{n}\x1B[0m", 37, scores[j]),
+ };
+ eprint!("{delim}{s:>26}");
+ }
+ eprintln!();
+ }
+ });
+ }
+
+ static NODES: LazyLock<[StaticNodeUsage; 4]> = LazyLock::new(|| {
+ [
+ StaticNodeUsage {
+ name: "node1".to_owned(),
+ cpu: 2.0 + 32.0,
+ maxcpu: 32,
+ mem: 2 + 8,
+ maxmem: 406,
+ },
+ StaticNodeUsage {
+ name: "node2".to_owned(),
+ cpu: 2.0,
+ maxcpu: 32,
+ mem: 400,
+ maxmem: 406,
+ },
+ StaticNodeUsage {
+ name: "node4".to_owned(),
+ cpu: 2.0 + 32.0,
+ maxcpu: 32,
+ mem: 32 + 8,
+ maxmem: 406,
+ },
+ StaticNodeUsage {
+ name: "node5".to_owned(),
+ cpu: 2.0 + 32.0,
+ maxcpu: 32,
+ mem: 2 + 8,
+ maxmem: 406,
+ },
+ ]
+ });
+
+ static SERVICE: StaticServiceUsage = StaticServiceUsage {
+ maxcpu: 32.0,
+ maxmem: 21,
+ };
+
+ trait Evaluate {
+ fn run(
+ &self,
+ nodes: &[StaticNodeUsage],
+ service: &StaticServiceUsage,
+ ) -> Vec<(String, f64)>;
+ fn name(&self) -> String;
+ }
+
+ #[derive(Clone, Debug)]
+ enum Candidate {
+ Base,
+ MinMax,
+ MoreMaxCpu(usize),
+ }
+
+ impl Evaluate for Candidate {
+ fn run(
+ &self,
+ nodes: &[StaticNodeUsage],
+ service: &StaticServiceUsage,
+ ) -> Vec<(String, f64)> {
+ match self {
+ Self::Base => score_nodes_to_start_service_with_variant(
+ nodes,
+ service,
+ None::<&Topsis>,
+ None::<&StaticResourceScheduling>,
+ ),
+ Self::MinMax => score_nodes_to_start_service_with_variant(
+ nodes,
+ service,
+ Some(&Topsis::MinMaxRelativeDistance),
+ Some(&StaticResourceScheduling::Base),
+ ),
+ Self::MoreMaxCpu(n) => score_nodes_to_start_service_with_variant(
+ nodes,
+ service,
+ None::<&Topsis>,
+ Some(&StaticResourceScheduling::RmsNTimesMoreMaxcpu(*n)),
+ ),
+ }
+ }
+
+ fn name(&self) -> String {
+ match self {
+ Candidate::Base => "base".to_owned(),
+ Candidate::MinMax => "minmax".to_owned(),
+ Candidate::MoreMaxCpu(n) => format!("{n}xmaxcpu"),
+ }
+ }
+ }
+
+ #[test]
+ fn comparison_single() {
+ print_table(
+ &[
+ Candidate::Base,
+ Candidate::MinMax,
+ Candidate::MoreMaxCpu(2),
+ Candidate::MoreMaxCpu(8),
+ Candidate::MoreMaxCpu(10),
+ Candidate::MoreMaxCpu(35),
+ ],
+ NODES.as_slice(),
+ &SERVICE,
+ );
+ }
+
+ const REPEAT_N_TIMES: u32 = 300;
+
+ const MIN_NODES: usize = 2;
+ const MAX_NODES: usize = 40;
+
+ const MAX_CPU: usize = 32;
+ const MAX_MEM: usize = 406;
+
+ fn service() -> impl Strategy<Value = StaticServiceUsage> {
+ (1.0..(MAX_CPU as f64), 1..MAX_MEM)
+ .prop_map(|(maxcpu, maxmem)| StaticServiceUsage { maxcpu, maxmem })
+ }
+
+ fn nodes() -> impl Strategy<Value = Vec<StaticNodeUsage>> {
+ prop::collection::vec((0.0..(MAX_CPU as f64), 0..MAX_MEM), MIN_NODES..MAX_NODES).prop_map(
+ move |resources| {
+ resources
+ .into_iter()
+ .enumerate()
+ .map(|(i, (cpu, mem))| StaticNodeUsage {
+ name: format!("node{i}"),
+ cpu,
+ maxcpu: MAX_CPU,
+ mem,
+ maxmem: MAX_MEM,
+ })
+ .collect()
+ },
+ )
+ }
+
+ proptest! {
+ #![proptest_config(ProptestConfig {
+ failure_persistence: None,
+ cases: REPEAT_N_TIMES,
+ ..ProptestConfig::default()
+ })]
+
+ //#[test]
+ fn minmax_is_not_last(nodes in nodes(), service in service()) {
+ let mut scores = vec![];
+
+ for c in [
+ Candidate::Base,
+ Candidate::MinMax,
+ Candidate::MoreMaxCpu(2),
+ Candidate::MoreMaxCpu(8),
+ Candidate::MoreMaxCpu(10),
+ Candidate::MoreMaxCpu(35),
+ ] {
+ let mut score = 0.0;
+
+ let mut result = c.run(&nodes, &service);
+ result.sort_by(|(_, x), (_, y)| y.total_cmp(x));
+
+ if let Some(node) = result
+ .first()
+ .and_then(|(winner, _)| nodes.iter().find(|n| n.name == *winner))
+ {
+ if let Some(imbalance) = nodes
+ .iter()
+ .position(|n| n == node)
+ .map(|i| imbalance(i, &nodes, &service))
+ {
+ score += imbalance;
+
+ if (node.cpu + service.maxcpu) > node.maxcpu as f64 {
+ score += 0.5;
+ }
+ if (node.mem + service.maxmem) > node.maxmem {
+ score += 1.0;
+ }
+
+ scores.push((c.name(), score, node, imbalance));
+ };
+ }
+
+ }
+
+ scores.sort_by(|(_, x,_,_), (_, y,_,_)| y.total_cmp(x));
+
+ if let Some((a, b,_,_)) = scores.first() {
+ dbg!(&scores);
+ if scores
+ .iter()
+ .any(|(_, x,_,_)| x != b) {
+ assert!(*a != Candidate::MinMax.name())
+ }
+ }
+ }
+
+
+ #[test]
+ fn comparison_multiple(nodes in nodes(), service in service()) {
+ print_table(
+ &[
+ Candidate::Base,
+ Candidate::MinMax,
+ Candidate::MoreMaxCpu(2),
+ Candidate::MoreMaxCpu(8),
+ Candidate::MoreMaxCpu(10),
+ Candidate::MoreMaxCpu(35),
+ ],
+ &nodes,
+ &service
+ );
+ }
+ }
+}
+
+#[cfg(feature = "lab")]
+#[allow(dead_code)]
+mod minmax_neq_35xcpu {
+ //! [`crate::skewed_stats::comparison`] suggests that "minmax" and "35xcpu" make similar
+ //! decisions on nodes with skewed stats.
+ //!
+ //! This proptest shrinks the search space to find cases where "minmax" and "35xcpu" do not
+ //! agree on a winner.
+ use proptest::prelude::*;
+ use proxmox_resource_scheduling::{
+ pve_static::{
+ evaluate::{
+ imbalance, score_nodes_to_start_service_with_variant, StaticResourceScheduling,
+ },
+ StaticNodeUsage, StaticServiceUsage,
+ },
+ topsis::evaluate::Topsis,
+ };
+
+ fn print_table(
+ input: &[(&str, &[(String, f64)])],
+ nodes: &[StaticNodeUsage],
+ service: &StaticServiceUsage,
+ ) {
+ let delim = " ";
+
+ let (result_names, results) = {
+ let (mut lhs, mut rhs) = (vec![], vec![]);
+ input.iter().for_each(|(l, r)| {
+ lhs.push(l);
+ rhs.push(r)
+ });
+ (lhs, rhs)
+ };
+
+ let (service_cpu, service_mem) = (service.maxcpu, service.maxmem);
+
+ eprintln!();
+ eprint!(
+ "{:<15}{delim}{:>10}{delim}{:>10}{delim}{:>10}",
+ "name",
+ format!("cpu+{service_cpu:.2}"),
+ format!("mem+{service_mem}"),
+ "imbalance"
+ );
+ for x in &result_names {
+ eprint!("{delim}{:>10}", x.chars().take(10).collect::<String>());
+ }
+ eprintln!();
+
+ eprint!(
+ "{:-<15}{delim}{:->10}{delim}{:->10}{delim}{:->10}",
+ "", "", "", ""
+ );
+ for _ in result_names {
+ eprint!("{delim}{:->10}", "");
+ }
+ eprintln!();
+
+ let sorted_results = results
+ .iter()
+ .map(|result| {
+ let mut xs = result.to_vec();
+ xs.sort_by(|(_, a), (_, b)| b.total_cmp(a));
+ xs
+ })
+ .collect::<Vec<_>>();
+
+ let k = sorted_results.len();
+
+ nodes.iter().enumerate().for_each(|(i, n)| {
+ let pivot = &n.name;
+ let scores = sorted_results
+ .iter()
+ .filter_map(|result| result.iter().find(|(n, _)| n == pivot).map(|x| x.1))
+ .collect::<Vec<_>>();
+ let ranks = sorted_results
+ .iter()
+ .filter_map(|result| result.iter().position(|(n, _)| n == pivot).map(|x| x + 1))
+ .collect::<Vec<_>>();
+
+ if let Some(node) = nodes.iter().find(|n| n.name == *pivot) {
+ eprint!(
+ "{:<15}{delim}{:>10.2}{delim}{:>10}{delim}{:>10.2}",
+ node.name,
+ node.cpu,
+ node.mem,
+ imbalance(i, nodes, service)
+ );
+ for i in 0..k {
+ let s = format!(
+ "\x1B[{}m{:.3}:{}\x1B[0m",
+ (31 + i) % 37,
+ scores[i],
+ ranks[i]
+ );
+ eprint!("{delim}{s:>19}");
+ }
+ eprintln!();
+ }
+ });
+ }
+
+ const REPEAT_N_TIMES: u32 = 100;
+
+ const MIN_NODES: usize = 2;
+ const MAX_NODES: usize = 40;
+
+ const MAX_CPU: usize = 32;
+ const MAX_MEM: usize = 406;
+
+ fn service() -> impl Strategy<Value = StaticServiceUsage> {
+ (0.0..(MAX_CPU as f64), 0..MAX_MEM)
+ .prop_map(|(maxcpu, maxmem)| StaticServiceUsage { maxcpu, maxmem })
+ }
+
+ fn nodes() -> impl Strategy<Value = Vec<StaticNodeUsage>> {
+ prop::collection::vec((0.0..(MAX_CPU as f64), 0..MAX_MEM), MIN_NODES..MAX_NODES).prop_map(
+ move |resources| {
+ resources
+ .into_iter()
+ .enumerate()
+ .map(|(i, (cpu, mem))| StaticNodeUsage {
+ name: format!("node{i}"),
+ cpu,
+ maxcpu: MAX_CPU,
+ mem,
+ maxmem: MAX_MEM,
+ })
+ .collect()
+ },
+ )
+ }
+
+ proptest! {
+ #![proptest_config(ProptestConfig {
+ failure_persistence: None,
+ cases: REPEAT_N_TIMES,
+ ..ProptestConfig::default()
+ })]
+
+ //#[test]
+ fn shrink_different_winners(nodes in nodes(), service in service()) {
+ let mut lhs = score_nodes_to_start_service_with_variant(
+ &nodes,
+ &service,
+ Some(&Topsis::MinMaxRelativeDistance),
+ Some(&StaticResourceScheduling::Base),
+ );
+
+ let mut rhs = score_nodes_to_start_service_with_variant(
+ &nodes,
+ &service,
+ None::<&Topsis>,
+ Some(&StaticResourceScheduling::RmsNTimesMoreMaxcpu(35)),
+ );
+
+ print_table(&[("minmax", &lhs), ("35xcpu", &rhs)], &nodes, &service);
+
+ lhs.sort_by(|(_, x), (_, y)| y.total_cmp(x));
+ rhs.sort_by(|(_, x), (_, y)| y.total_cmp(x));
+
+ if let Some((winner_lhs, winner_rhs)) = lhs
+ .first()
+ .and_then(|node| nodes.iter().find(|n| n.name == node.0))
+ .zip(
+ rhs.first()
+ .and_then(|node| nodes.iter().find(|n| n.name == node.0)),
+ )
+ {
+ assert_eq!(winner_lhs, winner_rhs);
+ }
+
+ }
+
+ }
+}
--
2.47.3
prev parent reply other threads:[~2026-03-06 8:20 UTC|newest]
Thread overview: 4+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-03-06 8:20 [RFC proxmox 0/3] " Dominik Rusovac
2026-03-06 8:20 ` [RFC proxmox 1/3] resource-scheduling: add lab feature Dominik Rusovac
2026-03-06 8:20 ` [RFC proxmox 2/3] resource-scheduling: add {topsis,pve_static}-variants with basic proptests Dominik Rusovac
2026-03-06 8:20 ` Dominik Rusovac [this message]
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260306082046.34311-4-d.rusovac@proxmox.com \
--to=d.rusovac@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox