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 ────────────────────