use rlx_ir::{Dim, Graph, NodeId, Op, OpKind};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ShardAxis {
Batch,
OutChannel,
Rows,
Cols,
}
impl ShardAxis {
pub fn as_str(self) -> &'static str {
match self {
Self::Batch => "batch",
Self::OutChannel => "out-channel",
Self::Rows => "rows",
Self::Cols => "cols",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Shard {
pub start: usize,
pub len: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SliceSpec {
pub operand: usize,
pub axis: usize,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Residency {
pub shared_resident: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ShardOption {
pub axis: ShardAxis,
pub output_axis: usize,
pub sliced: Vec<SliceSpec>,
pub shared: Vec<usize>,
pub shards: Vec<Shard>,
pub elem_bytes: u64,
pub sliced_elems: u64,
pub shared_elems: u64,
pub output_elems: u64,
}
impl ShardOption {
pub fn count(&self) -> usize {
self.shards.len()
}
pub fn moved_bytes(&self, residency: Residency) -> u64 {
let workers = self.count() as u64;
let shared = if residency.shared_resident {
0
} else {
self.shared_elems.saturating_mul(workers)
};
self.sliced_elems
.saturating_add(shared)
.saturating_add(self.output_elems)
.saturating_mul(self.elem_bytes)
}
pub fn widest_shard_elems(&self) -> u64 {
let total: usize = self.shards.iter().map(|s| s.len).sum();
if total == 0 {
return 0;
}
let widest = self.shards.iter().map(|s| s.len).max().unwrap_or(0) as u64;
self.output_elems.saturating_mul(widest) / total as u64
}
}
pub fn shard_ranges(len: usize, count: usize) -> Vec<Shard> {
let count = count.clamp(1, len.max(1));
let base = len / count;
let extra = len % count;
let mut out = Vec::with_capacity(count);
let mut start = 0;
for i in 0..count {
let this = base + usize::from(i < extra);
out.push(Shard { start, len: this });
start += this;
}
out
}
fn static_dim(shape: &rlx_ir::Shape, axis: usize) -> Option<usize> {
match shape.dims().get(axis)? {
Dim::Static(n) => Some(*n),
Dim::Dynamic(_) => None,
}
}
fn elems(shape: &rlx_ir::Shape) -> Option<u64> {
shape.num_elements().map(|n| n as u64)
}
pub fn shardings(graph: &Graph, node: NodeId, max_shards: usize) -> Vec<ShardOption> {
let n = graph.node(node);
let out_shape = &n.shape;
if !out_shape.is_static() || max_shards < 2 {
return Vec::new();
}
let elem_bytes = out_shape.dtype().size_bytes() as u64;
let operand =
|i: usize| -> Option<&rlx_ir::Shape> { n.inputs.get(i).map(|id| &graph.node(*id).shape) };
let mut layouts: Vec<(ShardAxis, usize, Vec<SliceSpec>, Vec<usize>)> = Vec::new();
match n.op.kind() {
OpKind::Conv | OpKind::Conv3d => {
let groups = match &n.op {
Op::Conv { groups, .. } => *groups,
_ => 1,
};
let mut shared = vec![1];
if n.inputs.len() > 2 {
shared.push(2); }
layouts.push((
ShardAxis::Batch,
0,
vec![SliceSpec {
operand: 0,
axis: 0,
}],
shared,
));
if groups <= 1 {
let mut sliced = vec![SliceSpec {
operand: 1,
axis: 0,
}];
if n.inputs.len() > 2 {
sliced.push(SliceSpec {
operand: 2,
axis: 0,
});
}
layouts.push((ShardAxis::OutChannel, 1, sliced, vec![0]));
}
}
OpKind::MatMul => {
let rank = out_shape.rank();
if rank < 2 {
return Vec::new();
}
layouts.push((
ShardAxis::Rows,
rank - 2,
vec![SliceSpec {
operand: 0,
axis: operand(0).map_or(rank - 2, |s| s.rank().saturating_sub(2)),
}],
vec![1],
));
layouts.push((
ShardAxis::Cols,
rank - 1,
vec![SliceSpec {
operand: 1,
axis: operand(1).map_or(rank - 1, |s| s.rank().saturating_sub(1)),
}],
vec![0],
));
}
_ => return Vec::new(),
}
let output_elems = match elems(out_shape) {
Some(e) => e,
None => return Vec::new(),
};
let mut options = Vec::new();
for (axis, output_axis, sliced, shared) in layouts {
let Some(axis_len) = static_dim(out_shape, output_axis) else {
continue;
};
if axis_len < 2 {
continue;
}
let mut sliced_elems = 0u64;
let mut ok = true;
for spec in &sliced {
match operand(spec.operand) {
Some(s) if static_dim(s, spec.axis) == Some(axis_len) => match elems(s) {
Some(e) => sliced_elems += e,
None => ok = false,
},
_ => ok = false,
}
}
let mut shared_elems = 0u64;
for &i in &shared {
match operand(i).and_then(elems) {
Some(e) => shared_elems += e,
None => ok = false,
}
}
if !ok {
continue;
}
for count in 2..=max_shards.min(axis_len) {
options.push(ShardOption {
axis,
output_axis,
sliced: sliced.clone(),
shared: shared.clone(),
shards: shard_ranges(axis_len, count),
elem_bytes,
sliced_elems,
shared_elems,
output_elems,
});
}
}
options
}
#[derive(Clone, Copy, Debug)]
pub struct LinkCost {
pub bytes_per_s: f64,
pub latency_s: f64,
}
impl LinkCost {
pub fn send_time(&self, bytes: u64) -> f64 {
if self.bytes_per_s <= 0.0 {
return f64::INFINITY;
}
self.latency_s + bytes as f64 / self.bytes_per_s
}
}
#[derive(Clone, Debug)]
pub struct WorkerCost {
pub label: String,
pub elems_per_s: f64,
pub rankable: bool,
}
#[derive(Clone, Debug)]
pub struct Choice {
pub option: Option<usize>,
pub score_s: f64,
pub moved_bytes: u64,
pub why: String,
pub excluded: Vec<String>,
}
pub fn choose(
options: &[ShardOption],
workers: &[WorkerCost],
link: LinkCost,
residency: Residency,
whole_elems_per_s: f64,
) -> Choice {
let excluded: Vec<String> = workers
.iter()
.filter(|w| !w.rankable)
.map(|w| w.label.clone())
.collect();
let usable: Vec<&WorkerCost> = workers
.iter()
.filter(|w| w.rankable && w.elems_per_s > 0.0)
.collect();
let whole_elems = options.first().map_or(0, |o| o.output_elems);
let baseline = if whole_elems_per_s > 0.0 {
whole_elems as f64 / whole_elems_per_s
} else {
f64::INFINITY
};
let mut best = Choice {
option: None,
score_s: baseline,
moved_bytes: 0,
why: format!("unsharded: {:.6}s, no split beat it", baseline),
excluded: excluded.clone(),
};
if usable.is_empty() {
best.why = if workers.is_empty() {
"no workers offered".into()
} else {
"no worker has a rankable cost model; nothing to rank".into()
};
return best;
}
for (i, opt) in options.iter().enumerate() {
if opt.count() > usable.len() {
continue; }
let slowest_rate = usable
.iter()
.take(opt.count())
.map(|w| w.elems_per_s)
.fold(f64::INFINITY, f64::min);
let compute_s = opt.widest_shard_elems() as f64 / slowest_rate;
let bytes = opt.moved_bytes(residency);
let score = compute_s + link.send_time(bytes);
if score < best.score_s {
best = Choice {
option: Some(i),
score_s: score,
moved_bytes: bytes,
why: format!(
"{} × {}: {:.6}s compute + {:.6}s moving {} bytes",
opt.axis.as_str(),
opt.count(),
compute_s,
link.send_time(bytes),
bytes
),
excluded: excluded.clone(),
};
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
use rlx_ir::{DType, Shape};
fn conv_graph(
n: usize,
c_in: usize,
c_out: usize,
hw: usize,
groups: usize,
) -> (Graph, NodeId) {
let f = DType::F32;
let mut g = Graph::new("conv");
let x = g.input("x", Shape::new(&[n, c_in, hw, hw], f));
let w = g.param("w", Shape::new(&[c_out, c_in / groups, 3, 3], f));
let out = g.conv2d(x, w, [3, 3], [1, 1], [0, 0], [1, 1], groups);
(g, out)
}
fn matmul_graph(m: usize, k: usize, n: usize) -> (Graph, NodeId) {
let f = DType::F32;
let mut g = Graph::new("mm");
let a = g.input("a", Shape::new(&[m, k], f));
let b = g.param("b", Shape::new(&[k, n], f));
let out = g.matmul(a, b, Shape::new(&[m, n], f));
(g, out)
}
#[test]
fn shard_ranges_cover_the_axis_exactly() {
for len in [1usize, 2, 7, 8, 100] {
for count in [1usize, 2, 3, 5, 8] {
let shards = shard_ranges(len, count);
assert!(!shards.is_empty());
assert!(
shards.iter().all(|s| s.len > 0),
"empty piece: {len}/{count}"
);
let mut at = 0;
for s in &shards {
assert_eq!(s.start, at, "gap/overlap at {len}/{count}");
at += s.len;
}
assert_eq!(at, len, "did not cover {len}/{count}");
assert!(shards.len() <= count.max(1));
}
}
}
#[test]
fn conv_offers_batch_and_channel_splits_with_the_right_operands() {
let (g, out) = conv_graph(8, 4, 16, 8, 1);
let opts = shardings(&g, out, 4);
assert!(!opts.is_empty());
let batch = opts.iter().find(|o| o.axis == ShardAxis::Batch).unwrap();
assert_eq!(batch.output_axis, 0);
assert_eq!(
batch.sliced,
vec![SliceSpec {
operand: 0,
axis: 0
}]
);
assert_eq!(batch.shared, vec![1], "batch split must replicate weights");
let chan = opts
.iter()
.find(|o| o.axis == ShardAxis::OutChannel)
.unwrap();
assert_eq!(chan.output_axis, 1);
assert_eq!(
chan.sliced,
vec![SliceSpec {
operand: 1,
axis: 0
}]
);
assert_eq!(
chan.shared,
vec![0],
"channel split must replicate activations"
);
}
#[test]
fn a_grouped_conv_offers_only_the_batch_split() {
let (g, out) = conv_graph(8, 8, 8, 8, 4);
let opts = shardings(&g, out, 4);
assert!(!opts.is_empty());
assert!(
opts.iter().all(|o| o.axis == ShardAxis::Batch),
"grouped conv must not offer a channel split"
);
}
#[test]
fn matmul_offers_row_and_column_splits() {
let (g, out) = matmul_graph(64, 32, 16);
let opts = shardings(&g, out, 2);
let rows = opts.iter().find(|o| o.axis == ShardAxis::Rows).unwrap();
assert_eq!(rows.shared, vec![1], "row split replicates B");
let cols = opts.iter().find(|o| o.axis == ShardAxis::Cols).unwrap();
assert_eq!(cols.shared, vec![0], "column split replicates A");
}
#[test]
fn the_cheaper_axis_is_the_one_replicating_less() {
let (g, out) = conv_graph(32, 4, 8, 32, 1);
let opts = shardings(&g, out, 4);
let pick = |axis| {
opts.iter()
.filter(|o| o.axis == axis && o.count() == 4)
.map(|o| o.moved_bytes(Residency::default()))
.next()
.unwrap()
};
assert!(
pick(ShardAxis::Batch) < pick(ShardAxis::OutChannel),
"batch split should win when weights are the small operand"
);
let (g2, out2) = conv_graph(2, 64, 256, 4, 1);
let opts2 = shardings(&g2, out2, 4);
let pick2 = |axis| {
opts2
.iter()
.filter(|o| o.axis == axis && o.count() == 2)
.map(|o| o.moved_bytes(Residency::default()))
.next()
.unwrap()
};
assert!(
pick2(ShardAxis::OutChannel) < pick2(ShardAxis::Batch),
"channel split should win when activations are the small operand"
);
}
#[test]
fn residency_removes_the_replication_cost() {
let (g, out) = conv_graph(8, 4, 16, 8, 1);
let batch = shardings(&g, out, 4)
.into_iter()
.find(|o| o.axis == ShardAxis::Batch && o.count() == 4)
.unwrap();
let cold = batch.moved_bytes(Residency::default());
let warm = batch.moved_bytes(Residency {
shared_resident: true,
});
assert!(warm < cold, "resident weights must cost less, not the same");
assert_eq!(
warm,
(batch.sliced_elems + batch.output_elems) * batch.elem_bytes
);
}
#[test]
fn a_slow_link_makes_running_it_whole_the_right_answer() {
let (g, out) = conv_graph(8, 4, 16, 8, 1);
let opts = shardings(&g, out, 4);
let workers = vec![
WorkerCost {
label: "a".into(),
elems_per_s: 1e6,
rankable: true,
},
WorkerCost {
label: "b".into(),
elems_per_s: 1e6,
rankable: true,
},
];
let slow = choose(
&opts,
&workers,
LinkCost {
bytes_per_s: 1.0e4,
latency_s: 0.01,
},
Residency::default(),
1e6,
);
assert!(slow.option.is_none(), "{}", slow.why);
let fast = choose(
&opts,
&workers,
LinkCost {
bytes_per_s: 1.0e10,
latency_s: 1e-6,
},
Residency::default(),
5e4,
);
assert!(fast.option.is_some(), "{}", fast.why);
assert!(fast.score_s < slow.score_s);
}
#[test]
fn unrankable_workers_are_excluded_and_reported() {
let (g, out) = conv_graph(8, 4, 16, 8, 1);
let opts = shardings(&g, out, 4);
let workers = vec![
WorkerCost {
label: "guessed".into(),
elems_per_s: 1e12,
rankable: false,
},
WorkerCost {
label: "measured".into(),
elems_per_s: 1e6,
rankable: true,
},
];
let c = choose(
&opts,
&workers,
LinkCost {
bytes_per_s: 1e10,
latency_s: 1e-6,
},
Residency::default(),
1e3,
);
assert_eq!(c.excluded, vec!["guessed".to_string()]);
let only_guess = vec![WorkerCost {
label: "guessed".into(),
elems_per_s: 1e12,
rankable: false,
}];
let c2 = choose(
&opts,
&only_guess,
LinkCost {
bytes_per_s: 1e10,
latency_s: 1e-6,
},
Residency::default(),
1e3,
);
assert!(c2.option.is_none());
assert!(c2.why.contains("rankable"), "{}", c2.why);
}
#[test]
fn unshardable_and_dynamic_ops_offer_nothing() {
let f = DType::F32;
let mut g = Graph::new("u");
let x = g.input("x", Shape::new(&[8, 8], f));
let y = g.activation(rlx_ir::op::Activation::Relu, x, Shape::new(&[8, 8], f));
assert!(shardings(&g, y, 4).is_empty(), "relu is not sharded here");
let mut g2 = Graph::new("d");
let a = g2.input(
"a",
Shape::from_dims(&[Dim::Dynamic(0), Dim::Static(32)], f),
);
let b = g2.param("b", Shape::new(&[32, 16], f));
let out = g2.matmul(
a,
b,
Shape::from_dims(&[Dim::Dynamic(0), Dim::Static(16)], f),
);
assert!(
shardings(&g2, out, 4).is_empty(),
"a dynamic axis cannot be split into static ranges"
);
}
}