use rlx_ir::op::BinaryOp;
use rlx_ir::{Graph, NodeId, Op};
use std::collections::HashMap;
const BOUNDARY_TAIL_GUARD_BYTES: usize = 128;
fn boundary_min_slot_bytes(op: &rlx_ir::Op, alignment: usize) -> usize {
if matches!(
op,
rlx_ir::Op::Input { .. } | rlx_ir::Op::Param { .. } | rlx_ir::Op::Constant { .. }
) {
alignment.max(1)
} else {
0
}
}
fn boundary_tail_guard(op: &rlx_ir::Op, alignment: usize) -> usize {
if matches!(
op,
rlx_ir::Op::Input { .. } | rlx_ir::Op::Param { .. } | rlx_ir::Op::Constant { .. }
) {
alignment.max(BOUNDARY_TAIL_GUARD_BYTES)
} else {
0
}
}
fn pure_view_offset(graph: &Graph, node: &rlx_ir::Node) -> Option<(NodeId, usize)> {
match &node.op {
Op::Reshape { .. } => Some((node.inputs[0], 0)),
Op::KvAppend { .. } => Some((node.inputs[0], 0)),
Op::Cast { to } => {
let parent = graph.node(node.inputs[0]);
if parent.shape.dtype() == *to {
Some((node.inputs[0], 0))
} else {
None
}
}
Op::Narrow {
axis,
start,
len: _,
} if *axis == 0 => {
let parent = graph.node(node.inputs[0]);
let inner_elems: usize = (1..parent.shape.rank())
.map(|i| parent.shape.dim(i).unwrap_static())
.product();
let dt_bytes = parent.shape.dtype().size_bytes();
Some((node.inputs[0], start * inner_elems * dt_bytes))
}
_ => None,
}
}
pub fn is_pure_view(graph: &Graph, node: &rlx_ir::Node) -> bool {
!matches!(node.op, Op::KvAppend { .. }) && pure_view_offset(graph, node).is_some()
}
pub fn is_elidable_bank_transpose(graph: &Graph, node: &rlx_ir::Node) -> bool {
is_elidable_bank_transpose_gated(graph, node, false)
}
pub fn is_elidable_bank_transpose_gated(
graph: &Graph,
node: &rlx_ir::Node,
require_small_m: bool,
) -> bool {
if !matches!(&node.op, Op::Transpose { perm } if perm.as_slice() == [0, 2, 1])
|| node.shape.rank() != 3
|| node.shape.dtype() != rlx_ir::DType::F32
{
return false;
}
let mut readers = 0usize;
for n in graph.nodes() {
for (slot, &i) in n.inputs.iter().enumerate() {
if i != node.id {
continue;
}
readers += 1;
let folds = slot == 1
&& matches!(n.op, Op::GroupedMatMul)
&& n.shape.dtype() == rlx_ir::DType::F32;
if !folds {
return false;
}
}
}
if require_small_m {
for n in graph.nodes() {
if !n.inputs.contains(&node.id) {
continue;
}
let small = rlx_ir::shape::grouped_matmul_dims(
&graph.node(n.inputs[0]).shape,
&graph.node(n.inputs[1]).shape,
Some(&n.shape),
)
.map(|gd| gd.m <= SMALL_M_GROUPED)
.unwrap_or(false);
if !small {
return false;
}
}
}
readers > 0
}
pub const SMALL_M_GROUPED: usize = 4;
pub fn is_elidable_matmul_transpose(graph: &Graph, node: &rlx_ir::Node) -> bool {
if !matches!(&node.op, Op::Transpose { perm } if perm.as_slice() == [1, 0])
|| node.shape.rank() != 2
{
return false;
}
let mut reader: Option<&rlx_ir::Node> = None;
for n in graph.nodes() {
for &i in &n.inputs {
if i != node.id {
continue;
}
if reader.is_some() {
return false; }
reader = Some(n);
}
}
let Some(mm) = reader else {
return false;
};
matches!(mm.op, Op::MatMul)
&& mm.shape.dtype() == rlx_ir::DType::F32
&& mm.inputs.len() >= 2
&& graph.node(mm.inputs[0]).shape.rank() == 2
&& graph.node(mm.inputs[1]).shape.rank() == 2
}
pub fn is_elidable_folded_transpose(graph: &Graph, node: &rlx_ir::Node) -> bool {
is_elidable_folded_transpose_gated(graph, node, false)
}
pub fn is_elidable_folded_transpose_gated(
graph: &Graph,
node: &rlx_ir::Node,
require_small_m: bool,
) -> bool {
is_elidable_bank_transpose_gated(graph, node, require_small_m)
|| (!require_small_m && is_elidable_matmul_transpose(graph, node))
}
#[derive(Debug, Clone)]
pub struct BufferSlot {
pub offset: usize,
pub size: usize,
}
#[derive(Debug, Clone)]
pub struct MemoryPlan {
pub arena_size: usize,
pub assignments: HashMap<NodeId, BufferSlot>,
pub schedule: Vec<NodeId>,
}
impl MemoryPlan {
pub fn total_unshared_bytes(&self) -> usize {
self.assignments.values().map(|s| s.size).sum()
}
pub fn bytes_saved(&self) -> usize {
self.total_unshared_bytes().saturating_sub(self.arena_size)
}
pub fn report(&self) -> String {
let mut rows: Vec<(usize, usize, NodeId)> = self
.assignments
.iter()
.map(|(id, slot)| (slot.offset, slot.size, *id))
.collect();
rows.sort();
let mut out = String::new();
out.push_str(&format!(
"# arena_size={} total_unshared={} saved={}\n",
self.arena_size,
self.total_unshared_bytes(),
self.bytes_saved()
));
out.push_str("# offset\tsize\tnode\n");
for (off, sz, id) in rows {
out.push_str(&format!("{off}\t{sz}\t{id}\n"));
}
out
}
}
pub fn collect_view_aliases(graph: &Graph) -> HashMap<NodeId, (NodeId, usize)> {
let mut out = HashMap::new();
for node in graph.nodes() {
if pure_view_offset(graph, node).is_some() {
let (root, off) = resolve_view_root(graph, node.id);
out.insert(node.id, (root, off));
}
}
out
}
fn resolve_view_root(graph: &Graph, mut id: NodeId) -> (NodeId, usize) {
let mut total_offset = 0usize;
loop {
let node = graph.node(id);
match pure_view_offset(graph, node) {
Some((parent, off)) => {
total_offset += off;
id = parent;
}
None => return (id, total_offset),
}
}
}
#[derive(Debug, Default, Clone)]
pub struct Conv3dEpilogueFolds {
pub absorbed: HashMap<NodeId, NodeId>,
pub read_through: HashMap<NodeId, NodeId>,
}
pub fn conv3d_epilogue_folds(graph: &Graph) -> Conv3dEpilogueFolds {
use rlx_ir::op::BinaryOp;
let mut out = Conv3dEpilogueFolds::default();
let mut uses: HashMap<NodeId, u32> = HashMap::new();
for n in graph.nodes() {
for &i in &n.inputs {
*uses.entry(i).or_insert(0) += 1;
}
}
let is_conv3d = |id: NodeId| {
let n = graph.node(id);
match &n.op {
Op::Conv3d { .. } => true,
Op::FusedConvBiasAct {
activation: None,
has_residual: false,
..
} => n.shape.rank() == 5 && n.inputs.len() == 3,
_ => false,
}
};
let scalar_const = |id: NodeId| -> bool {
matches!(&graph.node(id).op, Op::Constant { data } if data.len() == 4)
};
for node in graph.nodes() {
if matches!(node.op, Op::Binary(BinaryOp::Max)) && node.inputs.len() == 2 {
for (ci, mi) in [(0usize, 1usize), (1, 0)] {
let (c, m) = (node.inputs[ci], node.inputs[mi]);
if !is_conv3d(c) {
continue;
}
let mn = graph.node(m);
if !matches!(mn.op, Op::Binary(BinaryOp::Mul)) || mn.inputs.len() != 2 {
continue;
}
let k = if mn.inputs[0] == c {
mn.inputs[1]
} else if mn.inputs[1] == c {
mn.inputs[0]
} else {
continue;
};
if !scalar_const(k) || uses.get(&c) != Some(&2) || uses.get(&m) != Some(&1) {
continue;
}
if graph.outputs.contains(&c) || graph.outputs.contains(&m) {
continue;
}
out.absorbed.insert(node.id, c);
out.absorbed.insert(m, c);
break;
}
}
if is_conv3d(node.id) {
let cat_id = node.inputs[0];
let cat = graph.node(cat_id);
if let Op::Concat { axis: 1 } = &cat.op
&& cat.inputs.len() == 2
&& uses.get(&cat_id) == Some(&1)
&& !graph.outputs.contains(&cat_id)
{
for &src in &cat.inputs {
out.read_through.insert(src, node.id);
}
let a = cat.inputs[0];
if let Op::Reshape { .. } = &graph.node(a).op {
let e = graph.node(a).inputs[0];
if let Op::Expand { .. } = &graph.node(e).op {
let r = graph.node(e).inputs[0];
if let Op::Reshape { .. } = &graph.node(r).op {
out.read_through.insert(graph.node(r).inputs[0], node.id);
}
}
}
}
}
}
out
}
fn extend_conv3d_epilogue_fold_liveness(
graph: &Graph,
ranges: &mut HashMap<NodeId, (usize, usize)>,
) {
let folds = conv3d_epilogue_folds(graph);
if rlx_ir::env::flag("RLX_FOLD_LIVENESS_DEBUG") {
eprintln!(
"[fold-liveness] absorbed={} read_through={}",
folds.absorbed.len(),
folds.read_through.len()
);
}
if folds.absorbed.is_empty() && folds.read_through.is_empty() {
return;
}
let step_of: HashMap<NodeId, usize> = graph
.nodes()
.iter()
.enumerate()
.map(|(i, n)| (n.id, i))
.collect();
for (&absorbed, &conv) in &folds.absorbed {
let Some(&cs) = step_of.get(&conv) else {
continue;
};
if let Some(r) = ranges.get_mut(&absorbed) {
r.0 = r.0.min(cs);
}
}
for (&tensor, &conv) in &folds.read_through {
let Some(&cs) = step_of.get(&conv) else {
continue;
};
let (root, _) = resolve_view_root(graph, tensor);
if let Some(r) = ranges.get_mut(&root) {
r.1 = r.1.max(cs);
}
if root != tensor
&& let Some(r) = ranges.get_mut(&tensor)
{
r.1 = r.1.max(cs);
}
}
}
#[allow(dead_code)]
fn compute_live_ranges(graph: &Graph) -> HashMap<NodeId, (usize, usize)> {
compute_live_ranges_opts(graph, true)
}
fn compute_live_ranges_opts(
graph: &Graph,
pin_output_ancestors: bool,
) -> HashMap<NodeId, (usize, usize)> {
let mut ranges: HashMap<NodeId, (usize, usize)> = HashMap::new();
for (step, node) in graph.nodes().iter().enumerate() {
ranges.entry(node.id).or_insert((step, step));
for &input in &node.inputs {
let (root, _off) = resolve_view_root(graph, input);
ranges.entry(root).and_modify(|r| r.1 = r.1.max(step));
if root != input {
ranges.entry(input).and_modify(|r| r.1 = r.1.max(step));
}
}
}
let last_step = graph.len();
for &out in &graph.outputs {
let (root, _off) = resolve_view_root(graph, out);
ranges.entry(root).and_modify(|r| r.1 = last_step);
if root != out {
ranges.entry(out).and_modify(|r| r.1 = last_step);
}
}
{
let mut stack: Vec<NodeId> = graph.outputs.clone();
let mut seen = std::collections::HashSet::new();
while let Some(id) = stack.pop() {
if !seen.insert(id) {
continue;
}
let (root, _) = resolve_view_root(graph, id);
ranges.entry(root).and_modify(|r| r.1 = last_step);
if root != id {
ranges.entry(id).and_modify(|r| r.1 = last_step);
}
if pin_output_ancestors {
for &input in &graph.node(id).inputs {
stack.push(input);
}
}
}
}
for node in graph.nodes() {
if matches!(
node.op,
rlx_ir::Op::Param { .. } | rlx_ir::Op::Input { .. } | rlx_ir::Op::Constant { .. }
) {
ranges.entry(node.id).and_modify(|r| {
r.0 = 0;
r.1 = last_step;
});
}
}
ranges
}
fn extend_node_chain_liveness_to_end(
graph: &Graph,
ranges: &mut HashMap<NodeId, (usize, usize)>,
start: NodeId,
last_step: usize,
) {
let mut stack = vec![start];
let mut seen = std::collections::HashSet::new();
while let Some(id) = stack.pop() {
if !seen.insert(id) {
continue;
}
let (root, _) = resolve_view_root(graph, id);
ranges.entry(root).and_modify(|r| r.1 = last_step);
if root != id {
ranges.entry(id).and_modify(|r| r.1 = last_step);
}
for &input in &graph.node(id).inputs {
stack.push(input);
}
}
}
fn extend_static_weight_pack_liveness(graph: &Graph, ranges: &mut HashMap<NodeId, (usize, usize)>) {
let last_step = graph.len();
let mut memo: HashMap<NodeId, bool> = HashMap::new();
for node in graph.nodes() {
if !is_static_weight_tensor(graph, node.id, &mut memo) {
continue;
}
if matches!(
&node.op,
Op::Param { .. } | Op::Constant { .. } | Op::Input { .. }
) {
continue;
}
ranges
.entry(node.id)
.and_modify(|r| *r = (0usize, last_step));
}
}
pub fn static_weight_pack_skip_enabled() -> bool {
rlx_ir::env::flag_or("RLX_STATIC_WEIGHT_PACK", true)
}
pub fn is_static_weight_tensor(
graph: &Graph,
id: NodeId,
memo: &mut HashMap<NodeId, bool>,
) -> bool {
if let Some(&v) = memo.get(&id) {
return v;
}
let node = graph.node(id);
let v = match &node.op {
Op::Param { .. } | Op::Constant { .. } => true,
Op::Input { .. } => false,
Op::Cast { .. }
| Op::Reshape { .. }
| Op::Transpose { .. }
| Op::Narrow { .. }
| Op::Expand { .. }
| Op::Activation(_)
| Op::Concat { .. } => {
!node.inputs.is_empty()
&& node
.inputs
.iter()
.all(|&inp| is_static_weight_tensor(graph, inp, memo))
}
Op::Binary(_) | Op::Where | Op::Fma => node
.inputs
.iter()
.all(|&inp| is_static_weight_tensor(graph, inp, memo)),
_ => false,
};
memo.insert(id, v);
v
}
static PIN_HOST_STRUCTURE: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub fn set_pin_host_structure(on: bool) {
PIN_HOST_STRUCTURE.store(on, std::sync::atomic::Ordering::Relaxed);
}
fn pin_host_structure() -> bool {
PIN_HOST_STRUCTURE.load(std::sync::atomic::Ordering::Relaxed)
|| rlx_ir::env::flag("RLX_ARENA_PIN_HOST_STRUCTURE")
}
fn extend_custom_op_input_liveness(
graph: &Graph,
ranges: &mut HashMap<NodeId, (usize, usize)>,
dequant_host_fallback: bool,
) {
let last_step = graph.len();
for node in graph.nodes() {
let Op::Custom {
name, num_inputs, ..
} = &node.op
else {
continue;
};
if !name.starts_with("onnx.") {
continue;
}
let n = (*num_inputs as usize).min(node.inputs.len());
for &input in &node.inputs[..n] {
extend_node_chain_liveness_to_end(graph, ranges, input, last_step);
}
}
if pin_host_structure() {
for node in graph.nodes() {
if matches!(
&node.op,
Op::Expand { .. } | Op::Concat { .. } | Op::Transpose { .. } | Op::Narrow { .. }
) {
for &input in &node.inputs {
extend_node_chain_liveness_to_end(graph, ranges, input, last_step);
}
extend_node_chain_liveness_to_end(graph, ranges, node.id, last_step);
}
}
}
if dequant_host_fallback {
for node in graph.nodes() {
match &node.op {
Op::DequantMatMul { .. } => {
if let Some(&x) = node.inputs.first() {
extend_node_chain_liveness_to_end(graph, ranges, x, last_step);
}
}
Op::DequantGroupedMatMul { .. } => {
if let Some(&x) = node.inputs.first() {
extend_node_chain_liveness_to_end(graph, ranges, x, last_step);
}
}
_ => {}
}
}
}
}
fn extend_bert_hidden_liveness(graph: &Graph, ranges: &mut HashMap<NodeId, (usize, usize)>) {
let uses_onnx_qmatmul = graph.nodes().iter().any(|node| {
matches!(
&node.op,
Op::Custom { name, .. } if name == "onnx.QMatMul" || name == "onnx.ActCopy"
)
});
if !uses_onnx_qmatmul {
return;
}
let last_step = graph.len();
for node in graph.nodes() {
match &node.op {
Op::LayerNorm { .. } | Op::LayerNorm2d { .. } => {
if let Some(&input) = node.inputs.first() {
extend_node_chain_liveness_to_end(graph, ranges, input, last_step);
}
ranges.entry(node.id).and_modify(|r| r.1 = last_step);
}
Op::Binary(BinaryOp::Add) => {
for &input in &node.inputs {
extend_node_chain_liveness_to_end(graph, ranges, input, last_step);
}
ranges.entry(node.id).and_modify(|r| r.1 = last_step);
}
_ => {}
}
}
}
fn extend_onnx_duration_epilogue_liveness(
graph: &Graph,
ranges: &mut HashMap<NodeId, (usize, usize)>,
) {
if !graph_exports_onnx_duration(graph) {
return;
}
let last_step = graph.len();
for &out in &graph.outputs {
extend_node_chain_liveness_to_end(graph, ranges, out, last_step);
}
for node in graph.nodes() {
let keep = match &node.op {
Op::Custom { name, .. }
if name == "onnx.ConcatFromSequence" || name == "onnx.KittenConcatFromSequence" =>
{
true
}
Op::Expand { .. } => node.shape.dtype() == rlx_ir::DType::I64,
Op::Cast { to, .. } => *to == rlx_ir::DType::I64,
Op::Where => node.shape.dtype() == rlx_ir::DType::I64,
Op::Binary(_) => node.shape.dtype() == rlx_ir::DType::I64,
_ => node.shape.dtype() == rlx_ir::DType::I64 && node.shape.rank() <= 2,
};
if keep {
extend_node_chain_liveness_to_end(graph, ranges, node.id, last_step);
ranges.entry(node.id).and_modify(|r| r.1 = last_step);
}
}
}
fn graph_exports_onnx_duration(graph: &Graph) -> bool {
graph
.outputs
.iter()
.any(|&id| graph.node(id).shape.dtype() == rlx_ir::DType::I64)
}
#[allow(dead_code)]
fn graph_uses_onnx_duration_epilogue(graph: &Graph) -> bool {
if graph.nodes().iter().any(|node| {
matches!(
&node.op,
Op::Custom { name, .. }
if name == "onnx.ConcatFromSequence"
|| name == "onnx.KittenConcatFromSequence"
)
}) {
return true;
}
graph_exports_onnx_duration(graph)
}
fn extend_packed_qkv_parent_liveness(graph: &Graph, ranges: &mut HashMap<NodeId, (usize, usize)>) {
for (step, node) in graph.nodes().iter().enumerate() {
let rlx_ir::Op::Attention { .. } = &node.op else {
continue;
};
if node.inputs.len() < 3 {
continue;
}
let Some((parent, _, _)) = rlx_ir::detect_packed_bshd_qkv_attention(
graph,
node.inputs[0],
node.inputs[1],
node.inputs[2],
) else {
continue;
};
let (root, _) = resolve_view_root(graph, parent);
ranges.entry(root).and_modify(|r| r.1 = r.1.max(step));
if root != parent {
ranges.entry(parent).and_modify(|r| r.1 = r.1.max(step));
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MemoryPlanOptions {
pub allocate_params: bool,
pub allocate_inputs: bool,
pub allocate_constants: bool,
pub arena_no_reuse: bool,
pub pin_output_ancestors: bool,
pub dequant_host_fallback: bool,
pub elide_bank_transposes: bool,
pub fold_conv3d_epilogue: bool,
pub elide_requires_small_m: bool,
}
impl MemoryPlanOptions {
pub fn inference() -> Self {
Self {
allocate_params: true,
allocate_inputs: true,
allocate_constants: true,
arena_no_reuse: rlx_ir::env::var("RLX_ARENA_NO_REUSE")
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")),
pin_output_ancestors: true,
dequant_host_fallback: true,
elide_bank_transposes: false,
fold_conv3d_epilogue: false,
elide_requires_small_m: false,
}
}
pub fn backward_activations_only() -> Self {
Self {
allocate_params: false,
allocate_inputs: true,
allocate_constants: true,
arena_no_reuse: rlx_ir::env::var("RLX_ARENA_NO_REUSE")
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true")),
pin_output_ancestors: true,
dequant_host_fallback: true,
elide_bank_transposes: false,
fold_conv3d_epilogue: false,
elide_requires_small_m: false,
}
}
}
impl Default for MemoryPlanOptions {
fn default() -> Self {
Self::inference()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SharedWeightLayout {
pub arena_size: usize,
pub slots: Vec<WeightSlot>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WeightSlot {
pub name: String,
pub forward_id: NodeId,
pub offset: usize,
pub size: usize,
}
impl SharedWeightLayout {
pub fn from_forward(graph: &Graph, plan: &MemoryPlan) -> Self {
let mut slots = Vec::new();
for node in graph.nodes() {
if let rlx_ir::Op::Param { name } = &node.op {
if let Some(slot) = plan.assignments.get(&node.id) {
slots.push(WeightSlot {
name: name.clone(),
forward_id: node.id,
offset: slot.offset,
size: slot.size,
});
}
}
}
slots.sort_by(|a, b| a.name.cmp(&b.name));
let arena_size = slots.iter().map(|s| s.offset + s.size).max().unwrap_or(0);
Self { arena_size, slots }
}
pub fn apply_to_plan(&self, graph: &Graph, plan: &mut MemoryPlan) {
let by_name: std::collections::HashMap<&str, &WeightSlot> =
self.slots.iter().map(|s| (s.name.as_str(), s)).collect();
for node in graph.nodes() {
if let rlx_ir::Op::Param { name } = &node.op {
let Some(slot) = by_name.get(name.as_str()) else {
continue;
};
plan.assignments.insert(
node.id,
BufferSlot {
offset: slot.offset,
size: slot.size,
},
);
}
}
plan.arena_size = plan.arena_size.max(self.arena_size);
}
}
#[inline]
fn plans_boundary_buffer(op: &rlx_ir::Op, opts: MemoryPlanOptions) -> bool {
match op {
rlx_ir::Op::Param { .. } => opts.allocate_params,
rlx_ir::Op::Input { .. } => opts.allocate_inputs,
rlx_ir::Op::Constant { .. } => opts.allocate_constants,
_ => true,
}
}
pub fn plan_memory(graph: &Graph) -> MemoryPlan {
plan_memory_aligned(graph, 64)
}
pub fn plan_memory_with_options(
graph: &Graph,
alignment: usize,
opts: MemoryPlanOptions,
) -> MemoryPlan {
plan_memory_aligned_inner(graph, alignment, opts, None, ArenaWidthPolicy::Native)
}
pub fn plan_memory_with_options_policy(
graph: &Graph,
alignment: usize,
opts: MemoryPlanOptions,
policy: ArenaWidthPolicy,
) -> MemoryPlan {
plan_memory_aligned_inner(graph, alignment, opts, None, policy)
}
pub fn plan_memory_aligned(graph: &Graph, alignment: usize) -> MemoryPlan {
plan_memory_aligned_inner(
graph,
alignment,
MemoryPlanOptions::default(),
None,
ArenaWidthPolicy::Native,
)
}
pub fn plan_memory_f32_uniform(graph: &Graph, alignment: usize) -> MemoryPlan {
let pin = graph_has_host_indexing(graph)
|| rlx_ir::env::var("RLX_PIN_OUTPUT_ANCESTORS").as_deref() == Some("1");
let opts = MemoryPlanOptions {
pin_output_ancestors: pin,
..MemoryPlanOptions::default()
};
plan_memory_aligned_inner(graph, alignment, opts, None, ArenaWidthPolicy::F32Uniform)
}
pub fn plan_memory_native(graph: &Graph, alignment: usize) -> MemoryPlan {
let opts = MemoryPlanOptions {
pin_output_ancestors: graph_has_host_indexing(graph),
..MemoryPlanOptions::default()
};
plan_memory_aligned_inner(graph, alignment, opts, None, ArenaWidthPolicy::Native)
}
pub fn plan_memory_native_in_order(graph: &Graph, alignment: usize) -> MemoryPlan {
let opts = MemoryPlanOptions {
pin_output_ancestors: graph_has_host_indexing(graph),
dequant_host_fallback: false,
elide_bank_transposes: true,
fold_conv3d_epilogue: false,
..MemoryPlanOptions::default()
};
plan_memory_aligned_inner(
graph,
alignment,
opts,
None,
ArenaWidthPolicy::NativeHalfWidened,
)
}
pub fn plan_memory_hybrid(graph: &Graph, alignment: usize) -> MemoryPlan {
let opts = MemoryPlanOptions {
pin_output_ancestors: graph_has_host_indexing(graph),
..MemoryPlanOptions::default()
};
plan_memory_aligned_inner(graph, alignment, opts, None, ArenaWidthPolicy::Hybrid)
}
pub fn plan_memory_with_policy(
graph: &Graph,
alignment: usize,
policy: ArenaWidthPolicy,
) -> MemoryPlan {
let opts = MemoryPlanOptions {
pin_output_ancestors: graph_has_host_indexing(graph),
..MemoryPlanOptions::default()
};
plan_memory_aligned_inner(graph, alignment, opts, None, policy)
}
pub fn plan_memory_f32_uniform_no_params(graph: &Graph, alignment: usize) -> MemoryPlan {
let pin = graph_has_host_indexing(graph);
let opts = MemoryPlanOptions {
pin_output_ancestors: pin,
allocate_params: false,
..MemoryPlanOptions::default()
};
plan_memory_aligned_inner(graph, alignment, opts, None, ArenaWidthPolicy::F32Uniform)
}
fn graph_has_host_indexing(graph: &Graph) -> bool {
graph.nodes().iter().any(|n| {
matches!(
&n.op,
Op::Gather { .. }
| Op::ScatterNd { .. }
| Op::ScatterElements { .. }
| Op::GatherNd { .. }
| Op::GatherElements { .. }
| Op::LayerNormBackwardGamma { .. }
)
})
}
pub fn plan_memory_backward(
graph: &Graph,
alignment: usize,
weights: &SharedWeightLayout,
) -> MemoryPlan {
plan_memory_aligned_inner(
graph,
alignment,
MemoryPlanOptions::backward_activations_only(),
Some(weights),
ArenaWidthPolicy::Native,
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ArenaWidthPolicy {
#[default]
F32Uniform,
Native,
Hybrid,
NativeHalfWidened,
NativeBf16Widened,
}
pub const fn arena_width_policy(target: crate::fusion_pipeline::FusionTarget) -> ArenaWidthPolicy {
use crate::fusion_pipeline::FusionTarget as T;
match target {
T::Cpu => ArenaWidthPolicy::NativeHalfWidened,
T::Metal => ArenaWidthPolicy::NativeBf16Widened,
T::Mlx | T::Cuda | T::Rocm => ArenaWidthPolicy::Native,
T::Wgpu => ArenaWidthPolicy::F32Uniform,
T::Tpu => ArenaWidthPolicy::F32Uniform,
}
}
fn native_width_nodes(
graph: &Graph,
policy: ArenaWidthPolicy,
) -> std::collections::HashSet<rlx_ir::NodeId> {
let mut seeds = std::collections::HashSet::new();
if !matches!(
policy,
ArenaWidthPolicy::NativeHalfWidened | ArenaWidthPolicy::NativeBf16Widened
) {
return seeds;
}
for n in graph.nodes() {
if matches!(
n.op,
rlx_ir::Op::Custom { .. } | rlx_ir::Op::KvAppend { .. }
) {
seeds.insert(n.id);
seeds.extend(n.inputs.iter().copied());
}
}
if seeds.is_empty() {
return seeds;
}
let mut family: std::collections::HashMap<rlx_ir::NodeId, Vec<rlx_ir::NodeId>> =
std::collections::HashMap::new();
for n in graph.nodes() {
family
.entry(resolve_view_root(graph, n.id).0)
.or_default()
.push(n.id);
}
let mut out = seeds.clone();
for (root, members) in family {
if seeds.contains(&root) || members.iter().any(|m| seeds.contains(m)) {
out.insert(root);
out.extend(members);
}
}
out
}
#[inline]
fn node_slot_bytes(node: &rlx_ir::Node, policy: ArenaWidthPolicy, keep_native: bool) -> usize {
let native = node.shape.size_bytes().unwrap_or(0);
let f32_wide = || native.max(node.shape.num_elements().unwrap_or(0) * 4);
let is_param = matches!(node.op, rlx_ir::Op::Param { .. });
let non_f32_param = is_param && node.shape.dtype() != rlx_ir::DType::F32;
let low_prec_act =
!is_param && matches!(node.shape.dtype(), rlx_ir::DType::F16 | rlx_ir::DType::BF16);
match policy {
ArenaWidthPolicy::Native => native,
ArenaWidthPolicy::F32Uniform => {
if non_f32_param { native } else { f32_wide() }
}
ArenaWidthPolicy::Hybrid => {
if non_f32_param || low_prec_act {
native
} else {
f32_wide()
}
}
ArenaWidthPolicy::NativeHalfWidened => {
if low_prec_act && !keep_native {
f32_wide()
} else {
native
}
}
ArenaWidthPolicy::NativeBf16Widened => {
let bf16_act = !is_param && node.shape.dtype() == rlx_ir::DType::BF16;
if bf16_act && !keep_native {
f32_wide()
} else {
native
}
}
}
}
thread_local! {
static VERIFY_PLAN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
#[cfg(test)]
fn with_plan_verify<R>(f: impl FnOnce() -> R) -> R {
VERIFY_PLAN.with(|v| v.set(true));
let r = f();
VERIFY_PLAN.with(|v| v.set(false));
r
}
fn plan_memory_aligned_inner(
graph: &Graph,
alignment: usize,
opts: MemoryPlanOptions,
weights: Option<&SharedWeightLayout>,
width: ArenaWidthPolicy,
) -> MemoryPlan {
let mut ranges = compute_live_ranges_opts(graph, opts.pin_output_ancestors);
extend_packed_qkv_parent_liveness(graph, &mut ranges);
extend_custom_op_input_liveness(graph, &mut ranges, opts.dequant_host_fallback);
extend_bert_hidden_liveness(graph, &mut ranges);
extend_onnx_duration_epilogue_liveness(graph, &mut ranges);
extend_static_weight_pack_liveness(graph, &mut ranges);
if opts.fold_conv3d_epilogue {
extend_conv3d_epilogue_fold_liveness(graph, &mut ranges);
}
let mut opts = opts;
if graph_exports_onnx_duration(graph) {
opts.arena_no_reuse = true;
}
struct BufInfo {
id: NodeId,
size: usize,
birth: usize,
death: usize,
}
let keep_native = native_width_nodes(graph, width);
let mut buffers: Vec<BufInfo> = Vec::new();
for node in graph.nodes() {
if pure_view_offset(graph, node).is_some() {
continue;
}
if opts.elide_bank_transposes
&& is_elidable_folded_transpose_gated(graph, node, opts.elide_requires_small_m)
{
continue;
}
let raw_size = node_slot_bytes(node, width, keep_native.contains(&node.id));
let size = if raw_size == 0 {
boundary_min_slot_bytes(&node.op, alignment)
} else {
raw_size
};
if size > 0
&& let Some(&(birth, death)) = ranges.get(&node.id)
&& plans_boundary_buffer(&node.op, opts)
{
buffers.push(BufInfo {
id: node.id,
size,
birth,
death,
});
}
}
buffers.sort_by_key(|b| std::cmp::Reverse(b.size));
let mut assignments: HashMap<NodeId, BufferSlot> = HashMap::new();
let mut arena_size: usize = 0;
let mut placed: Vec<(usize, usize, usize, usize)> = Vec::new();
for buf in &buffers {
let align = alignment;
let node = graph.node(buf.id);
let tail_guard = boundary_tail_guard(&node.op, align);
let placement_size = buf.size + tail_guard;
let mut occupied: Vec<(usize, usize)> = placed
.iter()
.filter(|&&(_, _, p_birth, p_death)| buf.birth <= p_death && buf.death >= p_birth)
.map(|&(p_off, p_size, _, _)| (p_off, p_off + p_size))
.collect();
occupied.sort_unstable();
let mut cursor = 0usize;
let mut best_offset: Option<usize> = None;
for (start, end) in occupied {
let aligned = (cursor + align - 1) & !(align - 1);
if aligned + placement_size <= start {
best_offset = Some(aligned);
break;
}
cursor = cursor.max(end);
}
if best_offset.is_none() {
best_offset = Some((cursor + align - 1) & !(align - 1));
}
if VERIFY_PLAN.with(|v| v.get()) || rlx_ir::env::flag("RLX_PLAN_VERIFY") {
let mut candidates = vec![0usize];
for &(p_off, p_size, _, _) in &placed {
candidates.push(p_off + p_size);
}
candidates.sort_unstable();
candidates.dedup();
let mut want: Option<usize> = None;
for &cand in &candidates {
let a = (cand + align - 1) & !(align - 1);
let end = a + placement_size;
let conflict = placed.iter().any(|&(p_off, p_size, p_birth, p_death)| {
a < p_off + p_size
&& end > p_off
&& buf.birth <= p_death
&& buf.death >= p_birth
});
if !conflict && want.is_none_or(|w| a < w) {
want = Some(a);
}
}
let want = want.unwrap_or_else(|| (arena_size + align - 1) & !(align - 1));
assert_eq!(
best_offset,
Some(want),
"plan sweep disagrees with the candidate scan for buffer {:?} \
(size {}, live {}..{})",
buf.id,
buf.size,
buf.birth,
buf.death
);
}
let aligned = if opts.arena_no_reuse {
(arena_size + align - 1) & !(align - 1)
} else {
best_offset.unwrap_or_else(|| {
(arena_size + align - 1) & !(align - 1)
})
};
assignments.insert(
buf.id,
BufferSlot {
offset: aligned,
size: buf.size,
},
);
placed.push((aligned, placement_size, buf.birth, buf.death));
arena_size = arena_size.max(aligned + placement_size);
}
if !opts.arena_no_reuse {
let ids: Vec<NodeId> = buffers.iter().map(|b| b.id).collect();
for id in ids {
let node = graph.node(id);
let Some(out) = assignments.get(&id).cloned() else {
continue;
};
let out_size = node_slot_bytes(node, width, keep_native.contains(&node.id)).max(1);
let out_end = out.offset + out_size;
let mut overlaps_input = false;
for &inp in &node.inputs {
let (root, _off) = resolve_view_root(graph, inp);
if root == id {
continue;
}
if let Some(rs) = assignments.get(&root) {
let r_size =
node_slot_bytes(graph.node(root), width, keep_native.contains(&root))
.max(1);
if out.offset < rs.offset + r_size && out_end > rs.offset {
overlaps_input = true;
break;
}
}
}
if overlaps_input {
let align = alignment;
let aligned = (arena_size + align - 1) & !(align - 1);
let guard = boundary_tail_guard(&node.op, align);
assignments.insert(
id,
BufferSlot {
offset: aligned,
size: out.size,
},
);
arena_size = arena_size.max(aligned + out.size + guard);
}
}
}
for node in graph.nodes() {
if pure_view_offset(graph, node).is_some() {
let (root, off) = resolve_view_root(graph, node.id);
if let Some(root_slot) = assignments.get(&root).cloned() {
let view_size = node_slot_bytes(node, width, keep_native.contains(&node.id));
assignments.insert(
node.id,
BufferSlot {
offset: root_slot.offset + off,
size: view_size,
},
);
}
}
}
if rlx_ir::env::flag("RLX_MEM_VERIFY") {
let mut violations = 0usize;
for (i, a) in buffers.iter().enumerate() {
let Some(sa) = assignments.get(&a.id) else {
continue;
};
let a_end = sa.offset + a.size.max(1);
for b in &buffers[i + 1..] {
let Some(sb) = assignments.get(&b.id) else {
continue;
};
let b_end = sb.offset + b.size.max(1);
let mem = sa.offset < b_end && a_end > sb.offset;
let time = a.birth <= b.death && a.death >= b.birth;
if mem && time {
violations += 1;
if violations <= 20 {
eprintln!(
"[mem-verify] OVERLAP {:?} off={}..{} live[{},{}] <> {:?} off={}..{} live[{},{}]",
a.id,
sa.offset,
a_end,
a.birth,
a.death,
b.id,
sb.offset,
b_end,
b.birth,
b.death,
);
}
}
}
}
eprintln!(
"[mem-verify] {violations} live+memory overlaps among {} real buffers",
buffers.len()
);
let mut read_after_death = 0usize;
for (step, node) in graph.nodes().iter().enumerate() {
for &input in &node.inputs {
let (root, _off) = resolve_view_root(graph, input);
if let Some(&(_b, d)) = ranges.get(&root) {
if d < step {
read_after_death += 1;
if read_after_death <= 20 {
eprintln!(
"[mem-verify] READ-AFTER-DEATH node {:?} step={step} reads {:?} (via {:?}) whose death={d}",
node.id, root, input,
);
}
}
}
}
}
eprintln!("[mem-verify] {read_after_death} reads-after-death");
let mut view_ovf = 0usize;
for node in graph.nodes() {
if pure_view_offset(graph, node).is_none() {
continue;
}
let (root, off) = resolve_view_root(graph, node.id);
let view_size = node_slot_bytes(node, width, keep_native.contains(&node.id));
let root_size = node_slot_bytes(graph.node(root), width, keep_native.contains(&root));
if off + view_size > root_size {
view_ovf += 1;
if view_ovf <= 20 {
eprintln!(
"[mem-verify] VIEW-OVERFLOW node {:?} off={off}+size={view_size} > root {:?} size={root_size} (by {})",
node.id,
root,
(off + view_size) - root_size,
);
}
}
}
eprintln!("[mem-verify] {view_ovf} view-past-root overflows");
}
let schedule = graph.topo_order().collect();
let mut plan = MemoryPlan {
arena_size,
assignments,
schedule,
};
if let Some(w) = weights {
w.apply_to_plan(graph, &mut plan);
}
plan
}
#[cfg(test)]
mod tests {
use super::*;
use rlx_ir::*;
#[test]
fn arena_width_policies_size_slots() {
let mut g = Graph::new("mix");
let x = g.input("x", Shape::new(&[64, 64], DType::F32));
let wbf = g.param("wbf", Shape::new(&[64, 64], DType::BF16)); let abf = g.add_node(
Op::Cast { to: DType::BF16 },
vec![x],
Shape::new(&[64, 64], DType::BF16),
); let abool = g.add_node(
Op::Cast { to: DType::Bool },
vec![x],
Shape::new(&[64, 64], DType::Bool),
); let ne = 64 * 64;
use ArenaWidthPolicy::*;
for p in [F32Uniform, Native, Hybrid] {
assert_eq!(
node_slot_bytes(g.node(wbf), p, false),
ne * 2,
"bf16 param {p:?}"
);
}
assert_eq!(node_slot_bytes(g.node(abf), F32Uniform, false), ne * 4);
assert_eq!(node_slot_bytes(g.node(abf), Native, false), ne * 2);
assert_eq!(node_slot_bytes(g.node(abf), Hybrid, false), ne * 2);
assert_eq!(node_slot_bytes(g.node(abool), F32Uniform, false), ne * 4);
assert_eq!(node_slot_bytes(g.node(abool), Native, false), ne);
assert_eq!(node_slot_bytes(g.node(abool), Hybrid, false), ne * 4);
for p in [F32Uniform, Native, Hybrid] {
assert_eq!(
node_slot_bytes(g.node(x), p, false),
ne * 4,
"f32 input {p:?}"
);
}
}
#[test]
fn non_overlapping_buffers_share_memory() {
let mut g = Graph::new("test");
let f = DType::F32;
let x = g.input("x", Shape::new(&[100, 384], f)); let w1 = g.param("w1", Shape::new(&[384, 384], f));
let w2 = g.param("w2", Shape::new(&[384, 384], f));
let mm1 = g.matmul(x, w1, Shape::new(&[100, 384], f)); let mm2 = g.matmul(mm1, w2, Shape::new(&[100, 384], f)); g.set_outputs(vec![mm2]);
let plan = plan_memory(&g);
println!("Arena size: {} bytes", plan.arena_size);
for (id, slot) in &plan.assignments {
if let Some((b, d)) = compute_live_ranges(&g).get(id) {
println!(
" {id}: offset={}, size={}, live=[{b}, {d}]",
slot.offset, slot.size
);
}
}
let total_logical: usize = plan.assignments.values().map(|s| s.size).sum();
let align_slack = plan.assignments.len() * BOUNDARY_TAIL_GUARD_BYTES;
assert!(
plan.arena_size <= total_logical + align_slack,
"arena {} should be <= logical sum {} + slack {}",
plan.arena_size,
total_logical,
align_slack
);
}
#[test]
fn plan_report_includes_savings() {
let mut g = Graph::new("rep");
let f = DType::F32;
let x = g.input("x", Shape::new(&[16], f));
let w = g.param("w", Shape::new(&[16, 16], f));
let mm1 = g.matmul(x, w, Shape::new(&[1, 16], f));
let mm2 = g.matmul(mm1, w, Shape::new(&[1, 16], f));
g.set_outputs(vec![mm2]);
let plan = plan_memory(&g);
let r = plan.report();
assert!(r.starts_with("# arena_size="));
assert!(r.contains("total_unshared="));
assert!(r.contains("saved="));
let body: Vec<&str> = r.lines().filter(|l| !l.starts_with('#')).collect();
assert!(!body.is_empty());
assert!(plan.assignments.contains_key(&mm1));
assert!(plan.assignments.contains_key(&mm2));
}
#[test]
fn view_ops_alias_parent_slot() {
use rlx_ir::GraphExt;
let mut g = Graph::new("views");
let f = DType::F32;
let x = g.input("x", Shape::new(&[8, 4], f)); let w = g.param("w", Shape::new(&[4, 4], f)); let mm = g.matmul(x, w, Shape::new(&[8, 4], f)); let r = g.reshape_(mm, vec![32]); let c = g.cast(r, DType::F32); let n = g.narrow_(c, 0, 8, 16); g.set_outputs(vec![n]);
let plan = plan_memory(&g);
let mm_off = plan.assignments[&mm].offset;
assert_eq!(
plan.assignments[&r].offset, mm_off,
"reshape view should alias mm slot exactly"
);
assert_eq!(
plan.assignments[&c].offset, mm_off,
"same-dtype cast view should alias mm slot exactly"
);
assert_eq!(
plan.assignments[&n].offset,
mm_off + 32,
"axis-0 narrow start=8 should alias mm slot + 8*4 bytes"
);
assert_eq!(
plan.assignments[&n].size, 64,
"narrow view's size is its own (16 f32 = 64B), not parent's"
);
}
#[test]
fn backward_plan_aliases_forward_param_slots() {
let f = DType::F32;
let mut fwd = Graph::new("fwd");
let x = fwd.input("x", Shape::new(&[2, 4], f));
let w = fwd.param("w", Shape::new(&[4, 4], f));
let mm = fwd.matmul(x, w, Shape::new(&[2, 4], f));
fwd.set_outputs(vec![mm]);
let fwd_plan = plan_memory_aligned(&fwd, 64);
let layout = SharedWeightLayout::from_forward(&fwd, &fwd_plan);
let mut bwd = Graph::new("bwd_grad");
let x2 = bwd.input("x", Shape::new(&[2, 4], f));
let w2 = bwd.param("w", Shape::new(&[4, 4], f));
let mm2 = bwd.matmul(x2, w2, Shape::new(&[2, 4], f));
bwd.set_outputs(vec![mm2]);
let bwd_plan = plan_memory_backward(&bwd, 64, &layout);
let fwd_w_off = fwd_plan.assignments[&w].offset;
let bwd_w_off = bwd_plan.assignments[&w2].offset;
assert_eq!(bwd_w_off, fwd_w_off, "backward w must share forward offset");
assert!(
!bwd_plan.assignments.contains_key(&w2)
|| bwd_plan.assignments[&w2].offset == fwd_w_off
);
}
#[test]
fn overlapping_buffers_get_separate_memory() {
let mut g = Graph::new("test");
let f = DType::F32;
let x = g.input("x", Shape::new(&[100, 384], f));
let w = g.param("w", Shape::new(&[384, 384], f));
let mm = g.matmul(x, w, Shape::new(&[100, 384], f));
let add = g.binary(BinaryOp::Add, mm, x, Shape::new(&[100, 384], f));
g.set_outputs(vec![add]);
let plan = plan_memory(&g);
let mm_slot = &plan.assignments[&mm];
let add_slot = &plan.assignments[&add];
let mm_end = mm_slot.offset + mm_slot.size;
let add_end = add_slot.offset + add_slot.size;
let no_overlap = mm_end <= add_slot.offset || add_end <= mm_slot.offset;
assert!(no_overlap, "overlapping buffers must have separate memory");
}
#[test]
fn zero_length_inputs_get_arena_slots() {
let mut g = Graph::new("empty_past");
let f = DType::F32;
let past = g.input("past_k", Shape::new(&[1, 0, 8], f));
let x = g.input("x", Shape::new(&[1, 1, 8], f));
let cat = g.concat(vec![past, x], 1, Shape::new(&[1, 1, 8], f));
g.set_outputs(vec![cat]);
let plan = plan_memory(&g);
assert!(
plan.assignments.contains_key(&past),
"zero-length decode past input must have an arena slot"
);
assert!(plan.assignments[&past].size >= 64);
}
#[test]
fn duration_export_forces_no_reuse_waveform_only_does_not() {
let f = DType::F32;
let mut wave_only = Graph::new("wave_only");
let w = wave_only.input("wave", Shape::new(&[1024], f));
wave_only.set_outputs(vec![w]);
assert!(!graph_exports_onnx_duration(&wave_only));
let mut dual = Graph::new("dual");
let w2 = dual.input("wave", Shape::new(&[1024], f));
let d = dual.input("dur", Shape::new(&[8], DType::I64));
dual.set_outputs(vec![w2, d]);
assert!(graph_exports_onnx_duration(&dual));
}
#[test]
fn plan_sweep_matches_candidate_scan() {
use rlx_ir::infer::GraphExt;
let mut g = Graph::new("deep");
let mut x = g.input("x", Shape::new(&[32, 64], DType::F32));
for i in 0..200 {
let w = 32 + (i % 7) * 16;
let p = g.param(format!("w{i}"), Shape::new(&[64, w], DType::F32));
let m = g.mm(x, p);
let r = g.relu(m);
let p2 = g.param(format!("v{i}"), Shape::new(&[w, 64], DType::F32));
x = g.mm(r, p2);
}
g.set_outputs(vec![x]);
let plan =
with_plan_verify(|| plan_memory_with_options(&g, 128, MemoryPlanOptions::inference()));
assert!(
plan.assignments.len() > 400,
"expected a large plan, got {}",
plan.assignments.len()
);
let ranges = compute_live_ranges_opts(&g, true);
let placed: Vec<_> = plan
.assignments
.iter()
.filter_map(|(id, slot)| ranges.get(id).map(|&(b, d)| (slot.offset, slot.size, b, d)))
.collect();
for (i, &(o1, s1, b1, d1)) in placed.iter().enumerate() {
for &(o2, s2, b2, d2) in &placed[i + 1..] {
let mem = o1 < o2 + s2 && o2 < o1 + s1;
let time = b1 <= d2 && b2 <= d1;
assert!(
!(mem && time),
"overlap: [{o1},{s1}) {b1}..{d1} vs [{o2},{s2}) {b2}..{d2}"
);
}
}
}
}