use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use tracing::{debug, info};
use super::algorithms::{BinPackScheduler, SchedulingAlgorithm};
use super::sim::{CoPlacement, GangGroup};
use super::{NodeResources, ResourceRequirements, Workload};
use crate::types::NodeId;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GangReservation {
pub node: NodeId,
pub resources: ResourceRequirements,
pub gpu_ids: Vec<u32>,
}
#[derive(Debug, Clone)]
pub struct GangDecision {
pub group_id: String,
pub placements: Vec<(String, NodeId)>,
pub reservations: Vec<GangReservation>,
pub committed: bool,
pub reason: String,
}
impl GangDecision {
fn failed(group_id: &str, reason: impl Into<String>) -> Self {
Self {
group_id: group_id.to_string(),
placements: Vec::new(),
reservations: Vec::new(),
committed: false,
reason: reason.into(),
}
}
}
pub struct GangScheduler {
algorithm: Box<dyn SchedulingAlgorithm>,
}
impl Default for GangScheduler {
fn default() -> Self {
Self::new()
}
}
impl GangScheduler {
pub fn new() -> Self {
Self {
algorithm: Box::new(BinPackScheduler::new()),
}
}
pub fn with_algorithm<A: SchedulingAlgorithm + 'static>(algorithm: A) -> Self {
Self {
algorithm: Box::new(algorithm),
}
}
pub fn schedule_gang(
&self,
group: &GangGroup,
nodes: &mut HashMap<NodeId, NodeResources>,
) -> GangDecision {
if group.is_empty() {
return GangDecision::failed(&group.id, "gang has no members");
}
let result = match group.co_placement {
CoPlacement::SameNode | CoPlacement::InterconnectLocalGpu => {
self.commit_same_node(group, nodes)
}
CoPlacement::Spread => self.commit_spread(group, nodes),
};
match result {
Ok((placements, reservations)) => {
info!(
group = %group.id,
members = placements.len(),
policy = ?group.co_placement,
"gang committed atomically"
);
GangDecision {
group_id: group.id.clone(),
placements,
reservations,
committed: true,
reason: "all members placed".to_string(),
}
}
Err(reason) => {
debug!(group = %group.id, %reason, "gang placement aborted; nothing reserved");
GangDecision::failed(&group.id, reason)
}
}
}
fn ranked_nodes(
&self,
workload: &Workload,
nodes: &HashMap<NodeId, NodeResources>,
exclude: &[NodeId],
) -> Vec<NodeId> {
let mut ranked: Vec<NodeId> = nodes
.keys()
.copied()
.filter(|n| !exclude.contains(n))
.collect();
ranked.sort_by(|a, b| {
let sa = self.algorithm.score(workload, &nodes[a]);
let sb = self.algorithm.score(workload, &nodes[b]);
sb.partial_cmp(&sa)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| nodes[b].cpu_available().cmp(&nodes[a].cpu_available()))
});
ranked
}
fn commit_same_node(
&self,
group: &GangGroup,
nodes: &mut HashMap<NodeId, NodeResources>,
) -> Result<(Vec<(String, NodeId)>, Vec<GangReservation>), String> {
let anchor = &group.members[0].workload;
for node_id in self.ranked_nodes(anchor, nodes, &[]) {
if group.co_placement.wants_gpu_locality()
&& !self.has_interconnect_local_gpus(&nodes[&node_id], group)
{
continue;
}
let before_gpus: Vec<u32> = nodes[&node_id].gpus_allocated.clone();
let mut trial = nodes[&node_id].clone();
if Self::try_fit_all_on_node(group, &mut trial) {
let gpu_ids: Vec<u32> = trial
.gpus_allocated
.iter()
.filter(|d| !before_gpus.contains(d))
.copied()
.collect();
nodes.insert(node_id, trial);
let placements: Vec<(String, NodeId)> = group
.members
.iter()
.map(|m| (m.workload.id.clone(), node_id))
.collect();
let reservations = vec![GangReservation {
node: node_id,
resources: Self::sum_resources(group),
gpu_ids,
}];
return Ok((placements, reservations));
}
}
Err(format!(
"no single node fits all {} members under {:?}",
group.members.len(),
group.co_placement
))
}
fn sum_resources(group: &GangGroup) -> ResourceRequirements {
let mut cpu = 0u64;
let mut mem = 0u64;
let mut gpu = 0u32;
for m in &group.members {
cpu += m.workload.resources.cpu_millis;
mem += m.workload.resources.memory_mb;
gpu += m.workload.resources.gpu_count;
}
ResourceRequirements {
cpu_millis: cpu,
memory_mb: mem,
gpu_count: gpu,
gpu_memory_mb: 0,
storage_mb: 0,
network_mbps: 0,
}
}
fn commit_spread(
&self,
group: &GangGroup,
nodes: &mut HashMap<NodeId, NodeResources>,
) -> Result<(Vec<(String, NodeId)>, Vec<GangReservation>), String> {
let mut placements: Vec<(String, NodeId)> = Vec::with_capacity(group.members.len());
let mut committed: Vec<(NodeId, ResourceRequirements, Vec<u32>)> = Vec::new();
let mut used: Vec<NodeId> = Vec::new();
for member in &group.members {
let req = &member.workload.resources;
let chosen = self
.ranked_nodes(&member.workload, nodes, &used)
.into_iter()
.find(|n| nodes.get(n).map(|node| node.can_fit(req)).unwrap_or(false));
match chosen {
Some(node_id) => {
let node = nodes.get_mut(&node_id).expect("ranked node exists");
let before: Vec<u32> = node.gpus_allocated.clone();
if !node.allocate(req) {
Self::release_committed(&committed, nodes);
return Err(format!(
"spread: allocation raced for member {}",
member.workload.id
));
}
let gpu_ids: Vec<u32> = node
.gpus_allocated
.iter()
.filter(|d| !before.contains(d))
.copied()
.collect();
committed.push((node_id, req.clone(), gpu_ids));
used.push(node_id);
placements.push((member.workload.id.clone(), node_id));
}
None => {
Self::release_committed(&committed, nodes);
return Err(format!(
"spread: no distinct node available for member {}",
member.workload.id
));
}
}
}
let reservations = committed
.into_iter()
.map(|(node, resources, gpu_ids)| GangReservation {
node,
resources,
gpu_ids,
})
.collect();
Ok((placements, reservations))
}
fn release_committed(
committed: &[(NodeId, ResourceRequirements, Vec<u32>)],
nodes: &mut HashMap<NodeId, NodeResources>,
) {
for (node_id, req, gpu_ids) in committed {
if let Some(node) = nodes.get_mut(node_id) {
node.release(req, gpu_ids);
}
}
}
fn try_fit_all_on_node(group: &GangGroup, node: &mut NodeResources) -> bool {
for member in &group.members {
if !node.allocate(&member.workload.resources) {
return false;
}
}
true
}
fn has_interconnect_local_gpus(&self, node: &NodeResources, group: &GangGroup) -> bool {
let needed = group.total_gpu_count() as usize;
if needed == 0 {
return true;
}
let max_per_gpu_mem = group
.members
.iter()
.map(|m| m.workload.resources.gpu_memory_mb)
.max()
.unwrap_or(0);
let mut free: Vec<u32> = node
.gpus
.iter()
.filter(|g| !node.gpus_allocated.contains(&g.device_id))
.filter(|g| g.available_memory_mb() >= max_per_gpu_mem)
.map(|g| g.device_id)
.collect();
free.sort_unstable();
if free.len() < needed {
return false;
}
let mut run = 1usize;
for w in free.windows(2) {
if w[1] == w[0] + 1 {
run += 1;
if run >= needed {
return true;
}
} else {
run = 1;
}
}
free.len() >= needed
}}
#[cfg(test)]
mod tests {
use super::*;
use crate::scheduler::sim::{AgentPolicy, SimCell, SimWorld};
use crate::scheduler::ResourceRequirements;
use crate::types::{GpuResources, NodeId};
use std::time::Duration;
fn gpu_node(device_ids: &[u32], cpu: u64, mem: u64, gpu_mem: u64) -> NodeResources {
let mut node = NodeResources::new(NodeId::new(), cpu, mem);
for &id in device_ids {
node = node.with_gpu(GpuResources::new(id, "A100", gpu_mem).with_tensor_cores(true));
}
node
}
fn store(nodes: Vec<NodeResources>) -> HashMap<NodeId, NodeResources> {
nodes.into_iter().map(|n| (n.node_id, n)).collect()
}
fn cell() -> SimCell {
SimCell::new("c1", SimWorld::cpu(1000, 2048), Duration::from_millis(50))
.with_agent(AgentPolicy::gpu("a0", 200, 256, 4096))
.with_agent(AgentPolicy::gpu("a1", 200, 256, 4096))
}
#[test]
fn gang_fully_places_when_one_node_fits_all() {
let group = cell().gang_group();
let node = gpu_node(&[0, 1], 8000, 16384, 8192);
let nid = node.node_id;
let mut nodes = store(vec![node]);
let decision = GangScheduler::new().schedule_gang(&group, &mut nodes);
assert!(decision.committed, "reason: {}", decision.reason);
assert_eq!(decision.placements.len(), 3);
assert!(decision.placements.iter().all(|(_, n)| *n == nid));
assert_eq!(nodes[&nid].gpus_allocated.len(), 2);
assert_eq!(nodes[&nid].cpu_allocated, 1000 + 200 + 200);
}
#[test]
fn gang_fully_fails_and_reserves_nothing_when_no_node_fits() {
let group = cell().gang_group();
let node = gpu_node(&[0], 8000, 16384, 8192);
let nid = node.node_id;
let before_cpu = node.cpu_allocated;
let mut nodes = store(vec![node]);
let decision = GangScheduler::new().schedule_gang(&group, &mut nodes);
assert!(!decision.committed);
assert!(decision.placements.is_empty());
assert_eq!(nodes[&nid].cpu_allocated, before_cpu);
assert_eq!(nodes[&nid].gpus_allocated.len(), 0);
}
#[test]
fn gang_does_not_split_across_two_partial_nodes() {
let group = cell().gang_group();
let n1 = gpu_node(&[0], 8000, 16384, 8192);
let n2 = gpu_node(&[0], 8000, 16384, 8192);
let id1 = n1.node_id;
let id2 = n2.node_id;
let mut nodes = store(vec![n1, n2]);
let decision = GangScheduler::new().schedule_gang(&group, &mut nodes);
assert!(!decision.committed);
assert_eq!(nodes[&id1].gpus_allocated.len(), 0);
assert_eq!(nodes[&id2].gpus_allocated.len(), 0);
}
#[test]
fn spread_places_members_on_distinct_nodes() {
let cell = SimCell::new("s1", SimWorld::cpu(500, 512), Duration::from_millis(50))
.with_agent(AgentPolicy::new(
"a0",
ResourceRequirements::new().cpu(500).memory(512),
))
.with_agent(AgentPolicy::new(
"a1",
ResourceRequirements::new().cpu(500).memory(512),
))
.with_co_placement(CoPlacement::Spread);
let group = cell.gang_group();
let nodes_vec: Vec<NodeResources> = (0..3)
.map(|_| NodeResources::new(NodeId::new(), 4000, 4096))
.collect();
let mut nodes = store(nodes_vec);
let decision = GangScheduler::new().schedule_gang(&group, &mut nodes);
assert!(decision.committed, "reason: {}", decision.reason);
assert_eq!(decision.placements.len(), 3);
let mut placed: Vec<NodeId> = decision.placements.iter().map(|(_, n)| *n).collect();
placed.sort_by_key(|n| *n.as_uuid());
placed.dedup();
assert_eq!(placed.len(), 3, "spread must use distinct nodes");
}
#[test]
fn spread_fails_when_too_few_nodes() {
let cell = SimCell::new("s2", SimWorld::cpu(500, 512), Duration::from_millis(50))
.with_agent(AgentPolicy::new(
"a0",
ResourceRequirements::new().cpu(500).memory(512),
))
.with_co_placement(CoPlacement::Spread);
let group = cell.gang_group();
let node = NodeResources::new(NodeId::new(), 4000, 4096);
let nid = node.node_id;
let mut nodes = store(vec![node]);
let decision = GangScheduler::new().schedule_gang(&group, &mut nodes);
assert!(!decision.committed);
assert_eq!(nodes[&nid].cpu_allocated, 0, "spread abort reserves nothing");
}
#[test]
fn spread_rollback_frees_committed_gpu_devices() {
let cell = SimCell::new("g", SimWorld::cpu(1000, 1024), Duration::from_millis(50))
.with_agent(AgentPolicy::gpu("a0", 200, 256, 4096))
.with_agent(AgentPolicy::gpu("a1", 200, 256, 4096))
.with_co_placement(CoPlacement::Spread);
let group = cell.gang_group();
let n1 = gpu_node(&[0], 8000, 16384, 8192);
let n2 = gpu_node(&[0], 8000, 16384, 8192);
let id1 = n1.node_id;
let id2 = n2.node_id;
let mut nodes = store(vec![n1, n2]);
let decision = GangScheduler::new().schedule_gang(&group, &mut nodes);
assert!(!decision.committed);
assert!(decision.placements.is_empty());
assert_eq!(nodes[&id1].cpu_allocated, 0);
assert_eq!(nodes[&id2].cpu_allocated, 0);
assert_eq!(nodes[&id1].gpus_allocated.len(), 0, "GPU device must be freed on rollback");
assert_eq!(nodes[&id2].gpus_allocated.len(), 0, "GPU device must be freed on rollback");
}
}