all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Michael Ryom <Michael@RyomHerold.dk>
To: pve-devel@lists.proxmox.com
Cc: Michael Ryom <Michael@RyomHerold.dk>
Subject: [PATCH ha-manager 7/7] manager: auto rebalance: only balance under actual node resource pressure
Date: Sun, 20 Sep 2026 18:22:14 +0200	[thread overview]
Message-ID: <20260920162220.574802-8-Michael@RyomHerold.dk> (raw)
In-Reply-To: <20260920162220.574802-1-Michael@RyomHerold.dk>

The load balancer acted on load asymmetry alone. But asymmetric node
loads are not, by themselves, a performance problem: on an almost idle
cluster, the imbalance metric (a coefficient of variation, std/mean)
becomes hypersensitive, and observed live, a barely-qualifying
migration (60.4% -> 54.0% expected imbalance) was issued at 2-7% node
load, with nothing to gain for any workload. The purpose of the load
balancer is better performance for the guests, not symmetric load
figures.

Gate the balancer on the pressure stall information (PSI) that the
nodes already broadcast in their RRD stats: only balance while some
node reports a cpu or memory some-pressure (avg10) of at least 10%,
i.e. work on that node actually loses a meaningful share of its time
waiting on resources and could run better with more headroom. Unlike a
load threshold, this criterion is independent of the cluster's size and
load level, and it directly expresses the question "would anything
perform better elsewhere?" - akin to the contention-driven (rather than
load-driven) approach of other schedulers like VMware DRS.

Deliberately only the node pressure is considered, although the guests'
own pressure stats are collected as well: a guest saturating its own
vCPUs or its own memory reports pressure too, but gets neither more
cores nor more memory by being migrated, while host-caused starvation
(contention with other guests or host processes) shows up in the node
pressure - and only that kind can be improved by a migration.

The kernel's avg10 pressure values are already exponentially averaged
over ten seconds, so no additional smoothing is applied. IO pressure is
deliberately not considered for now, since with shared storage it
usually cannot be improved by a migration. If no pressure information
is available at all (e.g. nodes still broadcasting an older stats
schema), balancing proceeds as before.

The minimum pressure is a constant for now; like the other balancer
constants, it could be exposed as a ha-auto-rebalance-* option later.

Signed-off-by: Michael Ryom <Michael@RyomHerold.dk>
---
 src/PVE/HA/Env/PVE2.pm                        | 25 +++++++++++++
 src/PVE/HA/Manager.pm                         | 16 +++++++++
 src/PVE/HA/Sim/Hardware.pm                    | 16 +++++++--
 src/PVE/HA/Usage.pm                           |  9 +++++
 src/PVE/HA/Usage/Dynamic.pm                   | 23 ++++++++++++
 .../test-crs-dynamic-auto-rebalance9/README   | 15 ++++++++
 .../test-crs-dynamic-auto-rebalance9/cmdlist  |  4 +++
 .../datacenter.cfg                            |  6 ++++
 .../dynamic_service_stats                     |  4 +++
 .../hardware_status                           |  4 +++
 .../log.expect                                | 36 +++++++++++++++++++
 .../manager_status                            |  1 +
 .../service_config                            |  4 +++
 .../static_service_stats                      |  4 +++
 14 files changed, 165 insertions(+), 2 deletions(-)
 create mode 100644 src/test/test-crs-dynamic-auto-rebalance9/README
 create mode 100644 src/test/test-crs-dynamic-auto-rebalance9/cmdlist
 create mode 100644 src/test/test-crs-dynamic-auto-rebalance9/datacenter.cfg
 create mode 100644 src/test/test-crs-dynamic-auto-rebalance9/dynamic_service_stats
 create mode 100644 src/test/test-crs-dynamic-auto-rebalance9/hardware_status
 create mode 100644 src/test/test-crs-dynamic-auto-rebalance9/log.expect
 create mode 100644 src/test/test-crs-dynamic-auto-rebalance9/manager_status
 create mode 100644 src/test/test-crs-dynamic-auto-rebalance9/service_config
 create mode 100644 src/test/test-crs-dynamic-auto-rebalance9/static_service_stats

diff --git a/src/PVE/HA/Env/PVE2.pm b/src/PVE/HA/Env/PVE2.pm
index 8c2b03d..7d7fceb 100644
--- a/src/PVE/HA/Env/PVE2.pm
+++ b/src/PVE/HA/Env/PVE2.pm
@@ -47,6 +47,8 @@ use constant {
     RRD_VM_INDEX_MAXMEM => 7,
     RRD_VM_INDEX_MEM => 8,
     RRD_VM_INDEX_MEMHOST => 15,
+    RRD_VM_INDEX_PRESSURE_CPU_SOME => 16,
+    RRD_VM_INDEX_PRESSURE_MEM_SOME => 20,
 };
 
 # rrd entry indices for PVE nodes
@@ -56,8 +58,21 @@ use constant {
     RRD_NODE_INDEX_CPU => 5,
     RRD_NODE_INDEX_MAXMEM => 7,
     RRD_NODE_INDEX_MEM => 8,
+    RRD_NODE_INDEX_PRESSURE_CPU_SOME => 17,
+    RRD_NODE_INDEX_PRESSURE_MEM_SOME => 20,
 };
 
+# returns the numeric value of the given RRD column, or undef if the column
+# is not present (e.g. nodes broadcasting an older schema)
+my sub rrd_column_or_undef {
+    my ($rrdentry, $index) = @_;
+
+    my $value = $rrdentry->[$index];
+    return undef if !defined($value) || $value eq '' || $value eq 'U';
+
+    return $value + 0.0;
+}
+
 my $HOSTNAME_RE = qr/(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-]{,61}?[a-zA-Z0-9])?)/;
 
 sub new {
@@ -615,6 +630,11 @@ sub get_dynamic_service_stats {
             cpu => (($rrdentry->[RRD_VM_INDEX_CPU] || 0.0) + 0.0) * $maxcpu,
             maxmem => int($rrdentry->[RRD_VM_INDEX_MAXMEM] || 0),
             mem => $mem,
+            # PSI avg10 in percent; undef if the guest broadcasts an older
+            # schema without pressure information
+            pressurecpusome => rrd_column_or_undef($rrdentry, RRD_VM_INDEX_PRESSURE_CPU_SOME),
+            pressurememorysome =>
+                rrd_column_or_undef($rrdentry, RRD_VM_INDEX_PRESSURE_MEM_SOME),
         };
     }
 
@@ -660,6 +680,11 @@ sub get_dynamic_node_stats {
             cpu => (($rrdentry->[RRD_NODE_INDEX_CPU] || 0.0) + 0.0) * $maxcpu,
             maxmem => int($rrdentry->[RRD_NODE_INDEX_MAXMEM] || 0),
             mem => int($rrdentry->[RRD_NODE_INDEX_MEM] || 0),
+            # PSI avg10 in percent; undef if the node broadcasts an older
+            # schema without pressure information
+            pressurecpusome => rrd_column_or_undef($rrdentry, RRD_NODE_INDEX_PRESSURE_CPU_SOME),
+            pressurememorysome =>
+                rrd_column_or_undef($rrdentry, RRD_NODE_INDEX_PRESSURE_MEM_SOME),
         };
     }
 
diff --git a/src/PVE/HA/Manager.pm b/src/PVE/HA/Manager.pm
index 6e1827f..c34da89 100644
--- a/src/PVE/HA/Manager.pm
+++ b/src/PVE/HA/Manager.pm
@@ -155,6 +155,15 @@ my $auto_rebalance_failure_backoff_max = 3600;
 # minimum time before a resource is considered for rebalancing again after it
 # was successfully moved by the load balancer
 my $auto_rebalance_resource_cooldown = 600;
+# minimum pressure stall value (PSI avg10, in percent) among the nodes for
+# the load balancer to act at all: load asymmetry by itself is not a
+# performance problem, and the node pressure stall information directly
+# measures which share of its time work on the node spends waiting on
+# resources - i.e. whether anything could actually run better with more
+# headroom - independent of the cluster's size or load level; if no pressure
+# information is available at all (older stats schema), balancing proceeds
+# as before
+my $auto_rebalance_min_pressure = 10.0;
 # minimum absolute improvement of the node imbalance for a rebalance motion;
 # the relative margin alone provides almost no hysteresis when a single
 # resource dominates the cluster load: with two nodes, a resource load v and
@@ -368,6 +377,13 @@ sub load_balance {
         return;
     }
 
+    # nothing to gain from balancing while no workload is short on resources
+    my $max_pressure = $online_node_usage->max_pressure();
+    if (defined($max_pressure) && $max_pressure < $auto_rebalance_min_pressure) {
+        $self->{sustained_imbalance_round} = 0;
+        return;
+    }
+
     my $imbalance = $online_node_usage->calculate_node_imbalance();
 
     # do not load balance unless imbalance threshold has been exceeded
diff --git a/src/PVE/HA/Sim/Hardware.pm b/src/PVE/HA/Sim/Hardware.pm
index aa1a886..8766a6f 100644
--- a/src/PVE/HA/Sim/Hardware.pm
+++ b/src/PVE/HA/Sim/Hardware.pm
@@ -791,7 +791,8 @@ sub get_cfs_state {
 #   crm enable-node-maintenance <node>
 #   crm disable-node-maintenance <node>
 #   pve-manager-version <node> set <version>   # note: this is NOT the *ha*-manager version
-#   node <node> set-dynamic-stats [cpu <cores>] [mem <MiB>]  # load outside HA services
+#   node <node> set-dynamic-stats [cpu <cores>] [mem <MiB>] [pressurecpusome <pct>] [pressurememorysome <pct>]
+#     (load and pressure outside of the HA-managed services)
 #   reboot <node>
 #   shutdown <node>
 #   restart-lrm <node>
@@ -888,7 +889,12 @@ sub sim_hardware_cmd {
             die "sim_hardware_cmd: missing target stat for '$action' command"
                 if !@params;
 
-            my $conversions = { cpu => sub { 0.0 + $_[0] }, mem => sub { $_[0] * 1024**2 } };
+            my $conversions = {
+                cpu => sub { 0.0 + $_[0] },
+                mem => sub { $_[0] * 1024**2 },
+                pressurecpusome => sub { 0.0 + $_[0] },
+                pressurememorysome => sub { 0.0 + $_[0] },
+            };
 
             for my ($target, $val) (@params) {
                 die "sim_hardware_cmd: missing value for '$action $target' command"
@@ -1312,6 +1318,12 @@ sub get_dynamic_node_stats {
         $stats->{$node}->{cpu} = $cstatus->{$node}->{cpu} // 0.0;
         $stats->{$node}->{maxmem} = $stats->{$node}->{maxmem} // $default_node_maxmem;
         $stats->{$node}->{mem} = $cstatus->{$node}->{mem} // 0;
+        # PSI avg10 in percent; keep undef (pressure information not
+        # available) distinct from an explicit zero (no pressure)
+        for my $pressure (qw(pressurecpusome pressurememorysome)) {
+            $stats->{$node}->{$pressure} = $cstatus->{$node}->{$pressure}
+                if defined($cstatus->{$node}->{$pressure});
+        }
     }
 
     my $service_conf = $self->read_service_config();
diff --git a/src/PVE/HA/Usage.pm b/src/PVE/HA/Usage.pm
index 659ab30..e0890fb 100644
--- a/src/PVE/HA/Usage.pm
+++ b/src/PVE/HA/Usage.pm
@@ -66,6 +66,15 @@ sub calculate_node_imbalance {
     die "implement in subclass";
 }
 
+# Returns the highest pressure stall value (PSI avg10, in percent) among the
+# nodes, or undef if the usage implementation has no notion of pressure
+# (e.g. basic or static) or no node reported pressure information.
+sub max_pressure {
+    my ($self) = @_;
+
+    return undef;
+}
+
 sub score_best_balancing_migrations {
     my ($self, $migration_candidates, $limit) = @_;
 
diff --git a/src/PVE/HA/Usage/Dynamic.pm b/src/PVE/HA/Usage/Dynamic.pm
index fc88ddf..b2ed79a 100644
--- a/src/PVE/HA/Usage/Dynamic.pm
+++ b/src/PVE/HA/Usage/Dynamic.pm
@@ -184,6 +184,29 @@ sub calculate_node_imbalance {
     return $node_imbalance // 0.0;
 }
 
+# NOTE only the *node* pressure is considered: a guest saturating its own
+# vCPUs or its own memory reports pressure too, but gets neither more cores
+# nor more memory by being migrated, while starvation caused by the host
+# (contention with other guests or host processes) shows up in the node's
+# pressure - and only that kind can be improved by a migration
+sub max_pressure {
+    my ($self) = @_;
+
+    my $max_pressure;
+
+    for my $node ($self->list_nodes()) {
+        my $stats = $self->{'node-stats'}->{$node} or next;
+
+        for my $pressure ($stats->@{qw(pressurecpusome pressurememorysome)}) {
+            next if !defined($pressure);
+            $max_pressure = $pressure
+                if !defined($max_pressure) || $pressure > $max_pressure;
+        }
+    }
+
+    return $max_pressure;
+}
+
 sub score_best_balancing_migrations {
     my ($self, $migration_candidates, $limit) = @_;
 
diff --git a/src/test/test-crs-dynamic-auto-rebalance9/README b/src/test/test-crs-dynamic-auto-rebalance9/README
new file mode 100644
index 0000000..d19bd97
--- /dev/null
+++ b/src/test/test-crs-dynamic-auto-rebalance9/README
@@ -0,0 +1,15 @@
+Test that the auto rebalance system does not act while no node or HA-managed
+service reports meaningful resource pressure, even though the relative
+imbalance is high and a strongly qualifying rebalance motion exists.
+
+The cluster has two nodes with all load on node1: a HA resource excluded
+from auto rebalancing (vm:101) and a movable HA resource vm:100. The
+imbalance is 100% and migrating vm:100 to the empty node2 would improve it
+far beyond the margin and the minimum absolute improvement. But both nodes
+report zero pressure stall values, i.e. no workload spends any time waiting
+on resources, so there is no performance problem a migration could solve and
+no motion may be issued - regardless of node1's 30% load.
+
+Then node1 reports a CPU pressure of 25%, i.e. some workload now loses a
+quarter of its time waiting for CPU, and the pending migration of vm:100 to
+node2 must be carried out.
diff --git a/src/test/test-crs-dynamic-auto-rebalance9/cmdlist b/src/test/test-crs-dynamic-auto-rebalance9/cmdlist
new file mode 100644
index 0000000..3cb6c2a
--- /dev/null
+++ b/src/test/test-crs-dynamic-auto-rebalance9/cmdlist
@@ -0,0 +1,4 @@
+[
+    [ "power node1 on", "power node2 on" ],
+    [ "node node1 set-dynamic-stats pressurecpusome 25" ]
+]
diff --git a/src/test/test-crs-dynamic-auto-rebalance9/datacenter.cfg b/src/test/test-crs-dynamic-auto-rebalance9/datacenter.cfg
new file mode 100644
index 0000000..01c8114
--- /dev/null
+++ b/src/test/test-crs-dynamic-auto-rebalance9/datacenter.cfg
@@ -0,0 +1,6 @@
+{
+    "crs": {
+        "ha": "dynamic",
+        "ha-auto-rebalance": 1
+    }
+}
diff --git a/src/test/test-crs-dynamic-auto-rebalance9/dynamic_service_stats b/src/test/test-crs-dynamic-auto-rebalance9/dynamic_service_stats
new file mode 100644
index 0000000..7705333
--- /dev/null
+++ b/src/test/test-crs-dynamic-auto-rebalance9/dynamic_service_stats
@@ -0,0 +1,4 @@
+{
+    "vm:100": { "cpu": 9.6, "mem": 0 },
+    "vm:101": { "cpu": 4.8, "mem": 0 }
+}
diff --git a/src/test/test-crs-dynamic-auto-rebalance9/hardware_status b/src/test/test-crs-dynamic-auto-rebalance9/hardware_status
new file mode 100644
index 0000000..eeef22a
--- /dev/null
+++ b/src/test/test-crs-dynamic-auto-rebalance9/hardware_status
@@ -0,0 +1,4 @@
+{
+  "node1": { "power": "off", "network": "off", "maxcpu": 24, "maxmem": 34359738368, "pressurecpusome": 0.0, "pressurememorysome": 0.0 },
+  "node2": { "power": "off", "network": "off", "maxcpu": 24, "maxmem": 34359738368, "pressurecpusome": 0.0, "pressurememorysome": 0.0 }
+}
diff --git a/src/test/test-crs-dynamic-auto-rebalance9/log.expect b/src/test/test-crs-dynamic-auto-rebalance9/log.expect
new file mode 100644
index 0000000..aed4165
--- /dev/null
+++ b/src/test/test-crs-dynamic-auto-rebalance9/log.expect
@@ -0,0 +1,36 @@
+info      0     hardware: starting simulation
+info     20      cmdlist: execute power node1 on
+info     20    node1/crm: status change startup => wait_for_quorum
+info     20    node1/lrm: status change startup => wait_for_agent_lock
+info     20      cmdlist: execute power node2 on
+info     20    node2/crm: status change startup => wait_for_quorum
+info     20    node2/lrm: status change startup => wait_for_agent_lock
+info     20    node1/crm: got lock 'ha_manager_lock'
+info     20    node1/crm: status change wait_for_quorum => master
+info     20    node1/crm: using scheduler mode 'dynamic'
+info     20    node1/crm: node 'node1': state changed from 'unknown' => 'online'
+info     20    node1/crm: node 'node2': state changed from 'unknown' => 'online'
+info     20    node1/crm: adding new service 'vm:100' on node 'node1'
+info     20    node1/crm: adding new service 'vm:101' on node 'node1'
+info     20    node1/crm: service 'vm:100': state changed from 'request_start' to 'started'  (node = node1)
+info     20    node1/crm: service 'vm:101': state changed from 'request_start' to 'started'  (node = node1)
+info     21    node1/lrm: got lock 'ha_agent_node1_lock'
+info     21    node1/lrm: status change wait_for_agent_lock => active
+info     21    node1/lrm: starting service vm:100
+info     21    node1/lrm: service status vm:100 started
+info     21    node1/lrm: starting service vm:101
+info     21    node1/lrm: service status vm:101 started
+info     22    node2/crm: status change wait_for_quorum => slave
+info    120      cmdlist: execute node node1 set-dynamic-stats pressurecpusome 25
+info    160    node1/crm: auto rebalance - migrate vm:100 to node2 (expected change for imbalance from 100.0% to 33.3%)
+info    160    node1/crm: got crm command: migrate vm:100 node2
+info    160    node1/crm: migrate service 'vm:100' to node 'node2'
+info    160    node1/crm: service 'vm:100': state changed from 'started' to 'migrate'  (node = node1, target = node2)
+info    161    node1/lrm: service vm:100 - start migrate to node 'node2'
+info    161    node1/lrm: service vm:100 - end migrate to node 'node2'
+info    163    node2/lrm: got lock 'ha_agent_node2_lock'
+info    163    node2/lrm: status change wait_for_agent_lock => active
+info    180    node1/crm: service 'vm:100': state changed from 'migrate' to 'started'  (node = node2)
+info    183    node2/lrm: starting service vm:100
+info    183    node2/lrm: service status vm:100 started
+info    720     hardware: exit simulation - done
diff --git a/src/test/test-crs-dynamic-auto-rebalance9/manager_status b/src/test/test-crs-dynamic-auto-rebalance9/manager_status
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/src/test/test-crs-dynamic-auto-rebalance9/manager_status
@@ -0,0 +1 @@
+{}
diff --git a/src/test/test-crs-dynamic-auto-rebalance9/service_config b/src/test/test-crs-dynamic-auto-rebalance9/service_config
new file mode 100644
index 0000000..3f12542
--- /dev/null
+++ b/src/test/test-crs-dynamic-auto-rebalance9/service_config
@@ -0,0 +1,4 @@
+{
+    "vm:100": { "node": "node1", "state": "started" },
+    "vm:101": { "node": "node1", "state": "started", "auto-rebalance": 0 }
+}
diff --git a/src/test/test-crs-dynamic-auto-rebalance9/static_service_stats b/src/test/test-crs-dynamic-auto-rebalance9/static_service_stats
new file mode 100644
index 0000000..0dfad75
--- /dev/null
+++ b/src/test/test-crs-dynamic-auto-rebalance9/static_service_stats
@@ -0,0 +1,4 @@
+{
+    "vm:100": { "maxcpu": 12.0, "maxmem": 8589934592 },
+    "vm:101": { "maxcpu": 6.0, "maxmem": 8589934592 }
+}
-- 
2.47.3




      parent reply	other threads:[~2026-09-20 16:23 UTC|newest]

Thread overview: 9+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-09-20 16:22 [PATCH ha-manager 0/7] auto rebalance: fix failure retry loop, oscillation and idle-cluster churn Michael Ryom
2026-09-20 16:22 ` [PATCH ha-manager 1/7] fix #8059: manager: auto rebalance: back off failed motions and add per-resource cooldown Michael Ryom
2026-09-20 16:22 ` [PATCH ha-manager 2/7] env: dynamic service stats: use host-side memory footprint of guests Michael Ryom
2026-09-21  9:18   ` Dominik Rusovac
2026-09-20 16:22 ` [PATCH ha-manager 3/7] manager: auto rebalance: require a minimum absolute imbalance improvement Michael Ryom
2026-09-20 16:22 ` [PATCH ha-manager 4/7] sim: hardware: report actual running state in cluster service stats Michael Ryom
2026-09-20 16:22 ` [PATCH ha-manager 5/7] sim: hardware: allow setting a base load for nodes Michael Ryom
2026-09-20 16:22 ` [PATCH ha-manager 6/7] usage: dynamic: smooth the unaccounted node load Michael Ryom
2026-09-20 16:22 ` Michael Ryom [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=20260920162220.574802-8-Michael@RyomHerold.dk \
    --to=michael@ryomherold.dk \
    --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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal