use crate::comm::ReduceOp;
use fuel::lazy::Tensor;
use fuel::{Device, DeviceLocation, Error, Result};
#[derive(Debug, Clone)]
pub struct DeviceGroup {
devices: Vec<Device>,
}
impl DeviceGroup {
pub fn new(devices: Vec<Device>) -> Result<Self> {
if devices.is_empty() {
return Err(Error::Msg(
"DeviceGroup::new: at least one device is required (the first is the leader)"
.into(),
));
}
Ok(Self { devices })
}
pub fn size(&self) -> usize {
self.devices.len()
}
pub fn leader(&self) -> &Device {
&self.devices[0]
}
pub fn devices(&self) -> &[Device] {
&self.devices
}
pub fn all_reduce(&self, shards: &[Tensor], op: ReduceOp) -> Result<Tensor> {
let shards = self.check_shards(shards, "all_reduce")?;
let mut acc = self.bring_to_leader(&shards[0], 0);
for (i, shard) in shards.iter().enumerate().skip(1) {
let here = self.bring_to_leader(shard, i);
acc = match op {
ReduceOp::Sum => acc.add(&here),
ReduceOp::Product => acc.mul(&here),
ReduceOp::Max => acc.maximum(&here),
ReduceOp::Min => acc.minimum(&here),
}
.map_err(|e| Error::Msg(format!("DeviceGroup::all_reduce: {op:?} failed: {e}")))?;
}
Ok(acc)
}
pub fn all_gather(&self, shards: &[Tensor], dim: usize) -> Result<Tensor> {
let shards = self.check_shards(shards, "all_gather")?;
let mut acc = self.bring_to_leader(&shards[0], 0);
for (i, shard) in shards.iter().enumerate().skip(1) {
let here = self.bring_to_leader(shard, i);
acc = acc.concat(&here, dim).map_err(|e| {
Error::Msg(format!(
"DeviceGroup::all_gather: concat on dim {dim} failed: {e}"
))
})?;
}
Ok(acc)
}
pub fn realize_f32(&self, t: &Tensor) -> Result<Vec<f32>> {
let extras: Vec<&Device> = self.devices[1..].iter().collect();
let graph = t.graph().clone();
fuel::pipelined_bridge::realize_one_as_multi_device::<f32>(
&graph,
t.node_id(),
self.leader(),
&extras,
)
}
fn bring_to_leader(&self, shard: &Tensor, rank: usize) -> Tensor {
let leader = self.leader();
let Some(src) = self.devices.get(rank) else {
return shard.copy_to_device(leader);
};
let (src_loc, leader_loc) = (src.location(), leader.location());
if src_loc == leader_loc {
return shard.copy_to_device(leader);
}
let cpu = DeviceLocation::Cpu;
if src_loc == cpu || leader_loc == cpu {
return shard.copy_to_device(leader);
}
shard.copy_to_device(&Device::cpu()).copy_to_device(leader)
}
fn check_shards<'a>(&self, shards: &'a [Tensor], who: &str) -> Result<&'a [Tensor]> {
if shards.is_empty() {
return Err(Error::Msg(format!(
"DeviceGroup::{who}: no shards to combine (expected at least one)"
)));
}
if shards.len() > self.devices.len() {
return Err(Error::Msg(format!(
"DeviceGroup::{who}: {} shards for a group of {} devices — shard i is taken to \
live on device i, so there cannot be more shards than devices",
shards.len(),
self.devices.len(),
)));
}
let g0 = shards[0].graph_id();
if let Some(bad) = shards.iter().position(|s| s.graph_id() != g0) {
return Err(Error::Msg(format!(
"DeviceGroup::{who}: shards must live on the same graph — shard 0 is on graph \
#{g0}, shard {bad} on graph #{}; each `from_*` constructor mints a NEW graph, so \
build the shards with `from_*_on(first.graph(), ..)` or `const_*_like`",
shards[bad].graph_id(),
)));
}
Ok(shards)
}
}
#[cfg(test)]
mod tests {
use super::*;
use fuel::Shape;
fn shard(anchor: Option<&Tensor>, data: Vec<f32>, dev: &Device) -> Tensor {
let shape = Shape::from_dims(&[data.len()]);
match anchor {
Some(a) => Tensor::from_f32_on(a.graph(), data, shape, dev).unwrap(),
None => Tensor::from_f32(data, shape, dev).unwrap(),
}
}
#[test]
fn new_rejects_an_empty_device_list() {
assert!(
DeviceGroup::new(vec![]).is_err(),
"a group with no devices has no leader, so collectives on it are undefined",
);
}
#[test]
fn leader_is_the_first_device() {
let g = DeviceGroup::new(vec![Device::cpu()]).unwrap();
assert_eq!(g.size(), 1);
assert_eq!(g.leader().location(), Device::cpu().location());
}
fn cpu_group(n: usize) -> (DeviceGroup, Device) {
let dev = Device::cpu();
(DeviceGroup::new(vec![dev.clone(); n]).unwrap(), dev)
}
#[test]
fn all_reduce_sums_across_shards() {
let (g, dev) = cpu_group(2);
let a = shard(None, vec![1.0, 2.0], &dev);
let b = shard(Some(&a), vec![10.0, 20.0], &dev);
let out = g.all_reduce(&[a, b], ReduceOp::Sum).unwrap();
assert_eq!(out.realize_f32(), vec![11.0, 22.0]);
}
#[test]
fn all_reduce_takes_the_elementwise_max() {
let (g, dev) = cpu_group(2);
let a = shard(None, vec![1.0, 50.0], &dev);
let b = shard(Some(&a), vec![10.0, 20.0], &dev);
let out = g.all_reduce(&[a, b], ReduceOp::Max).unwrap();
assert_eq!(out.realize_f32(), vec![10.0, 50.0]);
}
#[test]
fn all_reduce_of_one_shard_is_that_shard() {
let dev = Device::cpu();
let g = DeviceGroup::new(vec![dev.clone()]).unwrap();
let a = shard(None, vec![3.0, 4.0], &dev);
let out = g
.all_reduce(std::slice::from_ref(&a), ReduceOp::Sum)
.unwrap();
assert_eq!(out.realize_f32(), vec![3.0, 4.0]);
}
#[test]
fn all_reduce_rejects_empty_shards() {
let g = DeviceGroup::new(vec![Device::cpu()]).unwrap();
assert!(
g.all_reduce(&[], ReduceOp::Sum).is_err(),
"zero shards is an error, not a panic (never-panic contract)",
);
}
#[test]
fn all_reduce_rejects_cross_graph_shards_and_names_the_fix() {
let (g, dev) = cpu_group(2);
let a = shard(None, vec![1.0, 2.0], &dev);
let b = shard(None, vec![3.0, 4.0], &dev);
let err = g
.all_reduce(&[a, b], ReduceOp::Sum)
.unwrap_err()
.to_string();
assert!(err.contains("same graph"), "got: {err}");
assert!(
err.contains("const_*_like") || err.contains("from_*_on"),
"must name the fix; got: {err}"
);
}
#[test]
fn all_reduce_rejects_more_shards_than_devices() {
let dev = Device::cpu();
let g = DeviceGroup::new(vec![dev.clone()]).unwrap();
let a = shard(None, vec![1.0], &dev);
let b = shard(Some(&a), vec![2.0], &dev);
let err = g
.all_reduce(&[a, b], ReduceOp::Sum)
.unwrap_err()
.to_string();
assert!(err.contains("2 shards"), "got: {err}");
assert!(err.contains("1 devices"), "got: {err}");
}
#[test]
fn same_device_ranks_take_the_single_hop_path() {
let dev = Device::cpu();
let g = DeviceGroup::new(vec![dev.clone(), dev.clone(), dev.clone()]).unwrap();
let a = shard(None, vec![1.0, 1.0], &dev);
let b = shard(Some(&a), vec![2.0, 2.0], &dev);
let c = shard(Some(&a), vec![3.0, 3.0], &dev);
let out = g.all_reduce(&[a, b, c], ReduceOp::Sum).unwrap();
assert_eq!(out.realize_f32(), vec![6.0, 6.0]);
}
#[test]
fn all_gather_concatenates_shards() {
let (g, dev) = cpu_group(2);
let a = shard(None, vec![1.0, 2.0], &dev);
let b = shard(Some(&a), vec![3.0, 4.0], &dev);
let out = g.all_gather(&[a, b], 0).unwrap();
assert_eq!(out.realize_f32(), vec![1.0, 2.0, 3.0, 4.0]);
}
}