use rlx_ir::op::OpKind;
use rlx_ir::{Graph, NodeId, Shape};
#[derive(Debug, Clone)]
pub struct NodeCost {
pub id: NodeId,
pub kind: OpKind,
pub name: Option<String>,
pub bytes_read: u64,
pub bytes_written: u64,
pub bytes_weights: u64,
pub flops: u64,
}
impl NodeCost {
pub fn bytes(&self) -> u64 {
self.bytes_read + self.bytes_written
}
pub fn intensity(&self) -> Option<f64> {
let b = self.bytes();
(b > 0).then(|| self.flops as f64 / b as f64)
}
}
#[derive(Debug, Clone, Default)]
pub struct GraphCost {
pub nodes: Vec<NodeCost>,
}
impl GraphCost {
pub fn total_bytes(&self) -> u64 {
self.nodes.iter().map(|n| n.bytes()).sum()
}
pub fn total_flops(&self) -> u64 {
self.nodes.iter().map(|n| n.flops).sum()
}
pub fn total_weight_bytes(&self) -> u64 {
self.nodes.iter().map(|n| n.bytes_weights).sum()
}
pub fn intensity(&self) -> f64 {
let b = self.total_bytes();
if b == 0 {
return 0.0;
}
self.total_flops() as f64 / b as f64
}
pub fn top_by_bytes(&self, n: usize) -> Vec<&NodeCost> {
let mut v: Vec<&NodeCost> = self.nodes.iter().filter(|c| c.bytes() > 0).collect();
v.sort_by_key(|c| std::cmp::Reverse(c.bytes()));
v.truncate(n);
v
}
pub fn by_kind(&self) -> Vec<(OpKind, u64, u64)> {
let mut acc: Vec<(OpKind, u64, u64)> = Vec::new();
for c in &self.nodes {
match acc.iter_mut().find(|(k, _, _)| *k == c.kind) {
Some(e) => {
e.1 += c.bytes();
e.2 += c.flops;
}
None => acc.push((c.kind, c.bytes(), c.flops)),
}
}
acc.sort_by_key(|(_, b, _)| std::cmp::Reverse(*b));
acc
}
pub fn report(&self, top: usize) -> String {
use std::fmt::Write;
let mut s = String::new();
let tb = self.total_bytes();
let tw = self.total_weight_bytes();
let _ = writeln!(
s,
"bytes moved (lower bound): {} flops: {} intensity: {:.2} flop/byte",
human_bytes(tb),
human_count(self.total_flops()),
self.intensity()
);
if tb > 0 {
let _ = writeln!(
s,
" of which weights/constants: {} ({:.1}%)",
human_bytes(tw),
100.0 * tw as f64 / tb as f64
);
}
let _ = writeln!(s, "\ntop {top} nodes by traffic:");
for c in self.top_by_bytes(top) {
let _ = writeln!(
s,
" {:>10} {:>8.2} f/B {:?}{}",
human_bytes(c.bytes()),
c.intensity().unwrap_or(0.0),
c.kind,
c.name
.as_deref()
.map(|n| format!(" [{n}]"))
.unwrap_or_default()
);
}
let _ = writeln!(s, "\nby op kind:");
for (k, b, f) in self.by_kind().into_iter().take(top) {
if b == 0 {
continue;
}
let _ = writeln!(
s,
" {:>10} {:>8.2} f/B {k:?}",
human_bytes(b),
f as f64 / b as f64
);
}
s
}
pub fn roofline_split(&self, ridge_flops_per_byte: f64) -> (u64, u64) {
let mut mem = 0u64;
let mut comp = 0u64;
for c in &self.nodes {
match c.intensity() {
Some(i) if i >= ridge_flops_per_byte => comp += c.bytes(),
Some(_) => mem += c.bytes(),
None => {}
}
}
(mem, comp)
}
}
fn tensor_bytes(shape: &Shape) -> u64 {
match shape.num_elements() {
Some(n) => (n * shape.dtype().size_bytes()) as u64,
None => 0,
}
}
pub fn analyze(graph: &Graph) -> GraphCost {
let nodes = graph
.nodes()
.iter()
.map(|node| {
let mut bytes_read = 0u64;
let mut bytes_weights = 0u64;
for &input in &node.inputs {
let src = graph.node(input);
let b = tensor_bytes(&src.shape);
bytes_read += b;
if matches!(src.op.kind(), OpKind::Param | OpKind::Constant) {
bytes_weights += b;
}
}
let is_leaf = matches!(
node.op.kind(),
OpKind::Input | OpKind::Param | OpKind::Constant
);
let bytes_written = if is_leaf {
0
} else {
tensor_bytes(&node.shape)
};
NodeCost {
id: node.id,
kind: node.op.kind(),
name: node.name.clone(),
bytes_read: if is_leaf { 0 } else { bytes_read },
bytes_written,
bytes_weights: if is_leaf { 0 } else { bytes_weights },
flops: flops(graph, node),
}
})
.collect();
GraphCost { nodes }
}
fn flops(graph: &Graph, node: &rlx_ir::graph::Node) -> u64 {
let out = node.shape.num_elements().unwrap_or(0) as u64;
let operand_elems = |i: usize| -> u64 {
node.inputs
.get(i)
.and_then(|id| graph.node(*id).shape.num_elements())
.unwrap_or(0) as u64
};
let contract = || -> u64 {
node.inputs
.first()
.map(|id| &graph.node(*id).shape)
.and_then(|s| s.dims().last().copied())
.and_then(|d| match d {
rlx_ir::Dim::Static(n) => Some(n as u64),
_ => None,
})
.unwrap_or(0)
};
match node.op.kind() {
OpKind::MatMul
| OpKind::DequantMatMul
| OpKind::ScaledMatMul
| OpKind::ScaledGroupedMatMul
| OpKind::SynthMatMul => 2 * out * contract(),
OpKind::Binary | OpKind::Compare => out.max(operand_elems(0)),
OpKind::Activation | OpKind::Cast => out,
OpKind::Reduce | OpKind::Cumsum | OpKind::CumProd | OpKind::CumMax => operand_elems(0),
OpKind::LayerNorm | OpKind::RmsNorm => 3 * operand_elems(0),
OpKind::Softmax => 3 * operand_elems(0),
OpKind::Reshape
| OpKind::Transpose
| OpKind::Slice
| OpKind::Concat
| OpKind::Pad
| OpKind::Expand
| OpKind::Gather
| OpKind::ScatterAdd
| OpKind::Input
| OpKind::Param
| OpKind::Constant => 0,
_ => 0,
}
}
pub fn human_bytes(b: u64) -> String {
const U: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut v = b as f64;
let mut i = 0;
while v >= 1024.0 && i + 1 < U.len() {
v /= 1024.0;
i += 1;
}
if i == 0 {
format!("{b} B")
} else {
format!("{v:.2} {}", U[i])
}
}
pub fn human_count(n: u64) -> String {
const U: [&str; 5] = ["", "K", "M", "G", "T"];
let mut v = n as f64;
let mut i = 0;
while v >= 1000.0 && i + 1 < U.len() {
v /= 1000.0;
i += 1;
}
if i == 0 {
format!("{n}")
} else {
format!("{v:.2} {}", U[i])
}
}
#[cfg(test)]
mod tests {
use super::*;
use rlx_ir::{DType, Op, Shape};
#[test]
fn matmul_cost_matches_closed_form() {
let (m, k, n) = (64usize, 128usize, 32usize);
let mut g = Graph::new("mm");
let a = g.add_node(
Op::Input { name: "x".into() },
vec![],
Shape::new(&[m, k], DType::F32),
);
let b = g.add_node(
Op::Param { name: "w".into() },
vec![],
Shape::new(&[k, n], DType::F32),
);
let c = g.add_node(Op::MatMul, vec![a, b], Shape::new(&[m, n], DType::F32));
let cost = analyze(&g);
let mm = cost.nodes.iter().find(|x| x.id == c).unwrap();
assert_eq!(mm.flops, (2 * m * n * k) as u64);
assert_eq!(mm.bytes_read, ((m * k + k * n) * 4) as u64);
assert_eq!(mm.bytes_written, (m * n * 4) as u64);
assert_eq!(mm.bytes_weights, (k * n * 4) as u64);
}
#[test]
fn leaves_do_not_double_count() {
let mut g = Graph::new("leaf");
let a = g.add_node(
Op::Param { name: "w".into() },
vec![],
Shape::new(&[1024], DType::F32),
);
let _ = g.add_node(
Op::Activation(rlx_ir::op::Activation::Neg),
vec![a],
Shape::new(&[1024], DType::F32),
);
let cost = analyze(&g);
let leaf = cost.nodes.iter().find(|c| c.id == a).unwrap();
assert_eq!(leaf.bytes(), 0, "a leaf moves no bytes by itself");
assert_eq!(cost.total_bytes(), 1024 * 4 * 2);
}
#[test]
fn narrower_weights_halve_weight_traffic() {
let build = |dt: DType| {
let mut g = Graph::new("w");
let x = g.add_node(
Op::Input { name: "x".into() },
vec![],
Shape::new(&[1, 4096], DType::F32),
);
let w = g.add_node(
Op::Param { name: "w".into() },
vec![],
Shape::new(&[4096, 4096], dt),
);
g.add_node(Op::MatMul, vec![x, w], Shape::new(&[1, 4096], DType::F32));
analyze(&g)
};
let f32_cost = build(DType::F32);
let f16_cost = build(DType::F16);
assert_eq!(
f32_cost.total_weight_bytes(),
2 * f16_cost.total_weight_bytes()
);
let share = f32_cost.total_weight_bytes() as f64 / f32_cost.total_bytes() as f64;
assert!(
share > 0.99,
"decode GEMV should be ~all weight bytes: {share}"
);
}
#[test]
fn roofline_separates_gemv_from_gemm() {
let mk = |m: usize| {
let mut g = Graph::new("r");
let x = g.add_node(
Op::Input { name: "x".into() },
vec![],
Shape::new(&[m, 4096], DType::F32),
);
let w = g.add_node(
Op::Param { name: "w".into() },
vec![],
Shape::new(&[4096, 4096], DType::F32),
);
let y = g.add_node(Op::MatMul, vec![x, w], Shape::new(&[m, 4096], DType::F32));
let c = analyze(&g);
c.nodes
.iter()
.find(|n| n.id == y)
.unwrap()
.intensity()
.unwrap()
};
let gemv = mk(1);
let gemm = mk(4096);
assert!(gemv < 10.0, "GEMV intensity should be low: {gemv}");
assert!(gemm > 100.0, "large GEMM should be compute-bound: {gemm}");
assert!(gemm > gemv * 50.0);
}
#[test]
fn dynamic_shapes_are_skipped_not_guessed() {
let mut g = Graph::new("dyn");
let s = Shape::from_dims(
&[rlx_ir::Dim::Dynamic(0), rlx_ir::Dim::Static(16)],
DType::F32,
);
let a = g.add_node(Op::Input { name: "x".into() }, vec![], s.clone());
let _ = g.add_node(Op::Activation(rlx_ir::op::Activation::Neg), vec![a], s);
assert_eq!(analyze(&g).total_bytes(), 0);
}
#[test]
fn report_handles_empty_graph() {
let g = Graph::new("empty");
let r = analyze(&g).report(5);
assert!(r.contains("bytes moved"));
}
}