Skip to main content

rlx_fusion/
control_flow.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Control-flow lowering passes: `Op::If` → `Where` + inlined
17//! branches; `Op::While` → bounded unroll of body replicas.
18//!
19//! Backends that don't have native sub-graph executors run
20//! `LowerControlFlow` BEFORE the legalize / supported-set check so
21//! they never see `Op::If` or `Op::While`. Used by rlx-cpu and
22//! rlx-metal (the runtime-side `run_if` / `run_while` helpers exist
23//! but the executor wiring through their thunk schedules is more
24//! invasive than this rewrite). Other backends (rlx-wgpu, rlx-cuda,
25//! rlx-rocm, rlx-tpu) ship their own per-backend unfuse passes that
26//! do equivalent work — this module is the portable, IR-level
27//! version.
28//!
29//! Trade-offs:
30//!   * `Op::If` always evaluates **both** branches in the rewritten
31//!     graph. That's the price of expressing it via primitives. Fine
32//!     for inference where Op::If is rare; if a workload hits a
33//!     hot Op::If on a path where both branches are expensive, the
34//!     fix is a backend-native If executor, not this rewrite.
35//!   * `Op::While` requires `max_iterations = Some(N)` — unbounded
36//!     loops have no terminating count and panic with a clear
37//!     message pointing at `rlx_runtime::subgraph::run_while` for
38//!     the dynamic alternative.
39//!
40//! Capture binding (used by both passes): each sub-graph's
41//! `Op::Input` nodes appear in the same order as the parent's
42//! captures (`inputs[1..]` for `Op::If` past the predicate, all
43//! `inputs[..]` for `Op::While`). Sub-graph `Op::Input[i]` rewires
44//! to `captures[i]` when inlined into the parent.
45
46use crate::pass::Pass;
47use rlx_ir::op::BinaryOp;
48use rlx_ir::shape::Dim;
49use rlx_ir::{DType, Graph, NodeId, Op, Shape};
50use std::collections::HashMap;
51
52/// Pass form: rewrites `Op::If` and `Op::While` into primitive ops.
53/// No-op when neither op is present.
54pub struct LowerControlFlow;
55
56impl Pass for LowerControlFlow {
57    fn name(&self) -> &str {
58        "LowerControlFlow"
59    }
60    fn run(&self, graph: Graph) -> Graph {
61        let g = inline_if(graph);
62        unroll_while(g)
63    }
64}
65
66/// Pass form: unroll `Op::Scan` into inlined body replicas. Used to lower Scan
67/// for backends without a native scan kernel (HLO/codegen targets); backends
68/// that list `Scan` in their supported set keep the op and host-fallback it.
69pub struct LowerScan;
70
71impl Pass for LowerScan {
72    fn name(&self) -> &str {
73        "LowerScan"
74    }
75    fn run(&self, graph: Graph) -> Graph {
76        unroll_scan(graph)
77    }
78}
79
80/// Bounded-unroll `Op::Scan` into `length` inlined body copies. Each step
81/// inlines the body with `[carry, bcast.., xs[t]]` (slicing `xs` at row `t`);
82/// the produced carry feeds the next step. With `save_trajectory`, the per-step
83/// carries are stacked into `[length, *carry]`. All emitted ops are primitives
84/// (`Narrow`/`Reshape`/`Concat` + the body), so the result runs on any backend
85/// that supports the body's ops. Large graphs for long `length` — a native loop
86/// (e.g. HLO `kWhile`) is the perf path for those backends.
87pub fn unroll_scan(g: Graph) -> Graph {
88    let mut out = Graph::new(g.name.clone());
89    let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
90    let nodes: Vec<rlx_ir::Node> = g.nodes().to_vec();
91
92    for node in &nodes {
93        let new_inputs: Vec<NodeId> = node.inputs.iter().map(|i| id_map[i]).collect();
94        let new_id = match &node.op {
95            Op::Scan {
96                body,
97                length,
98                save_trajectory,
99                num_bcast,
100                num_xs,
101                ..
102            } => {
103                let nb = *num_bcast as usize;
104                let nx = *num_xs as usize;
105                let length = *length as usize;
106                debug_assert!(length >= 1, "unroll_scan: length must be ≥1");
107                let init = new_inputs[0];
108                let bcasts: Vec<NodeId> = new_inputs[1..1 + nb].to_vec();
109                let xs: Vec<NodeId> = new_inputs[1 + nb..1 + nb + nx].to_vec();
110                let carry_shape = out.node(init).shape.clone();
111
112                let mut carry = init;
113                let mut rows: Vec<NodeId> = Vec::with_capacity(length);
114                for t in 0..length {
115                    // Slice each xs at step t: [length, *ps] → Narrow → [1, *ps] → Reshape → [*ps].
116                    let mut xs_t: Vec<NodeId> = Vec::with_capacity(nx);
117                    for &x in &xs {
118                        let xsh = out.node(x).shape.clone();
119                        let ps_usize: Vec<usize> = xsh
120                            .dims()
121                            .iter()
122                            .skip(1)
123                            .map(|d| d.unwrap_static())
124                            .collect();
125                        let ps_i64: Vec<i64> = ps_usize.iter().map(|&d| d as i64).collect();
126                        let mut nar_dims = vec![1usize];
127                        nar_dims.extend(ps_usize.iter().copied());
128                        let narrowed = out.add_node(
129                            Op::Narrow {
130                                axis: 0,
131                                start: t,
132                                len: 1,
133                            },
134                            vec![x],
135                            Shape::new(&nar_dims, xsh.dtype()),
136                        );
137                        let reshaped = out.add_node(
138                            Op::Reshape { new_shape: ps_i64 },
139                            vec![narrowed],
140                            Shape::new(&ps_usize, xsh.dtype()),
141                        );
142                        xs_t.push(reshaped);
143                    }
144                    let mut captures = vec![carry];
145                    captures.extend(bcasts.iter().copied());
146                    captures.extend(xs_t);
147                    let outs = inline_subgraph_into_outputs(body, &captures, &mut out);
148                    carry = outs[0];
149                    if *save_trajectory {
150                        rows.push(carry);
151                    }
152                }
153
154                if *save_trajectory {
155                    let cdims: Vec<usize> = carry_shape
156                        .dims()
157                        .iter()
158                        .map(|d| d.unwrap_static())
159                        .collect();
160                    let mut rdims = vec![1usize];
161                    rdims.extend(cdims.iter().copied());
162                    let ri64: Vec<i64> = rdims.iter().map(|&d| d as i64).collect();
163                    let reshaped_rows: Vec<NodeId> = rows
164                        .iter()
165                        .map(|&r| {
166                            out.add_node(
167                                Op::Reshape {
168                                    new_shape: ri64.clone(),
169                                },
170                                vec![r],
171                                Shape::new(&rdims, carry_shape.dtype()),
172                            )
173                        })
174                        .collect();
175                    out.add_node(Op::Concat { axis: 0 }, reshaped_rows, node.shape.clone())
176                } else {
177                    carry
178                }
179            }
180            _ => out.add_node(node.op.clone(), new_inputs, node.shape.clone()),
181        };
182        id_map.insert(node.id, new_id);
183    }
184    let new_outputs: Vec<NodeId> = g.outputs.iter().map(|i| id_map[i]).collect();
185    out.set_outputs(new_outputs);
186    out
187}
188
189/// Inline `Op::If` sub-graphs into the parent and replace the If
190/// node with `Where(predicate, then_output, else_output)`. Both
191/// branches are present in the rewritten graph and always evaluate.
192pub fn inline_if(g: Graph) -> Graph {
193    let mut out = Graph::new(g.name.clone());
194    let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
195    let nodes: Vec<rlx_ir::Node> = g.nodes().to_vec();
196
197    for node in &nodes {
198        let new_inputs: Vec<NodeId> = node.inputs.iter().map(|i| id_map[i]).collect();
199        let new_id = match &node.op {
200            Op::If {
201                then_branch,
202                else_branch,
203            } => {
204                let captures: Vec<NodeId> = new_inputs[1..].to_vec();
205                let then_out = inline_subgraph_into(then_branch, &captures, &mut out);
206                let else_out = inline_subgraph_into(else_branch, &captures, &mut out);
207                // Most backends' Where kernel requires the predicate
208                // to share the output's element count (no broadcast
209                // inside the kernel). Expand a smaller predicate up
210                // to the output shape so the rewritten graph runs
211                // out of the box on CPU/Metal.
212                let predicate = expand_to_shape(new_inputs[0], &node.shape, &mut out);
213                out.add_node(
214                    Op::Where,
215                    vec![predicate, then_out, else_out],
216                    node.shape.clone(),
217                )
218            }
219            _ => out.add_node(node.op.clone(), new_inputs, node.shape.clone()),
220        };
221        id_map.insert(node.id, new_id);
222    }
223    let new_outputs: Vec<NodeId> = g.outputs.iter().map(|i| id_map[i]).collect();
224    out.set_outputs(new_outputs);
225    out
226}
227
228/// Bounded-unroll `Op::While` up to `max_iterations`. Each iteration
229/// inlines `cond` and `body` with all loop-carried captures, then
230/// applies `Where(active, body_out, carried)` per carry (MLX semantics).
231pub fn unroll_while(g: Graph) -> Graph {
232    let mut out = Graph::new(g.name.clone());
233    let mut id_map: HashMap<NodeId, NodeId> = HashMap::new();
234    let nodes: Vec<rlx_ir::Node> = g.nodes().to_vec();
235    let scalar_f32 = Shape::new(&[1], DType::F32);
236
237    for node in &nodes {
238        let new_inputs: Vec<NodeId> = node.inputs.iter().map(|i| id_map[i]).collect();
239        let new_id = match &node.op {
240            Op::While {
241                cond,
242                body,
243                max_iterations: Some(n),
244                ..
245            } => {
246                if new_inputs.is_empty() {
247                    panic!(
248                        "Op::While unroll: at least one \
249                            loop-carried input required"
250                    );
251                }
252                let one = out.add_node(
253                    Op::Constant {
254                        data: 1.0_f32.to_le_bytes().to_vec(),
255                    },
256                    vec![],
257                    scalar_f32.clone(),
258                );
259                let mut active = one;
260                let mut carried = new_inputs;
261                for _ in 0..*n {
262                    let cond_out = inline_subgraph_into(cond, &carried, &mut out);
263                    let cond_f = cond_to_f32_mask(cond_out, &mut out);
264                    let cond_shape = out.node(cond_f).shape.clone();
265                    let active_lhs = expand_to_shape(active, &cond_shape, &mut out);
266                    active = out.binary(BinaryOp::Mul, active_lhs, cond_f, cond_shape);
267
268                    let body_outs = inline_subgraph_into_outputs(body, &carried, &mut out);
269                    assert_eq!(
270                        body_outs.len(),
271                        carried.len(),
272                        "Op::While: body output count must match loop-carried arity"
273                    );
274                    let mut next = Vec::with_capacity(carried.len());
275                    for (body_out, &prev) in body_outs.iter().zip(carried.iter()) {
276                        let shape = out.node(prev).shape.clone();
277                        let mask = expand_to_shape(active, &shape, &mut out);
278                        let merged = out.add_node(Op::Where, vec![mask, *body_out, prev], shape);
279                        next.push(merged);
280                    }
281                    carried = next;
282                }
283                carried[0]
284            }
285            Op::While {
286                max_iterations: None,
287                ..
288            } => {
289                panic!(
290                    "LowerControlFlow: Op::While requires \
291                        max_iterations = Some(N) for unrolling. \
292                        Either set a bounded max_iterations on the \
293                        forward graph, or use the dynamic \
294                        `rlx_runtime::subgraph::run_while` helper."
295                );
296            }
297            _ => out.add_node(node.op.clone(), new_inputs, node.shape.clone()),
298        };
299        id_map.insert(node.id, new_id);
300    }
301    let new_outputs: Vec<NodeId> = g.outputs.iter().map(|i| id_map[i]).collect();
302    out.set_outputs(new_outputs);
303    out
304}
305
306/// Cast a cond-subgraph output to an f32 loop mask. Bool uses the
307/// Bool→I32→F32 chain because CPU `Cast` Bool→F32 is a raw byte copy.
308fn cond_to_f32_mask(cond_out: NodeId, out: &mut Graph) -> NodeId {
309    let cond_shape = out.node(cond_out).shape.clone();
310    match cond_shape.dtype() {
311        DType::F32 => cond_out,
312        DType::Bool => {
313            let f32_shape = cond_shape.clone().with_dtype(DType::F32);
314            let i32_shape = cond_shape.with_dtype(DType::I32);
315            let as_i32 = out.add_node(Op::Cast { to: DType::I32 }, vec![cond_out], i32_shape);
316            out.add_node(Op::Cast { to: DType::F32 }, vec![as_i32], f32_shape)
317        }
318        _ => out.add_node(
319            Op::Cast { to: DType::F32 },
320            vec![cond_out],
321            cond_shape.with_dtype(DType::F32),
322        ),
323    }
324}
325
326/// Expand a tensor up to `target` via `Op::Expand` if its shape
327/// (specifically its element count) differs from the target. Used to
328/// promote a scalar / smaller predicate up to the Where output shape
329/// during `Op::If` lowering.
330fn expand_to_shape(src: NodeId, target: &rlx_ir::Shape, out: &mut Graph) -> NodeId {
331    let src_shape = out.node(src).shape.clone();
332    let src_n = src_shape
333        .dims()
334        .iter()
335        .filter_map(|d| match d {
336            Dim::Static(n) => Some(*n),
337            _ => None,
338        })
339        .product::<usize>();
340    let tgt_n = target
341        .dims()
342        .iter()
343        .filter_map(|d| match d {
344            Dim::Static(n) => Some(*n),
345            _ => None,
346        })
347        .product::<usize>();
348    if src_shape.dims() == target.dims() {
349        return src;
350    }
351    let target_dims_i64: Vec<i64> = target
352        .dims()
353        .iter()
354        .map(|d| match d {
355            Dim::Static(n) => *n as i64,
356            _ => -1,
357        })
358        .collect();
359    // Op::Expand requires equal rank (broadcast via 1-dim only).
360    // If src has a smaller rank, left-pad with 1s via a Reshape first.
361    let src_rank = src_shape.rank();
362    let tgt_rank = target.dims().len();
363    let to_expand = if src_rank < tgt_rank {
364        let mut padded_dims: Vec<Dim> = std::iter::repeat_n(Dim::Static(1), tgt_rank - src_rank)
365            .chain(src_shape.dims().iter().copied())
366            .collect();
367        // Width of last dim follows src; rank gain pads with 1s.
368        let _ = src_n;
369        let _ = tgt_n;
370        let dtype = src_shape.dtype();
371        let pad_dims_i64: Vec<i64> = padded_dims
372            .iter()
373            .map(|d| match d {
374                Dim::Static(n) => *n as i64,
375                _ => -1,
376            })
377            .collect();
378        // Borrow the padded shape for Reshape's output.
379        let pad_shape = rlx_ir::Shape::from_dims(&padded_dims, dtype);
380        padded_dims.clear();
381        out.reshape(src, pad_dims_i64, pad_shape)
382    } else {
383        src
384    };
385    out.add_node(
386        Op::Expand {
387            target_shape: target_dims_i64,
388        },
389        vec![to_expand],
390        target.clone(),
391    )
392}
393
394/// Inline `sub` into `out`, wiring `Op::Input` slots to `captures` in
395/// subgraph node order. Returns every output node (declaration order).
396pub fn inline_subgraph_into_outputs(
397    sub: &Graph,
398    captures: &[NodeId],
399    out: &mut Graph,
400) -> Vec<NodeId> {
401    let mut sub_to_parent: HashMap<NodeId, NodeId> = HashMap::new();
402    let mut input_idx = 0usize;
403    for sub_node in sub.nodes() {
404        let new_id = match &sub_node.op {
405            Op::Input { .. } => {
406                let parent_id = captures[input_idx];
407                input_idx += 1;
408                parent_id
409            }
410            _ => {
411                let new_inputs: Vec<NodeId> =
412                    sub_node.inputs.iter().map(|i| sub_to_parent[i]).collect();
413                out.add_node(sub_node.op.clone(), new_inputs, sub_node.shape.clone())
414            }
415        };
416        sub_to_parent.insert(sub_node.id, new_id);
417    }
418    assert_eq!(
419        input_idx,
420        captures.len(),
421        "Op::While/If sub-graph: {} Op::Input nodes but {} captures",
422        input_idx,
423        captures.len()
424    );
425    sub.outputs.iter().map(|o| sub_to_parent[o]).collect()
426}
427
428/// Helper: copy `sub`'s nodes into `out`, mapping each Op::Input
429/// by position to the corresponding capture. Returns the new
430/// NodeId in `out` of the sub-graph's first declared output.
431pub fn inline_subgraph_into(sub: &Graph, captures: &[NodeId], out: &mut Graph) -> NodeId {
432    inline_subgraph_into_outputs(sub, captures, out)[0]
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use rlx_ir::op::{Activation, BinaryOp};
439    use rlx_ir::{DType, Shape};
440
441    #[test]
442    fn lower_control_flow_pass_handles_both_if_and_while() {
443        let s = Shape::new(&[2], DType::F32);
444
445        let mut then_g = Graph::new("th");
446        let ti = then_g.input("c", s.clone());
447        let to = then_g.activation(Activation::Relu, ti, s.clone());
448        then_g.set_outputs(vec![to]);
449        let mut else_g = Graph::new("el");
450        let ei = else_g.input("c", s.clone());
451        let eo = else_g.activation(Activation::Sigmoid, ei, s.clone());
452        else_g.set_outputs(vec![eo]);
453
454        let mut body_g = Graph::new("body");
455        let bi = body_g.input("c", s.clone());
456        let bo = body_g.binary(BinaryOp::Mul, bi, bi, s.clone());
457        body_g.set_outputs(vec![bo]);
458        let mut cond_g = Graph::new("cond");
459        let ci = cond_g.input("c", s.clone());
460        cond_g.set_outputs(vec![ci]);
461
462        let mut g = Graph::new("parent");
463        let x = g.input("x", s.clone());
464        let pred = g.input("p", Shape::new(&[1], DType::F32));
465        let if_out = g.add_node(
466            Op::If {
467                then_branch: Box::new(then_g),
468                else_branch: Box::new(else_g),
469            },
470            vec![pred, x],
471            s.clone(),
472        );
473        let w_out = g.add_node(
474            Op::While {
475                cond: Box::new(cond_g),
476                body: Box::new(body_g),
477                max_iterations: Some(2),
478            },
479            vec![if_out],
480            s.clone(),
481        );
482        g.set_outputs(vec![w_out]);
483
484        let lowered = LowerControlFlow.run(g);
485        let has_if = lowered
486            .nodes()
487            .iter()
488            .any(|n| matches!(n.op, Op::If { .. }));
489        let has_while = lowered
490            .nodes()
491            .iter()
492            .any(|n| matches!(n.op, Op::While { .. }));
493        assert!(
494            !has_if && !has_while,
495            "LowerControlFlow should erase both If and While"
496        );
497        // 1 Where from If; While unroll adds 1 Where per iteration per
498        // carry (MLX semantics, see `unroll_while`).
499        let n_where = lowered
500            .nodes()
501            .iter()
502            .filter(|n| matches!(n.op, Op::Where))
503            .count();
504        let n_mul = lowered
505            .nodes()
506            .iter()
507            .filter(|n| matches!(n.op, Op::Binary(BinaryOp::Mul)))
508            .count();
509        assert_eq!(
510            n_where, 3,
511            "expected 1 Where from If + 2 from While (N=2, 1 carry)"
512        );
513        assert_eq!(
514            n_mul, 4,
515            "expected 2 body Mul + 2 active*cond_f Mul from While (N=2)"
516        );
517    }
518
519    #[test]
520    fn unroll_while_multi_carry_cond_freezes_updates() {
521        let v_shape = Shape::new(&[2], DType::F32);
522        let s_shape = Shape::new(&[1], DType::F32);
523
524        let mut body = Graph::new("body");
525        let v_in = body.input("v", v_shape.clone());
526        let s_in = body.input("s", s_shape.clone());
527        let one = body.add_node(
528            Op::Constant {
529                data: 1.0_f32.to_le_bytes().to_vec(),
530            },
531            vec![],
532            s_shape.clone(),
533        );
534        let v_out = body.binary(BinaryOp::Add, v_in, one, v_shape.clone());
535        body.set_outputs(vec![v_out, s_in]);
536
537        let mut cond = Graph::new("cond");
538        let v_c = cond.input("v", v_shape.clone());
539        let _s_c = cond.input("s", s_shape.clone());
540        let ten = cond.add_node(
541            Op::Constant {
542                data: 10.0_f32.to_le_bytes().to_vec(),
543            },
544            vec![],
545            s_shape.clone(),
546        );
547        let lt = cond.add_node(
548            Op::Compare(rlx_ir::op::CmpOp::Lt),
549            vec![v_c, ten],
550            Shape::new(&[1], DType::Bool),
551        );
552        cond.set_outputs(vec![lt]);
553
554        let mut g = Graph::new("parent");
555        let v0 = g.input("v0", v_shape.clone());
556        let s0 = g.input("s0", s_shape.clone());
557        let w = g.add_node(
558            Op::While {
559                cond: Box::new(cond),
560                body: Box::new(body),
561                max_iterations: Some(3),
562            },
563            vec![v0, s0],
564            v_shape.clone(),
565        );
566        g.set_outputs(vec![w]);
567
568        let lowered = unroll_while(g);
569        assert!(
570            !lowered
571                .nodes()
572                .iter()
573                .any(|n| matches!(n.op, Op::While { .. })),
574            "While should be erased"
575        );
576        let n_where = lowered
577            .nodes()
578            .iter()
579            .filter(|n| matches!(n.op, Op::Where))
580            .count();
581        assert_eq!(n_where, 6, "expected 3 iters × 2 carries Where masks");
582    }
583
584    #[test]
585    fn unroll_while_squares_on_cpu_thunks() {
586        let s = Shape::new(&[2], DType::F32);
587        let mut body_g = Graph::new("body");
588        let bi = body_g.input("c", s.clone());
589        let bo = body_g.binary(BinaryOp::Mul, bi, bi, s.clone());
590        body_g.set_outputs(vec![bo]);
591        let mut cond_g = Graph::new("cond");
592        let ci = cond_g.input("c", s.clone());
593        cond_g.set_outputs(vec![ci]);
594
595        let mut g = Graph::new("while_test");
596        let x = g.input("x", s.clone());
597        let y = g.add_node(
598            Op::While {
599                cond: Box::new(cond_g),
600                body: Box::new(body_g),
601                max_iterations: Some(3),
602            },
603            vec![x],
604            s.clone(),
605        );
606        g.set_outputs(vec![y]);
607
608        let lowered = unroll_while(g);
609        assert!(
610            !lowered
611                .nodes()
612                .iter()
613                .any(|n| matches!(n.op, Op::While { .. }))
614        );
615
616        let x_id = lowered
617            .nodes()
618            .iter()
619            .find(|n| matches!(&n.op, Op::Input { name, .. } if name == "x"))
620            .expect("lowered graph missing input x")
621            .id;
622        let plan = rlx_opt::memory::plan_memory(&lowered);
623        let mut arena = rlx_cpu::arena::Arena::from_plan(plan);
624        let sched = rlx_cpu::thunk::compile_thunks(&lowered, &arena);
625        for node in lowered.nodes() {
626            if let Op::Constant { data } = &node.op
627                && arena.has_buffer(node.id)
628                && !data.is_empty()
629            {
630                let buf = arena.slice_mut(node.id);
631                let n_floats = data.len() / 4;
632                let n = buf.len().min(n_floats);
633                for i in 0..n {
634                    let bytes = [
635                        data[i * 4],
636                        data[i * 4 + 1],
637                        data[i * 4 + 2],
638                        data[i * 4 + 3],
639                    ];
640                    buf[i] = f32::from_le_bytes(bytes);
641                }
642            }
643        }
644        let x_off = arena.byte_offset(x_id);
645        let out_id = lowered.outputs[0];
646        let out_off = arena.byte_offset(out_id);
647        let buf = arena.raw_buf_mut();
648        unsafe {
649            let px = buf.as_mut_ptr().add(x_off) as *mut f32;
650            *px.add(0) = 2.0;
651            *px.add(1) = 3.0;
652        }
653        rlx_cpu::thunk::execute_thunks(&sched, arena.raw_buf_mut());
654        let got: Vec<f32> = unsafe {
655            let p = arena.raw_buf().as_ptr().add(out_off) as *const f32;
656            vec![*p.add(0), *p.add(1)]
657        };
658        let want = [256.0_f32, 6561.0_f32];
659        for (i, (&a, &b)) in got.iter().zip(&want).enumerate() {
660            assert!(
661                (a - b).abs() < 1e-3,
662                "unrolled while[{i}]: got {a} want {b}"
663            );
664        }
665    }
666}