This is an automated email from the ASF dual-hosted git repository.

RosiKyu pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/main by this push:
     new 6caa33e4ddb Add 'balancedweighted' VM allocation algorithm (#14109)
6caa33e4ddb is described below

commit 6caa33e4ddbcb5884c8986ee90df5abfe6b11847
Author: Brad House - Nexthop <[email protected]>
AuthorDate: Thu Nov 12 10:45:09 2026 -0500

    Add 'balancedweighted' VM allocation algorithm (#14109)
    
    * Add a DAO method to count VMs per host
    
    Counts VMs occupying each host in a zone, pod or cluster, optionally only
    those that changed state recently. One query for the whole scope rather
    than one per host.
    
    Signed-off-by: Brad House <[email protected]>
    
    * Track a moving average of real host utilisation
    
    StatsCollector already polls CPU and memory utilisation for every host,
    but it keeps only the newest sample and nothing uses it for placement.
    
    - fold those samples into an exponentially weighted moving average
    - weight by elapsed time, so a missed poll decays correctly instead of
      over-weighting the previous value
    - report nothing usable until a host has been sampled, so callers can
      fall back to allocation figures
    
    Two settings: host.load.sample.interval and host.load.half.life.
    
    Signed-off-by: Brad House <[email protected]>
    
    * Add 'balancedweighted' VM allocation algorithm
    
    Opt-in via vm.allocation.algorithm. Existing algorithms and the default
    are untouched.
    
    Allocated capacity alone is a poor ranking under heavy overprovisioning:
    it is measured against a total already multiplied by the overprovisioning
    factor, so a host under real strain still reports a low percentage and
    keeps being chosen. Anything allocation cannot see - VMs the scheduler
    has lost track of, guests using more than they asked for - is invisible.
    
    Ranks on a blend, lower is better:
    
      | term                | source                                    |
      |---------------------|-------------------------------------------|
      | CPU allocated       | op_host_capacity, over the inflated total |
      | CPU utilisation     | moving average of real usage              |
      | memory allocated    | op_host_capacity, over the inflated total |
      | memory utilisation  | moving average of real usage              |
      | VM count            | VMs on the host                           |
      | recent starts       | VMs started within the last few minutes   |
    
    A dominant resource term is added on top so a host nearly out of any one
    resource does not rank well on a good average. Hosts measurably too busy
    are held back, unless that would leave nowhere to deploy.
    
    Selection is random among the best few rather than strictly ordered:
    capacity is only charged once a VM starts, so concurrent deployments all
    read the same figures and strict ordering makes them agree on one host.
    
    All weights and thresholds are settings, most cluster scoped.
    
    Signed-off-by: Brad House <[email protected]>
    
    * Fix three defects found in review of the weighted scoring
    
    Allocated fraction was measured against the wrong total. op_host_capacity
    stores totals raw and overprovisioning is applied when they are read, so
    dividing by the stored total made the fraction reach 1 at the host's
    physical size. On a cluster overcommitted 10 times every host clamped to
    1, killing both the allocation term and the dominant resource term - on
    exactly the clusters this algorithm is for.
    
    - apply the cluster ratio to the denominator
    - drop hosts missing a CPU or memory capacity row instead of scoring the
      missing resource as untouched, which made them rank first
    
    Utilisation thresholds could be bypassed. Held-back hosts were appended
    before the random spread was applied, so the spread could shuffle a busy
    host into the lead. With 1 healthy host and a spread of 3, two thirds of
    deployments picked an over-threshold host.
    
    - spread over healthy hosts only, before anything else is appended
    
    A host with no load samples was treated as idle. It was exempt from the
    thresholds and its dominant resource term fell back to allocation, so a
    host with broken stats outranked every measured host and collected the
    deployments.
    
    - rank hosts we cannot measure behind every host we can
    - when nothing can be measured, ranking falls back to allocation as before
    
    Signed-off-by: Brad House <[email protected]>
    
    * Address the rest of the weighted scoring review
    
    Utilisation average
      - a host whose agent stops reporting kept vouching for itself forever:
        StatsCollector hands back the previous entry when a poll fails, and
        that unchanged reading was folded again every minute. Detect the
        repeat, and expire an average that stops being updated
      - sample on a scheduled executor catching Throwable, not a Timer, which
        dies permanently and silently on one escaping error
      - only collect when an algorithm that reads the figures is selected
      - read the half life once per sample rather than inside the map update
      - document what getCpuUtilization means per hypervisor: it is what this
        assumes on KVM, a reservation figure on VMware, and scaled by core
        count on XenServer
    
    Scoring
      - a negative weight would rank the most loaded host first; floor at
        zero and say so
      - zeroing all six terms no longer discards the dominant resource term
      - read weights once per ranking instead of once per host
    
    Queries
      - one query per ranking instead of two, returning both counts
      - count Stopping VMs, which still hold their host
      - correct the doc: every host in scope is returned, including empty ones
    
    Tests
      - cover rank() end to end, which is where the defects were: the
        capacity denominator, the thresholds, the spread and the ordering of
        measured against unmeasured hosts
      - the distribution simulation drew different random streams per arm, so
        the arms saw different workloads. Fix the workload up front and add
        an allocation-only-plus-spread control, which shows the scoring and
        not the spread is what evens out real load
    
    Signed-off-by: Brad House <[email protected]>
    
    * Address review feedback on the weighted allocation algorithm
    
    vm_instance.update_time is a TIMESTAMP column, so there is no date that
    reliably means "never" - anything past 2038 is out of range. Counting
    with no cut-off used Long.MAX_VALUE, which is roughly year 292 million.
    The query now leaves the timestamp out entirely in that case rather than
    binding an impossible one.
    
    Also catch Exception rather than Throwable in the new query, and put the
    config key array one entry per line.
    
    Signed-off-by: Brad House <[email protected]>
    
    * Hold back hosts with a backlog of VMs still starting
    
    The symptom this addresses is a burst of deployments all landing on one
    host and then sitting in Starting for minutes instead of seconds, while
    that host works through a queue of starts its storage and agent command
    path cannot absorb.
    
    Nothing in the algorithm saw that. Capacity is not charged until a VM
    reaches Starting, which is after the allocator has returned, and the
    utilisation average is a sample interval and a half life behind, so a
    burst is over before either notices. The recent-start term is the
    closest thing to a guard and it cannot do the job: it counts VMs whose
    state changed within a window, so a VM stuck in Starting for ten minutes
    stops counting after five - the signal decays fastest on the hosts that
    are worst off - and its contribution is capped at 0.111 of the score, so
    a host with 200 VMs stuck in Starting ranks the same as one with 50.
    
    Count the VMs actually in Starting instead, and use it as a gate rather
    than a weight. It does not expire while the condition persists, it is
    read fresh on every ranking rather than sampled, and a gate cannot be
    outvoted by the other terms. It costs nothing: the per-host VM count
    query already filters on vm.state, so it is one more aggregate in a
    query that was already being made.
    
    Held-back hosts are still never dropped, only moved down the list, so
    this cannot turn a deployment that would have succeeded into an
    InsufficientServerCapacity failure. Within the held-back group the order
    is by starting count, so if every host is backed up the least backed up
    one is tried first.
    
    Also fix a hole in the selection spread. It was applied to the healthy
    list only, so whenever no host was eligible that list was empty, the
    shuffle was a no-op, and the held-back hosts were handed out in strict
    score order - concurrent deployments agreeing on one host in exactly the
    conditions that caused the pile-up. The spread now applies to whichever
    tier leads, which also removes the special-cased branch it was hiding
    in. The "short of capacity" warning stays narrow: hosts merely being
    unmeasured, as they all are on a freshly started management server, is
    not a capacity problem and must not warn on every deployment.
    
    Signed-off-by: Brad House <[email protected]>
    
    * Rank each candidate host once, however many tags it carries
    
    listAllUpAndEnabledNonHAHosts joins host_tags to filter out hosts whose
    tags are rules, and the join carries no DISTINCT, so a host comes back
    once per non-rule tag it holds. That path is taken whenever a VM has no
    host tag to filter the candidate list by, which in practice means system
    VMs: a virtual router deploying onto a twelve host cluster where three
    hosts carry a second tag is handed fifteen candidates.
    
    An allocator that walks the list and takes the first host that fits is
    indifferent to a repeat, which is why this has gone unnoticed. The
    weighted ranking is not. Scores are keyed by host, so a repeat changes no
    score, but the selection spread shuffles the leading few entries and the
    caller takes whichever ends up first - so a host holding two of the three
    slots draws two thirds of the deployments rather than one third.
    
    Collapse the repeats in the DAO, where the join creates them and every
    caller benefits, and again in the scorer, so ranking cannot be skewed by
    whatever a future caller hands it.
    
    Signed-off-by: Brad House <[email protected]>
    
    * Let WeightedHostScorer implement HostScoringWeights
    
    The four shared weights are then inherited like the scorer's own keys
    instead of being qualified with the interface name. The scorer's own
    description suffix is renamed so it no longer hides the interface's.
    
    Signed-off-by: Brad House <[email protected]>
    
    ---------
    
    Signed-off-by: Brad House <[email protected]>
---
 PendingReleaseNotes                                |  28 ++
 .../com/cloud/deploy/DeploymentClusterPlanner.java |   7 +-
 .../java/com/cloud/deploy/DeploymentPlanner.java   |   2 +-
 .../java/com/cloud/host/HostScoringWeights.java    |  57 +++
 .../main/java/com/cloud/host/dao/HostDaoImpl.java  |  10 +-
 .../main/java/com/cloud/vm/dao/VMInstanceDao.java  |  13 +
 .../java/com/cloud/vm/dao/VMInstanceDaoImpl.java   |  56 +++
 .../java/com/cloud/host/dao/HostDaoImplTest.java   |  27 ++
 .../manager/allocator/impl/FirstFitAllocator.java  |   5 +
 .../agent/manager/allocator/impl/HostLoad.java     |  60 +++
 .../manager/allocator/impl/HostLoadTracker.java    | 260 +++++++++++
 .../manager/allocator/impl/WeightedHostScorer.java | 513 +++++++++++++++++++++
 .../spring-server-allocator-context.xml            |   6 +
 .../allocator/impl/HostLoadTrackerTest.java        | 182 ++++++++
 .../allocator/impl/WeightedHostScorerRankTest.java | 339 ++++++++++++++
 .../allocator/impl/WeightedHostScorerTest.java     | 175 +++++++
 .../impl/WeightedPlacementDistributionTest.java    | 319 +++++++++++++
 17 files changed, 2055 insertions(+), 4 deletions(-)

diff --git a/PendingReleaseNotes b/PendingReleaseNotes
index c9afb25f7ed..823a13bd6ae 100644
--- a/PendingReleaseNotes
+++ b/PendingReleaseNotes
@@ -91,3 +91,31 @@ example.ver.1 > example.ver.2:
     no device-mapper multipath configuration is required. A new
     Storage.StoragePoolType.NVMeTCP + MultipathNVMeOFAdapterBase /
     NVMeTCPAdapter on the KVM side back the new pool type.
+
+  * New VM allocation algorithm 'balancedweighted' for 
vm.allocation.algorithm. Existing
+    algorithms and the default are unchanged; the new one is opt-in.
+
+    Existing algorithms rank hosts on allocated capacity alone. Under a large 
overprovisioning
+    factor that reads badly: allocation is measured against a total that has 
already been
+    multiplied by the factor, so a host under real strain can still report a 
low percentage and
+    keep attracting new VMs. Concurrent deployments make it worse, since they 
all read the same
+    figures before any of them is accounted for.
+
+    'balancedweighted' ranks hosts on a blend of allocated CPU and memory, 
measured CPU and
+    memory utilisation, VM count, and how many VMs started on the host 
recently. It holds back
+    hosts that are measurably too busy, and chooses at random from among the 
best scoring hosts
+    so that simultaneous deployments do not all pick the same one.
+
+    It also holds back hosts that already have 
host.weighted.starting.vms.threshold VMs sitting
+    in the Starting state, so that a burst of deployments does not queue up 
behind itself on one
+    host. This is the only signal that reacts within a single deployment: 
capacity is not charged
+    until a VM reaches Starting and a utilisation average is a sample interval 
behind, so neither
+    notices a burst until it is over. Set the threshold to 0 to disable it.
+
+    Held-back hosts are never excluded, only moved to the back of the list, so 
no threshold can
+    turn a deployment that would have succeeded into an 
InsufficientServerCapacity failure.
+
+    Tuned with the host.weighted.* settings, most of which are cluster scoped. 
Measured
+    utilisation comes from a moving average configured with 
host.load.sample.interval and
+    host.load.half.life. Until a management server has collected samples, 
ranking falls back to
+    allocation figures alone.
diff --git a/api/src/main/java/com/cloud/deploy/DeploymentClusterPlanner.java 
b/api/src/main/java/com/cloud/deploy/DeploymentClusterPlanner.java
index 9471c3d5c84..5bf7dffbbf9 100644
--- a/api/src/main/java/com/cloud/deploy/DeploymentClusterPlanner.java
+++ b/api/src/main/java/com/cloud/deploy/DeploymentClusterPlanner.java
@@ -62,11 +62,14 @@ public interface DeploymentClusterPlanner extends 
DeploymentPlanner {
             "vm.allocation.algorithm",
             "Advanced",
             "random",
-            "Order in which hosts within a cluster will be considered for VM 
allocation. The value can be 'random', 'firstfit', 'userdispersing', or 
'firstfitleastconsumed'.",
+            "Order in which hosts within a cluster will be considered for VM 
allocation. The value can be 'random', "
+                    + "'firstfit', 'userdispersing', 'firstfitleastconsumed', 
or 'balancedweighted'. 'balancedweighted' "
+                    + "ranks hosts on a blend of allocated capacity, measured 
utilisation, VM count and how many VMs "
+                    + "started recently, and is tuned with the host.weighted.* 
settings.",
             true,
             ConfigKey.Scope.Global, null, null, null, null, null,
             ConfigKey.Kind.Select,
-            "random,firstfit,userdispersing,firstfitleastconsumed");
+            
"random,firstfit,userdispersing,firstfitleastconsumed,balancedweighted");
 
     /**
      * This is called to determine list of possible clusters where a virtual
diff --git a/api/src/main/java/com/cloud/deploy/DeploymentPlanner.java 
b/api/src/main/java/com/cloud/deploy/DeploymentPlanner.java
index 22d796d4a77..2d2964916ee 100644
--- a/api/src/main/java/com/cloud/deploy/DeploymentPlanner.java
+++ b/api/src/main/java/com/cloud/deploy/DeploymentPlanner.java
@@ -70,7 +70,7 @@ public interface DeploymentPlanner extends Adapter {
     boolean canHandle(VirtualMachineProfile vm, DeploymentPlan plan, 
ExcludeList avoid);
 
     public enum AllocationAlgorithm {
-        random, firstfit, userdispersing, firstfitleastconsumed;
+        random, firstfit, userdispersing, firstfitleastconsumed, 
balancedweighted;
     }
 
     public enum PlannerResourceUsage {
diff --git a/api/src/main/java/com/cloud/host/HostScoringWeights.java 
b/api/src/main/java/com/cloud/host/HostScoringWeights.java
new file mode 100644
index 00000000000..886487aeedf
--- /dev/null
+++ b/api/src/main/java/com/cloud/host/HostScoringWeights.java
@@ -0,0 +1,57 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package com.cloud.host;
+
+import org.apache.cloudstack.framework.config.ConfigKey;
+
+/**
+ * How much each signal counts when ranking hosts by how loaded they are.
+ *
+ * Shared by initial placement and by rebalancing on purpose. If the two 
weighted these differently
+ * they would disagree about which host is the better one, and rebalancing 
could move VMs off hosts
+ * that placement had just chosen, only for placement to put them back.
+ *
+ * Weights are relative to each other; only their ratios matter, and zero 
disables a term. Terms that
+ * only make sense for one of the two - how many VMs a host carries, how many 
started recently - stay
+ * with whichever uses them.
+ */
+public interface HostScoringWeights {
+
+    String WEIGHT_DESCRIPTION_SUFFIX = " Relative weight, only meaningful 
compared with the other "
+            + "host.weighted.* weights. Zero disables the term. Used by both 
the 'balancedweighted' "
+            + "allocation algorithm and the 'weighted' DRS algorithm.";
+
+    ConfigKey<Double> CpuAllocatedWeight = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Double.class, "host.weighted.cpu.allocated.weight", "1.0",
+            "How much CPU allocated on a host counts against it." + 
WEIGHT_DESCRIPTION_SUFFIX,
+            true, ConfigKey.Scope.Cluster);
+
+    ConfigKey<Double> CpuUsedWeight = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Double.class, "host.weighted.cpu.used.weight", "2.0",
+            "How much measured CPU utilisation counts against a host." + 
WEIGHT_DESCRIPTION_SUFFIX,
+            true, ConfigKey.Scope.Cluster);
+
+    ConfigKey<Double> MemoryAllocatedWeight = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Double.class, "host.weighted.memory.allocated.weight", "1.0",
+            "How much memory allocated on a host counts against it." + 
WEIGHT_DESCRIPTION_SUFFIX,
+            true, ConfigKey.Scope.Cluster);
+
+    ConfigKey<Double> MemoryUsedWeight = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Double.class, "host.weighted.memory.used.weight", "2.0",
+            "How much measured memory utilisation counts against a host." + 
WEIGHT_DESCRIPTION_SUFFIX,
+            true, ConfigKey.Scope.Cluster);
+}
diff --git a/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java 
b/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java
index 15727d9d8e6..64a7d75dfd8 100644
--- a/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java
+++ b/engine/schema/src/main/java/com/cloud/host/dao/HostDaoImpl.java
@@ -24,6 +24,7 @@ import java.util.Arrays;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -935,7 +936,14 @@ public class HostDaoImpl extends GenericDaoBase<HostVO, 
Long> implements HostDao
         sc.setParameters("status", Status.Up);
         sc.setParameters("resourceState", ResourceState.Enabled);
 
-        return listBy(sc);
+        // The tag join carries no DISTINCT, so a host comes back once per 
non-rule tag it holds.
+        // Callers read this as a set of candidate hosts, so collapse the 
repeats here rather than
+        // leave each of them to cope with the same host arriving more than 
once.
+        Map<Long, HostVO> distinctHosts = new LinkedHashMap<>();
+        for (HostVO host : listBy(sc)) {
+            distinctHosts.putIfAbsent(host.getId(), host);
+        }
+        return new ArrayList<>(distinctHosts.values());
     }
 
     @Override
diff --git a/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDao.java 
b/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDao.java
index 4316a483954..b41bc4f8fce 100755
--- a/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDao.java
+++ b/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDao.java
@@ -25,6 +25,7 @@ import java.util.Set;
 
 import com.cloud.hypervisor.Hypervisor;
 import com.cloud.utils.Pair;
+import com.cloud.utils.Ternary;
 import com.cloud.utils.db.GenericDao;
 import com.cloud.utils.fsm.StateDao;
 import com.cloud.vm.VMInstanceVO;
@@ -131,6 +132,18 @@ public interface VMInstanceDao extends 
GenericDao<VMInstanceVO, Long>, StateDao<
 
     List<Long> listHostIdsByVmCount(long dcId, Long podId, Long clusterId, 
long accountId);
 
+    /**
+     * Counts the VMs occupying each host in a zone, pod or cluster, in one 
query.
+     *
+     * @param changedStateAfter
+     *         cut-off for the second count: VMs whose state last changed 
after this. Approximates
+     *         the VMs still working through their startup load, which neither 
allocation nor a
+     *         utilisation average has caught up with yet.
+     * @return host id to {total VMs, VMs that changed state recently, VMs 
still in Starting}. Every
+     *         host in scope appears, including hosts with no VMs.
+     */
+    Map<Long, Ternary<Long, Long, Long>> countVmsByHost(long dcId, Long podId, 
Long clusterId, Date changedStateAfter);
+
     Long countRunningAndStartingByAccount(long accountId);
 
     Long countByZoneAndState(long zoneId, State state);
diff --git 
a/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDaoImpl.java 
b/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDaoImpl.java
index 761b9b51511..ad81d785f05 100755
--- a/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDaoImpl.java
+++ b/engine/schema/src/main/java/com/cloud/vm/dao/VMInstanceDaoImpl.java
@@ -18,6 +18,7 @@ package com.cloud.vm.dao;
 
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
+import java.sql.Timestamp;
 import java.sql.SQLException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -45,6 +46,7 @@ import com.cloud.storage.dao.VolumeDao;
 import com.cloud.tags.dao.ResourceTagDao;
 import com.cloud.utils.DateUtil;
 import com.cloud.utils.Pair;
+import com.cloud.utils.Ternary;
 import com.cloud.utils.db.Attribute;
 import com.cloud.utils.db.DB;
 import com.cloud.utils.db.Filter;
@@ -156,6 +158,21 @@ public class VMInstanceDaoImpl extends 
GenericDaoBase<VMInstanceVO, Long> implem
     private static final String COUNT_VMS_BASED_ON_VGPU_TYPES2 =
             "GROUP BY gpu_card.name, vgpu_profile.name";
 
+    // %s is the "changed state recently" test, or a constant 0 when no 
cut-off is given. It is not
+    // a bound parameter because there is no timestamp that reliably means 
"never" - update_time is
+    // a TIMESTAMP column, so anything past 2038 is out of range.
+    //
+    // The third count is VMs still in Starting. Unlike the 
recent-state-change count it does not
+    // expire on a clock: a VM that has been starting for ten minutes still 
counts, which is the
+    // case that matters, since a host that is struggling is exactly the one 
whose VMs take longest
+    // to leave Starting.
+    private static final String COUNT_VMS_BY_HOST = "SELECT host.id, 
COUNT(vm.id), SUM(IF(%s, 1, 0)), " +
+            "SUM(IF(vm.state = 'Starting', 1, 0)) " +
+            "FROM `cloud`.`host` host LEFT JOIN `cloud`.`vm_instance` vm " +
+            "ON vm.host_id = host.id AND vm.state IN ('Running', 'Starting', 
'Stopping', 'Migrating') " +
+            "AND vm.removed IS NULL WHERE host.type = 'Routing' AND 
host.removed IS NULL AND host.data_center_id = ? ";
+    private static final String COUNT_VMS_BY_HOST_PART2 = " GROUP BY host.id ";
+
     private static final String UPDATE_SYSTEM_VM_TEMPLATE_ID_FOR_HYPERVISOR = 
"UPDATE `cloud`.`vm_instance` SET vm_template_id = ? WHERE type <> 'User' AND 
hypervisor_type = ? AND removed is NULL";
 
     private static final String COUNT_VMS_BY_ZONE_AND_STATE_AND_HOST_TAG = 
"SELECT COUNT(1) FROM vm_instance vi JOIN service_offering so ON 
vi.service_offering_id=so.id " +
@@ -801,6 +818,45 @@ public class VMInstanceDaoImpl extends 
GenericDaoBase<VMInstanceVO, Long> implem
         }
     }
 
+
+    @Override
+    public Map<Long, Ternary<Long, Long, Long>> countVmsByHost(long dcId, Long 
podId, Long clusterId, Date changedStateAfter) {
+        TransactionLegacy txn = TransactionLegacy.currentTxn();
+        Map<Long, Ternary<Long, Long, Long>> result = new HashMap<>();
+        String sql = String.format(COUNT_VMS_BY_HOST, changedStateAfter != 
null ? "vm.update_time > ?" : "0");
+        if (podId != null) {
+            sql = sql + " AND host.pod_id = ? ";
+        }
+        if (clusterId != null) {
+            sql = sql + " AND host.cluster_id = ? ";
+        }
+        sql = sql + COUNT_VMS_BY_HOST_PART2;
+        try {
+            PreparedStatement pstmt = txn.prepareAutoCloseStatement(sql);
+            int index = 1;
+            if (changedStateAfter != null) {
+                pstmt.setTimestamp(index++, new 
Timestamp(changedStateAfter.getTime()));
+            }
+            pstmt.setLong(index++, dcId);
+            if (podId != null) {
+                pstmt.setLong(index++, podId);
+            }
+            if (clusterId != null) {
+                pstmt.setLong(index++, clusterId);
+            }
+            ResultSet rs = pstmt.executeQuery();
+            while (rs.next()) {
+                // SUM over no rows is NULL, which getLong reports as 0 - 
correct for an empty host
+                result.put(rs.getLong(1), new Ternary<>(rs.getLong(2), 
rs.getLong(3), rs.getLong(4)));
+            }
+            return result;
+        } catch (SQLException e) {
+            throw new CloudRuntimeException("DB Exception on: " + sql, e);
+        } catch (Exception e) {
+            throw new CloudRuntimeException("Caught: " + sql, e);
+        }
+    }
+
     @Override
     public List<Long> listHostIdsByVmCount(long dcId, Long podId, Long 
clusterId, long accountId) {
         TransactionLegacy txn = TransactionLegacy.currentTxn();
diff --git 
a/engine/schema/src/test/java/com/cloud/host/dao/HostDaoImplTest.java 
b/engine/schema/src/test/java/com/cloud/host/dao/HostDaoImplTest.java
index c76eedb01e9..a6a0af14062 100644
--- a/engine/schema/src/test/java/com/cloud/host/dao/HostDaoImplTest.java
+++ b/engine/schema/src/test/java/com/cloud/host/dao/HostDaoImplTest.java
@@ -39,6 +39,7 @@ import org.mockito.junit.MockitoJUnitRunner;
 
 import com.cloud.cpu.CPU;
 import com.cloud.host.Host;
+import com.cloud.host.HostTagVO;
 import com.cloud.host.HostVO;
 import com.cloud.host.Status;
 import com.cloud.hypervisor.Hypervisor;
@@ -60,6 +61,32 @@ public class HostDaoImplTest {
     @Mock
     private SearchCriteria<HostVO> mockSearchCriteria;
 
+    @Test
+    public void testListAllUpAndEnabledNonHAHostsCollapsesRepeatedHosts() {
+        // the host_tags join carries no DISTINCT, so a host comes back once 
per non-rule tag it
+        // holds. Callers read the result as a set of candidate hosts.
+        SearchBuilder<HostTagVO> tagSearchBuilder = mock(SearchBuilder.class);
+        when(tagSearchBuilder.entity()).thenReturn(mock(HostTagVO.class));
+        HostTagsDao hostTagsDao = mock(HostTagsDao.class);
+        when(hostTagsDao.createSearchBuilder()).thenReturn(tagSearchBuilder);
+        hostDao._hostTagsDao = hostTagsDao;
+
+        when(mockSearchBuilder.entity()).thenReturn(mock(HostVO.class));
+        when(mockSearchBuilder.create()).thenReturn(mockSearchCriteria);
+        doReturn(mockSearchBuilder).when(hostDao).createSearchBuilder();
+
+        HostVO twoTags = mock(HostVO.class);
+        when(twoTags.getId()).thenReturn(1L);
+        HostVO oneTag = mock(HostVO.class);
+        when(oneTag.getId()).thenReturn(2L);
+        doReturn(Arrays.asList(twoTags, twoTags, 
oneTag)).when(hostDao).listBy(any(SearchCriteria.class));
+
+        List<HostVO> hosts = 
hostDao.listAllUpAndEnabledNonHAHosts(Host.Type.Routing, 3L, 2L, 1L, null);
+
+        assertEquals(2, hosts.size());
+        assertEquals(Arrays.asList(1L, 2L), 
hosts.stream().map(HostVO::getId).collect(Collectors.toList()));
+    }
+
     @Test
     public void testCountUpAndEnabledHostsInZone() {
         long testZoneId = 100L;
diff --git 
a/server/src/main/java/com/cloud/agent/manager/allocator/impl/FirstFitAllocator.java
 
b/server/src/main/java/com/cloud/agent/manager/allocator/impl/FirstFitAllocator.java
index 4bc34d8a5c6..6a5669d1797 100644
--- 
a/server/src/main/java/com/cloud/agent/manager/allocator/impl/FirstFitAllocator.java
+++ 
b/server/src/main/java/com/cloud/agent/manager/allocator/impl/FirstFitAllocator.java
@@ -16,6 +16,7 @@
 // under the License.
 package com.cloud.agent.manager.allocator.impl;
 
+import static 
com.cloud.deploy.DeploymentPlanner.AllocationAlgorithm.balancedweighted;
 import static 
com.cloud.deploy.DeploymentPlanner.AllocationAlgorithm.firstfitleastconsumed;
 import static com.cloud.deploy.DeploymentPlanner.AllocationAlgorithm.random;
 import static 
com.cloud.deploy.DeploymentPlanner.AllocationAlgorithm.userdispersing;
@@ -95,6 +96,8 @@ public class FirstFitAllocator extends BaseAllocator {
     CapacityDao _capacityDao;
     @Inject
     VMInstanceDetailsDao vmInstanceDetailsDao;
+    @Inject
+    WeightedHostScorer weightedHostScorer;
 
     boolean _checkHvm = true;
 
@@ -209,6 +212,8 @@ public class FirstFitAllocator extends BaseAllocator {
             hosts = reorderHostsByNumberOfVms(plan, hosts, account);
         } else if 
(firstfitleastconsumed.toString().equals(vmAllocationAlgorithm)) {
             hosts = reorderHostsByCapacity(plan, hosts);
+        } else if (balancedweighted.toString().equals(vmAllocationAlgorithm)) {
+            hosts = weightedHostScorer.rank(plan.getDataCenterId(), 
plan.getPodId(), plan.getClusterId(), hosts);
         }
 
         logger.debug("FirstFitAllocator has {} hosts to check for allocation 
{}.", hosts.size(), hosts);
diff --git 
a/server/src/main/java/com/cloud/agent/manager/allocator/impl/HostLoad.java 
b/server/src/main/java/com/cloud/agent/manager/allocator/impl/HostLoad.java
new file mode 100644
index 00000000000..2e3d14ba838
--- /dev/null
+++ b/server/src/main/java/com/cloud/agent/manager/allocator/impl/HostLoad.java
@@ -0,0 +1,60 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package com.cloud.agent.manager.allocator.impl;
+
+/**
+ * Smoothed view of what a host is actually doing, as opposed to what has been 
allocated on it.
+ * Fractions are of the host's real capacity and ignore overprovisioning.
+ */
+public class HostLoad {
+
+    public static final HostLoad UNKNOWN = new HostLoad(0, 0, 0);
+
+    private final double cpuUtilisation;
+    private final double memoryUtilisation;
+    private final long samples;
+
+    public HostLoad(double cpuUtilisation, double memoryUtilisation, long 
samples) {
+        this.cpuUtilisation = cpuUtilisation;
+        this.memoryUtilisation = memoryUtilisation;
+        this.samples = samples;
+    }
+
+    public double getCpuUtilisation() {
+        return cpuUtilisation;
+    }
+
+    public double getMemoryUtilisation() {
+        return memoryUtilisation;
+    }
+
+    public long getSamples() {
+        return samples;
+    }
+
+    /**
+     * False until enough has been observed to rank on. Callers fall back to 
allocation figures.
+     */
+    public boolean isUsable() {
+        return samples > 0;
+    }
+
+    @Override
+    public String toString() {
+        return String.format("HostLoad[cpu=%.3f, memory=%.3f, samples=%d]", 
cpuUtilisation, memoryUtilisation, samples);
+    }
+}
diff --git 
a/server/src/main/java/com/cloud/agent/manager/allocator/impl/HostLoadTracker.java
 
b/server/src/main/java/com/cloud/agent/manager/allocator/impl/HostLoadTracker.java
new file mode 100644
index 00000000000..4543af9b8b1
--- /dev/null
+++ 
b/server/src/main/java/com/cloud/agent/manager/allocator/impl/HostLoadTracker.java
@@ -0,0 +1,260 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package com.cloud.agent.manager.allocator.impl;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import javax.inject.Inject;
+
+import org.apache.cloudstack.framework.config.ConfigKey;
+import org.apache.cloudstack.framework.config.Configurable;
+import org.apache.cloudstack.managed.context.ManagedContextRunnable;
+
+import com.cloud.host.HostStats;
+import com.cloud.host.HostVO;
+import com.cloud.host.Status;
+import com.cloud.host.dao.HostDao;
+import com.cloud.server.StatsCollector;
+import com.cloud.deploy.DeploymentClusterPlanner;
+import com.cloud.deploy.DeploymentPlanner.AllocationAlgorithm;
+import com.cloud.utils.component.ManagerBase;
+import com.cloud.utils.concurrency.NamedThreadFactory;
+
+/**
+ * Keeps a smoothed view of how hard each host is actually working.
+ *
+ * StatsCollector already polls every host, but it keeps only the newest 
sample and nothing uses it
+ * for placement. A single sample is too noisy to rank on: a host can look 
idle moments before a
+ * batch of VMs starts work. This folds those samples into an exponentially 
weighted moving average
+ * so ranking reflects a trend rather than an instant.
+ *
+ * The average is per management server and is not persisted. Every management 
server polls every
+ * host, so all of them converge on the same picture, and a restarted server 
simply reports nothing
+ * usable until it has sampled - callers then fall back to allocation figures.
+ *
+ * What getCpuUtilization means depends on the hypervisor, and only KVM 
reports what this class
+ * assumes:
+ *
+ * <ul>
+ *   <li>KVM reports busy time as a percentage of the host's cores, which is 
what is wanted.</li>
+ *   <li>VMware reports the share of CPU that is reserved rather than the 
share that is busy, so
+ *       the CPU term becomes a second allocation signal there rather than a 
load signal.</li>
+ *   <li>XenServer sums per-core averages without dividing by core count, so 
the value ranges up to
+ *       the number of cores and is under-reported here by roughly that 
factor.</li>
+ * </ul>
+ *
+ * Memory is taken as used over total and is sound everywhere.
+ */
+public class HostLoadTracker extends ManagerBase implements Configurable {
+
+    public static final ConfigKey<Integer> HostLoadSampleInterval = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Integer.class, "host.load.sample.interval", "60",
+            "Seconds between samples of host CPU and memory utilisation, for 
placement algorithms that " +
+                    "consider actual load. Should not be shorter than 
host.stats.interval.",
+            false, ConfigKey.Scope.Global);
+
+    public static final ConfigKey<Integer> HostLoadStaleAfter = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Integer.class, "host.load.stale.after", "600",
+            "Seconds after which a host's utilisation average is considered 
out of date and stops being used " +
+                    "for placement. A host whose agent stops reporting would 
otherwise keep vouching for itself " +
+                    "with figures that never change.",
+            true, ConfigKey.Scope.Global);
+
+    public static final ConfigKey<Integer> HostLoadHalfLife = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Integer.class, "host.load.half.life", "300",
+            "Half life in seconds of the moving average of host utilisation. 
Larger values react more " +
+                    "slowly and are less affected by short spikes or by guests 
periodically releasing memory.",
+            true, ConfigKey.Scope.Global);
+
+    @Inject
+    private HostDao hostDao;
+
+    @Inject
+    private StatsCollector statsCollector;
+
+    private final Map<Long, Sample> samples = new ConcurrentHashMap<>();
+
+    private ScheduledExecutorService executor;
+
+    @Override
+    public boolean start() {
+        int interval = Math.max(1, HostLoadSampleInterval.value());
+        executor = Executors.newSingleThreadScheduledExecutor(
+                new NamedThreadFactory("HostLoadTracker"));
+        // catch Throwable: an escaping error would cancel all future runs, 
and the failure would be
+        // silent - placement would quietly go back to ranking on allocation 
alone
+        executor.scheduleWithFixedDelay(new ManagedContextRunnable() {
+            @Override
+            protected void runInContext() {
+                try {
+                    sampleAllHosts();
+                } catch (Throwable t) {
+                    logger.warn("Unable to sample host load", t);
+                }
+            }
+        }, interval, interval, TimeUnit.SECONDS);
+        return true;
+    }
+
+    @Override
+    public boolean stop() {
+        if (executor != null) {
+            executor.shutdownNow();
+        }
+        return true;
+    }
+
+    protected void sampleAllHosts() {
+        if (!isInUse()) {
+            samples.clear();
+            return;
+        }
+        for (HostVO host : 
hostDao.listByType(com.cloud.host.Host.Type.Routing)) {
+            if (host.getStatus() != Status.Up) {
+                samples.remove(host.getId());
+                continue;
+            }
+            record(host.getId(), statsCollector.getHostStats(host.getId()));
+        }
+    }
+
+    /**
+     * Only the placement algorithms that read these figures pay for 
collecting them.
+     */
+    protected boolean isInUse() {
+        return AllocationAlgorithm.balancedweighted.toString()
+                
.equals(DeploymentClusterPlanner.VmAllocationAlgorithm.value());
+    }
+
+    protected void record(long hostId, HostStats stats) {
+        record(hostId, stats, System.currentTimeMillis());
+    }
+
+    protected void record(long hostId, HostStats stats, long now) {
+        if (stats == null) {
+            return;
+        }
+        double totalMemory = stats.getTotalMemoryKBs();
+        if (totalMemory <= 0) {
+            return;
+        }
+
+        Sample previous = samples.get(hostId);
+        if (previous != null && previous.isSameReadingAs(stats)) {
+            // StatsCollector keeps the previous entry when a poll fails, so 
an unchanged object is
+            // a reading we have already folded, not a fresh measurement
+            return;
+        }
+
+        // getCpuUtilization is a percentage of the host's real cores. That 
holds for KVM; see the
+        // class javadoc for what it means on other hypervisors.
+        double cpu = clamp(stats.getCpuUtilization() / 100.0);
+        double memory = clamp((totalMemory - stats.getFreeMemoryKBs()) / 
totalMemory);
+        int halfLife = HostLoadHalfLife.value();
+
+        samples.compute(hostId, (id, current) -> current == null
+                ? new Sample(cpu, memory, now, stats)
+                : current.fold(cpu, memory, now, halfLife, stats));
+    }
+
+    public HostLoad getLoad(long hostId) {
+        return getLoad(hostId, System.currentTimeMillis());
+    }
+
+    protected HostLoad getLoad(long hostId, long now) {
+        Sample sample = samples.get(hostId);
+        if (sample == null) {
+            return HostLoad.UNKNOWN;
+        }
+        long staleAfter = Math.max(1, HostLoadStaleAfter.value()) * 1000L;
+        if (now - sample.updatedAt > staleAfter) {
+            // the host has stopped reporting; stop letting its last known 
figures speak for it
+            return HostLoad.UNKNOWN;
+        }
+        return sample.toHostLoad();
+    }
+
+    protected void clear() {
+        samples.clear();
+    }
+
+    private static double clamp(double value) {
+        if (Double.isNaN(value) || value < 0) {
+            return 0;
+        }
+        return Math.min(value, 1);
+    }
+
+    @Override
+    public String getConfigComponentName() {
+        return HostLoadTracker.class.getSimpleName();
+    }
+
+    @Override
+    public ConfigKey<?>[] getConfigKeys() {
+        return new ConfigKey<?>[] {HostLoadSampleInterval, HostLoadHalfLife, 
HostLoadStaleAfter};
+    }
+
+    /**
+     * One host's running average. Weighting is by elapsed time rather than by 
sample count, so a
+     * missed poll decays the old value by the right amount instead of 
over-weighting it.
+     */
+    private static final class Sample {
+        private final double cpu;
+        private final double memory;
+        private final long updatedAt;
+        private final long count;
+        private final HostStats reading;
+
+        private Sample(double cpu, double memory, long updatedAt, HostStats 
reading) {
+            this(cpu, memory, updatedAt, 1, reading);
+        }
+
+        private Sample(double cpu, double memory, long updatedAt, long count, 
HostStats reading) {
+            this.cpu = cpu;
+            this.memory = memory;
+            this.updatedAt = updatedAt;
+            this.count = count;
+            this.reading = reading;
+        }
+
+        private boolean isSameReadingAs(HostStats stats) {
+            return reading == stats;
+        }
+
+        private Sample fold(double newCpu, double newMemory, long now, int 
halfLifeSeconds, HostStats reading) {
+            double alpha = alpha(now - updatedAt, halfLifeSeconds);
+            return new Sample(cpu + alpha * (newCpu - cpu), memory + alpha * 
(newMemory - memory), now,
+                    count + 1, reading);
+        }
+
+        private static double alpha(long elapsedMillis, int halfLifeSeconds) {
+            if (halfLifeSeconds <= 0 || elapsedMillis <= 0) {
+                return 1;
+            }
+            return 1 - Math.exp(-(elapsedMillis / 1000.0) * Math.log(2) / 
halfLifeSeconds);
+        }
+
+        private HostLoad toHostLoad() {
+            return new HostLoad(cpu, memory, count);
+        }
+    }
+}
diff --git 
a/server/src/main/java/com/cloud/agent/manager/allocator/impl/WeightedHostScorer.java
 
b/server/src/main/java/com/cloud/agent/manager/allocator/impl/WeightedHostScorer.java
new file mode 100644
index 00000000000..581c2052ffc
--- /dev/null
+++ 
b/server/src/main/java/com/cloud/agent/manager/allocator/impl/WeightedHostScorer.java
@@ -0,0 +1,513 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package com.cloud.agent.manager.allocator.impl;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.stream.Collectors;
+
+import javax.inject.Inject;
+
+import org.apache.cloudstack.framework.config.ConfigKey;
+import org.apache.cloudstack.framework.config.Configurable;
+
+import com.cloud.capacity.Capacity;
+import com.cloud.capacity.CapacityManager;
+import com.cloud.capacity.CapacityVO;
+import com.cloud.capacity.dao.CapacityDao;
+import com.cloud.dc.ClusterDetailsDao;
+import com.cloud.dc.ClusterDetailsVO;
+import com.cloud.host.Host;
+import com.cloud.host.HostScoringWeights;
+import com.cloud.utils.Ternary;
+import com.cloud.utils.component.AdapterBase;
+import com.cloud.vm.VmDetailConstants;
+import com.cloud.vm.dao.VMInstanceDao;
+
+/**
+ * Ranks hosts on a blend of what has been allocated on them and what they are 
actually doing.
+ *
+ * Ordering purely by allocated capacity misreads a heavily overprovisioned 
cluster: allocation is
+ * measured against a total that has been multiplied by the overprovisioning 
factor, so hosts under
+ * real strain can still look close to empty and keep attracting new VMs. This 
blends allocation
+ * with measured utilisation, VM count, and how many VMs started on the host 
recently, since a VM
+ * that has just started is usually working harder than its long run average.
+ *
+ * Scores run from 0 (idle) upwards and lower is better. Hosts are then chosen 
from among the best
+ * rather than strictly in order - see {@link #applySelectionSpread}.
+ */
+public class WeightedHostScorer extends AdapterBase implements Configurable, 
HostScoringWeights {
+
+    private static final String ALLOCATOR_WEIGHT_DESCRIPTION_SUFFIX =
+            " Relative weight, only meaningful compared with the other 
host.weighted.* weights. Zero disables the term.";
+
+    public static final ConfigKey<Double> VmCountWeight = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Double.class, "host.weighted.vm.count.weight", "1.0",
+            "How much the number of VMs already on a host counts against it, 
regardless of how busy they are."
+                    + ALLOCATOR_WEIGHT_DESCRIPTION_SUFFIX,
+            true, ConfigKey.Scope.Cluster);
+
+    public static final ConfigKey<Double> RecentStartWeight = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Double.class, "host.weighted.recent.start.weight", "2.0",
+            "How much VMs started recently on a host count against it. Guards 
against sending a burst of new "
+                    + "VMs to one host, since neither allocation nor 
utilisation has caught up with them yet."
+                    + ALLOCATOR_WEIGHT_DESCRIPTION_SUFFIX,
+            true, ConfigKey.Scope.Cluster);
+
+    public static final ConfigKey<Double> DominantResourceWeight = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Double.class, "host.weighted.dominant.resource.weight", "1.0",
+            "How much a host's single most stressed resource counts against 
it, on top of the average across "
+                    + "resources. Keeps a host that is fine on average but 
nearly out of one resource from ranking well."
+                    + ALLOCATOR_WEIGHT_DESCRIPTION_SUFFIX,
+            true, ConfigKey.Scope.Cluster);
+
+    public static final ConfigKey<Integer> RecentStartWindow = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Integer.class, "host.weighted.recent.start.window", "300",
+            "Seconds for which a newly started VM counts as recently started.",
+            true, ConfigKey.Scope.Global);
+
+    public static final ConfigKey<Integer> ExpectedVmsPerHost = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Integer.class, "host.weighted.expected.vms.per.host", "50",
+            "Roughly how many VMs a host is expected to carry. Used only to 
bring VM counts onto the same "
+                    + "0 to 1 scale as the other terms; it is not a limit and 
is never enforced.",
+            true, ConfigKey.Scope.Cluster);
+
+    public static final ConfigKey<Double> CpuUtilisationThreshold = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Double.class, "host.weighted.cpu.utilisation.threshold", "0.85",
+            "Hosts whose measured CPU utilisation is above this fraction are 
held back from new VMs. "
+                    + "Ignored if it would leave nowhere to deploy. Set to 1 
to disable.",
+            true, ConfigKey.Scope.Cluster);
+
+    public static final ConfigKey<Double> MemoryUtilisationThreshold = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Double.class, "host.weighted.memory.utilisation.threshold", "0.90",
+            "Hosts whose measured memory utilisation is above this fraction 
are held back from new VMs. "
+                    + "Ignored if it would leave nowhere to deploy. Set to 1 
to disable.",
+            true, ConfigKey.Scope.Cluster);
+
+    public static final ConfigKey<Integer> SelectionSpread = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Integer.class, "host.weighted.selection.spread", "3",
+            "How many of the best scoring hosts to choose between at random. 
Ranking strictly by score sends "
+                    + "concurrent deployments to the same host, because they 
all read the same figures before any "
+                    + "of them is accounted for. 1 restores strict ordering.",
+            true, ConfigKey.Scope.Cluster);
+
+    public static final ConfigKey<Integer> StartingVmsThreshold = new 
ConfigKey<>(ConfigKey.CATEGORY_ADVANCED,
+            Integer.class, "host.weighted.starting.vms.threshold", "10",
+            "Hosts with at least this many VMs still in the Starting state are 
held back from new VMs, so "
+                    + "that a burst of deployments does not queue up behind 
itself on one host. Unlike the "
+                    + "utilisation thresholds this reacts within a single 
deployment rather than waiting for a "
+                    + "load sample. Ignored if it would leave nowhere to 
deploy. Set to 0 to disable.",
+            true, ConfigKey.Scope.Cluster);
+
+    private static final Ternary<Long, Long, Long> NO_VMS = new Ternary<>(0L, 
0L, 0L);
+
+    @Inject
+    private CapacityDao capacityDao;
+
+    @Inject
+    private ClusterDetailsDao clusterDetailsDao;
+
+    @Inject
+    private VMInstanceDao vmInstanceDao;
+
+    @Inject
+    private HostLoadTracker hostLoadTracker;
+
+    protected Random random = new Random();
+
+    /**
+     * Orders hosts best first. Hosts absent from the capacity tables keep 
their original relative
+     * order at the end of the list rather than being dropped.
+     */
+    public List<Host> rank(long zoneId, Long podId, Long clusterId, List<? 
extends Host> hosts) {
+        List<Host> candidates = distinctHosts(hosts);
+        if (candidates.size() <= 1) {
+            return candidates;
+        }
+
+        Map<Long, Ternary<Long, Long, Long>> vmCounts = countVmsByHost(zoneId, 
podId, clusterId);
+        Map<Long, Double> scores = score(zoneId, clusterId, candidates, 
vmCounts);
+
+        List<Host> unscored = new ArrayList<>();
+        List<Host> measured = new ArrayList<>();
+        List<Host> unmeasured = new ArrayList<>();
+        for (Host host : candidates) {
+            if (!scores.containsKey(host.getId())) {
+                unscored.add(host);
+            } else if (hostLoadTracker.getLoad(host.getId()).isUsable()) {
+                measured.add(host);
+            } else {
+                unmeasured.add(host);
+            }
+        }
+
+        Comparator<Host> byScore = Comparator.comparingDouble(h -> 
scores.get(h.getId()));
+        measured.sort(byScore);
+        unmeasured.sort(byScore);
+
+        List<Host> healthy = new ArrayList<>();
+        List<Host> tooBusy = new ArrayList<>();
+        partitionByUtilisation(clusterId, measured, healthy, tooBusy);
+
+        // held back for having too many VMs still starting. Taken out of 
healthy, so a host that is
+        // fine on resources but already working through a queue of starts 
stops attracting more.
+        List<Host> backedUp = new ArrayList<>();
+        partitionByStartingVms(clusterId, healthy, vmCounts, backedUp);
+
+        // Tiers, best first. Nothing is ever dropped: a tier is only reached 
when every better tier
+        // is empty, so holding a host back can never turn a deployment that 
would have succeeded
+        // into an InsufficientServerCapacity failure.
+        //
+        //   healthy    - no known problem
+        //   backedUp   - fine on resources, momentarily queueing starts; 
clears on its own
+        //   unmeasured - unknown, and not assumed to be idle
+        //   tooBusy    - measurably over a resource threshold
+        List<List<Host>> tiers = List.of(healthy, backedUp, unmeasured, 
tooBusy);
+        List<Host> result = new ArrayList<>();
+        for (List<Host> tier : tiers) {
+            // the spread belongs on whichever tier deployments will actually 
draw from, which is
+            // the first non-empty one. Applying it only to healthy left 
concurrent deployments in
+            // strict score order - and so agreeing on one host - whenever 
healthy was empty.
+            if (result.isEmpty()) {
+                applySelectionSpread(clusterId, tier);
+            }
+            result.addAll(tier);
+        }
+
+        // only when a held-back host actually has to be used. Hosts merely 
being unmeasured, as
+        // they all are on a freshly started management server, is not a 
capacity problem.
+        if (healthy.isEmpty() && unmeasured.isEmpty() && !(backedUp.isEmpty() 
&& tooBusy.isEmpty())) {
+            logger.warn("No candidate host is within its utilisation 
thresholds and clear of the starting VM "
+                    + "threshold, so they are being ignored for this 
deployment. The cluster is short of capacity.");
+        }
+        if (!tooBusy.isEmpty()) {
+            logger.debug("Holding back {} host(s) above their utilisation 
threshold: {}", tooBusy.size(), tooBusy);
+        }
+        if (!backedUp.isEmpty()) {
+            logger.debug("Holding back {} host(s) with too many VMs still 
starting: {}", backedUp.size(), backedUp);
+        }
+        logger.debug("Weighted host ranking: {}", () -> result.stream()
+                .filter(h -> scores.containsKey(h.getId()))
+                .map(h -> String.format("%s=%.4f", h.getName(), 
scores.get(h.getId())))
+                .collect(Collectors.joining(", ")));
+
+        result.addAll(unscored);
+        return result;
+    }
+
+    /**
+     * The candidate list can hold the same host twice. 
listAllUpAndEnabledNonHAHosts joins host_tags
+     * without collapsing the rows, so a host carrying two tags arrives once 
per tag, and that path is
+     * taken whenever a VM has no host tag to filter on - system VMs, most 
commonly. Ranking is a set
+     * operation: a repeat is harmless to the score, which is keyed by host, 
but it costs a slot in
+     * the selection spread and would bias the shuffle towards whichever host 
happens to be repeated.
+     */
+    protected List<Host> distinctHosts(List<? extends Host> hosts) {
+        if (hosts == null) {
+            return new ArrayList<>();
+        }
+        Map<Long, Host> distinct = new LinkedHashMap<>();
+        for (Host host : hosts) {
+            distinct.putIfAbsent(host.getId(), host);
+        }
+        return new ArrayList<>(distinct.values());
+    }
+
+    protected Map<Long, Ternary<Long, Long, Long>> countVmsByHost(long zoneId, 
Long podId, Long clusterId) {
+        return vmInstanceDao.countVmsByHost(zoneId, podId, clusterId,
+                new Date(System.currentTimeMillis() - 
RecentStartWindow.value() * 1000L));
+    }
+
+    protected Map<Long, Double> score(long zoneId, Long podId, Long clusterId, 
List<? extends Host> hosts) {
+        return score(zoneId, clusterId, hosts, countVmsByHost(zoneId, podId, 
clusterId));
+    }
+
+    protected Map<Long, Double> score(long zoneId, Long clusterId, List<? 
extends Host> hosts,
+            Map<Long, Ternary<Long, Long, Long>> vmCounts) {
+        List<CapacityVO> capacities = 
capacityDao.listHostCapacityByCapacityTypes(zoneId, clusterId,
+                List.of(Capacity.CAPACITY_TYPE_CPU, 
Capacity.CAPACITY_TYPE_MEMORY));
+
+        Map<Long, Double[]> allocated = allocatedFractions(capacities);
+
+        Weights weights = new Weights(clusterId);
+        Map<Long, Double> scores = new HashMap<>();
+        for (Host host : hosts) {
+            Double[] alloc = allocated.get(host.getId());
+            if (alloc == null) {
+                continue;
+            }
+            Ternary<Long, Long, Long> counts = 
vmCounts.getOrDefault(host.getId(), NO_VMS);
+            scores.put(host.getId(), scoreHost(weights, alloc[0], alloc[1], 
hostLoadTracker.getLoad(host.getId()),
+                    counts.first(), counts.second()));
+        }
+        return scores;
+    }
+
+    /**
+     * Allocated CPU and memory as a fraction of what a host can hand out.
+     *
+     * op_host_capacity stores totals raw; overprovisioning is applied when 
they are read, so the
+     * cluster's ratio has to be applied here too. Without it the fraction 
reaches 1 at the host's
+     * physical size and every host on an overcommitted cluster clamps to 1, 
which is where this
+     * algorithm is most needed.
+     *
+     * Only hosts with both a CPU and a memory row are returned. A host 
missing one would otherwise
+     * score as if that resource were untouched, making it the most attractive 
host in the cluster.
+     */
+    protected Map<Long, Double[]> allocatedFractions(List<CapacityVO> 
capacities) {
+        Map<Long, Double[]> fractions = new HashMap<>();
+        Map<Long, Integer> seen = new HashMap<>();
+        for (CapacityVO capacity : capacities) {
+            long total = capacity.getTotalCapacity();
+            if (total <= 0) {
+                continue;
+            }
+            boolean isCpu = capacity.getCapacityType() == 
Capacity.CAPACITY_TYPE_CPU;
+            float overcommit = overcommitRatio(capacity.getClusterId(), isCpu);
+            double allocatable = total * overcommit;
+            double used = (double) (capacity.getUsedCapacity() + 
capacity.getReservedCapacity()) / allocatable;
+
+            Double[] entry = 
fractions.computeIfAbsent(capacity.getHostOrPoolId(), id -> new Double[] {0.0, 
0.0});
+            entry[isCpu ? 0 : 1] = clamp(used);
+            seen.merge(capacity.getHostOrPoolId(), isCpu ? 1 : 2, 
Integer::sum);
+        }
+        fractions.keySet().removeIf(hostId -> seen.getOrDefault(hostId, 0) != 
3);
+        return fractions;
+    }
+
+    /**
+     * The cluster's overprovisioning factor, defaulting to none if it cannot 
be read.
+     */
+    protected float overcommitRatio(Long clusterId, boolean forCpu) {
+        if (clusterId == null) {
+            return 1f;
+        }
+        String key = forCpu ? VmDetailConstants.CPU_OVER_COMMIT_RATIO : 
VmDetailConstants.MEMORY_OVER_COMMIT_RATIO;
+        ClusterDetailsVO detail = clusterDetailsDao.findDetail(clusterId, key);
+        if (detail == null || detail.getValue() == null) {
+            return 1f;
+        }
+        try {
+            float ratio = Float.parseFloat(detail.getValue());
+            return ratio > 0 ? ratio : 1f;
+        } catch (NumberFormatException e) {
+            logger.warn("Cluster {} has an unreadable {} of [{}], treating it 
as 1.", clusterId, key, detail.getValue());
+            return 1f;
+        }
+    }
+
+    /**
+     * The blend. Every term is a fraction of the host's capacity for that 
resource so the weights
+     * are directly comparable, and the dominant resource term is added on top 
of the weighted mean
+     * so that being nearly out of any one resource is penalised even when the 
average looks fine.
+     */
+    protected double scoreHostIn(Long clusterId, double cpuAllocated, double 
memoryAllocated, HostLoad load,
+            long vmCount, long recentStarts) {
+        return scoreHost(new Weights(clusterId), cpuAllocated, 
memoryAllocated, load, vmCount, recentStarts);
+    }
+
+    protected double scoreHost(Weights weights, double cpuAllocated, double 
memoryAllocated, HostLoad load,
+            long vmCount, long recentStarts) {
+        // a host with no usable load figures is ranked on allocation alone, 
and is placed behind
+        // every measured host by the caller rather than being assumed idle
+        double cpuUsedWeight = load.isUsable() ? weights.cpuUsed : 0;
+        double memoryUsedWeight = load.isUsable() ? weights.memoryUsed : 0;
+
+        double vmCountTerm = clamp(vmCount / weights.vmScale);
+        double recentStartTerm = clamp(recentStarts / weights.vmScale);
+
+        double weightSum = weights.cpuAllocated + cpuUsedWeight + 
weights.memoryAllocated + memoryUsedWeight
+                + weights.vmCount + weights.recentStart;
+
+        double mean = 0;
+        if (weightSum > 0) {
+            mean = (weights.cpuAllocated * cpuAllocated
+                    + cpuUsedWeight * load.getCpuUtilisation()
+                    + weights.memoryAllocated * memoryAllocated
+                    + memoryUsedWeight * load.getMemoryUtilisation()
+                    + weights.vmCount * vmCountTerm
+                    + weights.recentStart * recentStartTerm) / weightSum;
+        }
+
+        if (weights.dominant <= 0) {
+            return mean;
+        }
+        return (mean + weights.dominant * dominantResource(cpuAllocated, 
memoryAllocated, load)) / (1 + weights.dominant);
+    }
+
+    /**
+     * The most stressed resource on the host. Allocation and utilisation are 
both considered for
+     * each resource and the larger is taken, because memory that has been 
reclaimed from idle
+     * guests can be taken back as soon as those guests get busy.
+     */
+    protected double dominantResource(double cpuAllocated, double 
memoryAllocated, HostLoad load) {
+        double cpu = load.isUsable() ? Math.max(cpuAllocated, 
load.getCpuUtilisation()) : cpuAllocated;
+        double memory = load.isUsable() ? Math.max(memoryAllocated, 
load.getMemoryUtilisation()) : memoryAllocated;
+        return Math.max(cpu, memory);
+    }
+
+    /**
+     * Splits measurably busy hosts out from the rest. Only hosts with load 
samples can be held
+     * back; a host that cannot be measured is dealt with by the caller.
+     */
+    protected void partitionByUtilisation(Long clusterId, List<Host> measured, 
List<Host> healthy, List<Host> tooBusy) {
+        double cpuThreshold = valueIn(CpuUtilisationThreshold, clusterId);
+        double memoryThreshold = valueIn(MemoryUtilisationThreshold, 
clusterId);
+
+        for (Host host : measured) {
+            HostLoad load = hostLoadTracker.getLoad(host.getId());
+            if (load.getCpuUtilisation() > cpuThreshold || 
load.getMemoryUtilisation() > memoryThreshold) {
+                tooBusy.add(host);
+            } else {
+                healthy.add(host);
+            }
+        }
+    }
+
+    /**
+     * Moves hosts with too many VMs still in Starting out of {@code healthy} 
and into
+     * {@code backedUp}, least backed up first.
+     *
+     * This is the one signal in the algorithm that reacts inside a single 
deployment. Allocation is
+     * not charged until a VM reaches Starting, and a utilisation average is a 
sample interval and a
+     * half life behind, so neither notices a burst of deployments until it is 
over. The count of
+     * VMs in Starting is read fresh on every ranking, and a host that is 
struggling holds that count
+     * up for as long as it struggles - which is the point, since a host whose 
VMs take minutes
+     * rather than seconds to start is precisely the one that should stop 
being chosen.
+     *
+     * The fallback order within the held-back group is by starting count 
rather than by score: if
+     * every host ends up here, the least backed up one is the least bad place 
to add another.
+     */
+    protected void partitionByStartingVms(Long clusterId, List<Host> healthy,
+            Map<Long, Ternary<Long, Long, Long>> vmCounts, List<Host> 
backedUp) {
+        int threshold = startingVmsThreshold(clusterId);
+        if (threshold <= 0) {
+            return;
+        }
+        healthy.removeIf(host -> {
+            if (startingVms(vmCounts, host) >= threshold) {
+                backedUp.add(host);
+                return true;
+            }
+            return false;
+        });
+        backedUp.sort(Comparator.comparingLong(h -> startingVms(vmCounts, h)));
+    }
+
+    protected int startingVmsThreshold(Long clusterId) {
+        return (int) valueIn(StartingVmsThreshold, clusterId);
+    }
+
+    private static long startingVms(Map<Long, Ternary<Long, Long, Long>> 
vmCounts, Host host) {
+        return vmCounts.getOrDefault(host.getId(), NO_VMS).third();
+    }
+
+    /**
+     * Shuffles the best few hosts so that deployments made at the same moment 
do not all pick the
+     * same one. Capacity is only charged once a VM starts, so until then 
every concurrent decision
+     * sees the same figures and strict ordering makes them agree.
+     */
+    protected void applySelectionSpread(Long clusterId, List<Host> ranked) {
+        int spread = Math.min((int) valueIn(SelectionSpread, clusterId), 
ranked.size());
+        if (spread > 1) {
+            Collections.shuffle(ranked.subList(0, spread), random);
+        }
+    }
+
+    /**
+     * The weights for one ranking, read once rather than per host. Every host 
in a ranking is then
+     * scored with the same values even if a setting changes part way through, 
and a bad value is
+     * reported once per ranking rather than once per host.
+     *
+     * A negative weight would invert the ranking and make the most loaded 
host the best, so they
+     * are floored at zero and the bad value is reported.
+     */
+    protected final class Weights {
+        private final double cpuAllocated;
+        private final double cpuUsed;
+        private final double memoryAllocated;
+        private final double memoryUsed;
+        private final double vmCount;
+        private final double recentStart;
+        private final double dominant;
+        private final double vmScale;
+
+        protected Weights(Long clusterId) {
+            cpuAllocated = nonNegative(CpuAllocatedWeight, clusterId);
+            cpuUsed = nonNegative(CpuUsedWeight, clusterId);
+            memoryAllocated = nonNegative(MemoryAllocatedWeight, clusterId);
+            memoryUsed = nonNegative(MemoryUsedWeight, clusterId);
+            vmCount = nonNegative(VmCountWeight, clusterId);
+            recentStart = nonNegative(RecentStartWeight, clusterId);
+            dominant = nonNegative(DominantResourceWeight, clusterId);
+            vmScale = Math.max(1, valueIn(ExpectedVmsPerHost, clusterId));
+        }
+
+        private double nonNegative(ConfigKey<Double> key, Long clusterId) {
+            double value = valueIn(key, clusterId);
+            if (value < 0) {
+                logger.warn("{} is set to {}, which would rank the most loaded 
host first. Treating it as 0.",
+                        key.key(), value);
+                return 0;
+            }
+            return value;
+        }
+    }
+
+    private <T extends Number> double valueIn(ConfigKey<T> key, Long 
clusterId) {
+        T value = clusterId == null ? key.value() : key.valueIn(clusterId);
+        return value == null ? 0 : value.doubleValue();
+    }
+
+    private static double clamp(double value) {
+        if (Double.isNaN(value) || value < 0) {
+            return 0;
+        }
+        return Math.min(value, 1);
+    }
+
+    @Override
+    public String getConfigComponentName() {
+        return CapacityManager.class.getSimpleName();
+    }
+
+    @Override
+    public ConfigKey<?>[] getConfigKeys() {
+        return new ConfigKey<?>[] {
+                CpuAllocatedWeight,
+                CpuUsedWeight,
+                MemoryAllocatedWeight,
+                MemoryUsedWeight,
+                VmCountWeight,
+                RecentStartWeight,
+                DominantResourceWeight,
+                RecentStartWindow,
+                ExpectedVmsPerHost,
+                CpuUtilisationThreshold,
+                MemoryUtilisationThreshold,
+                SelectionSpread,
+                StartingVmsThreshold
+        };
+    }
+}
diff --git 
a/server/src/main/resources/META-INF/cloudstack/server-allocator/spring-server-allocator-context.xml
 
b/server/src/main/resources/META-INF/cloudstack/server-allocator/spring-server-allocator-context.xml
index 28b96b8d194..69aea2cf845 100644
--- 
a/server/src/main/resources/META-INF/cloudstack/server-allocator/spring-server-allocator-context.xml
+++ 
b/server/src/main/resources/META-INF/cloudstack/server-allocator/spring-server-allocator-context.xml
@@ -31,6 +31,12 @@
         <property name="name" value="Balance" />
     </bean>
 
+    <bean id="hostLoadTracker"
+        class="com.cloud.agent.manager.allocator.impl.HostLoadTracker" />
+
+    <bean id="weightedHostScorer"
+        class="com.cloud.agent.manager.allocator.impl.WeightedHostScorer" />
+
     <bean id="firstFitAllocator"
         class="com.cloud.agent.manager.allocator.impl.FirstFitAllocator" />
 
diff --git 
a/server/src/test/java/com/cloud/agent/manager/allocator/impl/HostLoadTrackerTest.java
 
b/server/src/test/java/com/cloud/agent/manager/allocator/impl/HostLoadTrackerTest.java
new file mode 100644
index 00000000000..0be9b1310f0
--- /dev/null
+++ 
b/server/src/test/java/com/cloud/agent/manager/allocator/impl/HostLoadTrackerTest.java
@@ -0,0 +1,182 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package com.cloud.agent.manager.allocator.impl;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import com.cloud.host.HostStats;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(MockitoJUnitRunner.class)
+public class HostLoadTrackerTest {
+
+    private static final long HOST_ID = 1L;
+    private static final long HALF_LIFE_MS = 300 * 1000L;
+
+    @InjectMocks
+    private HostLoadTracker tracker = new HostLoadTracker();
+
+    private long now;
+
+    @Before
+    public void setUp() {
+        tracker.clear();
+        now = 1_000_000L;
+    }
+
+    private HostStats stats(double cpuPercent, double usedMemoryFraction) {
+        HostStats stats = Mockito.mock(HostStats.class);
+        
Mockito.lenient().when(stats.getCpuUtilization()).thenReturn(cpuPercent);
+        Mockito.lenient().when(stats.getTotalMemoryKBs()).thenReturn(1000.0);
+        Mockito.lenient().when(stats.getFreeMemoryKBs()).thenReturn(1000.0 * 
(1 - usedMemoryFraction));
+        return stats;
+    }
+
+    private void sample(double cpuPercent, double usedMemoryFraction, long 
advanceMs) {
+        now += advanceMs;
+        tracker.record(HOST_ID, stats(cpuPercent, usedMemoryFraction), now);
+    }
+
+    private HostLoad load() {
+        return tracker.getLoad(HOST_ID, now);
+    }
+
+    @Test
+    public void testUnknownHostIsNotUsable() {
+        assertFalse(tracker.getLoad(999L, now).isUsable());
+    }
+
+    @Test
+    public void testFirstSampleIsTakenAsIs() {
+        sample(40, 0.6, 0);
+
+        HostLoad load = load();
+        assertTrue(load.isUsable());
+        assertEquals(0.40, load.getCpuUtilisation(), 1e-6);
+        assertEquals(0.60, load.getMemoryUtilisation(), 1e-6);
+    }
+
+    @Test
+    public void testSingleSpikeDoesNotDominateTheAverage() {
+        sample(10, 0.1, 0);
+        sample(100, 0.1, 60 * 1000L);
+
+        // one sample a fifth of a half life in should move the average part 
of the way, not all of it
+        double cpu = load().getCpuUtilisation();
+        assertTrue("a single spike must not take over the average: " + cpu, 
cpu < 0.30);
+        assertTrue("but it must move it: " + cpu, cpu > 0.10);
+    }
+
+    @Test
+    public void testSustainedLoadConvergesOnTheNewValue() {
+        sample(10, 0.1, 0);
+        for (int i = 0; i < 40; i++) {
+            sample(90, 0.9, 60 * 1000L);
+        }
+
+        HostLoad load = load();
+        assertEquals(0.90, load.getCpuUtilisation(), 0.01);
+        assertEquals(0.90, load.getMemoryUtilisation(), 0.01);
+    }
+
+    @Test
+    public void testHalfLifeMovesAverageHalfWay() {
+        sample(0, 0, 0);
+        sample(100, 1.0, HALF_LIFE_MS);
+
+        assertEquals(0.5, load().getCpuUtilisation(), 0.01);
+    }
+
+    @Test
+    public void testMissedSamplesDecayByElapsedTimeNotSampleCount() {
+        sample(0, 0, 0);
+        sample(100, 1.0, 4 * HALF_LIFE_MS);
+
+        // four half lives of catching up in one sample, so almost all the way 
there
+        assertTrue(load().getCpuUtilisation() > 0.9);
+    }
+
+    @Test
+    public void testNullStatsAreIgnored() {
+        tracker.record(HOST_ID, null, now);
+        assertFalse(load().isUsable());
+    }
+
+    @Test
+    public void testHostReportingNoMemoryIsIgnored() {
+        HostStats broken = Mockito.mock(HostStats.class);
+        Mockito.lenient().when(broken.getTotalMemoryKBs()).thenReturn(0.0);
+
+        tracker.record(HOST_ID, broken, now);
+
+        assertFalse(load().isUsable());
+    }
+
+    @Test
+    public void testUnchangedReadingIsNotFoldedAgain() {
+        // StatsCollector keeps the previous entry when a poll fails, so the 
same object comes back
+        HostStats reading = stats(10, 0.1);
+        tracker.record(HOST_ID, reading, now);
+        // stay inside the staleness window so this tests folding, not expiry
+        for (int i = 0; i < 5; i++) {
+            now += 60 * 1000L;
+            tracker.record(HOST_ID, reading, now);
+        }
+
+        assertEquals("re-reading one measurement must not count as six", 1, 
load().getSamples());
+    }
+
+    @Test
+    public void testAHostThatStopsReportingBecomesUnusable() {
+        HostStats reading = stats(10, 0.1);
+        tracker.record(HOST_ID, reading, now);
+        assertTrue(load().isUsable());
+
+        // the agent stops updating; StatsCollector keeps handing back the 
same stale entry
+        for (int i = 0; i < 20; i++) {
+            now += 60 * 1000L;
+            tracker.record(HOST_ID, reading, now);
+        }
+
+        assertFalse("a host that stopped reporting must not keep vouching for 
itself", load().isUsable());
+    }
+
+    @Test
+    public void testFreshReadingsKeepAHostUsable() {
+        for (int i = 0; i < 20; i++) {
+            sample(10 + i, 0.1, 60 * 1000L);
+        }
+        assertTrue(load().isUsable());
+    }
+
+    @Test
+    public void testOutOfRangeValuesAreClamped() {
+        sample(250, 2.0, 0);
+
+        HostLoad load = load();
+        assertEquals(1.0, load.getCpuUtilisation(), 1e-6);
+        assertEquals(1.0, load.getMemoryUtilisation(), 1e-6);
+    }
+}
diff --git 
a/server/src/test/java/com/cloud/agent/manager/allocator/impl/WeightedHostScorerRankTest.java
 
b/server/src/test/java/com/cloud/agent/manager/allocator/impl/WeightedHostScorerRankTest.java
new file mode 100644
index 00000000000..a597f3922c4
--- /dev/null
+++ 
b/server/src/test/java/com/cloud/agent/manager/allocator/impl/WeightedHostScorerRankTest.java
@@ -0,0 +1,339 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package com.cloud.agent.manager.allocator.impl;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import com.cloud.capacity.Capacity;
+import com.cloud.capacity.CapacityVO;
+import com.cloud.capacity.dao.CapacityDao;
+import com.cloud.dc.ClusterDetailsDao;
+import com.cloud.dc.ClusterDetailsVO;
+import com.cloud.host.Host;
+import com.cloud.utils.Ternary;
+import com.cloud.vm.dao.VMInstanceDao;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Exercises rank() as the allocator calls it, rather than the scoring 
function alone, so the
+ * capacity denominator, the utilisation thresholds and the random spread are 
all covered.
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class WeightedHostScorerRankTest {
+
+    private static final long ZONE = 1L;
+    private static final long CLUSTER = 7L;
+    private static final long CORES = 192L * 2400L;
+    private static final long MEMORY = 1_132_000L * 1024L * 1024L;
+    private static final int CPU_OVERCOMMIT = 10;
+    private static final int MEMORY_OVERCOMMIT = 4;
+
+    @Mock
+    private CapacityDao capacityDao;
+
+    @Mock
+    private VMInstanceDao vmInstanceDao;
+
+    @Mock
+    private ClusterDetailsDao clusterDetailsDao;
+
+    @Mock
+    private HostLoadTracker hostLoadTracker;
+
+    @InjectMocks
+    private WeightedHostScorer scorer = new WeightedHostScorer();
+
+
+    private final List<CapacityVO> capacities = new ArrayList<>();
+    private final Map<Long, Ternary<Long, Long, Long>> vmCounts = new 
HashMap<>();
+    private final Map<Long, Host> hosts = new HashMap<>();
+
+    @Before
+    public void setUp() {
+        scorer.random = new Random(1L);
+        
Mockito.lenient().when(clusterDetailsDao.findDetail(Mockito.eq(CLUSTER), 
Mockito.contains("cpu")))
+                .thenReturn(new ClusterDetailsVO(CLUSTER, 
"cpuOvercommitRatio", String.valueOf(CPU_OVERCOMMIT)));
+        
Mockito.lenient().when(clusterDetailsDao.findDetail(Mockito.eq(CLUSTER), 
Mockito.contains("memory")))
+                .thenReturn(new ClusterDetailsVO(CLUSTER, 
"memoryOvercommitRatio", String.valueOf(MEMORY_OVERCOMMIT)));
+        
Mockito.lenient().when(capacityDao.listHostCapacityByCapacityTypes(Mockito.eq(ZONE),
 Mockito.eq(CLUSTER), Mockito.any()))
+                .thenReturn(capacities);
+        Mockito.lenient().when(vmInstanceDao.countVmsByHost(Mockito.eq(ZONE), 
Mockito.any(), Mockito.eq(CLUSTER), Mockito.any()))
+                .thenReturn(vmCounts);
+    }
+
+    private CapacityVO capacity(long hostId, short type, long used, long 
total) {
+        CapacityVO capacity = new CapacityVO(hostId, ZONE, 1L, CLUSTER, used, 
total, type);
+        capacity.setReservedCapacity(0L);
+        return capacity;
+    }
+
+    /** Registers a host with a share of its allocatable CPU and memory 
already committed. */
+    private Host host(long id, String name, double cpuAllocatedFraction, 
double memoryAllocatedFraction,
+            HostLoad load, long vms) {
+        return host(id, name, cpuAllocatedFraction, memoryAllocatedFraction, 
load, vms, 0L);
+    }
+
+    /** As above, with a number of VMs left sitting in Starting on the host. */
+    private Host host(long id, String name, double cpuAllocatedFraction, 
double memoryAllocatedFraction,
+            HostLoad load, long vms, long startingVms) {
+        Host host = Mockito.mock(Host.class);
+        Mockito.lenient().when(host.getId()).thenReturn(id);
+        Mockito.lenient().when(host.getName()).thenReturn(name);
+        capacities.add(capacity(id, Capacity.CAPACITY_TYPE_CPU,
+                (long) (CORES * CPU_OVERCOMMIT * cpuAllocatedFraction), 
CORES));
+        capacities.add(capacity(id, Capacity.CAPACITY_TYPE_MEMORY,
+                (long) (MEMORY * MEMORY_OVERCOMMIT * memoryAllocatedFraction), 
MEMORY));
+        vmCounts.put(id, new Ternary<>(vms, 0L, startingVms));
+        Mockito.lenient().when(hostLoadTracker.getLoad(id)).thenReturn(load);
+        hosts.put(id, host);
+        return host;
+    }
+
+    private List<String> rankedNames(List<? extends Host> input) {
+        return scorer.rank(ZONE, 1L, CLUSTER, 
input).stream().map(Host::getName).collect(Collectors.toList());
+    }
+
+    @Test
+    public void testAllocationIsMeasuredAgainstTheOvercommittedTotal() {
+        // both hosts are well past their physical CPU, which is normal at a 
factor of 10.
+        // if the denominator ignored the factor both would clamp to 1.0 and 
rank equal.
+        Host light = host(1L, "light", 0.20, 0.20, new HostLoad(0.1, 0.1, 5), 
20);
+        Host heavy = host(2L, "heavy", 0.80, 0.20, new HostLoad(0.1, 0.1, 5), 
20);
+
+        Map<Long, Double> scores = scorer.score(ZONE, 1L, CLUSTER, 
Arrays.asList(light, heavy));
+
+        assertTrue("hosts past their physical size must still be 
distinguishable",
+                scores.get(heavy.getId()) > scores.get(light.getId()));
+    }
+
+    @Test
+    public void testARepeatedHostIsRankedOnce() {
+        // listAllUpAndEnabledNonHAHosts returns a host once per tag it 
carries, so the same host
+        // reaches the allocator more than once whenever a VM has no tag to 
filter the list by.
+        // A repeat would take an extra slot in the selection spread and bias 
the shuffle to it.
+        Host light = host(1L, "light", 0.10, 0.10, new HostLoad(0.10, 0.10, 
10), 10);
+        Host middle = host(2L, "middle", 0.40, 0.40, new HostLoad(0.40, 0.40, 
10), 40);
+        Host heavy = host(3L, "heavy", 0.70, 0.70, new HostLoad(0.70, 0.60, 
10), 70);
+
+        List<String> ranked = rankedNames(Arrays.asList(middle, light, middle, 
heavy, light));
+
+        assertEquals("each host must be offered exactly once", 3, 
ranked.size());
+        assertEquals("no host may appear twice", new HashSet<>(ranked).size(), 
ranked.size());
+        assertTrue("no host may be dropped", 
ranked.containsAll(Arrays.asList("light", "middle", "heavy")));
+    }
+
+    @Test
+    public void testBusyHostRanksBehindQuietOneAtEqualAllocation() {
+        Host quiet = host(1L, "quiet", 0.30, 0.30, new HostLoad(0.05, 0.05, 
10), 30);
+        Host busy = host(2L, "busy", 0.30, 0.30, new HostLoad(0.70, 0.30, 10), 
30);
+
+        assertEquals("quiet", rankedNames(Arrays.asList(busy, quiet)).get(0));
+    }
+
+    @Test
+    public void testHostOverThresholdIsNotChosenWhileAHealthyOneExists() {
+        // one healthy host and five over threshold: the spread must not 
shuffle a busy host in front
+        List<Host> input = new ArrayList<>();
+        input.add(host(1L, "healthy", 0.30, 0.30, new HostLoad(0.50, 0.50, 
10), 30));
+        for (long id = 2; id <= 6; id++) {
+            input.add(host(id, "busy" + id, 0.30, 0.30, new HostLoad(0.95, 
0.50, 10), 30));
+        }
+
+        for (int attempt = 0; attempt < 50; attempt++) {
+            assertEquals("the only healthy host must always lead", "healthy", 
rankedNames(input).get(0));
+        }
+    }
+
+    @Test
+    public void testUnmeasuredHostRanksBehindEveryMeasuredHost() {
+        // a host whose stats have stopped must not look idle and collect the 
deployments
+        Host measured = host(1L, "measured", 0.60, 0.60, new HostLoad(0.40, 
0.40, 10), 60);
+        Host unmeasured = host(2L, "unmeasured", 0.05, 0.05, HostLoad.UNKNOWN, 
2);
+
+        List<String> ranked = rankedNames(Arrays.asList(unmeasured, measured));
+
+        assertEquals("measured", ranked.get(0));
+        assertEquals("unmeasured", ranked.get(1));
+    }
+
+    @Test
+    public void testRankingFallsBackToAllocationWhenNothingIsMeasured() {
+        Host light = host(1L, "light", 0.10, 0.10, HostLoad.UNKNOWN, 5);
+        Host heavy = host(2L, "heavy", 0.90, 0.90, HostLoad.UNKNOWN, 90);
+
+        assertEquals("light", rankedNames(Arrays.asList(heavy, light)).get(0));
+    }
+
+    @Test
+    public void testEveryHostIsStillOfferedWhenTheWholeClusterIsBusy() {
+        List<Host> input = Arrays.asList(
+                host(1L, "a", 0.30, 0.30, new HostLoad(0.95, 0.50, 10), 30),
+                host(2L, "b", 0.40, 0.30, new HostLoad(0.97, 0.50, 10), 40));
+
+        List<String> ranked = rankedNames(input);
+
+        assertEquals("deployment must remain possible", 2, ranked.size());
+    }
+
+    @Test
+    public void testSpreadVariesTheLeadAmongHealthyHosts() {
+        List<Host> input = Arrays.asList(
+                host(1L, "a", 0.30, 0.30, new HostLoad(0.10, 0.10, 10), 30),
+                host(2L, "b", 0.31, 0.30, new HostLoad(0.10, 0.10, 10), 30),
+                host(3L, "c", 0.32, 0.30, new HostLoad(0.10, 0.10, 10), 30),
+                host(4L, "d", 0.90, 0.30, new HostLoad(0.10, 0.10, 10), 90));
+
+        Set<String> leaders = new HashSet<>();
+        for (int attempt = 0; attempt < 200; attempt++) {
+            leaders.add(rankedNames(input).get(0));
+        }
+
+        assertTrue("concurrent deployments must not all pick one host", 
leaders.size() > 1);
+        assertFalse("the clearly worst host must never lead", 
leaders.contains("d"));
+    }
+
+    @Test
+    public void testHostMissingACapacityRowIsNotRankedFirst() {
+        Host complete = host(1L, "complete", 0.60, 0.60, new HostLoad(0.40, 
0.40, 10), 60);
+        Host partial = Mockito.mock(Host.class);
+        Mockito.lenient().when(partial.getId()).thenReturn(2L);
+        Mockito.lenient().when(partial.getName()).thenReturn("partial");
+        // only a CPU row: memory must not be treated as untouched
+        capacities.add(capacity(2L, Capacity.CAPACITY_TYPE_CPU, 0L, CORES));
+
+        List<String> ranked = rankedNames(Arrays.asList(partial, complete));
+
+        assertEquals("complete", ranked.get(0));
+        assertEquals("partial", ranked.get(1));
+    }
+
+    @Test
+    public void testHostWithTooManyVmsStartingIsHeldBack() {
+        // the emptiest host in the cluster by every other measure, but 
already working through a
+        // queue of starts: the next deployment must not join that queue
+        Host queueing = host(1L, "queueing", 0.05, 0.05, new HostLoad(0.05, 
0.05, 10), 5, 12);
+        Host busier = host(2L, "busier", 0.60, 0.60, new HostLoad(0.50, 0.50, 
10), 60, 0);
+
+        List<String> ranked = rankedNames(Arrays.asList(queueing, busier));
+
+        assertEquals("busier", ranked.get(0));
+        assertEquals("queueing", ranked.get(1));
+    }
+
+    @Test
+    public void testHostBelowTheStartingThresholdIsNotHeldBack() {
+        Host starting = host(1L, "starting", 0.05, 0.05, new HostLoad(0.05, 
0.05, 10), 5, 9);
+        Host busier = host(2L, "busier", 0.60, 0.60, new HostLoad(0.50, 0.50, 
10), 60, 0);
+
+        assertEquals("nine starts is under the default threshold of ten",
+                "starting", rankedNames(Arrays.asList(starting, 
busier)).get(0));
+    }
+
+    @Test
+    public void testEveryHostBackedUpStillDeploys() {
+        // nothing is eligible, but a deployment must still be placed rather 
than failing. The
+        // held-back group is ordered by starting count rather than by score, 
so the most backed up
+        // host stays last even though it is the cheapest on every other 
measure.
+        Host worst = host(1L, "worst", 0.10, 0.10, new HostLoad(0.10, 0.10, 
10), 10, 40);
+        Host least = host(2L, "least", 0.80, 0.80, new HostLoad(0.60, 0.60, 
10), 80, 11);
+        Host middle = host(3L, "middle", 0.10, 0.10, new HostLoad(0.10, 0.10, 
10), 10, 20);
+        Host nearly = host(4L, "nearly", 0.10, 0.10, new HostLoad(0.10, 0.10, 
10), 10, 25);
+
+        List<Host> input = Arrays.asList(worst, least, middle, nearly);
+
+        for (int attempt = 0; attempt < 200; attempt++) {
+            List<String> ranked = rankedNames(input);
+            assertEquals("every host must still be offered", 4, ranked.size());
+            assertEquals("the most backed up host must stay last", "worst", 
ranked.get(3));
+            assertFalse("the most backed up host must never lead", 
"worst".equals(ranked.get(0)));
+        }
+    }
+
+    @Test
+    public void testStartingThresholdOfZeroDisablesTheGate() {
+        scorer = new WeightedHostScorer() {
+            @Override
+            protected int startingVmsThreshold(Long clusterId) {
+                return 0;
+            }
+        };
+        ReflectionTestUtils.setField(scorer, "capacityDao", capacityDao);
+        ReflectionTestUtils.setField(scorer, "clusterDetailsDao", 
clusterDetailsDao);
+        ReflectionTestUtils.setField(scorer, "vmInstanceDao", vmInstanceDao);
+        ReflectionTestUtils.setField(scorer, "hostLoadTracker", 
hostLoadTracker);
+        scorer.random = new Random(1L);
+
+        Host queueing = host(1L, "queueing", 0.05, 0.05, new HostLoad(0.05, 
0.05, 10), 5, 50);
+        Host busier = host(2L, "busier", 0.60, 0.60, new HostLoad(0.50, 0.50, 
10), 60, 0);
+
+        assertEquals("with the gate disabled only the score matters",
+                "queueing", rankedNames(Arrays.asList(queueing, 
busier)).get(0));
+    }
+
+    @Test
+    public void testSpreadStillAppliesWhenNoHostIsEligible() {
+        // regression: the spread used to be applied to the healthy list only. 
With every host held
+        // back, healthy was empty, the shuffle was a no-op and concurrent 
deployments fell through
+        // in strict score order - agreeing on one host in exactly the 
conditions that created the
+        // pile-up in the first place.
+        List<Host> input = Arrays.asList(
+                host(1L, "a", 0.30, 0.30, new HostLoad(0.10, 0.10, 10), 30, 
20),
+                host(2L, "b", 0.31, 0.30, new HostLoad(0.10, 0.10, 10), 30, 
20),
+                host(3L, "c", 0.32, 0.30, new HostLoad(0.10, 0.10, 10), 30, 
20));
+
+        Set<String> leaders = new HashSet<>();
+        for (int attempt = 0; attempt < 200; attempt++) {
+            leaders.add(rankedNames(input).get(0));
+        }
+
+        assertTrue("held-back hosts must still be spread over, not handed out 
in strict order",
+                leaders.size() > 1);
+    }
+
+    @Test
+    public void testUnmeasuredHostIsPreferredOverAResourceSaturatedOne() {
+        // regression for the same branch: with healthy empty but an 
unmeasured host present, the
+        // ordering between the remaining tiers must still hold
+        Host saturated = host(1L, "saturated", 0.95, 0.95, new HostLoad(0.99, 
0.99, 10), 95);
+        Host unknown = host(2L, "unknown", 0.10, 0.10, HostLoad.UNKNOWN, 10);
+
+        List<String> ranked = rankedNames(Arrays.asList(saturated, unknown));
+
+        assertEquals("unknown", ranked.get(0));
+        assertEquals("saturated", ranked.get(1));
+    }
+}
diff --git 
a/server/src/test/java/com/cloud/agent/manager/allocator/impl/WeightedHostScorerTest.java
 
b/server/src/test/java/com/cloud/agent/manager/allocator/impl/WeightedHostScorerTest.java
new file mode 100644
index 00000000000..d5097d79d21
--- /dev/null
+++ 
b/server/src/test/java/com/cloud/agent/manager/allocator/impl/WeightedHostScorerTest.java
@@ -0,0 +1,175 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package com.cloud.agent.manager.allocator.impl;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import com.cloud.host.Host;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(MockitoJUnitRunner.class)
+public class WeightedHostScorerTest {
+
+    private static final HostLoad IDLE = new HostLoad(0.0, 0.0, 10);
+
+    @Mock
+    private HostLoadTracker hostLoadTracker;
+
+    @InjectMocks
+    private WeightedHostScorer scorer = new WeightedHostScorer();
+
+    private long nextHostId;
+
+    @Before
+    public void setUp() {
+        nextHostId = 1;
+    }
+
+    private Host host(String name) {
+        Host host = Mockito.mock(Host.class);
+        Mockito.lenient().when(host.getId()).thenReturn(nextHostId++);
+        Mockito.lenient().when(host.getName()).thenReturn(name);
+        return host;
+    }
+
+    private double score(double cpuAllocated, double memAllocated, HostLoad 
load, long vms, long recentStarts) {
+        return scorer.scoreHostIn(null, cpuAllocated, memAllocated, load, vms, 
recentStarts);
+    }
+
+    @Test
+    public void testIdleHostScoresZero() {
+        assertEquals(0.0, score(0, 0, IDLE, 0, 0), 1e-9);
+    }
+
+    @Test
+    public void testMoreAllocationScoresHigher() {
+        assertTrue(score(0.5, 0.5, IDLE, 0, 0) > score(0.1, 0.1, IDLE, 0, 0));
+    }
+
+    @Test
+    public void testBusyHostScoresHigherThanIdleHostWithSameAllocation() {
+        HostLoad busy = new HostLoad(0.9, 0.5, 10);
+        assertTrue("measured load must separate hosts that look identical by 
allocation",
+                score(0.05, 0.05, busy, 20, 0) > score(0.05, 0.05, IDLE, 20, 
0));
+    }
+
+    @Test
+    public void testUtilisationIgnoredUntilThereAreSamples() {
+        // a host with no samples must not be treated as idle, it must rank on 
allocation alone
+        double noSamples = score(0.4, 0.4, HostLoad.UNKNOWN, 10, 0);
+        double idleSamples = score(0.4, 0.4, IDLE, 10, 0);
+        assertTrue("a host with no load samples should not outrank a 
measurably idle one",
+                noSamples >= idleSamples);
+    }
+
+    @Test
+    public void testDominantResourcePenalisesLopsidedHost() {
+        // same mean across resources, but one host is nearly out of memory
+        double balanced = score(0.5, 0.5, IDLE, 0, 0);
+        double lopsided = score(0.05, 0.95, IDLE, 0, 0);
+        assertTrue("a host nearly out of one resource must not rank as well as 
an evenly loaded one",
+                lopsided > balanced);
+    }
+
+    @Test
+    public void testDominantResourceUsesUtilisationWhenHigherThanAllocation() {
+        HostLoad reclaimedButBusy = new HostLoad(0.95, 0.95, 10);
+        assertTrue(score(0.05, 0.05, reclaimedButBusy, 0, 0) > score(0.05, 
0.05, IDLE, 0, 0));
+    }
+
+    @Test
+    public void testVmCountPenalisesHost() {
+        assertTrue(score(0.1, 0.1, IDLE, 120, 0) > score(0.1, 0.1, IDLE, 5, 
0));
+    }
+
+    @Test
+    public void testRecentStartsPenaliseHost() {
+        assertTrue("VMs that just started are not yet visible in allocation or 
utilisation",
+                score(0.1, 0.1, IDLE, 20, 20) > score(0.1, 0.1, IDLE, 20, 0));
+    }
+
+    @Test
+    public void testScoreStaysWithinUnitRange() {
+        assertTrue(score(1, 1, new HostLoad(1, 1, 10), 1000, 1000) <= 1.0);
+        assertTrue(score(0, 0, IDLE, 0, 0) >= 0.0);
+    }
+
+    @Test
+    public void testBusyHostIsHeldBackByThreshold() {
+        Host quiet = host("quiet");
+        Host busy = host("busy");
+        Mockito.when(hostLoadTracker.getLoad(quiet.getId())).thenReturn(new 
HostLoad(0.10, 0.10, 10));
+        Mockito.when(hostLoadTracker.getLoad(busy.getId())).thenReturn(new 
HostLoad(0.99, 0.10, 10));
+
+        List<Host> healthy = new ArrayList<>();
+        List<Host> tooBusy = new ArrayList<>();
+        scorer.partitionByUtilisation(null, new ArrayList<>(List.of(quiet, 
busy)), healthy, tooBusy);
+
+        assertEquals(1, healthy.size());
+        assertSame(quiet, healthy.get(0));
+        assertSame("host over the CPU threshold must be held back", busy, 
tooBusy.get(0));
+    }
+
+    @Test
+    public void testThresholdIsIgnoredWhenEveryHostIsBusy() {
+        Host a = host("a");
+        Host b = host("b");
+        
Mockito.when(hostLoadTracker.getLoad(Mockito.anyLong())).thenReturn(new 
HostLoad(0.99, 0.99, 10));
+
+        List<Host> healthy = new ArrayList<>();
+        List<Host> tooBusy = new ArrayList<>();
+        scorer.partitionByUtilisation(null, new ArrayList<>(List.of(a, b)), 
healthy, tooBusy);
+
+        assertEquals("both hosts are over threshold", 2, tooBusy.size());
+        assertTrue(healthy.isEmpty());
+    }
+
+    @Test
+    public void testSelectionSpreadVariesTheChosenHost() {
+        Set<String> chosen = new HashSet<>();
+        List<Host> hosts = List.of(host("a"), host("b"), host("c"), host("d"), 
host("e"));
+        for (int i = 0; i < 200; i++) {
+            List<Host> ranked = new ArrayList<>(hosts);
+            scorer.applySelectionSpread(null, ranked);
+            chosen.add(ranked.get(0).getName());
+        }
+        assertNotEquals("strict ordering sends every concurrent deployment to 
the same host", 1, chosen.size());
+        assertTrue("only the best scoring hosts should be candidates", 
chosen.size() <= 3);
+    }
+
+    @Test
+    public void testSelectionSpreadLeavesShortListsAlone() {
+        List<Host> ranked = new ArrayList<>(List.of(host("only")));
+        scorer.applySelectionSpread(null, ranked);
+        assertEquals(1, ranked.size());
+    }
+}
diff --git 
a/server/src/test/java/com/cloud/agent/manager/allocator/impl/WeightedPlacementDistributionTest.java
 
b/server/src/test/java/com/cloud/agent/manager/allocator/impl/WeightedPlacementDistributionTest.java
new file mode 100644
index 00000000000..22fa6c56583
--- /dev/null
+++ 
b/server/src/test/java/com/cloud/agent/manager/allocator/impl/WeightedPlacementDistributionTest.java
@@ -0,0 +1,319 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package com.cloud.agent.manager.allocator.impl;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Random;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Simulates placement over a churning, heavily overprovisioned fleet.
+ *
+ * This is the regression fixture for the failure the weighted scoring exists 
to prevent. Ranking
+ * hosts on allocated capacity alone is blind to what a host is really doing, 
and under a large
+ * overprovisioning factor the gap between the two can be enormous: a host can 
report a few percent
+ * allocated while its cores are saturated. Anything the scheduler is not told 
about - VMs it has
+ * lost track of, guests using far more than their share - is invisible, so 
the emptiest looking
+ * host keeps being chosen no matter how hard it is working.
+ *
+ * Three things are modelled that a single-shot unit test cannot show:
+ *
+ *  - deployments arrive in concurrent batches and every decision in a batch 
reads the same figures,
+ *    because capacity is only charged once a VM starts
+ *  - real load that allocation cannot account for
+ *  - VMs are short lived and their lifetimes vary, so hosts empty unevenly
+ *
+ * Deterministic: fixed seeds, no wall clock.
+ */
+public class WeightedPlacementDistributionTest {
+
+    private static final int HOSTS = 9;
+    private static final int CORES_PER_HOST = 192;
+    private static final int MEMORY_MB_PER_HOST = 1_132_000;
+    private static final int CPU_OVERCOMMIT = 10;
+    private static final int MEMORY_OVERCOMMIT = 4;
+
+    private static final int VM_CORES = 4;
+    private static final int VM_MEMORY_MB = 8_192;
+
+    private static final int BATCHES = 300;
+    private static final int VMS_PER_BATCH = 8;
+    private static final int SPREAD = 3;
+
+    /** Share of VMs that peg their cores for their whole life, as build 
runners do. */
+    private static final double BUSY_FRACTION = 0.35;
+
+    /**
+     * Cores in use on some hosts that allocation knows nothing about. Stands 
in for anything the
+     * scheduler cannot see - VMs it believes are gone, or guests far 
exceeding their request.
+     */
+    private static final int UNACCOUNTED_CORES = 60;
+
+    private static final class SimHost {
+        final long id;
+        final double unaccountedCores;
+        int vms;
+        int recentStarts;
+        double busyCores;
+        double memoryMb;
+
+        SimHost(long id, double unaccountedCores) {
+            this.id = id;
+            this.unaccountedCores = unaccountedCores;
+        }
+
+        /** What the capacity tables would report: uniform per VM, against an 
inflated total. */
+        double cpuAllocated() {
+            return (double) vms * VM_CORES / (CORES_PER_HOST * CPU_OVERCOMMIT);
+        }
+
+        double memoryAllocated() {
+            return (double) vms * VM_MEMORY_MB / ((double) MEMORY_MB_PER_HOST 
* MEMORY_OVERCOMMIT);
+        }
+
+        /** What the host is really doing, including what allocation cannot 
see. */
+        double realCores() {
+            return busyCores + unaccountedCores;
+        }
+
+        HostLoad load() {
+            return new HostLoad(Math.min(1, realCores() / CORES_PER_HOST),
+                    Math.min(1, memoryMb / MEMORY_MB_PER_HOST), 10);
+        }
+    }
+
+    /** Frozen figures for one batch, so every decision in the batch sees the 
same thing. */
+    private static final class HostView {
+        final SimHost host;
+        final double cpuAllocated;
+        final double memoryAllocated;
+        final HostLoad load;
+        final int vms;
+        final int recentStarts;
+
+        HostView(SimHost host) {
+            this.host = host;
+            this.cpuAllocated = host.cpuAllocated();
+            this.memoryAllocated = host.memoryAllocated();
+            this.load = host.load();
+            this.vms = host.vms;
+            this.recentStarts = host.recentStarts;
+        }
+    }
+
+    private interface Placement {
+        SimHost choose(List<HostView> snapshot, Random random);
+    }
+
+    /** What firstfitleastconsumed does today: strictly the lowest allocated 
fraction. */
+    private static final Placement LEAST_ALLOCATED = (snapshot, random) ->
+            snapshot.stream().min(Comparator.comparingDouble(v -> 
v.cpuAllocated)).orElseThrow().host;
+
+    /**
+     * Allocation-only ranking with the same random spread as the weighted 
arm. Isolates what the
+     * scoring contributes from what the spread alone contributes.
+     */
+    private static final Placement LEAST_ALLOCATED_WITH_SPREAD = (snapshot, 
random) -> {
+        List<HostView> ranked = new ArrayList<>(snapshot);
+        ranked.sort(Comparator.comparingDouble(v -> v.cpuAllocated));
+        return ranked.get(random.nextInt(Math.min(SPREAD, 
ranked.size()))).host;
+    };
+
+    private Placement weighted() {
+        WeightedHostScorer scorer = new WeightedHostScorer();
+        return (snapshot, random) -> {
+            List<HostView> ranked = new ArrayList<>(snapshot);
+            ranked.sort(Comparator.comparingDouble(v ->
+                    scorer.scoreHostIn(null, v.cpuAllocated, 
v.memoryAllocated, v.load, v.vms, v.recentStarts)));
+            return ranked.get(random.nextInt(Math.min(SPREAD, 
ranked.size()))).host;
+        };
+    }
+
+    private static final class Result {
+        final double[] vmCounts;
+        final double[] realCores;
+
+        Result(double[] vmCounts, double[] realCores) {
+            this.vmCounts = vmCounts;
+            this.realCores = realCores;
+        }
+
+        double vmSkew() {
+            return max(vmCounts) / mean(vmCounts);
+        }
+
+        double loadSkew() {
+            return max(realCores) / mean(realCores);
+        }
+    }
+
+    /** The VMs to be placed, fixed before any arm runs so all arms see the 
same workload. */
+    private List<int[]> workload(long seed) {
+        Random random = new Random(seed);
+        List<int[]> vms = new ArrayList<>();
+        for (int i = 0; i < BATCHES * VMS_PER_BATCH; i++) {
+            vms.add(new int[] {random.nextDouble() < BUSY_FRACTION ? 1 : 0, 10 
+ random.nextInt(50)});
+        }
+        return vms;
+    }
+
+    private Result run(Placement placement, long seed) {
+        List<int[]> workload = workload(seed);
+        // a separate stream for placement decisions, so arms that consult it 
differently still see
+        // the same workload
+        Random random = new Random(seed ^ 0x5DEECE66DL);
+        int next = 0;
+        List<SimHost> hosts = new ArrayList<>();
+        for (int i = 0; i < HOSTS; i++) {
+            // a third of the fleet carries load the scheduler cannot account 
for
+            hosts.add(new SimHost(i + 1, i % 3 == 0 ? UNACCOUNTED_CORES : 0));
+        }
+        List<int[]> live = new ArrayList<>();     // {hostIndex, busy, 
batchesLeft}
+
+        for (int batch = 0; batch < BATCHES; batch++) {
+            live.removeIf(vm -> {
+                if (--vm[2] > 0) {
+                    return false;
+                }
+                SimHost host = hosts.get(vm[0]);
+                host.vms--;
+                host.memoryMb -= VM_MEMORY_MB;
+                if (vm[1] == 1) {
+                    host.busyCores -= VM_CORES;
+                }
+                return true;
+            });
+
+            hosts.forEach(h -> h.recentStarts = 0);
+
+            List<HostView> snapshot = new ArrayList<>();
+            for (SimHost host : hosts) {
+                snapshot.add(new HostView(host));
+            }
+
+            for (int i = 0; i < VMS_PER_BATCH; i++) {
+                SimHost chosen = placement.choose(snapshot, random);
+                int[] vm = workload.get(next++);
+                boolean busy = vm[0] == 1;
+                chosen.vms++;
+                chosen.recentStarts++;
+                chosen.memoryMb += VM_MEMORY_MB;
+                if (busy) {
+                    chosen.busyCores += VM_CORES;
+                }
+                // lifetimes vary, so hosts do not empty in the order they 
filled
+                live.add(new int[] {hosts.indexOf(chosen), vm[0], vm[1]});
+            }
+        }
+
+        return new Result(hosts.stream().mapToDouble(h -> h.vms).toArray(),
+                hosts.stream().mapToDouble(SimHost::realCores).toArray());
+    }
+
+    private static double max(double[] values) {
+        return Arrays.stream(values).max().orElse(0);
+    }
+
+    private static double mean(double[] values) {
+        return Arrays.stream(values).average().orElse(0);
+    }
+
+    @Test
+    public void testAllocationOnlyOrderingPilesRealLoadOntoTheBusiestHosts() {
+        Result result = run(LEAST_ALLOCATED, 42L);
+
+        assertTrue(String.format("expected allocation-only ranking to be blind 
to real load, "
+                        + "got cores %s (max/mean %.2f)", 
Arrays.toString(result.realCores), result.loadSkew()),
+                result.loadSkew() > 1.4);
+    }
+
+    @Test
+    public void testWeightedScoringKeepsRealLoadEven() {
+        Result result = run(weighted(), 42L);
+
+        // a third of the fleet carries a fixed handicap the scheduler can 
only stop adding to, not
+        // remove, so some residual skew is expected. Measured across seeds: 
allocation-only ranking
+        // lands at 1.84 to 2.01, weighted at 1.27 to 1.40.
+        assertTrue(String.format("real load should be spread, got cores %s 
(max/mean %.2f)",
+                        Arrays.toString(result.realCores), result.loadSkew()),
+                result.loadSkew() < 1.5);
+    }
+
+    @Test
+    public void testWeightedScoringBeatsAllocationOnlyOnEverySeed() {
+        for (long seed : new long[] {1L, 7L, 42L, 99L, 12345L}) {
+            double baseline = run(LEAST_ALLOCATED, seed).loadSkew();
+            double improved = run(weighted(), seed).loadSkew();
+            assertTrue(String.format("seed %d: weighted %.2f should beat 
allocation-only %.2f",
+                            seed, improved, baseline),
+                    improved < baseline);
+        }
+    }
+
+    @Test
+    public void testTheScoringNotJustTheSpreadIsWhatEvensOutRealLoad() {
+        // the control: same random spread, ranking still blind to real load. 
If the spread alone
+        // were doing the work, this arm would do as well as the weighted one.
+        for (long seed : new long[] {1L, 42L, 12345L}) {
+            double spreadOnly = run(LEAST_ALLOCATED_WITH_SPREAD, 
seed).loadSkew();
+            double weighted = run(weighted(), seed).loadSkew();
+            assertTrue(String.format("seed %d: weighted %.2f should beat 
spread-only %.2f",
+                            seed, weighted, spreadOnly),
+                    weighted < spreadOnly);
+        }
+    }
+
+    @Test
+    public void testWeightedScoringGivesFewerVmsToHostsCarryingHiddenLoad() {
+        Result result = run(weighted(), 42L);
+
+        // hosts 0, 3 and 6 carry load that allocation cannot see, so they 
should get fewer VMs.
+        // uneven VM counts are the right answer here - it is real load that 
should come out even.
+        double withHiddenLoad = mean(new double[] {result.vmCounts[0], 
result.vmCounts[3], result.vmCounts[6]});
+        double withoutHiddenLoad = mean(new double[] {result.vmCounts[1], 
result.vmCounts[2], result.vmCounts[4],
+                result.vmCounts[5], result.vmCounts[7], result.vmCounts[8]});
+
+        assertTrue(String.format("hosts with hidden load should take fewer 
VMs: %.1f vs %.1f (counts %s)",
+                        withHiddenLoad, withoutHiddenLoad, 
Arrays.toString(result.vmCounts)),
+                withHiddenLoad < withoutHiddenLoad * 0.75);
+        assertTrue("no host should be left completely unused: " + 
Arrays.toString(result.vmCounts),
+                min(result.vmCounts) > 0);
+    }
+
+    @Test
+    public void testAllocationOnlyOrderingIgnoresHiddenLoadEntirely() {
+        Result result = run(LEAST_ALLOCATED, 42L);
+
+        double withHiddenLoad = mean(new double[] {result.vmCounts[0], 
result.vmCounts[3], result.vmCounts[6]});
+        double withoutHiddenLoad = mean(new double[] {result.vmCounts[1], 
result.vmCounts[2], result.vmCounts[4],
+                result.vmCounts[5], result.vmCounts[7], result.vmCounts[8]});
+
+        assertTrue(String.format("allocation-only ranking should treat loaded 
and idle hosts alike, got %.1f vs %.1f",
+                        withHiddenLoad, withoutHiddenLoad),
+                withHiddenLoad > withoutHiddenLoad * 0.9);
+    }
+
+    private static double min(double[] values) {
+        return Arrays.stream(values).min().orElse(0);
+    }
+}

Reply via email to