use std::collections::HashMap;
use rlx_ir::op::{CmpOp, ReduceOp, ScatterNdReduction};
use rlx_ir::{DType, Graph, NodeId, Op, Shape};
pub type TypedParams = std::collections::HashMap<String, (Vec<u8>, DType)>;
use crate::proto;
use crate::{CoremlError, Result};
mod helpers;
pub mod verify;
pub(crate) use helpers::bytes_to_f32;
use helpers::simple_op_flex;
use helpers::*;
const OPSET: &str = "CoreML6";
const SPEC_VERSION: i32 = 7;
const OPSET_IOS18: &str = "CoreML8";
const SPEC_VERSION_IOS18: i32 = 9;
#[derive(Debug, Clone)]
pub struct IoTensor {
pub ir_name: String,
pub feature_name: String,
pub dims: Vec<i64>,
pub dtype: DType,
pub flex_dims: Vec<bool>,
}
impl IoTensor {
pub fn numel(&self) -> usize {
self.dims.iter().product::<i64>().max(0) as usize
}
pub fn runtime_dims(&self, data_len: usize) -> Vec<i64> {
if !self.flex_dims.iter().any(|&f| f) {
return self.dims.clone();
}
let static_product: i64 = self
.dims
.iter()
.zip(self.flex_dims.iter())
.filter(|(_, flex)| !**flex)
.map(|(d, _)| *d)
.product();
let mut dims = self.dims.clone();
let denom = static_product.max(1);
for (d, flex) in dims.iter_mut().zip(self.flex_dims.iter()) {
if *flex {
*d = data_len as i64 / denom;
}
}
dims
}
pub fn runtime_numel(dims: &[i64]) -> usize {
dims.iter().product::<i64>().max(0) as usize
}
}
pub struct LoweredProgram {
pub model: proto::Model,
pub inputs: Vec<IoTensor>,
pub outputs: Vec<IoTensor>,
pub blob: Vec<u8>,
}
#[derive(Debug, Clone, Copy)]
pub struct LowerOptions {
pub float_dtype: DType,
pub flexible_inputs: bool,
pub ondevice_dequant: bool,
pub q1_mode: Option<quant::Q1Mode>,
}
impl Default for LowerOptions {
fn default() -> Self {
Self {
float_dtype: DType::F32,
flexible_inputs: false,
ondevice_dequant: true,
q1_mode: None,
}
}
}
fn io_feature_shape(shape: &Shape) -> Shape {
if shape.rank() == 0 {
Shape::new(&[1], shape.dtype())
} else {
shape.clone()
}
}
pub fn lower_graph(
graph: &Graph,
params: &HashMap<String, Vec<f32>>,
typed_params: &TypedParams,
) -> Result<LoweredProgram> {
lower_graph_with_options(graph, params, typed_params, &LowerOptions::default())
}
pub fn lower_graph_with_options(
graph: &Graph,
params: &HashMap<String, Vec<f32>>,
typed_params: &TypedParams,
opts: &LowerOptions,
) -> Result<LoweredProgram> {
let mut ctx = LowerCtx::new(graph, params, typed_params, *opts);
ctx.run()?;
ctx.finish()
}
struct LowerCtx<'a> {
graph: &'a Graph,
params: &'a HashMap<String, Vec<f32>>,
typed_params: &'a TypedParams,
opts: LowerOptions,
names: HashMap<u32, String>,
func_inputs: Vec<proto::NamedValueType>,
operations: Vec<proto::Operation>,
inputs: Vec<IoTensor>,
used_feature_names: HashMap<String, u32>,
numeric_casts: HashMap<u32, String>,
narrow_needs_gather: std::collections::HashSet<u32>,
narrow_unsupported: std::collections::HashSet<u32>,
blob: crate::mlpackage::BlobWriter,
name_prefix: String,
}
mod activation;
mod attention;
mod complex;
mod conv_pool;
mod loss;
mod matmul;
mod norm;
mod quant;
pub use quant::Q1Mode;
mod reduce_index;
mod rnn;
mod rope;
mod ssm;
fn walk_to_narrow(graph: &Graph, id: NodeId) -> Option<NodeId> {
let mut cur = id;
for _ in 0..16 {
let node = graph.node(cur);
if matches!(node.op, Op::Narrow { .. }) {
return Some(cur);
}
if node.inputs.len() != 1 {
return None;
}
let parent = node.inputs[0];
if graph.shape(parent) != graph.shape(cur) {
return None;
}
cur = parent;
}
None
}
fn narrow_nodes_feeding_reshape(graph: &Graph) -> std::collections::HashSet<u32> {
narrow_nodes_feeding_reshape_inner(graph, false)
}
fn narrow_nodes_miscompiled_through_elementwise(graph: &Graph) -> std::collections::HashSet<u32> {
narrow_nodes_feeding_reshape_inner(graph, true)
}
fn narrow_nodes_feeding_reshape_inner(
graph: &Graph,
indirect_only: bool,
) -> std::collections::HashSet<u32> {
let mut out = std::collections::HashSet::new();
for node in graph.nodes() {
let Op::Reshape { new_shape } = &node.op else {
continue;
};
let Some(&first) = node.inputs.first() else {
continue;
};
let Some(src) = walk_to_narrow(graph, first) else {
continue;
};
if indirect_only && src == first {
continue; }
if !indirect_only && src != first {
continue; }
let Op::Narrow { axis, start, len } = graph.node(src).op else {
continue;
};
if start == 0 || new_shape.iter().any(|&d| d < 0) {
continue;
}
let parent = graph.shape(graph.node(src).inputs[0]).clone();
let prank = parent.rank();
if prank == 0 || axis + 1 != prank {
continue; }
let last = parent.dim(prank - 1).unwrap_static();
let new_dims: Vec<usize> = new_shape.iter().map(|&d| d as usize).collect();
let lead_new: usize = new_dims[..new_dims.len() - 1].iter().product();
let lead_old: usize = (0..prank - 1)
.map(|i| parent.dim(i).unwrap_static())
.product();
if len != last && lead_new == lead_old && *new_dims.last().unwrap() == len {
out.insert(src.0);
}
}
out
}
impl<'a> LowerCtx<'a> {
pub(crate) fn new(
graph: &'a Graph,
params: &'a HashMap<String, Vec<f32>>,
typed_params: &'a TypedParams,
opts: LowerOptions,
) -> Self {
LowerCtx {
graph,
params,
typed_params,
opts,
names: HashMap::new(),
func_inputs: Vec::new(),
operations: Vec::new(),
inputs: Vec::new(),
used_feature_names: HashMap::new(),
numeric_casts: HashMap::new(),
narrow_needs_gather: narrow_nodes_feeding_reshape(graph),
narrow_unsupported: narrow_nodes_miscompiled_through_elementwise(graph),
blob: crate::mlpackage::BlobWriter::new(),
name_prefix: String::new(),
}
}
pub(crate) fn val(&self, id: NodeId) -> String {
self.names
.get(&id.0)
.cloned()
.unwrap_or_else(|| format!("v{}", id.0))
}
pub(crate) fn val_numeric(&mut self, id: NodeId) -> Result<String> {
let name = self.val(id);
if self.graph.shape(id).dtype() == DType::Bool {
if let Some(existing) = self.numeric_casts.get(&id.0) {
return Ok(existing.clone());
}
let cast_name = format!("{name}_f32m");
let shape = self.graph.shape(id).clone().with_dtype(DType::F32);
self.emit(
"cast",
&cast_name,
&shape,
vec![
("x", bind_name(&name)),
("dtype", bind_value(scalar_str("fp32"))),
],
)?;
self.numeric_casts.insert(id.0, cast_name.clone());
Ok(cast_name)
} else {
Ok(name)
}
}
pub(crate) fn run(&mut self) -> Result<()> {
for id in self.graph.topo_order() {
self.lower_node(id)?;
}
Ok(())
}
pub(crate) fn lower_node(&mut self, id: NodeId) -> Result<()> {
let node = self.graph.node(id);
let out_name = format!("{}v{}", self.name_prefix, id.0);
match &node.op {
Op::Input { name } => {
let feat = self.unique_feature_name(name);
let io_dtype = if node.shape.dtype().is_float() {
if self.opts.float_dtype == DType::F16 {
DType::F16
} else {
node.shape.dtype()
}
} else {
DType::F32
};
let io_shape = io_feature_shape(&node.shape).with_dtype(io_dtype);
let (dims, flex_dims) = io_dims(&io_shape, self.opts.flexible_inputs)?;
self.func_inputs
.push(named_value_type_flex(&feat, &io_shape, &flex_dims)?);
self.inputs.push(IoTensor {
ir_name: name.clone(),
feature_name: feat.clone(),
dims,
dtype: io_dtype,
flex_dims,
});
self.names.insert(id.0, feat);
}
Op::Param { name } => {
if let Some(data) = self.params.get(name) {
let shape = if self.opts.float_dtype == DType::F16
&& node.shape.dtype() == DType::F32
{
node.shape.clone().with_dtype(DType::F16)
} else {
node.shape.clone()
};
let op = make_const_float(
&mut self.blob,
&out_name,
&shape,
data,
self.opts.float_dtype,
)?;
self.operations.push(op);
self.names.insert(id.0, out_name);
} else if let Some((bytes, dtype)) = self.typed_params.get(name) {
match *dtype {
DType::I8 | DType::U8 => {
}
DType::I64
| DType::I32
| DType::U32
| DType::I16
| DType::F32
| DType::F16
| DType::BF16
| DType::Bool => {
let floats =
bytes_to_f32(bytes, &node.shape.clone().with_dtype(*dtype))?;
let shape = node.shape.clone().with_dtype(DType::F32);
let op = make_const_float(
&mut self.blob,
&out_name,
&shape,
&floats,
self.opts.float_dtype,
)?;
self.operations.push(op);
self.names.insert(id.0, out_name);
}
other => {
return Err(CoremlError::Unsupported(format!(
"CoreML typed param '{name}' dtype {other:?}"
)));
}
}
} else {
return Err(CoremlError::Runtime(format!(
"missing baked param '{name}' for CoreML"
)));
}
}
Op::Constant { data } => {
let floats = bytes_to_f32(data, &node.shape)?;
let cshape = match node.shape.dtype() {
DType::Bool | DType::F32 => node.shape.clone(),
_ => node.shape.clone().with_dtype(DType::F32),
};
let op = make_const(&mut self.blob, &out_name, &cshape, &floats)?;
self.operations.push(op);
self.names.insert(id.0, out_name);
}
Op::MatMul => {
let x = self.val(node.inputs[0]);
let y = self.val(node.inputs[1]);
let op = self.simple_op(
"matmul",
&out_name,
&node.shape,
vec![
("x", bind_name(&x)),
("y", bind_name(&y)),
("transpose_x", bind_value(scalar_bool(false))),
("transpose_y", bind_value(scalar_bool(false))),
],
)?;
self.push_named(id, out_name, op);
}
Op::Binary(b) => {
let ty = binary_mil(*b);
let x = self.val_numeric(node.inputs[0])?;
let y = self.val_numeric(node.inputs[1])?;
let op = self.simple_op(
ty,
&out_name,
&node.shape,
vec![("x", bind_name(&x)), ("y", bind_name(&y))],
)?;
self.push_named(id, out_name, op);
}
Op::Fma => {
self.lower_fma(id, &out_name)?;
}
Op::Activation(act) => {
self.lower_activation(id, *act, &out_name)?;
}
Op::Softmax { axis } => {
let x = self.val(node.inputs[0]);
let op = self.simple_op(
"softmax",
&out_name,
&node.shape,
vec![
("x", bind_name(&x)),
("axis", bind_value(scalar_i32(*axis))),
],
)?;
self.push_named(id, out_name, op);
}
Op::Reshape { new_shape } => {
let x = self.val(node.inputs[0]);
let shp: Vec<i32> = new_shape.iter().map(|&d| d as i32).collect();
let op = self.simple_op(
"reshape",
&out_name,
&node.shape,
vec![("x", bind_name(&x)), ("shape", bind_value(vec_i32(&shp)))],
)?;
self.push_named(id, out_name, op);
}
Op::Transpose { perm } => {
let x = self.val(node.inputs[0]);
let p: Vec<i32> = perm.iter().map(|&d| d as i32).collect();
let op = self.simple_op(
"transpose",
&out_name,
&node.shape,
vec![("x", bind_name(&x)), ("perm", bind_value(vec_i32(&p)))],
)?;
self.push_named(id, out_name, op);
}
Op::LayerNorm { axis, eps } => {
self.lower_layer_norm(id, *axis, *eps, &out_name)?;
}
Op::RmsNorm { axis, eps } => {
self.lower_rms_norm(id, *axis, *eps, &out_name)?;
}
Op::AdaLayerNorm { norm, eps } => {
self.lower_ada_layer_norm(id, *norm, *eps, &out_name)?;
}
Op::GatedResidual => {
self.lower_gated_residual(id, &out_name)?;
}
#[cfg(feature = "training")]
Op::RmsNormBackwardInput { axis, eps } => {
self.lower_rms_norm_backward_input(id, *axis, *eps, &out_name)?;
}
#[cfg(feature = "training")]
Op::RmsNormBackwardGamma { axis, eps } => {
self.lower_rms_norm_backward_gamma(id, *axis, *eps, &out_name)?;
}
#[cfg(feature = "training")]
Op::RmsNormBackwardBeta { axis, eps } => {
self.lower_rms_norm_backward_beta(id, *axis, *eps, &out_name)?;
}
#[cfg(feature = "training")]
Op::LayerNormBackwardInput { axis, eps } => {
self.lower_layer_norm_backward_input(id, *axis, *eps, &out_name)?;
}
#[cfg(feature = "training")]
Op::LayerNormBackwardGamma { axis, eps } => {
self.lower_layer_norm_backward_gamma(id, *axis, *eps, &out_name)?;
}
#[cfg(feature = "training")]
Op::AdaLayerNormBackward { norm, eps } => {
self.lower_ada_layer_norm_backward(id, *norm, *eps, &out_name)?;
}
#[cfg(feature = "training")]
Op::GatedResidualBackward => {
self.lower_gated_residual_backward(id, &out_name)?;
}
#[cfg(feature = "training")]
Op::GroupNormBackwardInput { num_groups, eps } => {
self.lower_group_norm_backward_input(id, *num_groups, *eps, &out_name)?;
}
#[cfg(feature = "training")]
Op::GroupNormBackwardGamma { num_groups, eps } => {
self.lower_group_norm_backward_gamma(id, *num_groups, *eps, &out_name)?;
}
#[cfg(feature = "training")]
Op::GroupNormBackwardBeta { .. } => {
self.lower_group_norm_backward_beta(id, &out_name)?;
}
#[cfg(feature = "training")]
Op::AttentionBackward {
num_heads,
head_dim,
mask_kind,
wrt,
} => {
self.lower_attention_backward(
id, *num_heads, *head_dim, *mask_kind, *wrt, &out_name,
)?;
}
#[cfg(feature = "training")]
Op::MaxPool2dBackward {
kernel_size,
stride,
padding,
} => {
self.lower_max_pool2d_backward(id, kernel_size, stride, padding, &out_name)?;
}
#[cfg(feature = "training")]
Op::Conv2dBackwardInput {
stride,
padding,
dilation,
groups,
..
} => {
self.lower_conv(
id,
true,
stride,
padding,
dilation,
&[0, 0],
*groups,
&out_name,
)?;
}
#[cfg(feature = "training")]
Op::Conv2dBackwardWeight {
stride,
padding,
groups,
..
} => {
self.lower_conv2d_backward_weight(id, stride, padding, *groups, &out_name)?;
}
#[cfg(feature = "training")]
Op::SoftmaxCrossEntropyWithLogits => {
self.lower_softmax_cross_entropy_with_logits(id, &out_name)?;
}
#[cfg(feature = "training")]
Op::SoftmaxCrossEntropyBackward => {
self.lower_softmax_cross_entropy_backward(id, &out_name)?;
}
Op::Reduce { op, axes, keep_dim } => {
let ty = match op {
ReduceOp::Sum => "reduce_sum",
ReduceOp::Mean => "reduce_mean",
ReduceOp::Max => "reduce_max",
ReduceOp::Min => "reduce_min",
ReduceOp::Prod => "reduce_prod",
};
let x = self.val(node.inputs[0]);
let ax: Vec<i32> = axes.iter().map(|&a| a as i32).collect();
let op = self.simple_op(
ty,
&out_name,
&node.shape,
vec![
("x", bind_name(&x)),
("axes", bind_value(vec_i32(&ax))),
("keep_dims", bind_value(scalar_bool(*keep_dim))),
],
)?;
self.push_named(id, out_name, op);
}
Op::Concat { axis } => {
let names: Vec<String> = node.inputs.iter().map(|&i| self.val(i)).collect();
let op = self.simple_op(
"concat",
&out_name,
&node.shape,
vec![
("values", bind_names(&names)),
("axis", bind_value(scalar_i32(*axis as i32))),
("interleave", bind_value(scalar_bool(false))),
],
)?;
self.push_named(id, out_name, op);
}
Op::Gather { axis } => {
let x = self.val(node.inputs[0]);
let idx_id = node.inputs[1];
let ic = format!("{out_name}_idx_i32");
let ishape = self.graph.shape(idx_id).clone().with_dtype(DType::I32);
self.emit(
"cast",
&ic,
&ishape,
vec![
("x", bind_name(&self.val(idx_id))),
("dtype", bind_value(scalar_str("int32"))),
],
)?;
let idx = ic;
let op = self.simple_op(
"gather",
&out_name,
&node.shape,
vec![
("x", bind_name(&x)),
("indices", bind_name(&idx)),
("axis", bind_value(scalar_i32(*axis as i32))),
("validate_indices", bind_value(scalar_bool(false))),
],
)?;
self.push_named(id, out_name, op);
}
Op::Narrow { axis, start, len } if self.narrow_unsupported.contains(&id.0) => {
return Err(CoremlError::Unsupported(format!(
"Narrow(axis={axis}, start={start}, len={len}) feeding a Reshape \
through an elementwise op (node {}): MPSGraph miscompiles this \
shape and aborts the process during module verification. \
Qwen3.5-style Gated DeltaNet trunks hit this; use \
cpu/metal/mlx for such models.",
id.0
)));
}
Op::Narrow { axis, start, len } if self.narrow_needs_gather.contains(&id.0) => {
let x = self.val(node.inputs[0]);
let idx_name = format!("{out_name}_gidx");
let idx_f: Vec<f32> = (0..*len).map(|i| (*start + i) as f32).collect();
let idx_shape = Shape::new(&[*len], DType::F32);
self.operations
.push(make_const(&mut self.blob, &idx_name, &idx_shape, &idx_f)?);
let idx_i32 = format!("{out_name}_gidx_i32");
self.emit(
"cast",
&idx_i32,
&Shape::new(&[*len], DType::I32),
vec![
("x", bind_name(&idx_name)),
("dtype", bind_value(scalar_str("int32"))),
],
)?;
let op = self.simple_op(
"gather",
&out_name,
&node.shape,
vec![
("x", bind_name(&x)),
("indices", bind_name(&idx_i32)),
("axis", bind_value(scalar_i32(*axis as i32))),
("validate_indices", bind_value(scalar_bool(false))),
],
)?;
self.push_named(id, out_name, op);
}
Op::Narrow { axis, start, len } => {
let x = self.val(node.inputs[0]);
let rank = node.shape.rank();
let mut begin = vec![0i32; rank];
let mut size: Vec<i32> = node
.shape
.dims()
.iter()
.map(|d| d.unwrap_static() as i32)
.collect();
begin[*axis] = *start as i32;
size[*axis] = *len as i32;
let op = self.simple_op(
"slice_by_size",
&out_name,
&node.shape,
vec![
("x", bind_name(&x)),
("begin", bind_value(vec_i32(&begin))),
("size", bind_value(vec_i32(&size))),
],
)?;
self.push_named(id, out_name, op);
}
Op::Rope {
head_dim,
n_rot,
style,
} => {
self.lower_rope(id, *head_dim, *n_rot, *style, &out_name)?;
}
Op::Attention {
num_heads,
head_dim,
v_head_dim,
mask_kind,
score_scale,
attn_logit_softcap,
} => {
assert!(
v_head_dim.is_none_or(|v| v == *head_dim),
"rlx-coreml: asymmetric v_head_dim (MLA) not yet supported"
);
self.lower_attention(
id,
*num_heads,
*head_dim,
*mask_kind,
*score_scale,
*attn_logit_softcap,
&out_name,
)?;
}
Op::Cast { to } => {
let x = self.val(node.inputs[0]);
let dt = mil_cast_dtype(*to)?;
let op = self.simple_op(
"cast",
&out_name,
&node.shape,
vec![("x", bind_name(&x)), ("dtype", bind_value(scalar_str(dt)))],
)?;
self.push_named(id, out_name, op);
}
Op::Compare(cmp) => {
let ty = match cmp {
CmpOp::Eq => "equal",
CmpOp::Ne => "not_equal",
CmpOp::Lt => "less",
CmpOp::Le => "less_equal",
CmpOp::Gt => "greater",
CmpOp::Ge => "greater_equal",
};
let mut x = self.val(node.inputs[0]);
let mut y = self.val(node.inputs[1]);
for (slot, inp, name_ref) in [
(0usize, node.inputs[0], &mut x),
(1usize, node.inputs[1], &mut y),
] {
let _ = slot;
if self.graph.shape(inp).dtype() == DType::Bool {
let cb = format!("{out_name}_i{}", inp.0);
self.emit(
"cast",
&cb,
&self.graph.shape(inp).clone().with_dtype(DType::I32),
vec![
("x", bind_name(name_ref)),
("dtype", bind_value(scalar_str("int32"))),
],
)?;
*name_ref = cb;
}
}
let op = self.simple_op(
ty,
&out_name,
&node.shape,
vec![("x", bind_name(&x)), ("y", bind_name(&y))],
)?;
self.push_named(id, out_name, op);
}
Op::Where => {
let cond_in = node.inputs[0];
let cond_shape = self.graph.shape(cond_in).clone();
let mut cond = self.val(cond_in);
if cond_shape.dtype() != DType::Bool {
let cb = format!("{out_name}_condb");
self.emit(
"greater",
&cb,
&cond_shape.with_dtype(DType::Bool),
vec![("x", bind_name(&cond)), ("y", bind_value(scalar_f32(0.5)))],
)?;
cond = cb;
}
let a = self.val(node.inputs[1]);
let b = self.val(node.inputs[2]);
let op = self.simple_op(
"select",
&out_name,
&node.shape,
vec![
("cond", bind_name(&cond)),
("a", bind_name(&a)),
("b", bind_name(&b)),
],
)?;
self.push_named(id, out_name, op);
}
Op::Expand { .. } => {
let n = node.shape.num_elements().unwrap_or(0);
if node.shape.dtype() == DType::Bool {
let xf = self.val_numeric(node.inputs[0])?; let f32_shape = node.shape.clone().with_dtype(DType::F32);
let ones = format!("{out_name}_ones");
self.operations.push(make_const(
&mut self.blob,
&ones,
&f32_shape,
&vec![1.0f32; n],
)?);
let bf = format!("{out_name}_bf");
self.emit(
"mul",
&bf,
&f32_shape,
vec![("x", bind_name(&xf)), ("y", bind_name(&ones))],
)?;
let op = self.simple_op(
"cast",
&out_name,
&node.shape,
vec![
("x", bind_name(&bf)),
("dtype", bind_value(scalar_str("bool"))),
],
)?;
self.push_named(id, out_name, op);
} else {
let oshape = if node.shape.dtype().is_float() {
node.shape.clone()
} else {
node.shape.clone().with_dtype(DType::F32)
};
let x = self.val(node.inputs[0]);
let ones = format!("{out_name}_ones");
self.operations.push(make_const(
&mut self.blob,
&ones,
&oshape,
&vec![1.0f32; n],
)?);
let op = self.simple_op(
"mul",
&out_name,
&oshape,
vec![("x", bind_name(&x)), ("y", bind_name(&ones))],
)?;
self.push_named(id, out_name, op);
}
}
Op::Cumsum { axis, exclusive } => {
let x = self.val(node.inputs[0]);
let op = self.simple_op(
"cumsum",
&out_name,
&node.shape,
vec![
("x", bind_name(&x)),
("axis", bind_value(scalar_i32(*axis))),
("exclusive", bind_value(scalar_bool(*exclusive))),
("reverse", bind_value(scalar_bool(false))),
],
)?;
self.push_named(id, out_name, op);
}
Op::ScatterAdd { axis } => {
assert_eq!(
*axis, 0,
"rlx-coreml: ScatterAdd axis {{axis}} reached the backend; \
LowerScatterAddAxis must run first"
);
let updates = self.val(node.inputs[0]);
let idx_in = node.inputs[1];
let idx = self.val(idx_in);
let idx_i32 = format!("{out_name}_idx");
let idx_shape = self.graph.shape(idx_in).clone().with_dtype(DType::I32);
self.emit(
"cast",
&idx_i32,
&idx_shape,
vec![
("x", bind_name(&idx)),
("dtype", bind_value(scalar_str("int32"))),
],
)?;
let zeros = format!("{out_name}_zeros");
let n = node.shape.num_elements().unwrap_or(0);
self.operations.push(make_const(
&mut self.blob,
&zeros,
&node.shape,
&vec![0.0f32; n],
)?);
let op = self.simple_op(
"scatter",
&out_name,
&node.shape,
vec![
("data", bind_name(&zeros)),
("indices", bind_name(&idx_i32)),
("updates", bind_name(&updates)),
("axis", bind_value(scalar_i32(0))),
("mode", bind_value(scalar_str("add"))),
],
)?;
self.push_named(id, out_name, op);
}
Op::ScatterNd { reduction } => {
let data = self.val(node.inputs[0]);
let idx_id = node.inputs[1];
let updates = self.val(node.inputs[2]);
let idx_i32 = format!("{out_name}_idx");
let idx_shape = self.graph.shape(idx_id).clone().with_dtype(DType::I32);
self.emit(
"cast",
&idx_i32,
&idx_shape,
vec![
("x", bind_name(&self.val(idx_id))),
("dtype", bind_value(scalar_str("int32"))),
],
)?;
let mode = match reduction {
ScatterNdReduction::Add => "add",
ScatterNdReduction::Mul => "mul",
ScatterNdReduction::Max => "max",
ScatterNdReduction::Min => "min",
ScatterNdReduction::None => "update",
};
let op = self.simple_op(
"scatter_nd",
&out_name,
&node.shape,
vec![
("data", bind_name(&data)),
("indices", bind_name(&idx_i32)),
("updates", bind_name(&updates)),
("mode", bind_value(scalar_str(mode))),
],
)?;
self.push_named(id, out_name, op);
}
Op::Custom {
name,
attrs,
num_inputs: 3,
} if name == "onnx.ScatterND" => {
let data = self.val(node.inputs[0]);
let idx_id = node.inputs[1];
let updates = self.val(node.inputs[2]);
let idx_i32 = format!("{out_name}_idx");
let idx_shape = self.graph.shape(idx_id).clone().with_dtype(DType::I32);
self.emit(
"cast",
&idx_i32,
&idx_shape,
vec![
("x", bind_name(&self.val(idx_id))),
("dtype", bind_value(scalar_str("int32"))),
],
)?;
let reduction = if attrs.len() >= 4 {
i32::from_le_bytes([attrs[0], attrs[1], attrs[2], attrs[3]])
} else {
0
};
let mode = match reduction {
1 => "add",
2 => "mul",
3 => "max",
4 => "min",
_ => "update",
};
let op = self.simple_op(
"scatter_nd",
&out_name,
&node.shape,
vec![
("data", bind_name(&data)),
("indices", bind_name(&idx_i32)),
("updates", bind_name(&updates)),
("mode", bind_value(scalar_str(mode))),
],
)?;
self.push_named(id, out_name, op);
}
Op::BatchNormInference { eps } => {
self.lower_batch_norm(id, *eps, &out_name)?;
}
Op::GroupNorm { num_groups, eps } => {
self.lower_group_norm(id, *num_groups, *eps, &out_name)?;
}
Op::LayerNorm2d { eps } => {
self.lower_layer_norm2d(id, *eps, &out_name)?;
}
Op::LoraMatMul { scale } => {
self.lower_lora_matmul(id, *scale, &out_name)?;
}
Op::Conv {
kernel_size: _,
stride,
padding,
dilation,
groups,
} => {
self.lower_conv(
id,
false,
stride,
padding,
dilation,
&[],
*groups,
&out_name,
)?;
}
Op::ConvTranspose2d {
kernel_size: _,
stride,
padding,
dilation,
output_padding,
groups,
} => {
self.lower_conv(
id,
true,
stride,
padding,
dilation,
output_padding,
*groups,
&out_name,
)?;
}
Op::Conv3d {
stride,
padding,
dilation,
groups,
} => {
self.lower_conv(
id,
false,
stride,
padding,
dilation,
&[],
*groups,
&out_name,
)?;
}
Op::ConvTranspose3d {
stride,
padding,
dilation,
output_padding,
groups,
} => {
self.lower_conv(
id,
true,
stride,
padding,
dilation,
output_padding,
*groups,
&out_name,
)?;
}
Op::FusedMatMulBiasAct { activation } => {
self.lower_fused_matmul_bias_act(id, *activation, &out_name)?;
}
Op::FusedSwiGLU { .. } => {
self.lower_fused_swiglu(id, &out_name)?;
}
Op::FusedResidualLN { has_bias, eps } => {
self.lower_fused_residual_ln(id, *has_bias, *eps, &out_name)?;
}
Op::FusedResidualRmsNorm { has_bias, eps } => {
self.lower_fused_residual_rms_norm(id, *has_bias, *eps, &out_name)?;
}
Op::FakeQuantize {
bits,
axis,
ste: _,
scale_mode,
} => {
self.lower_fake_quantize(id, *bits, *axis, *scale_mode, &out_name)?;
}
Op::Pool {
kind,
kernel_size,
stride,
padding,
} => {
self.lower_pool(id, *kind, kernel_size, stride, padding, &out_name)?;
}
Op::TopK { k } => {
self.lower_topk(id, *k, &out_name)?;
}
Op::AxialRope2d {
end_x,
end_y,
head_dim,
num_heads,
theta,
repeat_factor,
} => {
self.lower_axial_rope2d(
id,
*end_x,
*end_y,
*head_dim,
*num_heads,
*theta,
*repeat_factor,
&out_name,
)?;
}
Op::ResizeNearest2x => {
let x = self.val(node.inputs[0]);
let op = self.simple_op(
"upsample_nearest_neighbor",
&out_name,
&node.shape,
vec![
("x", bind_name(&x)),
("scale_factor_height", bind_value(scalar_f32(2.0))),
("scale_factor_width", bind_value(scalar_f32(2.0))),
],
)?;
self.push_named(id, out_name, op);
}
Op::StopGradient => {
let x = self.val(node.inputs[0]);
let op = self.simple_op(
"identity",
&out_name,
&node.shape,
vec![("x", bind_name(&x))],
)?;
self.push_named(id, out_name, op);
}
Op::GroupedMatMul => {
self.lower_grouped_matmul(id, &out_name)?;
}
Op::DequantMatMul { scheme } => {
if self.opts.ondevice_dequant && scheme_supports_ondevice_block_dequant(*scheme) {
self.lower_dequant_matmul_ondevice(id, *scheme, &out_name)?;
} else {
self.lower_dequant_matmul(id, *scheme, &out_name)?;
}
}
Op::DequantMoEWeights { scheme } => {
self.lower_dequant_moe_weights(id, *scheme, &out_name)?;
}
Op::DequantGroupedMatMul { scheme } => {
if self.opts.ondevice_dequant && scheme_supports_ondevice_block_dequant(*scheme) {
self.lower_dequant_grouped_matmul_ondevice(id, *scheme, &out_name)?;
} else {
self.lower_dequant_grouped_matmul(id, *scheme, &out_name)?;
}
}
Op::Dequantize {
axis,
scales,
zero_points,
} => {
self.lower_dequantize(id, *axis, scales, zero_points, &out_name)?;
}
Op::Quantize {
axis,
scales,
zero_points,
} => {
self.lower_quantize(id, *axis, scales, zero_points, &out_name)?;
}
Op::SelectiveScan { state_size } => {
self.lower_selective_scan(id, *state_size, &out_name)?;
}
Op::Lstm {
hidden_size,
num_layers,
bidirectional,
carry: false,
} => {
self.lower_lstm(id, *hidden_size, *num_layers, *bidirectional, &out_name)?;
}
Op::Gru {
hidden_size,
num_layers,
bidirectional,
carry: false,
} => {
self.lower_gru(id, *hidden_size, *num_layers, *bidirectional, &out_name)?;
}
Op::Rnn {
hidden_size,
num_layers,
bidirectional,
carry: false,
relu,
} => {
self.lower_rnn(
id,
*hidden_size,
*num_layers,
*bidirectional,
*relu,
&out_name,
)?;
}
Op::GatedDeltaNet {
state_size,
carry_state,
gate_per_channel,
} => {
self.lower_gated_delta_net(
id,
*state_size,
*carry_state,
*gate_per_channel,
&out_name,
)?;
}
Op::ArgMax { axis, keep_dim } => {
self.lower_argreduce(id, *axis, *keep_dim, true, &out_name)?;
}
Op::ArgMin { axis, keep_dim } => {
self.lower_argreduce(id, *axis, *keep_dim, false, &out_name)?;
}
Op::Reverse { axes } => {
self.lower_reverse(id, axes, &out_name)?;
}
Op::Scan { .. } => {
self.lower_scan(id, &out_name)?;
}
Op::ComplexNormSq => {
self.lower_complex_norm_sq(id, &out_name)?;
}
Op::ComplexNormSqBackward => {
self.lower_complex_norm_sq_backward(id, &out_name)?;
}
Op::Conjugate => {
self.lower_conjugate(id, &out_name)?;
}
Op::FftButterflyStage { stage, n_fft } => {
self.lower_fft_butterfly_stage(id, *stage, *n_fft, &out_name)?;
}
other => {
return Err(CoremlError::Unsupported(format!(
"op {:?} (node {})",
other, id.0
)));
}
}
Ok(())
}
fn lower_scan(&mut self, id: NodeId, out_name: &str) -> Result<()> {
let outer_graph: &'a Graph = self.graph;
if let Op::Scan { length, .. } = outer_graph.node(id).op {
if rlx_ir::env::var("RLX_COREML_SEG_REPORT").is_some() {
eprintln!("[coreml] Scan node {} length={length}", id.0);
}
let cap: u32 = rlx_ir::env::var("RLX_COREML_MAX_SCAN_LEN")
.and_then(|v| v.parse().ok())
.unwrap_or(512);
if cap > 0 && length > cap {
return Err(CoremlError::Unsupported(format!(
"Scan of length {length} exceeds RLX_COREML_MAX_SCAN_LEN ({cap}): \
CoreML's while_loop compile is superlinear in the trip count \
(~{:.0}s here, measured), so this would hang rather than fail",
0.3 * (f64::from(length) / 1000.0).powi(2) * 1000.0
)));
}
}
let (body, length, num_bcast, num_xs, inputs, carry_shape) = {
let node = outer_graph.node(id);
let (body, length, num_bcast, num_xs): (&'a Graph, u32, usize, usize) = match &node.op {
Op::Scan {
body,
length,
num_bcast,
num_xs,
..
} => (&**body, *length, *num_bcast as usize, *num_xs as usize),
_ => unreachable!("lower_scan on non-Scan node"),
};
let carry_shape = outer_graph.node(node.inputs[0]).shape.clone();
(
body,
length,
num_bcast,
num_xs,
node.inputs.clone(),
carry_shape,
)
};
let init_carry = self.val(inputs[0]);
let bcast_names: Vec<String> = (0..num_bcast).map(|j| self.val(inputs[1 + j])).collect();
let xs_names: Vec<String> = (0..num_xs)
.map(|j| self.val(inputs[1 + num_bcast + j]))
.collect();
let body_inputs: Vec<NodeId> = body
.nodes()
.iter()
.filter(|n| matches!(n.op, Op::Input { .. }))
.map(|n| n.id)
.collect();
let f32s = scalar_shape(DType::F32);
let bools = scalar_shape(DType::Bool);
let i32s = scalar_shape(DType::I32);
let blk_inputs = |ctx: &Self| -> Result<Vec<proto::NamedValueType>> {
let _ = ctx;
Ok(vec![
named_value_type(&format!("{out_name}_lv_i"), &f32s)?,
named_value_type(&format!("{out_name}_lv_carry"), &carry_shape)?,
])
};
let pi = format!("{out_name}_lv_i");
let pc = format!("{out_name}_lv_carry");
let i0_name = format!("{out_name}_i0");
let c0 = make_const(&mut self.blob, &i0_name, &f32s, &[0.0])?;
self.operations.push(c0);
let cond_out = format!("{out_name}_cond");
let cond_op = self.simple_op(
"less",
&cond_out,
&bools,
vec![
("x", bind_name(&pi)),
("y", bind_value(scalar_f32(length as f32))),
],
)?;
let cond_block = proto::Block {
inputs: blk_inputs(self)?,
outputs: vec![cond_out],
operations: vec![cond_op],
attributes: HashMap::new(),
};
let saved_ops = std::mem::take(&mut self.operations);
let saved_names = std::mem::take(&mut self.names);
let i_int = format!("{out_name}_i_int");
self.emit(
"cast",
&i_int,
&i32s,
vec![
("x", bind_name(&pi)),
("dtype", bind_value(scalar_str("int32"))),
],
)?;
self.names.insert(body_inputs[0].0, pc.clone());
for j in 0..num_bcast {
self.names
.insert(body_inputs[1 + j].0, bcast_names[j].clone());
}
for j in 0..num_xs {
let in_id = body_inputs[1 + num_bcast + j];
let step_shape = body.node(in_id).shape.clone();
let gname = format!("{out_name}_xs{j}");
self.emit(
"gather",
&gname,
&step_shape,
vec![
("x", bind_name(&xs_names[j])),
("indices", bind_name(&i_int)),
("axis", bind_value(scalar_i32(0))),
("validate_indices", bind_value(scalar_bool(false))),
],
)?;
self.names.insert(in_id.0, gname);
}
let saved_prefix = std::mem::replace(&mut self.name_prefix, format!("{out_name}_b"));
self.graph = body;
for bn in body.nodes() {
if matches!(bn.op, Op::Input { .. }) {
continue;
}
self.lower_node(bn.id)?;
}
let new_carry = self.val(body.outputs[0]);
self.graph = outer_graph;
self.name_prefix = saved_prefix;
let new_i = format!("{out_name}_ni");
self.emit(
"add",
&new_i,
&f32s,
vec![("x", bind_name(&pi)), ("y", bind_value(scalar_f32(1.0)))],
)?;
let body_ops = std::mem::replace(&mut self.operations, saved_ops);
self.names = saved_names;
let body_block = proto::Block {
inputs: blk_inputs(self)?,
outputs: vec![new_i, new_carry],
operations: body_ops,
attributes: HashMap::new(),
};
let final_i = format!("{out_name}_fi");
let mut attrs = HashMap::new();
attrs.insert("name".to_string(), scalar_str(out_name));
let while_op = proto::Operation {
r#type: "while_loop".to_string(),
inputs: HashMap::from([("loop_vars".to_string(), bind_names(&[i0_name, init_carry]))]),
outputs: vec![
named_value_type(&final_i, &f32s)?,
named_value_type(out_name, &carry_shape)?,
],
blocks: vec![cond_block, body_block],
attributes: attrs,
};
self.operations.push(while_op);
self.names.insert(id.0, out_name.to_string());
Ok(())
}
pub(crate) fn emit(
&mut self,
ty: &str,
name: &str,
shape: &Shape,
binds: Vec<(&str, proto::Argument)>,
) -> Result<()> {
let op = simple_op_flex(ty, name, shape, binds, self.opts.flexible_inputs)?;
self.operations.push(op);
Ok(())
}
pub(crate) fn simple_op(
&self,
ty: &str,
out_name: &str,
out_shape: &Shape,
inputs: Vec<(&str, proto::Argument)>,
) -> Result<proto::Operation> {
simple_op_flex(ty, out_name, out_shape, inputs, self.opts.flexible_inputs)
}
pub(crate) fn slice_last(
&mut self,
src: &str,
src_rank: usize,
start: usize,
len: usize,
out_shape: &Shape,
dst: &str,
) -> Result<()> {
self.slice_axis(src, src_rank, src_rank - 1, start, len, out_shape, dst)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn slice_axis(
&mut self,
src: &str,
src_rank: usize,
axis: usize,
start: usize,
len: usize,
out_shape: &Shape,
dst: &str,
) -> Result<()> {
let mut begin = vec![0i32; src_rank];
let mut size = vec![-1i32; src_rank];
begin[axis] = start as i32;
size[axis] = len as i32;
self.emit(
"slice_by_size",
dst,
out_shape,
vec![
("x", bind_name(src)),
("begin", bind_value(vec_i32(&begin))),
("size", bind_value(vec_i32(&size))),
],
)
}
pub(crate) fn reshape_to(
&mut self,
src: &str,
dims: &[i64],
out_shape: &Shape,
dst: &str,
) -> Result<()> {
let s: Vec<i32> = dims.iter().map(|&v| v as i32).collect();
self.emit(
"reshape",
dst,
out_shape,
vec![("x", bind_name(src)), ("shape", bind_value(vec_i32(&s)))],
)
}
pub(crate) fn matmul(&mut self, dst: &str, x: &str, y: &str, out_shape: &Shape) -> Result<()> {
self.emit(
"matmul",
dst,
out_shape,
vec![
("x", bind_name(x)),
("y", bind_name(y)),
("transpose_x", bind_value(scalar_bool(false))),
("transpose_y", bind_value(scalar_bool(false))),
],
)
}
pub(crate) fn matmul_op(
&mut self,
dst: &str,
x: &str,
y: &str,
tx: bool,
ty: bool,
out_shape: &Shape,
) -> Result<()> {
self.emit(
"matmul",
dst,
out_shape,
vec![
("x", bind_name(x)),
("y", bind_name(y)),
("transpose_x", bind_value(scalar_bool(tx))),
("transpose_y", bind_value(scalar_bool(ty))),
],
)
}
pub(crate) fn push_named(&mut self, id: NodeId, name: String, op: proto::Operation) {
self.operations.push(op);
self.names.insert(id.0, name);
}
pub(crate) fn unique_feature_name(&mut self, raw: &str) -> String {
let base = sanitize(raw);
let n = self.used_feature_names.entry(base.clone()).or_insert(0);
let name = if *n == 0 {
base.clone()
} else {
format!("{base}_{n}")
};
*n += 1;
name
}
pub(crate) fn verify_refs(&self, block_outputs: &[String]) -> Result<()> {
let mut produced: std::collections::HashSet<&str> = std::collections::HashSet::new();
for nv in &self.func_inputs {
produced.insert(nv.name.as_str());
}
for op in &self.operations {
for out in &op.outputs {
produced.insert(out.name.as_str());
}
}
let undefined = |name: &str| -> CoremlError {
let hint = self
.graph
.nodes()
.iter()
.find(|n| match &n.op {
Op::Param { name: p } => sanitize(p) == name || format!("v{}", n.id.0) == name,
Op::Input { name: p } => sanitize(p) == name || format!("v{}", n.id.0) == name,
_ => format!("v{}", n.id.0) == name,
})
.map(|n| format!(" — source node {:?}: {:?}", n.id, n.op))
.unwrap_or_default();
CoremlError::Runtime(format!(
"CoreML lowering produced a dangling reference to value '{name}'{hint}: the source node \
was not lowered (e.g. a quantized Param used outside a Dequant* op, or an \
unhandled op). This is a backend bug, not a model error."
))
};
for op in &self.operations {
for arg in op.inputs.values() {
for b in &arg.arguments {
if let Some(proto::argument::binding::Binding::Name(n)) = &b.binding {
if !produced.contains(n.as_str()) {
return Err(undefined(n));
}
}
}
}
}
for name in block_outputs {
if !produced.contains(name.as_str()) {
return Err(undefined(name));
}
}
Ok(())
}
pub(crate) fn finish(mut self) -> Result<LoweredProgram> {
let graph = self.graph;
let mut output_names = Vec::new();
let mut outputs = Vec::new();
for &out_id in &graph.outputs {
let mut vname = self.val(out_id);
let out_shape = graph.shape(out_id);
let exposed = if out_shape.rank() == 0 {
let one = Shape::new(&[1], out_shape.dtype());
let reshaped = format!("{vname}_io1");
self.emit(
"reshape",
&reshaped,
&one,
vec![
("x", bind_name(&vname)),
("shape", bind_value(vec_i32(&[1]))),
],
)?;
vname = reshaped;
one
} else {
out_shape.clone()
};
output_names.push(vname.clone());
let (dims, flex_dims) = if self.opts.flexible_inputs {
io_dims(&exposed, true)?
} else {
(static_dims(&exposed)?, vec![false; exposed.rank()])
};
outputs.push(IoTensor {
ir_name: vname.clone(),
feature_name: vname.clone(),
dims,
dtype: exposed.dtype(),
flex_dims,
});
}
self.verify_refs(&output_names)?;
let needs_ios18 = self.inputs.is_empty()
|| self
.operations
.iter()
.any(|op| op.r#type == "constexpr_lut_to_dense");
let (opset, spec_version) = if needs_ios18 {
(OPSET_IOS18, SPEC_VERSION_IOS18)
} else {
(OPSET, SPEC_VERSION)
};
if !needs_ios18 {
for op in self.operations.iter_mut() {
if op.r#type == "gather" {
op.inputs.remove("validate_indices");
}
}
}
let block = proto::Block {
inputs: vec![],
outputs: output_names,
operations: self.operations,
attributes: HashMap::new(),
};
let mut block_specializations = HashMap::new();
block_specializations.insert(opset.to_string(), block);
let function = proto::Function {
inputs: self.func_inputs,
opset: opset.to_string(),
block_specializations,
attributes: HashMap::new(),
};
let mut functions = HashMap::new();
functions.insert("main".to_string(), function);
let program = proto::Program {
version: 1,
functions,
doc_string: String::new(),
attributes: HashMap::new(),
};
let description = proto::ModelDescription {
input: self
.inputs
.iter()
.map(feature_description)
.collect::<Result<_>>()?,
output: outputs
.iter()
.map(feature_description)
.collect::<Result<_>>()?,
metadata: Some(proto::Metadata {
short_description: "RLX-generated ML Program".into(),
author: "rlx-coreml".into(),
..Default::default()
}),
};
let model = proto::Model {
specification_version: spec_version,
description: Some(description),
is_updatable: false,
r#type: Some(proto::model::Type::MlProgram(program)),
};
if let Some(text) = verify::report(&model) {
eprint!("{text}");
if rlx_ir::env::var("RLX_COREML_VERIFY").as_deref() == Some("strict") {
return Err(CoremlError::Runtime(format!(
"MIL verification failed (RLX_COREML_VERIFY=strict):\n{text}"
)));
}
}
Ok(LoweredProgram {
model,
inputs: self.inputs,
outputs,
blob: self.blob.finish(),
})
}
}
#[cfg(test)]
mod narrow_fold_tests {
use super::*;
use rlx_ir::infer::GraphExt;
use rlx_ir::{DType, Shape};
fn gdn_prologue(with_elementwise: bool) -> (Graph, u32) {
let mut g = Graph::new("t");
let x = g.input("x", Shape::new(&[512, 8208], DType::F32));
let n = g.narrow_(x, 1, 8192, 16);
let feed = if with_elementwise { g.sigmoid(n) } else { n };
let out = g.reshape_(feed, vec![4, 128, 16]);
g.set_outputs(vec![out]);
(g, n.0)
}
#[test]
fn direct_reshape_of_narrow_takes_the_gather_path() {
let (g, narrow) = gdn_prologue(false);
assert!(narrow_nodes_feeding_reshape(&g).contains(&narrow));
assert!(!narrow_nodes_miscompiled_through_elementwise(&g).contains(&narrow));
}
#[test]
fn reshape_through_an_elementwise_op_is_unsupported_not_gathered() {
let (g, narrow) = gdn_prologue(true);
assert!(
!narrow_nodes_feeding_reshape(&g).contains(&narrow),
"the gather escape does not survive the elementwise hop"
);
assert!(
narrow_nodes_miscompiled_through_elementwise(&g).contains(&narrow),
"the indirect shape must be detected"
);
}
#[test]
fn offset_zero_is_never_flagged() {
let mut g = Graph::new("t");
let x = g.input("x", Shape::new(&[512, 8208], DType::F32));
let n = g.narrow_(x, 1, 0, 16);
let s = g.sigmoid(n);
let out = g.reshape_(s, vec![4, 128, 16]);
g.set_outputs(vec![out]);
assert!(narrow_nodes_feeding_reshape(&g).is_empty());
assert!(narrow_nodes_miscompiled_through_elementwise(&g).is_empty());
}
#[test]
fn the_walk_stops_at_a_shape_changing_op() {
let mut g = Graph::new("t");
let x = g.input("x", Shape::new(&[512, 8208], DType::F32));
let n = g.narrow_(x, 1, 8192, 16);
let mid = g.reshape_(n, vec![8192]);
let out = g.reshape_(mid, vec![4, 128, 16]);
g.set_outputs(vec![out]);
assert!(narrow_nodes_miscompiled_through_elementwise(&g).is_empty());
}
}