mod algorithm;
mod moves;
use std::{collections::VecDeque, thread};
pub use algorithm::{rebalance_experts, Algorithm};
pub use moves::{plan_slot_moves, SlotMoves};
pub const COMMIT_DELAY_FORWARDS: u64 = 200;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EplbSpecs {
pub num_layers: usize,
pub num_logical_experts: usize,
pub num_physical_experts: usize,
pub ep_size: usize,
pub ep_rank: usize,
pub ep_rank_nodes: Vec<i64>,
pub all_to_all_ep: bool,
pub num_groups: Option<usize>,
pub num_nodes: usize,
}
impl EplbSpecs {
pub fn num_local(&self) -> usize {
self.num_physical_experts / self.ep_size
}
}
#[derive(Debug, Clone)]
pub struct EplbConfig {
pub specs: EplbSpecs,
pub rebalance_num_iterations: u64,
pub layers_per_chunk: usize,
pub algorithm: Algorithm,
pub commit_delay_forwards: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InternalOp {
Snapshot,
Commit,
ApplyChunk(Vec<usize>),
}
impl InternalOp {
pub fn gate_code(&self) -> i64 {
match self {
InternalOp::Snapshot => 1,
InternalOp::Commit => 2,
InternalOp::ApplyChunk(_) => 3,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
Idle,
Computing,
Applying,
}
impl Phase {
pub fn name(self) -> &'static str {
match self {
Phase::Idle => "idle",
Phase::Computing => "computing",
Phase::Applying => "applying",
}
}
}
pub fn chunk_layer_ids(num_layers: usize, per_chunk: usize) -> Result<Vec<Vec<usize>>, String> {
if per_chunk == 0 || per_chunk > num_layers {
return Err(format!(
"layers_per_chunk={per_chunk} must be in [1, {num_layers}]"
));
}
Ok((0..num_layers)
.step_by(per_chunk)
.map(|start| (start..(start + per_chunk).min(num_layers)).collect())
.collect())
}
fn balancedness(per_slot: &[Vec<f64>], ep_size: usize) -> Vec<f64> {
per_slot
.iter()
.map(|row| {
let per_rank: Vec<f64> = row
.chunks(row.len() / ep_size)
.map(|chunk| chunk.iter().sum())
.collect();
let max = per_rank.iter().copied().fold(0.0f64, f64::max).max(1.0);
per_rank.iter().sum::<f64>() / per_rank.len() as f64 / max
})
.collect()
}
fn summary(values: &[f64]) -> (f64, f64) {
let min = values.iter().copied().fold(f64::INFINITY, f64::min);
let mean = values.iter().sum::<f64>() / values.len().max(1) as f64;
(min, mean)
}
fn solve(
logical_count: Vec<Vec<i64>>,
specs: EplbSpecs,
algorithm: Algorithm,
) -> Result<Vec<Vec<i32>>, String> {
let weight: Vec<Vec<f64>> = logical_count
.iter()
.map(|row| row.iter().map(|&count| count as f64).collect())
.collect();
let map = rebalance_experts(
&weight,
specs.num_physical_experts,
specs.num_groups,
specs.num_nodes,
specs.ep_size,
algorithm,
)?;
Ok(map
.into_iter()
.map(|row| row.into_iter().map(|e| e as i32).collect())
.collect())
}
pub struct Controller {
config: EplbConfig,
chunks: Vec<Vec<usize>>,
phase: Phase,
forwards: u64,
next_snapshot_at: u64,
commit_at: Option<u64>,
solver: Option<thread::JoinHandle<Result<Vec<Vec<i32>>, String>>>,
old_map: Option<Vec<Vec<i32>>>,
new_map: Option<Vec<Vec<i32>>>,
logical_count: Option<Vec<Vec<i64>>>,
moves_by_layer: Vec<SlotMoves>,
pending_chunks: VecDeque<Vec<usize>>,
rebalances_completed: u64,
}
impl Controller {
pub fn new(config: EplbConfig) -> Result<Self, String> {
if config.rebalance_num_iterations == 0 {
return Err("rebalance_num_iterations must be positive".to_owned());
}
let chunks = chunk_layer_ids(config.specs.num_layers, config.layers_per_chunk)?;
Ok(Self {
next_snapshot_at: config.rebalance_num_iterations,
config,
chunks,
phase: Phase::Idle,
forwards: 0,
commit_at: None,
solver: None,
old_map: None,
new_map: None,
logical_count: None,
moves_by_layer: Vec::new(),
pending_chunks: VecDeque::new(),
rebalances_completed: 0,
})
}
pub fn specs(&self) -> &EplbSpecs {
&self.config.specs
}
pub fn phase(&self) -> Phase {
self.phase
}
pub fn commit_delay_forwards(&self) -> u64 {
self.config.commit_delay_forwards
}
pub fn note_round(&mut self, forwarded: bool) -> Vec<InternalOp> {
if forwarded {
self.forwards += 1;
}
match self.phase {
Phase::Idle if self.forwards >= self.next_snapshot_at => vec![self.begin_snapshot()],
Phase::Computing if self.commit_at.is_some_and(|at| self.forwards >= at) => {
self.commit_at = None;
std::iter::once(InternalOp::Commit)
.chain(self.chunks.iter().cloned().map(InternalOp::ApplyChunk))
.collect()
}
_ => Vec::new(),
}
}
pub fn request_snapshot(&mut self) -> Option<InternalOp> {
(self.phase == Phase::Idle).then(|| self.begin_snapshot())
}
fn begin_snapshot(&mut self) -> InternalOp {
self.phase = Phase::Computing;
self.next_snapshot_at = self.forwards + self.config.rebalance_num_iterations;
InternalOp::Snapshot
}
pub fn on_snapshot(
&mut self,
physical_count: Vec<Vec<i64>>,
old_map: Vec<Vec<i32>>,
) -> Result<(), String> {
if self.phase != Phase::Computing || self.commit_at.is_some() {
return Err(format!(
"unexpected expert load snapshot in phase {}",
self.phase.name()
));
}
let specs = &self.config.specs;
let shape_ok = physical_count.len() == specs.num_layers
&& physical_count
.iter()
.all(|row| row.len() == specs.num_physical_experts);
if !shape_ok || old_map.len() != specs.num_layers {
return Err(format!(
"expert load has the wrong shape, expected [{}, {}]",
specs.num_layers, specs.num_physical_experts
));
}
let mut logical = vec![vec![0i64; specs.num_logical_experts]; specs.num_layers];
for (layer, (counts, map)) in physical_count.iter().zip(&old_map).enumerate() {
for (&count, &expert) in counts.iter().zip(map) {
logical[layer][expert as usize] += count;
}
}
let routes: i64 = physical_count.iter().flatten().sum();
let loads: Vec<Vec<f64>> = physical_count
.iter()
.map(|row| row.iter().map(|&count| count as f64).collect())
.collect();
let (min, mean) = summary(&balancedness(&loads, specs.ep_size));
tracing::info!(
"Expert rebalance: snapshot of {routes} routes over {} layers; balancedness min \
{min:.3} mean {mean:.3}; commit in {} forwards",
specs.num_layers,
self.config.commit_delay_forwards
);
if specs.ep_rank == 0 {
let (count, specs, algorithm) = (logical.clone(), specs.clone(), self.config.algorithm);
self.solver = Some(
thread::Builder::new()
.name("ts-eplb-solver".to_owned())
.spawn(move || solve(count, specs, algorithm))
.map_err(|error| error.to_string())?,
);
}
self.old_map = Some(old_map);
self.logical_count = Some(logical);
self.commit_at = Some(self.forwards + self.config.commit_delay_forwards);
Ok(())
}
pub fn placement_for_commit(&mut self) -> Result<Option<Vec<Vec<i32>>>, String> {
if self.phase != Phase::Computing || self.old_map.is_none() {
return Err(format!(
"unexpected expert placement commit in phase {}",
self.phase.name()
));
}
match self.solver.take() {
Some(solver) => solver
.join()
.map_err(|_| "the EPLB solver panicked".to_owned())?
.map(Some),
None => Ok(None),
}
}
pub fn on_commit(&mut self, new_map: Vec<Vec<i32>>) -> Result<(), String> {
let Some(old_map) = self
.old_map
.as_ref()
.filter(|_| self.phase == Phase::Computing)
else {
return Err(format!(
"unexpected expert placement commit in phase {}",
self.phase.name()
));
};
let specs = &self.config.specs;
if new_map.len() != old_map.len()
|| new_map
.iter()
.any(|row| row.len() != specs.num_physical_experts)
{
return Err("the committed placement has the wrong shape".to_owned());
}
self.moves_by_layer = old_map
.iter()
.zip(&new_map)
.map(|(old, new)| {
plan_slot_moves(
old,
new,
specs.ep_rank,
&specs.ep_rank_nodes,
specs.num_local(),
)
})
.collect::<Result<_, _>>()?;
if let Some(logical) = &self.logical_count {
let per_slot: Vec<Vec<f64>> = new_map
.iter()
.zip(logical)
.map(|(row, counts)| {
let mut replicas = vec![0i64; counts.len()];
for &e in row {
replicas[e as usize] += 1;
}
row.iter()
.map(|&e| counts[e as usize] as f64 / replicas[e as usize].max(1) as f64)
.collect()
})
.collect();
let (min, mean) = summary(&balancedness(&per_slot, specs.ep_size));
let moved: usize = self
.moves_by_layer
.iter()
.map(SlotMoves::num_changed_slots)
.sum();
tracing::info!(
"Expert rebalance: committed placement moves {moved} local slots; expected \
balancedness min {min:.3} mean {mean:.3}"
);
}
self.new_map = Some(new_map);
self.pending_chunks = self.chunks.iter().cloned().collect();
self.phase = Phase::Applying;
Ok(())
}
pub fn chunk_payload(
&self,
layer_ids: &[usize],
) -> Result<(Vec<Vec<i32>>, Vec<SlotMoves>), String> {
let Some(new_map) = self
.new_map
.as_ref()
.filter(|_| self.phase == Phase::Applying)
else {
return Err(format!(
"unexpected expert placement chunk in phase {}",
self.phase.name()
));
};
if self.pending_chunks.front().map(Vec::as_slice) != Some(layer_ids) {
return Err(format!(
"chunk {layer_ids:?} is out of order; expected {:?}",
self.pending_chunks.front()
));
}
Ok((
layer_ids
.iter()
.map(|&layer| new_map[layer].clone())
.collect(),
layer_ids
.iter()
.map(|&layer| self.moves_by_layer[layer].clone())
.collect(),
))
}
pub fn on_chunk_applied(&mut self, layer_ids: &[usize]) -> Result<(), String> {
if self.pending_chunks.front().map(Vec::as_slice) != Some(layer_ids) {
return Err(format!("chunk {layer_ids:?} was not the pending chunk"));
}
self.pending_chunks.pop_front();
if !self.pending_chunks.is_empty() {
return Ok(());
}
self.phase = Phase::Idle;
self.old_map = None;
self.new_map = None;
self.logical_count = None;
self.moves_by_layer.clear();
self.rebalances_completed += 1;
tracing::info!("Expert rebalance {} applied", self.rebalances_completed);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn controller(ep_rank: usize) -> Controller {
Controller::new(EplbConfig {
specs: EplbSpecs {
num_layers: 3,
num_logical_experts: 4,
num_physical_experts: 6,
ep_size: 2,
ep_rank,
ep_rank_nodes: vec![0, 0],
all_to_all_ep: false,
num_groups: None,
num_nodes: 1,
},
rebalance_num_iterations: 5,
layers_per_chunk: 2,
algorithm: Algorithm::Deepseek,
commit_delay_forwards: 3,
})
.unwrap()
}
fn rounds(controller: &mut Controller, n: usize, forwarded: bool) -> Vec<InternalOp> {
(0..n)
.flat_map(|_| controller.note_round(forwarded))
.collect()
}
fn trivial_map() -> Vec<Vec<i32>> {
vec![vec![0, 1, 2, 3, 0, 1]; 3]
}
fn snapshot(controller: &mut Controller) -> Result<(), String> {
let load = vec![vec![1, 1, 1, 40, 1, 1]; 3];
controller.on_snapshot(load, trivial_map())
}
fn apply_all(controller: &mut Controller) {
for ids in [vec![0, 1], vec![2]] {
controller.chunk_payload(&ids).unwrap();
controller.on_chunk_applied(&ids).unwrap();
}
}
#[test]
fn a_snapshot_is_due_after_n_forwards_and_its_commit_k_later() {
let mut controller = controller(0);
assert_eq!(rounds(&mut controller, 4, true), []);
assert_eq!(rounds(&mut controller, 10, false), []);
assert_eq!(controller.note_round(true), [InternalOp::Snapshot]);
assert_eq!(controller.phase(), Phase::Computing);
assert_eq!(rounds(&mut controller, 2, true), []);
snapshot(&mut controller).unwrap();
assert_eq!(rounds(&mut controller, 2, true), []);
assert_eq!(
controller.note_round(true),
[
InternalOp::Commit,
InternalOp::ApplyChunk(vec![0, 1]),
InternalOp::ApplyChunk(vec![2]),
]
);
assert_eq!(controller.phase(), Phase::Computing);
assert_eq!(rounds(&mut controller, 3, true), []);
}
#[test]
fn a_commit_plans_moves_and_chunks_complete_in_order() {
let mut controller = controller(0);
rounds(&mut controller, 5, true);
snapshot(&mut controller).unwrap();
rounds(&mut controller, 3, true);
let new_map = controller.placement_for_commit().unwrap().unwrap();
for row in &new_map {
assert_eq!(row.iter().filter(|&&e| e == 3).count(), 3, "{new_map:?}");
}
controller.on_commit(new_map.clone()).unwrap();
assert_eq!(controller.phase(), Phase::Applying);
let out_of_order = controller.chunk_payload(&[2]).unwrap_err();
assert!(out_of_order.contains("out of order"), "{out_of_order}");
let (rows, moves) = controller.chunk_payload(&[0, 1]).unwrap();
assert_eq!(rows, new_map[..2]);
assert!(moves.iter().all(|m| m.num_changed_slots() > 0));
controller.on_chunk_applied(&[0, 1]).unwrap();
assert_eq!(controller.phase(), Phase::Applying);
let (rows, _) = controller.chunk_payload(&[2]).unwrap();
assert_eq!(rows, new_map[2..]);
controller.on_chunk_applied(&[2]).unwrap();
assert_eq!(controller.phase(), Phase::Idle);
assert_eq!(controller.note_round(true), []);
assert_eq!(controller.note_round(true), [InternalOp::Snapshot]);
}
#[test]
fn other_ranks_take_the_broadcast_placement() {
let mut controller = controller(1);
rounds(&mut controller, 5, true);
snapshot(&mut controller).unwrap();
assert_eq!(controller.placement_for_commit().unwrap(), None);
let agreed = vec![vec![3, 1, 2, 3, 0, 1]; 3];
controller.on_commit(agreed.clone()).unwrap();
let (rows, moves) = controller.chunk_payload(&[0, 1]).unwrap();
assert_eq!(rows, agreed[..2]);
assert_eq!(moves[0].recv, []);
assert_eq!(moves[0].send, [(0, 0)]);
}
#[test]
fn a_manual_trigger_runs_only_while_idle_and_steps_are_phase_checked() {
let mut controller = controller(0);
assert_eq!(controller.request_snapshot(), Some(InternalOp::Snapshot));
assert_eq!(controller.request_snapshot(), None);
assert!(controller.placement_for_commit().is_err());
assert!(controller.chunk_payload(&[0, 1]).is_err());
let wrong_shape = controller
.on_snapshot(vec![vec![1; 6]; 2], trivial_map()[..2].to_vec())
.unwrap_err();
assert!(wrong_shape.contains("shape"), "{wrong_shape}");
snapshot(&mut controller).unwrap();
assert!(snapshot(&mut controller).is_err());
assert_eq!(rounds(&mut controller, 2, true), []);
assert_eq!(controller.note_round(true)[0], InternalOp::Commit);
let new_map = controller.placement_for_commit().unwrap().unwrap();
controller.on_commit(new_map).unwrap();
apply_all(&mut controller);
assert_eq!(controller.phase(), Phase::Idle);
assert_eq!(controller.note_round(true), []);
assert_eq!(controller.note_round(true), [InternalOp::Snapshot]);
}
#[test]
fn layers_split_into_chunks_of_the_configured_size() {
assert_eq!(
chunk_layer_ids(5, 2).unwrap(),
[vec![0, 1], vec![2, 3], vec![4]]
);
assert_eq!(chunk_layer_ids(3, 3).unwrap(), [vec![0, 1, 2]]);
assert!(chunk_layer_ids(3, 4).is_err());
assert!(chunk_layer_ids(3, 0).is_err());
}
#[test]
fn the_settings_are_validated() {
let mut config = controller(0).config;
config.rebalance_num_iterations = 0;
assert!(Controller::new(config.clone())
.err()
.unwrap()
.contains("positive"));
config.rebalance_num_iterations = 5;
config.layers_per_chunk = 4;
assert!(Controller::new(config)
.err()
.unwrap()
.contains("layers_per_chunk"));
}
}