use crate::registry::{BackwardKind, FusedOpEntry, FusedOpFamily, FusedOpParams, FusedOps};
use crate::{Graph, Node, NodeId, Op};
use fuel_ir::{DType, Shape};
pub fn awq_zeros_w(k: usize, group_size: usize) -> Option<usize> {
if (group_size != 64 && group_size != 128) || !k.is_multiple_of(group_size) {
return None;
}
let groups = k / group_size;
let mult = 128 / group_size;
Some(groups.div_ceil(8).div_ceil(mult) * mult)
}
pub fn entry() -> FusedOpEntry {
FusedOpEntry {
destructive_input: None,
id: FusedOps::AWQ_MATMUL,
name: "AwqMatmul",
family: FusedOpFamily::Quantized,
pattern: crate::registry::SubgraphPattern::Callable(canonical_pattern),
decompose,
backward: BackwardKind::Decompose,
shape_rule,
dtype_rule,
output_views: None,
}
}
fn shape_rule(input_shapes: &[Shape], _params: &FusedOpParams) -> Shape {
debug_assert_eq!(
input_shapes.len(),
4,
"AwqMatmul takes 4 inputs (activations, weight, zeros, scales)",
);
let a_dims = input_shapes[0].dims();
let w_dims = input_shapes[1].dims();
debug_assert!(
a_dims.len() >= 2,
"AwqMatmul: activations must be rank >= 2, got {a_dims:?}"
);
debug_assert_eq!(
w_dims.len(),
2,
"AwqMatmul: weight must be rank 2 [N, K/8], got {w_dims:?}"
);
let n = w_dims[0];
let mut out_dims: Vec<usize> = a_dims[..a_dims.len() - 1].to_vec();
out_dims.push(n);
Shape::from_dims(&out_dims)
}
fn dtype_rule(input_dtypes: &[DType], _params: &FusedOpParams) -> DType {
debug_assert_eq!(
input_dtypes.len(),
4,
"AwqMatmul takes 4 inputs (activations, weight, zeros, scales)",
);
input_dtypes[0]
}
pub fn canonical_pattern(_graph: &Graph, _root: NodeId) -> Option<crate::registry::PatternMatch> {
None
}
fn push(graph: &mut Graph, op: Op, inputs: Vec<NodeId>, shape: Shape, dtype: DType) -> NodeId {
graph.push(Node {
op,
inputs,
shape,
dtype,
})
}
fn unpack_u32_nibbles(graph: &mut Graph, packed: NodeId, out_shape: &Shape) -> [NodeId; 8] {
let f64_ = DType::F64;
let w = push(graph, Op::Cast(f64_), vec![packed], out_shape.clone(), f64_);
let mut positions: Vec<NodeId> = Vec::with_capacity(8);
for p in 0..8u32 {
let divisor = 16f64.powi(p as i32);
let shifted = if p == 0 {
w
} else {
push(
graph,
Op::MulScalar(1.0 / divisor),
vec![w],
out_shape.clone(),
f64_,
)
};
let shifted = push(graph, Op::Floor, vec![shifted], out_shape.clone(), f64_);
let shifted_div16 = push(
graph,
Op::MulScalar(1.0 / 16.0),
vec![shifted],
out_shape.clone(),
f64_,
);
let hi = push(
graph,
Op::Floor,
vec![shifted_div16],
out_shape.clone(),
f64_,
);
let hi16 = push(
graph,
Op::MulScalar(16.0),
vec![hi],
out_shape.clone(),
f64_,
);
let nibble = push(graph, Op::Sub, vec![shifted, hi16], out_shape.clone(), f64_);
positions.push(nibble);
}
positions.try_into().unwrap_or_else(|_| unreachable!())
}
fn interleave_and_cast_to_f32(
graph: &mut Graph,
positions: &[NodeId; 8],
half_shape: &Shape,
logical_shape: &Shape,
) -> NodeId {
let half_dims = half_shape.dims();
let mut unsq_shape_dims = half_dims.to_vec();
unsq_shape_dims.push(1);
let unsq_shape = Shape::from_dims(&unsq_shape_dims);
let unsqueezed: Vec<NodeId> = positions
.iter()
.map(|&p| {
push(
graph,
Op::Unsqueeze {
dim: half_dims.len(),
},
vec![p],
unsq_shape.clone(),
DType::F64,
)
})
.collect();
let mut stacked_dims = half_dims.to_vec();
stacked_dims.push(8);
let stacked_shape = Shape::from_dims(&stacked_dims);
let stacked = push(
graph,
Op::Concat {
dim: half_dims.len(),
},
unsqueezed,
stacked_shape,
DType::F64,
);
let codes_f64 = push(
graph,
Op::Reshape(logical_shape.clone()),
vec![stacked],
logical_shape.clone(),
DType::F64,
);
push(
graph,
Op::Cast(DType::F32),
vec![codes_f64],
logical_shape.clone(),
DType::F32,
)
}
fn drop_row_padding(
graph: &mut Graph,
x: NodeId,
n_out: usize,
full: usize,
groups: usize,
) -> NodeId {
if full == groups {
return x;
}
push(
graph,
Op::Slice {
dim: 1,
start: 0,
len: groups,
},
vec![x],
Shape::from_dims(&[n_out, groups]),
DType::F32,
)
}
pub fn decompose(graph: &mut Graph, id: NodeId, params: &FusedOpParams) -> NodeId {
let (group_size, _split_k_iters) = match params {
FusedOpParams::AwqMatmul {
group_size,
split_k_iters,
} => (*group_size, *split_k_iters),
_ => return id,
};
let (a_shape, w_shape, z_shape, s_shape, s_dtype, dtype) = {
let n = graph.node(id);
if n.inputs.len() != 4 {
return id;
}
let a_shape = graph.node(n.inputs[0]).shape.clone();
let w_shape = graph.node(n.inputs[1]).shape.clone();
let z_shape = graph.node(n.inputs[2]).shape.clone();
let s_node = graph.node(n.inputs[3]);
(
a_shape,
w_shape,
z_shape,
s_node.shape.clone(),
s_node.dtype,
n.dtype,
)
};
let (a_id, w_id, z_id, s_id) = {
let n = graph.node(id);
(n.inputs[0], n.inputs[1], n.inputs[2], n.inputs[3])
};
let w_dims = w_shape.dims();
let z_dims = z_shape.dims();
let s_dims = s_shape.dims();
let a_dims = a_shape.dims();
if w_dims.len() != 2 || z_dims.len() != 2 || s_dims.len() != 2 || a_dims.len() < 2 {
return id;
}
let n_out = w_dims[0];
let k_over_8 = w_dims[1];
let k = k_over_8 * 8;
let Some(zeros_w) = awq_zeros_w(k, group_size) else {
return id;
};
let n_groups = k / group_size;
if z_dims != [n_out, zeros_w] || s_dims != [n_out, 8 * zeros_w] {
return id;
}
let k_from_act = a_dims[a_dims.len() - 1];
if k_from_act != k {
return id;
}
let half_w_shape = Shape::from_dims(&[n_out, k_over_8]);
let w_positions = unpack_u32_nibbles(graph, w_id, &half_w_shape);
let wcodes = interleave_and_cast_to_f32(
graph,
&w_positions,
&half_w_shape,
&Shape::from_dims(&[n_out, k]),
);
let half_z_shape = Shape::from_dims(&[n_out, zeros_w]);
let z_positions = unpack_u32_nibbles(graph, z_id, &half_z_shape);
let zcodes_padded = interleave_and_cast_to_f32(
graph,
&z_positions,
&half_z_shape,
&Shape::from_dims(&[n_out, 8 * zeros_w]),
);
let zcodes = drop_row_padding(graph, zcodes_padded, n_out, 8 * zeros_w, n_groups);
let scales_f32 = if s_dtype == DType::F32 {
s_id
} else {
push(
graph,
Op::Cast(DType::F32),
vec![s_id],
s_shape.clone(),
DType::F32,
)
};
let scales = drop_row_padding(graph, scales_f32, n_out, 8 * zeros_w, n_groups);
let expand = |graph: &mut Graph, x: NodeId| -> NodeId {
let unsq = push(
graph,
Op::Unsqueeze { dim: 2 },
vec![x],
Shape::from_dims(&[n_out, n_groups, 1]),
DType::F32,
);
let bcast = push(
graph,
Op::BroadcastTo(Shape::from_dims(&[n_out, n_groups, group_size])),
vec![unsq],
Shape::from_dims(&[n_out, n_groups, group_size]),
DType::F32,
);
push(
graph,
Op::Reshape(Shape::from_dims(&[n_out, k])),
vec![bcast],
Shape::from_dims(&[n_out, k]),
DType::F32,
)
};
let zcodes_full = expand(graph, zcodes);
let scales_full = expand(graph, scales);
let centered = push(
graph,
Op::Sub,
vec![wcodes, zcodes_full],
Shape::from_dims(&[n_out, k]),
DType::F32,
);
let dequant_nk = push(
graph,
Op::Mul,
vec![centered, scales_full],
Shape::from_dims(&[n_out, k]),
DType::F32,
);
let dequant = push(
graph,
Op::Transpose,
vec![dequant_nk],
Shape::from_dims(&[k, n_out]),
DType::F32,
);
let dequant_typed = if dtype == DType::F32 {
dequant
} else {
push(
graph,
Op::Cast(dtype),
vec![dequant],
Shape::from_dims(&[k, n_out]),
dtype,
)
};
let m_prime: usize = a_dims[..a_dims.len() - 1].iter().product();
let a2 = push(
graph,
Op::Reshape(Shape::from_dims(&[m_prime, k])),
vec![a_id],
Shape::from_dims(&[m_prime, k]),
dtype,
);
let out2 = push(
graph,
Op::MatMul,
vec![a2, dequant_typed],
Shape::from_dims(&[m_prime, n_out]),
dtype,
);
let mut out_dims: Vec<usize> = a_dims[..a_dims.len() - 1].to_vec();
out_dims.push(n_out);
push(
graph,
Op::Reshape(Shape::from_dims(&out_dims)),
vec![out2],
Shape::from_dims(&out_dims),
dtype,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Node, Op};
use std::collections::HashMap;
fn const_node(g: &mut Graph, shape: Shape, dtype: DType) -> NodeId {
g.push(Node {
op: Op::Const,
inputs: vec![],
shape,
dtype,
})
}
struct Problem {
n: usize,
k: usize,
gs: usize,
m: usize,
zeros_w: usize,
}
impl Problem {
fn new(n: usize, k: usize, gs: usize, m: usize) -> Self {
let zeros_w = awq_zeros_w(k, gs).expect("test shape is valid");
Self {
n,
k,
gs,
m,
zeros_w,
}
}
fn groups(&self) -> usize {
self.k / self.gs
}
fn wcode(&self, n: usize, k: usize) -> u32 {
((n * 7 + k * 3 + k / 5) % 16) as u32
}
fn zcode(&self, n: usize, g: usize) -> u32 {
((n * 5 + g * 3 + 1) % 16) as u32
}
fn scale(&self, n: usize, g: usize) -> f64 {
0.25 * (((n + g) % 4) + 1) as f64
}
fn act(&self, mi: usize, k: usize) -> f64 {
((mi * 3 + k) % 11) as f64 - 5.0
}
fn weight_words(&self) -> Vec<f64> {
let mut out = vec![0u32; self.n * self.k / 8];
for n in 0..self.n {
for k in 0..self.k {
out[n * (self.k / 8) + k / 8] |= self.wcode(n, k) << (4 * (k % 8));
}
}
out.into_iter().map(f64::from).collect()
}
fn zeros_words(&self, pad_garbage: bool) -> Vec<f64> {
let mut out = vec![0u32; self.n * self.zeros_w];
for n in 0..self.n {
for g in 0..8 * self.zeros_w {
let code = if g < self.groups() {
self.zcode(n, g)
} else if pad_garbage {
0xF
} else {
0
};
out[n * self.zeros_w + g / 8] |= code << (4 * (g % 8));
}
}
out.into_iter().map(f64::from).collect()
}
fn scale_values(&self, pad_garbage: bool) -> Vec<f64> {
let w = 8 * self.zeros_w;
let mut out = vec![0f64; self.n * w];
for n in 0..self.n {
for g in 0..w {
out[n * w + g] = if g < self.groups() {
self.scale(n, g)
} else if pad_garbage {
99.0
} else {
0.0
};
}
}
out
}
fn reference(&self, skip_zero: bool, group_of: impl Fn(usize) -> usize) -> Vec<f64> {
let mut out = vec![0f64; self.m * self.n];
for mi in 0..self.m {
for n in 0..self.n {
let mut acc = 0f64;
for k in 0..self.k {
let g = group_of(k);
let z = if skip_zero {
0.0
} else {
self.zcode(n, g) as f64
};
acc += self.act(mi, k) * (self.wcode(n, k) as f64 - z) * self.scale(n, g);
}
out[mi * self.n + n] = acc;
}
}
out
}
fn correct_reference(&self) -> Vec<f64> {
let gs = self.gs;
self.reference(false, move |k| k / gs)
}
fn run_decompose(&self, pad_garbage: bool) -> Vec<f64> {
self.run_decompose_with_weight(pad_garbage, self.weight_words())
}
fn run_decompose_with_weight(&self, pad_garbage: bool, weight_words: Vec<f64>) -> Vec<f64> {
let mut g = Graph::new();
let act = const_node(&mut g, Shape::from_dims(&[self.m, self.k]), DType::F32);
let weight = const_node(&mut g, Shape::from_dims(&[self.n, self.k / 8]), DType::U32);
let zeros = const_node(
&mut g,
Shape::from_dims(&[self.n, self.zeros_w]),
DType::U32,
);
let scales = const_node(
&mut g,
Shape::from_dims(&[self.n, 8 * self.zeros_w]),
DType::F16,
);
let params = FusedOpParams::AwqMatmul {
group_size: self.gs,
split_k_iters: 8,
};
let fused = g.push(Node {
op: Op::Fused(FusedOps::AWQ_MATMUL, params.clone()),
inputs: vec![act, weight, zeros, scales],
shape: Shape::from_dims(&[self.m, self.n]),
dtype: DType::F32,
});
let new_root = decompose(&mut g, fused, ¶ms);
assert_ne!(new_root, fused, "decompose must fire, not fixpoint");
assert_eq!(g.node(new_root).shape, Shape::from_dims(&[self.m, self.n]));
let act_vals: Vec<f64> = (0..self.m)
.flat_map(|mi| (0..self.k).map(move |k| (mi, k)))
.map(|(mi, k)| self.act(mi, k))
.collect();
let bindings: HashMap<NodeId, Vec<f64>> = [
(act, act_vals),
(weight, weight_words),
(zeros, self.zeros_words(pad_garbage)),
(scales, self.scale_values(pad_garbage)),
]
.into_iter()
.collect();
eval_node(&g, new_root, &bindings)
}
}
fn assert_close(got: &[f64], want: &[f64]) {
assert_eq!(got.len(), want.len(), "length mismatch");
for (i, (&g, &w)) in got.iter().zip(want.iter()).enumerate() {
assert!((g - w).abs() < 1e-6, "output[{i}]: got {g}, want {w}");
}
}
fn fused_node_for(g: &mut Graph, p: &Problem) -> NodeId {
let act = const_node(g, Shape::from_dims(&[p.m, p.k]), DType::F32);
let weight = const_node(g, Shape::from_dims(&[p.n, p.k / 8]), DType::U32);
let zeros = const_node(g, Shape::from_dims(&[p.n, p.zeros_w]), DType::U32);
let scales = const_node(g, Shape::from_dims(&[p.n, 8 * p.zeros_w]), DType::F16);
g.push(Node {
op: Op::Fused(
FusedOps::AWQ_MATMUL,
FusedOpParams::AwqMatmul {
group_size: p.gs,
split_k_iters: 8,
},
),
inputs: vec![act, weight, zeros, scales],
shape: Shape::from_dims(&[p.m, p.n]),
dtype: DType::F32,
})
}
#[test]
fn awq_zeros_w_matches_the_kernel_padding_rule() {
assert_eq!(awq_zeros_w(64, 64), Some(2));
assert_eq!(awq_zeros_w(128, 128), Some(1));
assert_eq!(awq_zeros_w(17 * 64, 64), Some(4));
assert_eq!(awq_zeros_w(128 * 9, 128), Some(2));
assert_eq!(awq_zeros_w(64 * 8, 64), Some(2));
assert_eq!(awq_zeros_w(128 * 16, 128), Some(2));
assert_eq!(awq_zeros_w(64 * 24, 64), Some(4));
assert_eq!(
awq_zeros_w(64, 32),
None,
"only 64/128 are kernel group sizes"
);
assert_eq!(awq_zeros_w(100, 64), None, "group_size must divide k");
}
#[test]
fn awq_matmul_wrong_params_is_a_fixpoint_not_a_crash() {
let mut g = Graph::new();
let fused = fused_node_for(&mut g, &Problem::new(8, 64, 64, 2));
let before = g.len();
let out = decompose(&mut g, fused, &FusedOpParams::Rope);
assert_eq!(out, fused, "wrong params => typed decline => fixpoint");
assert_eq!(g.len(), before, "declined before any emission");
}
#[test]
fn awq_matmul_wrong_zeros_width_is_a_fixpoint_not_a_crash() {
let p = Problem::new(8, 64, 64, 2);
let mut g = Graph::new();
let act = const_node(&mut g, Shape::from_dims(&[p.m, p.k]), DType::F32);
let weight = const_node(&mut g, Shape::from_dims(&[p.n, p.k / 8]), DType::U32);
let zeros = const_node(&mut g, Shape::from_dims(&[p.n, 1]), DType::U32);
let scales = const_node(&mut g, Shape::from_dims(&[p.n, 8]), DType::F16);
let params = FusedOpParams::AwqMatmul {
group_size: 64,
split_k_iters: 8,
};
let fused = g.push(Node {
op: Op::Fused(FusedOps::AWQ_MATMUL, params.clone()),
inputs: vec![act, weight, zeros, scales],
shape: Shape::from_dims(&[p.m, p.n]),
dtype: DType::F32,
});
let before = g.len();
assert_eq!(decompose(&mut g, fused, ¶ms), fused);
assert_eq!(g.len(), before);
}
#[test]
fn awq_matmul_decompose_fires_and_matches_declared_shape_dtype() {
let p = Problem::new(8, 64, 64, 2);
let mut g = Graph::new();
let fused = fused_node_for(&mut g, &p);
let out_sh = g.node(fused).shape.clone();
let out_dt = g.node(fused).dtype;
let params = FusedOpParams::AwqMatmul {
group_size: 64,
split_k_iters: 8,
};
let new_root = decompose(&mut g, fused, ¶ms);
assert_ne!(new_root, fused, "decompose must fire, not fixpoint");
assert_eq!(g.node(new_root).shape, out_sh);
assert_eq!(g.node(new_root).dtype, out_dt);
}
#[test]
fn awq_matmul_decompose_matches_reference_one_group() {
let p = Problem::new(8, 64, 64, 3);
assert_close(&p.run_decompose(true), &p.correct_reference());
}
#[test]
fn awq_matmul_decompose_matches_reference_many_groups() {
let p = Problem::new(16, 640, 64, 2);
assert_eq!(p.groups(), 10);
assert_eq!(p.zeros_w, 2);
assert_close(&p.run_decompose(true), &p.correct_reference());
}
#[test]
fn awq_matmul_decompose_matches_reference_group_128() {
let p = Problem::new(8, 256, 128, 2);
assert_close(&p.run_decompose(true), &p.correct_reference());
}
#[test]
fn awq_matmul_decompose_ignores_row_padding() {
let p = Problem::new(8, 640, 64, 2);
assert_close(&p.run_decompose(true), &p.run_decompose(false));
}
#[test]
fn awq_matmul_sabotage_references_differ_from_the_decompose() {
let p = Problem::new(16, 640, 64, 2);
let got = p.run_decompose(true);
let groups = p.groups();
let no_zero = p.reference(true, |k| k / 64);
let wrong_group = p.reference(false, |k| k % groups);
for (name, bad) in [
("skip zero-point", no_zero),
("wrong group index", wrong_group),
] {
let max_diff = got
.iter()
.zip(bad.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0, f64::max);
assert!(
max_diff > 1e-3,
"sabotage `{name}` must change the result, max diff {max_diff}"
);
}
}
#[test]
fn awq_matmul_sabotage_interleaved_nibble_order_is_not_sequential() {
let p = Problem::new(8, 64, 64, 2);
let order = [0usize, 2, 4, 6, 1, 3, 5, 7];
let mut words = vec![0u32; p.n * p.k / 8];
for n in 0..p.n {
for k in 0..p.k {
let phys = order.iter().position(|&o| o == k % 8).unwrap();
words[n * (p.k / 8) + k / 8] |= p.wcode(n, k) << (4 * phys);
}
}
let interleaved: Vec<f64> = words.into_iter().map(f64::from).collect();
let got = p.run_decompose_with_weight(true, interleaved);
let want = p.correct_reference();
let max_diff = got
.iter()
.zip(want.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0, f64::max);
assert!(max_diff > 1e-3, "a layout mix-up must be observable");
}
fn eval_node(g: &Graph, id: NodeId, bindings: &HashMap<NodeId, Vec<f64>>) -> Vec<f64> {
if let Some(v) = bindings.get(&id) {
return v.clone();
}
let n = g.node(id);
let elem_count = n.shape.dims().iter().product::<usize>().max(1);
match &n.op {
Op::Cast(_) => eval_node(g, n.inputs[0], bindings),
Op::Floor => eval_node(g, n.inputs[0], bindings)
.into_iter()
.map(f64::floor)
.collect(),
Op::MulScalar(s) => eval_node(g, n.inputs[0], bindings)
.into_iter()
.map(|x| x * s)
.collect(),
Op::Sub => {
let a = eval_node(g, n.inputs[0], bindings);
let b = eval_node(g, n.inputs[1], bindings);
a.iter().zip(b.iter()).map(|(x, y)| x - y).collect()
}
Op::Mul => {
let a = eval_node(g, n.inputs[0], bindings);
let b = eval_node(g, n.inputs[1], bindings);
a.iter().zip(b.iter()).map(|(x, y)| x * y).collect()
}
Op::Unsqueeze { .. } | Op::Reshape(_) => eval_node(g, n.inputs[0], bindings),
Op::Concat { .. } => {
let arrs: Vec<Vec<f64>> = n
.inputs
.iter()
.map(|&i| eval_node(g, i, bindings))
.collect();
let k = arrs.len();
let outer = arrs.first().map(|a| a.len()).unwrap_or(0);
let mut out = vec![0f64; outer * k];
for (o, slot) in out.chunks_mut(k).enumerate() {
for (j, arr) in arrs.iter().enumerate() {
slot[j] = arr[o];
}
}
out
}
Op::BroadcastTo(target) => {
let src = eval_node(g, n.inputs[0], bindings);
let src_shape = g.node(n.inputs[0]).shape.dims().to_vec();
assert_eq!(src_shape.len(), 3);
assert_eq!(src_shape[2], 1);
let gsize = target.dims()[2];
src.iter()
.flat_map(|&x| std::iter::repeat_n(x, gsize))
.collect()
}
Op::Slice { dim, start, len } => {
assert_eq!(*dim, 1);
let src = eval_node(g, n.inputs[0], bindings);
let src_shape = g.node(n.inputs[0]).shape.dims().to_vec();
assert_eq!(src_shape.len(), 2);
let (rows, cols) = (src_shape[0], src_shape[1]);
let mut out = Vec::with_capacity(rows * len);
for r in 0..rows {
out.extend_from_slice(&src[r * cols + start..r * cols + start + len]);
}
out
}
Op::Transpose => {
let src = eval_node(g, n.inputs[0], bindings);
let src_shape = g.node(n.inputs[0]).shape.dims().to_vec();
assert_eq!(src_shape.len(), 2);
let (rows, cols) = (src_shape[0], src_shape[1]);
let mut out = vec![0f64; rows * cols];
for r in 0..rows {
for c in 0..cols {
out[c * rows + r] = src[r * cols + c];
}
}
out
}
Op::MatMul => {
let a = eval_node(g, n.inputs[0], bindings);
let b = eval_node(g, n.inputs[1], bindings);
let a_shape = g.node(n.inputs[0]).shape.dims().to_vec();
let b_shape = g.node(n.inputs[1]).shape.dims().to_vec();
let (m, k) = (a_shape[0], a_shape[1]);
let (_k2, nn) = (b_shape[0], b_shape[1]);
let mut out = vec![0f64; m * nn];
for i in 0..m {
for j in 0..nn {
let mut acc = 0f64;
for kk in 0..k {
acc += a[i * k + kk] * b[kk * nn + j];
}
out[i * nn + j] = acc;
}
}
out
}
other => panic!("eval_node: unhandled op {other:?} (elem_count={elem_count})"),
}
}
}