fuel_dispatch/runtime_fused_pathfinder.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Runtime-fused-op arm **pathfinder** — the match-finder
3//! [`crate::runtime_fused_arm`]'s module doc names as its remaining half:
4//! "the pathfinder that finds matches + splices reconverge points registers
5//! it into `optimize_graph`." This module is that pathfinder.
6//!
7//! For every op [adopted](fuel_graph::runtime_fused::runtime_entries) at
8//! runtime (JIT-synthesized or import-time), it scans the base map for
9//! subgraphs whose shape matches the op's region
10//! ([`fuel_graph::jit::match_region`]) and offers each match as a gated
11//! `Op::Branch` via [`offer_runtime_fused_arm`] — arm 0 stays the region's
12//! existing primitive subgraph (the correctness oracle), arm 1 is the
13//! synthesized kernel, offered only when
14//! [`fused_kernel_available_in`](crate::runtime_fused_kernels::fused_kernel_available_in)
15//! gates it open (reading the `OptimizationContext`-threaded binding table,
16//! never re-acquiring the global lock). Same constitutional posture as
17//! [`crate::decode_flash`]: the
18//! optimizer emits/prunes arms, backends never decide, and a graph with no
19//! adopted runtime op (or no structural match) is left byte-identical.
20//!
21//! v1 scope mirrors `fuel_graph::runtime_fused`'s own v1 note: every offered
22//! arm is pinned to [`BackendId::Cpu`]. A runtime op can be adopted for
23//! multiple backends (`adopt_runtime_fused` is called once per backend), so
24//! picking the *right* one — and potentially offering more than one fused
25//! arm per match — is a follow-up; see the module-level "unsure" note in the
26//! session report.
27
28use std::collections::HashMap;
29
30use fuel_graph::jit::match_region_extract;
31use fuel_graph::registry::FusedOpId;
32use fuel_graph::runtime_fused::runtime_entries;
33use fuel_graph::{Graph, NodeId};
34use fuel_ir::Result;
35use fuel_ir::probe::BackendId;
36
37use crate::driver::{OptimizationContext, Pathfinder};
38use crate::kernel::KernelBindingTable;
39use crate::runtime_fused_arm::{RuntimeFusedSpec, offer_runtime_fused_arm};
40
41/// A structural match of a registered runtime op's region against the base
42/// map, ready to become a [`RuntimeFusedSpec`] once collection is done (kept
43/// separate from the spec so the collection pass stays a read-only borrow of
44/// `graph`, mirroring [`crate::driver::PlacementForkPathfinder`]'s two-phase
45/// collect-then-mutate shape).
46struct RegionMatch {
47 runtime_id: FusedOpId,
48 inputs: Vec<NodeId>,
49 /// Live `extract:` values from the matched region's open scalar slots,
50 /// pattern pre-order (stamped into the arm's `Runtime { scalars }`).
51 scalars: Vec<f64>,
52 primitive_sink: NodeId,
53 reconverge: NodeId,
54}
55
56/// Scan `graph` for subgraphs matching an adopted runtime fused op's region
57/// and offer each as a gated `Op::Branch` (see [`offer_runtime_fused_arm`]).
58/// Returns the number of arms emitted — `0` for a graph with no adopted
59/// runtime op, or none whose region structurally matches anywhere in it.
60///
61/// For each registered runtime op, every node in `graph` is tried as the
62/// region's `root` (arm 0's exit / [`fuel_graph::jit::match_region`]'s sink). A match binds
63/// the region's external inputs; the candidate is only a genuine branch
64/// point when `root` has **exactly one** consumer (the sole reconverge —
65/// zero consumers is a dead end, ≥2 is ordinary fan-out neither arm can
66/// safely replace). Matches are collected first (read-only over `graph`),
67/// then spliced (mutating), exactly like the placement-fork pathfinder.
68///
69/// `table` is the binding table each match's capability gate reads —
70/// threaded straight through to [`offer_runtime_fused_arm`] (see its doc for
71/// why this is the non-nesting form of the gate).
72pub fn emit_runtime_fused_arms(graph: &mut Graph, table: &KernelBindingTable) -> Result<usize> {
73 let entries = runtime_entries();
74 if entries.is_empty() {
75 return Ok(0);
76 }
77
78 // Consumer counts over the WHOLE graph — a runtime region can sit
79 // anywhere in the base map, not just along a particular dispatch order
80 // (mirrors `match_region`'s own tests' `consumer_counts` helper).
81 let mut consumer_count: HashMap<NodeId, usize> = HashMap::new();
82 let mut sole_consumer: HashMap<NodeId, NodeId> = HashMap::new();
83 let len = graph.len();
84 for i in 0..len {
85 let id = NodeId(i);
86 for &input in &graph.node(id).inputs {
87 *consumer_count.entry(input).or_insert(0) += 1;
88 sole_consumer.insert(input, id);
89 }
90 }
91 let consumers = |n: NodeId| consumer_count.get(&n).copied().unwrap_or(0);
92
93 // A `root` claimed by one entry's match is not offered again for another
94 // entry: two runtime ops can register structurally-identical regions
95 // (e.g. the same fusion synthesized twice under different hashes), and a
96 // second `open_branch` at an already-branched diverge point is a
97 // malformed splice (the first branch's arm-0 already reads `root`, so the
98 // second attempt trips the "arm interior read from outside the branch"
99 // guard). First registered entry to match a given `root` wins it; this is
100 // a dedup, not a priority order — a graph rarely contains a genuine
101 // ambiguity like this, and when it does, offering one arm is still
102 // correct (arm 0 stays the primitive fallback either way).
103 let mut claimed: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
104 let mut matches: Vec<RegionMatch> = Vec::new();
105 for entry in &entries {
106 for i in 0..len {
107 let root = NodeId(i);
108 if claimed.contains(&root) {
109 continue;
110 }
111 let Some((inputs, scalars)) =
112 match_region_extract(graph, root, &entry.region, &consumers)
113 else {
114 continue;
115 };
116 // Exactly one consumer of `root` ⇒ a genuine reconverge point.
117 if consumers(root) != 1 {
118 continue;
119 }
120 claimed.insert(root);
121 matches.push(RegionMatch {
122 runtime_id: entry.id,
123 inputs,
124 scalars,
125 primitive_sink: root,
126 reconverge: sole_consumer[&root],
127 });
128 }
129 }
130
131 let mut emitted = 0usize;
132 for m in matches {
133 let spec = RuntimeFusedSpec {
134 runtime_id: m.runtime_id,
135 inputs: m.inputs,
136 primitive_sink: m.primitive_sink,
137 reconverge: m.reconverge,
138 // v1: pinned to Cpu — see the module doc's "unsure" note.
139 backend: BackendId::Cpu,
140 // The live `extract:` values (pattern pre-order) — stamped into the
141 // arm's `Runtime { scalars }`, launched as the trailing `p{i}` args.
142 scalars: m.scalars,
143 };
144 if offer_runtime_fused_arm(graph, &spec, table)?.is_some() {
145 emitted += 1;
146 }
147 }
148 Ok(emitted)
149}
150
151/// The registered [`Pathfinder`] wrapper around [`emit_runtime_fused_arms`].
152/// Ignores `ctx`'s ranked plan / dispatch order: a runtime-op region match is
153/// a purely structural question over the whole base map, independent of
154/// placement — the same reason [`crate::decode_flash`]'s arm is emitted by a
155/// dedicated call site rather than reading `ctx.plan`.
156pub struct RuntimeFusedArmPathfinder;
157
158impl Pathfinder for RuntimeFusedArmPathfinder {
159 fn name(&self) -> &'static str {
160 "RuntimeFusedArm"
161 }
162
163 fn propose(&self, graph: &mut Graph, ctx: &OptimizationContext<'_>) -> Result<()> {
164 emit_runtime_fused_arms(graph, ctx.bindings())?;
165 Ok(())
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::runtime_fused_kernels::adopt_runtime_fused;
173 use fuel_graph::jit::{OpAttrs, OpTag, PatternNode};
174 use fuel_graph::{Node, Op};
175 use fuel_ir::{DType, Layout, Shape};
176 use std::sync::{Arc, RwLock as StdRwLock};
177
178 fn noop_kernel(
179 _inputs: &[Arc<StdRwLock<fuel_memory::Storage>>],
180 _outputs: &mut [Arc<StdRwLock<fuel_memory::Storage>>],
181 _layouts: &[Layout],
182 _params: &crate::kernel::OpParams,
183 ) -> fuel_ir::Result<()> {
184 Ok(())
185 }
186
187 /// tanh(mul(a, b)) as a PatternNode region. Deliberately NOT the
188 /// `relu(add(a, b))` shape every other adopted-op test in this crate
189 /// uses (`fused_cost`, `jit_adopt`, `runtime_fused_arm`,
190 /// `runtime_fused_kernels`) — `runtime_entries()` is a process-global
191 /// sidecar shared by every test in this binary, so a region matching
192 /// one of those adopted shapes would non-deterministically match
193 /// whichever of them happened to register first, defeating the
194 /// `rid`-specific assertions below (and, before the `claimed`-root dedup
195 /// this pathfinder now has, would double-branch the same diverge point
196 /// and panic).
197 fn tanh_mul_region() -> PatternNode {
198 PatternNode::Op {
199 op: OpTag::Tanh,
200 attrs: OpAttrs::default(),
201 operands: vec![PatternNode::Op {
202 op: OpTag::Mul,
203 attrs: OpAttrs::default(),
204 operands: vec![
205 PatternNode::Bind { index: 0 },
206 PatternNode::Bind { index: 1 },
207 ],
208 }],
209 }
210 }
211
212 /// tanh(mul(a, b)) plus a downstream `neg` consumer (the reconverge).
213 fn graph_with_region() -> (Graph, NodeId) {
214 let mut g = Graph::new();
215 let s = Shape::from_dims(&[4]);
216 let leaf = |g: &mut Graph| {
217 g.push(Node {
218 op: Op::Const,
219 inputs: vec![],
220 shape: s.clone(),
221 dtype: DType::F32,
222 })
223 };
224 let a = leaf(&mut g);
225 let b = leaf(&mut g);
226 let mul = g.push(Node {
227 op: Op::Mul,
228 inputs: vec![a, b],
229 shape: s.clone(),
230 dtype: DType::F32,
231 });
232 let tanh = g.push(Node {
233 op: Op::Tanh,
234 inputs: vec![mul],
235 shape: s.clone(),
236 dtype: DType::F32,
237 });
238 let _neg = g.push(Node {
239 op: Op::Neg,
240 inputs: vec![tanh],
241 shape: s.clone(),
242 dtype: DType::F32,
243 });
244 (g, tanh)
245 }
246
247 #[test]
248 fn emits_a_fused_arm_for_an_adopted_region_match() {
249 let rid = adopt_runtime_fused(
250 "test::pathfinder::tanh_mul",
251 tanh_mul_region(),
252 noop_kernel as crate::kernel::KernelRef,
253 vec![DType::F32, DType::F32, DType::F32],
254 BackendId::Cpu,
255 )
256 .expect("registrable region");
257
258 let (mut g, tanh) = graph_with_region();
259 let before = g.len();
260
261 // adopt_runtime_fused binds into the GLOBAL table; pass it explicitly
262 // as the threaded table this test exercises the pathfinder against.
263 let emitted = emit_runtime_fused_arms(&mut g, &crate::dispatch::global_bindings())
264 .expect("no build-time error");
265 assert_eq!(emitted, 1, "exactly one match ⇒ one arm emitted");
266 assert!(g.len() > before, "the fused node + branch were appended");
267
268 // A Branch was recorded at (or after) `tanh`'s old position, and a
269 // Fused(rid, ..) node exists among the new nodes.
270 let mut saw_branch = false;
271 let mut saw_fused = false;
272 for i in before..g.len() {
273 match &g.node(NodeId(i)).op {
274 Op::Branch { .. } => saw_branch = true,
275 Op::Fused(id, _) if *id == rid => saw_fused = true,
276 _ => {}
277 }
278 }
279 assert!(saw_branch, "an Op::Branch decision point was emitted");
280 assert!(saw_fused, "an Op::Fused(rid, ..) arm was emitted");
281 // `tanh` (arm 0 / the primitive sink) is untouched — still the
282 // region's Tanh output the correctness oracle relies on.
283 assert!(matches!(g.node(tanh).op, Op::Tanh));
284 }
285
286 #[test]
287 fn no_adopted_op_emits_nothing() {
288 // No `adopt_runtime_fused` call in this test — `runtime_entries()`
289 // may still carry ops other tests adopted (a process-global sidecar),
290 // but NONE of them match a region this graph doesn't structurally
291 // contain: a bare `neg(a)` has no relu/add/tanh/mul anywhere.
292 let mut g = Graph::new();
293 let s = Shape::from_dims(&[4]);
294 let a = g.push(Node {
295 op: Op::Const,
296 inputs: vec![],
297 shape: s.clone(),
298 dtype: DType::F32,
299 });
300 let _n = g.push(Node {
301 op: Op::Neg,
302 inputs: vec![a],
303 shape: s.clone(),
304 dtype: DType::F32,
305 });
306 let before = g.len();
307
308 // The production table (any op other tests adopted lives here too —
309 // irrelevant, since none structurally matches this graph).
310 let emitted = emit_runtime_fused_arms(&mut g, &crate::dispatch::global_bindings())
311 .expect("no build-time error");
312 assert_eq!(emitted, 0, "no structural match ⇒ no arm emitted");
313 assert_eq!(g.len(), before, "graph untouched");
314 }
315
316 /// A slot-template region (`sigmoid(add_scalar(x))`, value open): the
317 /// pathfinder extracts the LIVE scalar from the matched graph node and
318 /// stamps it into the emitted arm's `Runtime { scalars }` — the `extract:`
319 /// round-trip at the arm level. Region shape unique to this test (the
320 /// sidecar is process-global; see `tanh_mul_region`'s rationale).
321 #[test]
322 fn emitted_arm_carries_the_extracted_live_scalar() {
323 let region = PatternNode::Op {
324 op: OpTag::Sigmoid,
325 attrs: OpAttrs::default(),
326 operands: vec![PatternNode::Op {
327 op: OpTag::AddScalar,
328 attrs: OpAttrs::default(), // empty = open slot
329 operands: vec![PatternNode::Bind { index: 0 }],
330 }],
331 };
332 let rid = adopt_runtime_fused(
333 "test::pathfinder::sigmoid_add_scalar",
334 region,
335 noop_kernel as crate::kernel::KernelRef,
336 vec![DType::F32, DType::F32],
337 BackendId::Cpu,
338 )
339 .expect("slot template is registrable");
340
341 // sigmoid(add_scalar(x, 7.25)) + a downstream consumer.
342 let mut g = Graph::new();
343 let s = Shape::from_dims(&[4]);
344 let x = g.push(Node {
345 op: Op::Const,
346 inputs: vec![],
347 shape: s.clone(),
348 dtype: DType::F32,
349 });
350 let asn = g.push(Node {
351 op: Op::AddScalar(7.25),
352 inputs: vec![x],
353 shape: s.clone(),
354 dtype: DType::F32,
355 });
356 let sig = g.push(Node {
357 op: Op::Sigmoid,
358 inputs: vec![asn],
359 shape: s.clone(),
360 dtype: DType::F32,
361 });
362 let _neg = g.push(Node {
363 op: Op::Neg,
364 inputs: vec![sig],
365 shape: s.clone(),
366 dtype: DType::F32,
367 });
368 let before = g.len();
369
370 // adopt_runtime_fused binds into the GLOBAL table; pass it explicitly
371 // as the threaded table this test exercises the pathfinder against.
372 let emitted = emit_runtime_fused_arms(&mut g, &crate::dispatch::global_bindings())
373 .expect("no build-time error");
374 assert_eq!(emitted, 1, "the slot region matched once");
375
376 let mut found = false;
377 for i in before..g.len() {
378 if let Op::Fused(fid, fuel_graph::registry::FusedOpParams::Runtime { scalars }) =
379 &g.node(NodeId(i)).op
380 && *fid == rid
381 {
382 assert_eq!(scalars, &vec![7.25], "the LIVE value rode into the arm");
383 found = true;
384 }
385 }
386 assert!(found, "an Op::Fused(rid, Runtime) arm was emitted");
387 }
388}