Skip to main content

rlx_ir/
lanes.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5//! Lane masks — per-lane reset expressed as **data**, not control flow.
6//!
7//! A batched workload runs `n_lanes` independent problems in one graph: parallel
8//! RL environments, or a batch of decode sequences. Lanes finish at different
9//! times, so periodically some subset needs to be reset while the rest keeps
10//! running.
11//!
12//! The obvious implementation resets on the host — rebuild the state, rebind the
13//! buffers, run again. That works, and it throws away the captured graph every
14//! time it happens, because the graph is what encodes which buffers are read.
15//! On CUDA the capture is dropped and re-taken (see `captured_graph = None` in
16//! the rlx-cuda dispatch loop); the cost lands exactly on the boundary between
17//! episodes, which for short episodes is most of them.
18//!
19//! The alternative is to make the reset part of the graph:
20//!
21//! ```text
22//! state' = where(mask[lane] != 0, reset_value, state)
23//! ```
24//!
25//! with `mask` a plain `[n_lanes]` **graph input**. Its *contents* change every
26//! step; the *graph* never does. Nothing is rebound, no shape changes, so a
27//! captured schedule stays valid across an arbitrary sequence of resets.
28//!
29//! This is the trick the Warp HOME-LBM environments use for their parallel
30//! worlds: `partial_reset` launches mask-guarded kernels that early-return on
31//! lanes which should not reset, and — unlike their full `reset` — never
32//! invalidates the captured graph.
33//!
34//! # No new op
35//!
36//! This needs [`Op::Where`], [`Op::Compare`], [`Op::Reshape`] and [`Op::Expand`],
37//! all of which every backend already has. Like the MRoPE seam, the useful thing
38//! here is the composition and the guarantee attached to it, not a new opcode.
39//!
40//! ```
41//! use rlx_ir::infer::GraphExt;
42//! use rlx_ir::lanes::LaneExt;
43//! use rlx_ir::{DType, Graph, Shape};
44//!
45//! let mut g = Graph::new("batched");
46//! let state = g.input("state", Shape::new(&[4, 8], DType::F32));
47//! let mask = g.input("reset", Shape::new(&[4], DType::F32));
48//! let fresh = g.reset_lanes_to_(state, 0.0, mask);
49//! g.set_outputs(vec![fresh]);
50//! ```
51
52use crate::infer::GraphExt;
53use crate::op::CmpOp;
54use crate::{DType, Dim, Graph, NodeId, Op, Shape};
55
56/// Builders for lane-masked state updates.
57///
58/// A *lane mask* is a rank-1 `f32` tensor of length `n_lanes`. Nonzero selects
59/// the lane for reset. `f32` rather than `bool` so it can be fed straight from
60/// the host through the ordinary input path, with the comparison to zero done
61/// inside the graph.
62pub trait LaneExt {
63    /// Broadcast a `[n_lanes]` mask to a boolean tensor shaped like `like`,
64    /// i.e. `[n_lanes, 1, 1, …]` expanded across the trailing dims.
65    ///
66    /// Returns `None` if `like` is rank-0, has a dynamic leading dim, or its
67    /// leading dim disagrees with the mask length — all cases where the
68    /// broadcast is not well defined and silently guessing would be worse than
69    /// refusing.
70    fn lane_mask_like_(&mut self, mask: NodeId, like: NodeId) -> Option<NodeId>;
71
72    /// `out[l, …] = if mask[l] != 0 { reset_to[l, …] } else { state[l, …] }`.
73    ///
74    /// `reset_to` must have the same shape as `state`.
75    fn reset_lanes_(&mut self, state: NodeId, reset_to: NodeId, mask: NodeId) -> NodeId;
76
77    /// [`reset_lanes_`](LaneExt::reset_lanes_) against a uniform scalar.
78    fn reset_lanes_to_(&mut self, state: NodeId, value: f64, mask: NodeId) -> NodeId;
79
80    /// Apply one mask to several state tensors at once — the usual case, since
81    /// a lane's state is spread over many tensors (six moment fields, or a K
82    /// and a V cache).
83    ///
84    /// The mask broadcast is built once per distinct shape, so `n` fields of the
85    /// same shape cost one reshape/compare/expand chain rather than `n`.
86    fn reset_lanes_many_(
87        &mut self,
88        states: &[NodeId],
89        reset_to: &[NodeId],
90        mask: NodeId,
91    ) -> Vec<NodeId>;
92}
93
94/// Static dims of `shape`, or `None` if any is dynamic.
95fn static_dims(shape: &Shape) -> Option<Vec<usize>> {
96    shape
97        .dims()
98        .iter()
99        .map(|d| match d {
100            Dim::Static(n) => Some(*n),
101            Dim::Dynamic(_) => None,
102        })
103        .collect()
104}
105
106impl LaneExt for Graph {
107    fn lane_mask_like_(&mut self, mask: NodeId, like: NodeId) -> Option<NodeId> {
108        let target = static_dims(&self.node(like).shape.clone())?;
109        if target.is_empty() {
110            return None;
111        }
112        let mask_dims = static_dims(&self.node(mask).shape.clone())?;
113        if mask_dims.len() != 1 || mask_dims[0] != target[0] {
114            return None;
115        }
116        let n_lanes = target[0];
117        let dtype = self.node(mask).shape.dtype();
118
119        // [L] -> [L, 1, 1, ...] so the lane axis lines up and the rest broadcast.
120        let mut keep = vec![n_lanes];
121        keep.extend(std::iter::repeat_n(1usize, target.len() - 1));
122        let reshaped = self.add_node(
123            Op::Reshape {
124                new_shape: keep.iter().map(|&d| d as i64).collect(),
125            },
126            vec![mask],
127            Shape::new(&keep, dtype),
128        );
129
130        // != 0 -> Bool. Comparing in the graph (rather than taking a Bool input)
131        // keeps the host side a plain f32 buffer.
132        let zero = self.constant(0.0, dtype);
133        let cond = self.add_node(
134            Op::Compare(CmpOp::Ne),
135            vec![reshaped, zero],
136            Shape::new(&keep, DType::Bool),
137        );
138
139        Some(self.add_node(
140            Op::Expand {
141                target_shape: target.iter().map(|&d| d as i64).collect(),
142            },
143            vec![cond],
144            Shape::new(&target, DType::Bool),
145        ))
146    }
147
148    fn reset_lanes_(&mut self, state: NodeId, reset_to: NodeId, mask: NodeId) -> NodeId {
149        let shape = self.node(state).shape.clone();
150        assert_eq!(
151            shape,
152            self.node(reset_to).shape,
153            "reset_lanes_: state and reset value must have the same shape"
154        );
155        let cond = self
156            .lane_mask_like_(mask, state)
157            .expect("reset_lanes_: mask must be [n_lanes] matching the state's leading dim");
158        self.add_node(Op::Where, vec![cond, reset_to, state], shape)
159    }
160
161    fn reset_lanes_to_(&mut self, state: NodeId, value: f64, mask: NodeId) -> NodeId {
162        let shape = self.node(state).shape.clone();
163        let dims = static_dims(&shape).expect("reset_lanes_to_: static shape required");
164        let fill = self.full(&dims, value as f32, shape.dtype());
165        self.reset_lanes_(state, fill, mask)
166    }
167
168    fn reset_lanes_many_(
169        &mut self,
170        states: &[NodeId],
171        reset_to: &[NodeId],
172        mask: NodeId,
173    ) -> Vec<NodeId> {
174        assert_eq!(
175            states.len(),
176            reset_to.len(),
177            "reset_lanes_many_: one reset value per state tensor"
178        );
179        // Cache the broadcast per shape: the six-field case would otherwise emit
180        // six identical reshape/compare/expand chains and lean on CSE to undo it.
181        let mut cache: Vec<(Shape, NodeId)> = Vec::new();
182        let mut out = Vec::with_capacity(states.len());
183        for (&s, &r) in states.iter().zip(reset_to.iter()) {
184            let shape = self.node(s).shape.clone();
185            let cond = match cache.iter().find(|(sh, _)| *sh == shape) {
186                Some((_, c)) => *c,
187                None => {
188                    let c = self
189                        .lane_mask_like_(mask, s)
190                        .expect("reset_lanes_many_: mask must match each state's leading dim");
191                    cache.push((shape.clone(), c));
192                    c
193                }
194            };
195            out.push(self.add_node(Op::Where, vec![cond, r, s], shape));
196        }
197        out
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    fn graph_shape_signature(g: &Graph) -> Vec<(String, Vec<Dim>)> {
206        g.nodes()
207            .iter()
208            .map(|n| (format!("{:?}", n.op.kind()), n.shape.dims().to_vec()))
209            .collect()
210    }
211
212    #[test]
213    fn lane_mask_broadcasts_to_state_rank() {
214        let mut g = Graph::new("t");
215        let state = g.input("s", Shape::new(&[4, 3, 2], DType::F32));
216        let mask = g.input("m", Shape::new(&[4], DType::F32));
217        let cond = g.lane_mask_like_(mask, state).expect("broadcast");
218        assert_eq!(g.node(cond).shape.dims(), state_dims(&g, state).as_slice());
219        assert_eq!(g.node(cond).shape.dtype(), DType::Bool);
220    }
221
222    fn state_dims(g: &Graph, id: NodeId) -> Vec<Dim> {
223        g.node(id).shape.dims().to_vec()
224    }
225
226    /// A mismatched or dynamic mask must be refused, not silently broadcast to
227    /// something plausible.
228    #[test]
229    fn lane_mask_refuses_ill_defined_broadcasts() {
230        let mut g = Graph::new("t");
231        let state = g.input("s", Shape::new(&[4, 3], DType::F32));
232        let wrong_len = g.input("m1", Shape::new(&[5], DType::F32));
233        assert!(g.lane_mask_like_(wrong_len, state).is_none());
234
235        let rank2 = g.input("m2", Shape::new(&[4, 1], DType::F32));
236        assert!(g.lane_mask_like_(rank2, state).is_none());
237
238        let scalar = g.input("sc", Shape::from_dims(&[], DType::F32));
239        let m = g.input("m3", Shape::new(&[4], DType::F32));
240        assert!(g.lane_mask_like_(m, scalar).is_none());
241
242        let dynamic = g.input(
243            "d",
244            Shape::from_dims(&[Dim::Dynamic(0), Dim::Static(3)], DType::F32),
245        );
246        assert!(g.lane_mask_like_(m, dynamic).is_none());
247    }
248
249    /// **The load-bearing property.** The graph must not depend on which lanes
250    /// are being reset — that is the whole reason this is worth doing, since a
251    /// graph that changes is a capture that gets thrown away.
252    ///
253    /// Built twice with the mask as an input and compared structurally; the mask
254    /// values never enter graph construction, so they cannot.
255    #[test]
256    fn graph_structure_is_independent_of_mask_contents() {
257        let build = || {
258            let mut g = Graph::new("b");
259            let s = g.input("s", Shape::new(&[4, 8], DType::F32));
260            let m = g.input("m", Shape::new(&[4], DType::F32));
261            let out = g.reset_lanes_to_(s, 0.0, m);
262            g.set_outputs(vec![out]);
263            g
264        };
265        let a = build();
266        let b = build();
267        assert_eq!(graph_shape_signature(&a), graph_shape_signature(&b));
268        // And the mask is a genuine input, not folded into a constant.
269        assert!(
270            a.nodes()
271                .iter()
272                .any(|n| matches!(&n.op, Op::Input { name } if name == "m")),
273            "the mask must remain a graph input"
274        );
275    }
276
277    /// Applying one mask to several same-shaped fields must build the broadcast
278    /// once — six copies would be six extra reshape/compare/expand chains per
279    /// step, relying on CSE to clean up after us.
280    #[test]
281    fn many_reuses_the_broadcast_per_shape() {
282        let mut g = Graph::new("many");
283        let shape = Shape::new(&[3, 5], DType::F32);
284        let states: Vec<NodeId> = (0..6)
285            .map(|i| g.input(format!("s{i}"), shape.clone()))
286            .collect();
287        let zeros: Vec<NodeId> = states
288            .iter()
289            .map(|_| g.zeros(&[3, 5], DType::F32))
290            .collect();
291        let mask = g.input("m", Shape::new(&[3], DType::F32));
292        let out = g.reset_lanes_many_(&states, &zeros, mask);
293        assert_eq!(out.len(), 6);
294        let expands = g
295            .nodes()
296            .iter()
297            .filter(|n| matches!(n.op, Op::Expand { .. }))
298            .count();
299        assert_eq!(expands, 1, "broadcast should be built once for one shape");
300        let wheres = g.nodes().iter().filter(|n| n.op == Op::Where).count();
301        assert_eq!(wheres, 6);
302    }
303}