Skip to main content

rlx_fusion/fusion/
attention_block.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//! `attention_block` — extracted from the `fusion` module for navigability (see `mod.rs`).
17
18#![allow(unused_imports)]
19
20use crate::pass::Pass;
21use rlx_ir::op::*;
22use rlx_ir::*;
23use std::collections::HashMap;
24
25// ── Helper: graph rewriter ──────────────────────────────────────────────
26
27use crate::graph_rewrite::Rewriter;
28
29// ── Pass 1: MatMul + Bias + Activation → FusedMatMulBiasAct ─────────────
30
31use super::*;
32
33/// Fuses `matmul(QKV) → narrow(Q,K,V) → [rope] → attention → matmul(out)`
34/// into a single FusedAttentionBlock when batch*seq is small.
35///
36/// The optimizer auto-detects batch size from graph input shapes. For small
37/// inputs (batch*seq ≤ 64), intermediate tensors fit in L1 cache, making a
38/// monolithic kernel faster than separate BLAS calls.
39///
40/// Threshold is configurable via `RLX_FUSE_ATTN_THRESHOLD` (default: 64).
41pub struct FuseAttentionBlock;
42
43impl FuseAttentionBlock {
44    /// Check if the graph has small enough inputs to benefit from fusion.
45    ///
46    /// Returns `true` when any 2-D+ input has `dim(0) * dim(1) ≤ threshold`,
47    /// where `threshold` defaults to 64 (overridable via
48    /// `RLX_FUSE_ATTN_THRESHOLD`). The cutoff matches the L1-cache budget for
49    /// keeping Q/K/V resident on CPU and reflects the dispatch-overhead
50    /// crossover for small-batch BERT-family encoders on GPU backends.
51    pub(crate) fn should_fuse(graph: &Graph) -> bool {
52        let threshold: usize = rlx_ir::env::var("RLX_FUSE_ATTN_THRESHOLD")
53            .and_then(|v| v.parse().ok())
54            .unwrap_or(64);
55        for node in graph.nodes() {
56            if let Op::Input { .. } = &node.op
57                && node.shape.rank() >= 2
58            {
59                let d0 = node.shape.dim(0);
60                let d1 = node.shape.dim(1);
61                if d0.is_static() && d1.is_static() {
62                    let b = d0.unwrap_static();
63                    let s = d1.unwrap_static();
64                    if b * s <= threshold {
65                        return true;
66                    }
67                }
68            }
69        }
70        false
71    }
72}
73
74impl Pass for FuseAttentionBlock {
75    fn name(&self) -> &str {
76        "fuse_attention_block"
77    }
78
79    fn run(&self, graph: Graph) -> Graph {
80        // Bail when graph input shape is too large to benefit (the L1-resident
81        // / single-dispatch win disappears once Q/K/V no longer fit on-chip).
82        if !Self::should_fuse(&graph) {
83            return graph;
84        }
85
86        // We rewrite the chain
87        //   hidden ─ FusedMatMulBiasAct(qkv_w, qkv_b) ─ narrow×3 ─ Attention(mask) ─ FusedMatMulBiasAct(out_w, out_b)
88        // into a single `Op::FusedAttentionBlock { has_bias: true, has_rope: false }`.
89        //
90        // Pattern preconditions:
91        //   * QKV producer's only consumers are the three narrows (and not a graph
92        //     output) — otherwise we'd duplicate compute on un-fuse.
93        //   * Each narrow has exactly one consumer (the attention).
94        //   * The attention has `MaskKind::Custom` (caller-supplied mask tensor).
95        //   * The attention's only consumer is the OutProj `FusedMatMulBiasAct`.
96        //   * The OutProj is not a graph output of an *intermediate* block (i.e.
97        //     fusing it is safe — its result is the layer's actual output).
98        //
99        // When any precondition fails we fall back to copying the chain through.
100
101        let mut is_output: HashMap<NodeId, ()> = HashMap::new();
102        for &oid in &graph.outputs {
103            is_output.insert(oid, ());
104        }
105
106        // Pre-scan: for each Attention with Custom mask, decide whether the
107        // surrounding chain matches. If yes, record the IDs that get folded away.
108        struct Match {
109            attn_id: NodeId,
110            qkv_mm_id: NodeId,
111            out_mm_id: NodeId,
112            narrows: [NodeId; 3],
113            hidden_id: NodeId,
114            qkv_w: NodeId,
115            qkv_b: NodeId,
116            out_w: NodeId,
117            out_b: NodeId,
118            mask: NodeId,
119            num_heads: usize,
120            head_dim: usize,
121            out_shape: Shape,
122        }
123        let mut matches: Vec<Match> = Vec::new();
124        let mut fused_away: HashMap<NodeId, ()> = HashMap::new();
125
126        for node in graph.nodes() {
127            let Op::Attention {
128                num_heads,
129                head_dim,
130                mask_kind,
131                score_scale,
132                attn_logit_softcap,
133            } = &node.op
134            else {
135                continue;
136            };
137            // Only the BERT-style mask form (caller-supplied [B, S] tensor),
138            // no score scale tweaks, no soft-cap.
139            if !matches!(mask_kind, MaskKind::Custom)
140                || score_scale.is_some()
141                || attn_logit_softcap.is_some()
142                || node.inputs.len() != 4
143            {
144                continue;
145            }
146            let (q, k, v, mask) = (
147                node.inputs[0],
148                node.inputs[1],
149                node.inputs[2],
150                node.inputs[3],
151            );
152
153            // All three of Q, K, V must be Narrows on the same parent at
154            // start=0,h,2h with len=h on the last (innermost) axis.
155            let qn = graph.node(q);
156            let kn = graph.node(k);
157            let vn = graph.node(v);
158            let (qp, q_axis, q_start, q_len) = match narrow_parent(qn) {
159                Some(p) => p,
160                None => continue,
161            };
162            let (kp, k_axis, k_start, k_len) = match narrow_parent(kn) {
163                Some(p) => p,
164                None => continue,
165            };
166            let (vp, v_axis, v_start, v_len) = match narrow_parent(vn) {
167                Some(p) => p,
168                None => continue,
169            };
170            if qp != kp || kp != vp {
171                continue;
172            }
173            let h = num_heads * head_dim;
174            let parent_rank = graph.node(qp).shape.rank();
175            let last_ax = parent_rank.saturating_sub(1);
176            if q_axis != last_ax || k_axis != last_ax || v_axis != last_ax {
177                continue;
178            }
179            if q_len != h || k_len != h || v_len != h {
180                continue;
181            }
182            if q_start != 0 || k_start != h || v_start != 2 * h {
183                continue;
184            }
185            // Narrows must be single-consumer to be safely consumed.
186            if graph.use_count(q) != 1
187                || graph.use_count(k) != 1
188                || graph.use_count(v) != 1
189                || is_output.contains_key(&q)
190                || is_output.contains_key(&k)
191                || is_output.contains_key(&v)
192            {
193                continue;
194            }
195
196            // Parent must be FusedMatMulBiasAct (post-FuseMatMulBiasAct shape).
197            let qkv_mm_node = graph.node(qp);
198            let (hidden_id, qkv_w, qkv_b) = match fused_mm_bias_none(qkv_mm_node) {
199                Some(t) => t,
200                None => continue,
201            };
202            // The QKV MM must have exactly the three narrows as consumers and
203            // must not be a graph output itself.
204            if graph.use_count(qp) != 3 || is_output.contains_key(&qp) {
205                continue;
206            }
207
208            // Find the OutProj consumer of the Attention.
209            if graph.use_count(node.id) != 1 || is_output.contains_key(&node.id) {
210                continue;
211            }
212            let out_consumer_id = match graph
213                .nodes()
214                .iter()
215                .find(|n| n.inputs.contains(&node.id))
216                .map(|n| n.id)
217            {
218                Some(id) => id,
219                None => continue,
220            };
221            let out_mm_node = graph.node(out_consumer_id);
222            let (out_in, out_w, out_b) = match fused_mm_bias_none(out_mm_node) {
223                Some(t) if t.0 == node.id => t,
224                _ => continue,
225            };
226            let _ = out_in;
227
228            // All checks passed — record the match.
229            matches.push(Match {
230                attn_id: node.id,
231                qkv_mm_id: qp,
232                out_mm_id: out_consumer_id,
233                narrows: [q, k, v],
234                hidden_id,
235                qkv_w,
236                qkv_b,
237                out_w,
238                out_b,
239                mask,
240                num_heads: *num_heads,
241                head_dim: *head_dim,
242                out_shape: out_mm_node.shape.clone(),
243            });
244            fused_away.insert(qp, ());
245            fused_away.insert(q, ());
246            fused_away.insert(k, ());
247            fused_away.insert(v, ());
248            fused_away.insert(node.id, ());
249            fused_away.insert(out_consumer_id, ());
250        }
251
252        if matches.is_empty() {
253            return graph;
254        }
255
256        // Index matches by the out-projection node id so we can swap it in-place.
257        let mut by_out: HashMap<NodeId, &Match> = HashMap::new();
258        for m in &matches {
259            by_out.insert(m.out_mm_id, m);
260        }
261
262        let mut rw = Rewriter::new(&graph.name);
263        for node in graph.nodes() {
264            if fused_away.contains_key(&node.id) {
265                if let Some(m) = by_out.get(&node.id) {
266                    // Make sure all referenced inputs are already in the new graph.
267                    rw.ensure_mapped(
268                        &graph,
269                        &[m.hidden_id, m.qkv_w, m.out_w, m.mask, m.qkv_b, m.out_b],
270                    );
271                    let fused_id = rw.add_fused(
272                        Op::FusedAttentionBlock {
273                            num_heads: m.num_heads,
274                            head_dim: m.head_dim,
275                            has_bias: true,
276                            has_rope: false,
277                        },
278                        &[m.hidden_id, m.qkv_w, m.out_w, m.mask, m.qkv_b, m.out_b],
279                        m.out_shape.clone(),
280                    );
281                    // Wire every old chain node to the new fused id so any
282                    // downstream consumer (residual add, LN, etc.) picks it up.
283                    rw.replace(m.qkv_mm_id, fused_id);
284                    rw.replace(m.narrows[0], fused_id);
285                    rw.replace(m.narrows[1], fused_id);
286                    rw.replace(m.narrows[2], fused_id);
287                    rw.replace(m.attn_id, fused_id);
288                    rw.replace(node.id, fused_id);
289                }
290                continue;
291            }
292            rw.copy_node(node);
293        }
294        rw.finish(&graph.outputs)
295    }
296}
297
298// ── Pass 5b: Full BERT layer → FusedTransformerLayer ────────────────────