fuel_graph/opt.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Graph-level, backend-agnostic optimization passes.
3//!
4//! Transforms a `fuel-graph` computation graph before execution. Every
5//! backend benefits from these passes because they operate purely on
6//! the abstract op graph — no backend-specific knowledge required.
7//!
8//! **Passes today:**
9//!
10//! - **CSE** (common subexpression elimination): if the graph contains
11//! two structurally-identical nodes — same op, same inputs (after
12//! prior simplification), same shape/dtype — consumers of the
13//! duplicate are redirected to the canonical node. The duplicate
14//! becomes unreferenced and is silently dropped by the executor's
15//! topo-walk. Commutative ops (`Add`, `Mul`) are keyed on sorted input
16//! IDs so `a + b` and `b + a` fold to the same canonical node.
17//! `Maximum`/`Minimum` are NOT in that set: commutative in value, not in
18//! bits, because the NaN tie-break returns the FIRST operand. See GAP-271.
19//!
20//! - **Algebraic simplification**: a handful of identity/zero rules
21//! that eliminate no-op ops:
22//! - `AddScalar(0.0)(x)` → `x`
23//! - `MulScalar(1.0)(x)` → `x`
24//! - `Neg(Neg(x))` → `x`
25//! - `Reshape(Reshape(x, _), s)` → `Reshape(x, s)`
26//!
27//! These rarely appear in hand-written user code but show up
28//! routinely in autograd-generated backward graphs and in
29//! generic transformer building blocks that sometimes collapse.
30//!
31//! **Design:** graphs are append-only, so rewrites don't mutate
32//! existing nodes. Instead, the pass walks topologically, builds a
33//! remap `HashMap<NodeId, NodeId>` of old → canonical, and appends
34//! newly-canonicalized nodes to the same graph. Unreferenced originals
35//! remain in the arena but are never visited during realize. This is
36//! effectively a combined CSE + simplification + free DCE pass.
37//!
38//! **Return value:** the function takes the roots the caller cares
39//! about and returns the rewritten roots. Callers use these to update
40//! their `NodeHandle` handles.
41
42use crate::registry::{FusedOpEntry, FusedOpId, FusedOpParams, SubgraphPattern, default_registry};
43use crate::{Graph, Node, NodeId, Op, ScanEmit, ScanRole, SharedGraph, topo_order_multi};
44use fuel_ir::{DType, DeviceLocation};
45use std::collections::HashMap;
46
47// ---- Rule registry framework ----------------------------------------------
48//
49// Optimization is a pipeline of rule-driven graph rewrites. Two rule
50// families:
51//
52// - `RuleFamily::Lowering` — high-level op → primitive subgraph. Exposes
53// fusion opportunities and lets backends without a fused kernel run
54// the composition through whichever primitives they DO have.
55// - `RuleFamily::Fusion` — recognized primitive subgraph → fused op.
56// Recovers (or improves on) the original-flavour kernel where one
57// exists.
58//
59// A `Rule` is `(matcher, rewriter)`. The driver in
60// [`RuleRegistry::optimize_to_fixpoint`] runs lowering rules to fixpoint
61// first, then fusion rules to fixpoint. End state is post-fusion: for
62// SoftmaxLastDim (whose lower and fuse rules are inverses of each
63// other) the round-trip is identity. For lowering rules that ship
64// without a fusion partner — RmsNorm, LayerNorm, Affine, Clamp — the
65// post-pipeline graph is the lowered form.
66//
67// PR 3 ships the framework + a single (lower, fuse) rule pair for
68// `Op::SoftmaxLastDim`. The locked design (see ROADMAP.md → Phase 7.5)
69// also covers transactional snapshots, in-flight switching, and budget
70// modes; those are subsequent PRs that wrap this synchronous loop
71// without rewriting it.
72
73/// Which family a [`Rule`] belongs to. Drives the phase ordering in
74/// [`RuleRegistry::optimize_to_fixpoint`]: lowering runs to fixpoint
75/// first, then fusion. This ordering ensures inverse rule pairs
76/// converge cleanly — fusion has the last word, and its output is
77/// what the executor runs.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub enum RuleFamily {
80 /// High-level op → primitive subgraph. Always grows the graph.
81 Lowering,
82 /// Recognized primitive subgraph → fused op. Always shrinks the
83 /// graph (the fused op replaces the canonical subgraph's output;
84 /// the orphaned subgraph nodes remain in the arena but are
85 /// unreachable from the user's roots after consumer rewiring).
86 Fusion,
87 /// Algebraic identity rewrites that don't fit the lowering or
88 /// fusion shapes: cast elimination (`Cast(src→dst) → Op(dst)`
89 /// becomes `Op(src)` when the consumer supports the source
90 /// dtype), constant folding, identity elimination, common-
91 /// subexpression hoisting. Runs after fusion so cast-elimination
92 /// can't break fusion-pattern dtype expectations.
93 Algebraic,
94}
95
96/// One graph-rewrite rule. A rule pairs a structural matcher with a
97/// rewriter that emits replacement nodes and records the
98/// `(old_id → new_id)` remapping so the driver can rewrite consumer
99/// input edges to point at the replacement.
100///
101/// Rules don't need to update the layout side-table for view nodes
102/// they insert: [`Graph::push`] auto-populates layout for view ops
103/// from the input's layout.
104///
105/// Rules don't express ordering edges for destructive ops they emit
106/// (e.g. `Op::Release`); [`derive_ordering`] does that automatically
107/// at execution-plan time.
108pub trait Rule: Send + Sync {
109 /// Stable, human-readable name. Shows up in panic messages and
110 /// debug traces.
111 fn name(&self) -> &'static str;
112
113 /// Which family this rule belongs to. Determines pass ordering.
114 fn family(&self) -> RuleFamily;
115
116 /// Whether this rule can rewrite the node at `id`. Should be
117 /// pure — same inputs, same answer. Called once per node per
118 /// pass; the first matching rule wins.
119 fn matches(&self, graph: &Graph, id: NodeId) -> bool;
120
121 /// Apply the rewrite: append fresh nodes to the graph, declare
122 /// any side-effect roots if the rewrite emits side-effecting
123 /// ops, and write `remap.insert(id, replacement_id)` so the
124 /// driver can redirect consumer edges. Calling this method
125 /// implies [`matches`](Self::matches) returned `true` for the
126 /// same `(graph, id)` — rewriters can rely on the structure
127 /// the matcher checked for.
128 fn rewrite(&self, graph: &mut Graph, id: NodeId, remap: &mut HashMap<NodeId, NodeId>);
129}
130
131/// A registry of [`Rule`]s. Drives optimize-to-fixpoint via
132/// [`Self::optimize_to_fixpoint`]: lowering rules run to fixpoint,
133/// then fusion rules run to fixpoint. The locked design (ROADMAP.md
134/// Phase 7.5) intends this entry point to be wrapped in transactions
135/// in subsequent PRs without rewriting the loop body — keep this API
136/// stable for that future evolution.
137#[derive(Default)]
138pub struct RuleRegistry {
139 rules: Vec<Box<dyn Rule>>,
140}
141
142impl RuleRegistry {
143 /// Empty registry. Use [`Self::with_rule`] to add rules, or
144 /// [`Self::default_rules`] / [`Self::lowering_only`] for the
145 /// shipped configurations.
146 pub fn new() -> Self {
147 Self { rules: Vec::new() }
148 }
149
150 /// Append a rule. Returns self for builder-style chaining.
151 pub fn with_rule(mut self, rule: Box<dyn Rule>) -> Self {
152 self.rules.push(rule);
153 self
154 }
155
156 /// Lower + fuse rules for every fused op registered in the
157 /// process-wide [`default_registry`]. Round-trip is identity for
158 /// canonical SoftmaxLastDim graphs (the lowered subgraph is
159 /// structurally re-fused). Use this in production realize paths.
160 ///
161 /// Phase 7.6 step 3: PR 3's hand-written
162 /// `SoftmaxLastDimLowerRule`/`SoftmaxLastDimFuseRule` are deleted;
163 /// equivalent behavior is auto-generated from the SoftmaxLastDim
164 /// registry entry. Subsequent fused-op migrations in step 4 add
165 /// their entries to the same registry; `default_rules` picks them
166 /// up automatically.
167 pub fn default_rules() -> Self {
168 Self::capability_gated_rules(|_| true).0
169 }
170
171 /// Build lower + fuse rules, but **fuse** a fused op only when
172 /// `has_kernel(id)` holds — the capability gate
173 /// (`runtime-fused-op-registration.md` §6). Every fused op (static +
174 /// runtime) still gets a *lowering* rule (so any already-fused node
175 /// decomposes); an op WITHOUT a kernel gets **no fusion rule**, so a
176 /// kernel-absent fused node never forms in the first place — the miss is
177 /// caught at the pattern-match step, not repaired afterward.
178 ///
179 /// Returns the rule set plus the ids that were **gated out of fusion** — the
180 /// miss candidates feeding the JIT work-order signal. The dispatch layer
181 /// supplies `has_kernel` from its `FusedKernelRegistry` + the target
182 /// backend; [`Self::default_rules`] is the all-available case.
183 pub fn capability_gated_rules(
184 has_kernel: impl Fn(FusedOpId) -> bool,
185 ) -> (Self, Vec<FusedOpId>) {
186 let registry = default_registry();
187 let mut r = Self::new();
188 let mut gated_out = Vec::new();
189 for entry in registry.entries_iter() {
190 r = r.with_rule(Box::new(LoweringRule::from_entry(entry)));
191 if has_kernel(entry.id) {
192 r = r.with_rule(Box::new(FusionRule::from_entry(entry)));
193 } else {
194 gated_out.push(entry.id);
195 }
196 }
197 // Runtime-registered fused ops (Tier-2), same gate. A runtime op with no
198 // kernel yet (JIT cold start) is gated out + reported as a miss — the
199 // synthesize work-order — and its region stays primitive this pass.
200 for e in crate::runtime_fused::runtime_entries() {
201 r = r.with_rule(Box::new(LoweringRule::runtime(e.id)));
202 if has_kernel(e.id) {
203 r = r.with_rule(Box::new(FusionRule::declarative(
204 e.id,
205 crate::registry::PatternTree {
206 root: e.region,
207 params: FusedOpParams::Runtime {
208 scalars: Vec::new(),
209 },
210 },
211 )));
212 } else {
213 gated_out.push(e.id);
214 }
215 }
216 (r, gated_out)
217 }
218
219 /// Lowering rules only — no fusion. Use this when the test or
220 /// caller wants the lowered form to be the post-pipeline state
221 /// (e.g. equivalence-testing the composed-math path on a backend
222 /// that has the primitives but not the fused kernel).
223 pub fn lowering_only() -> Self {
224 let registry = default_registry();
225 let mut r = Self::new();
226 for entry in registry.entries_iter() {
227 r = r.with_rule(Box::new(LoweringRule::from_entry(entry)));
228 }
229 // Runtime ops decompose to their region here too — this is the
230 // kernel-absent path (a backend without the synthesized kernel runs the
231 // primitive recipe).
232 for e in crate::runtime_fused::runtime_entries() {
233 r = r.with_rule(Box::new(LoweringRule::runtime(e.id)));
234 }
235 r
236 }
237
238 /// Number of registered rules.
239 pub fn len(&self) -> usize {
240 self.rules.len()
241 }
242
243 /// Whether the registry has any rules.
244 pub fn is_empty(&self) -> bool {
245 self.rules.is_empty()
246 }
247
248 /// Run lowering rules to fixpoint, then fusion rules to fixpoint.
249 /// Returns the (possibly-rewritten) roots — callers use these to
250 /// replace their `NodeHandle` handles.
251 ///
252 /// The two-phase ordering is important: rules that are inverses
253 /// of each other (e.g. SoftmaxLastDim's lower + fuse pair)
254 /// would oscillate in a single mixed-family fixpoint loop.
255 /// Phase ordering breaks the oscillation and gives fusion the
256 /// last word.
257 pub fn optimize_to_fixpoint(&self, graph: &SharedGraph, roots: &[NodeId]) -> Vec<NodeId> {
258 let mut roots = roots.to_vec();
259 if self.rules.is_empty() {
260 return roots;
261 }
262 // Phase 1: lowering to fixpoint.
263 loop {
264 let any = self.run_pass(RuleFamily::Lowering, graph, &mut roots);
265 if !any {
266 break;
267 }
268 }
269 // Phase 2: fusion to fixpoint.
270 loop {
271 let any = self.run_pass(RuleFamily::Fusion, graph, &mut roots);
272 if !any {
273 break;
274 }
275 }
276 // Phase 3: algebraic identities to fixpoint. Runs last so it
277 // can't disturb fusion patterns by eliminating casts that
278 // fusion was about to match on.
279 loop {
280 let any = self.run_pass(RuleFamily::Algebraic, graph, &mut roots);
281 if !any {
282 break;
283 }
284 }
285 roots
286 }
287
288 /// One pass over the graph for a single rule family. Returns
289 /// `true` if any rule fired during the pass — caller loops until
290 /// `false` for fixpoint.
291 fn run_pass(&self, family: RuleFamily, graph: &SharedGraph, roots: &mut [NodeId]) -> bool {
292 let order = {
293 let g = graph.read().unwrap();
294 topo_order_multi(&g, roots)
295 };
296
297 // Iterate this pass's family-rules in the order they were
298 // registered. First match wins per node.
299 let family_rules: Vec<&Box<dyn Rule>> =
300 self.rules.iter().filter(|r| r.family() == family).collect();
301 if family_rules.is_empty() {
302 return false;
303 }
304
305 let mut remap: HashMap<NodeId, NodeId> = HashMap::new();
306 let mut any_fired = false;
307
308 for id in order {
309 // Skip nodes that an earlier rule in this pass already
310 // rewrote — the consumer-edge rewrite happens after the
311 // pass, so the original node's structure still looks
312 // matchable mid-pass; we use `remap` as a "this pass
313 // already handled it" marker.
314 if remap.contains_key(&id) {
315 continue;
316 }
317
318 let firing_rule_idx = {
319 let g = graph.read().unwrap();
320 family_rules.iter().position(|r| r.matches(&g, id))
321 };
322 if let Some(idx) = firing_rule_idx {
323 let mut g = graph.write().unwrap();
324 let before = remap.len();
325 family_rules[idx].rewrite(&mut g, id, &mut remap);
326 // A rule that matched but recorded no remap entry made no
327 // progress — e.g. a `decompose` that returned self (G2's
328 // fixpoint signal: this op can't decompose further). Only
329 // count real rewrites, so the fixpoint loop terminates
330 // instead of spinning on a no-op match.
331 if remap.len() != before {
332 any_fired = true;
333 }
334 }
335 }
336
337 if !any_fired {
338 return false;
339 }
340
341 // Apply remap to every consumer's input list, in place. This
342 // is the moment the rewritten nodes become the live ones —
343 // any node whose inputs referenced a remapped id now reads
344 // from its replacement.
345 {
346 let mut g = graph.write().unwrap();
347 let n_nodes = g.len();
348 for nid in 0..n_nodes {
349 let inputs_len = g.node(NodeId(nid)).inputs.len();
350 for i in 0..inputs_len {
351 let cur = g.node(NodeId(nid)).inputs[i];
352 if let Some(&new) = remap.get(&cur) {
353 g.rewrite_input(NodeId(nid), cur, new);
354 }
355 }
356 }
357 }
358
359 // And update the user's roots so the next phase / the caller
360 // sees the canonical post-rewrite ids.
361 for r in roots.iter_mut() {
362 if let Some(&new) = remap.get(r) {
363 *r = new;
364 }
365 }
366
367 true
368 }
369}
370
371/// Lower every reachable fused op in `graph` (rooted at `roots`) to its
372/// primitive base map — the fixpoint of `decompose` over every node. A thin
373/// named wrapper over [`RuleRegistry::lowering_only`] + `optimize_to_fixpoint`
374/// (the machinery every fused op — static-registry AND runtime — already flows
375/// through). Never panics: a self-returning `decompose` is a clean fixpoint
376/// (the never-panic total-decompose contract), not a loop.
377pub fn lower_to_base_map(graph: &SharedGraph, roots: &[NodeId]) -> Vec<NodeId> {
378 RuleRegistry::lowering_only().optimize_to_fixpoint(graph, roots)
379}
380
381/// `NodeId`-independent content hash of the subgraph rooted at `root`.
382///
383/// Recursively hashes each node as `(op identity, child hashes)`, folding in
384/// each child's *hash* rather than its `NodeId` — so two independently-built
385/// graphs (different arenas, different `NodeId` numbering) that are
386/// structurally identical hash equal. Reuses this module's existing
387/// canonicalization primitives rather than duplicating them:
388///
389/// - `op_key` for op identity when available (covers every primitive +
390/// `Op::Fused` variant CSE already relies on).
391/// - `is_commutative` to sort child hashes for `Add`/`Mul`, so `a + b` and
392/// `b + a` hash equal (mirrors CSE's own commutative-operand
393/// canonicalization). `Maximum`/`Minimum` are excluded -- see GAP-271.
394///
395/// `op_key` deliberately excludes `Op::Const` (its payload lives in the
396/// graph's `storage_map` slot, not the `Op` enum) and returns `None`
397/// for a handful of other ops (in-place,
398/// indexing, and anything else not explicitly listed). For those, this
399/// function falls back to `(discriminant, shape, dtype)` — and, for
400/// `Op::Const` specifically, additionally folds the constant's real bytes
401/// when they're readable (see `fold_const_bytes` below) so two
402/// independently-built recipes with equal constants hash equal, not just
403/// equal-shaped placeholders.
404///
405/// This is a cheap structural *pre-filter* for cross-graph recipe identity:
406/// structurally-equal-up-to-commutative-reordering-and-fusion-depth base
407/// maps hash equal. It does NOT canonicalize associativity or
408/// distributivity (`(a + b) + c` vs `a + (b + c)` hash differently) — a
409/// numeric verify pass covers that residual. Hashes are only stable within
410/// one process / `DefaultHasher` instance; never persist or compare them
411/// across processes.
412pub fn base_map_hash(graph: &Graph, root: NodeId) -> u64 {
413 use std::collections::hash_map::DefaultHasher;
414 use std::hash::{Hash, Hasher};
415
416 // Fold a `Const` node's real bytes into `hasher`, when they're
417 // readable without blocking. Const bytes live in the graph's
418 // `storage_map` slot (`lib.rs:218-223`), populated via
419 // `Graph::set_storage`/`storage_for` — NOT in the `Op` enum, so
420 // `op_key` can't see them (by design; see `OpKey`'s doc comment).
421 //
422 // Falls back to a silent no-op (the caller has already folded this
423 // node's shape/dtype via the `op_key -> None` branch) when:
424 // - the slot is unpopulated (e.g. a bare `Node { op: Op::Const, .. }`
425 // pushed without ever calling `set_storage` — as the unit tests
426 // below do for their placeholder consts), or
427 // - the slot's `RwLock` is held by a writer (`try_read` avoids
428 // blocking a hash — a structural pre-filter — on live graph
429 // mutation), or
430 // - the backend can't produce a host buffer right now (e.g.
431 // device-only storage mid-capture).
432 //
433 // Documented limitation: in the fallback case, two *different*
434 // device-only (or unpopulated) consts of the same shape/dtype hash
435 // equal. `base_map_hash` is a pre-filter, not a full identity
436 // decision — a numeric verify pass is the source of truth when bytes
437 // aren't available at hash time.
438 fn fold_const_bytes(graph: &Graph, id: NodeId, hasher: &mut DefaultHasher) {
439 let Some(slot) = graph.storage_for(id) else {
440 return;
441 };
442 let Ok(guard) = slot.try_read() else { return };
443 let Ok(host) = guard.to_cpu_storage() else {
444 return;
445 };
446 fold_host_buffer(&host, hasher);
447 }
448
449 // Bridge every `HostBuffer` element dtype to something `Hash`. Integer
450 // variants (including the raw-byte-backed sub-byte dummy dtypes)
451 // already implement `Hash` directly; float variants don't (NaN
452 // equality is ambiguous under `Eq`, so std withholds `Hash`) — for
453 // those we fold each element's bit pattern via `to_bits()`, which is
454 // exact (including NaN payload bits) and matches the bit-pattern
455 // encoding `op_key` already uses for scalar payloads (e.g.
456 // `Op::AddScalar`'s `f64::to_bits()`).
457 fn fold_host_buffer(buf: &fuel_ir::HostBuffer, hasher: &mut DefaultHasher) {
458 use fuel_ir::HostBuffer as H;
459 match buf {
460 H::U8(v) | H::F6E2M3(v) | H::F6E3M2(v) | H::F4(v) | H::F8E8M0(v) | H::Bool(v) => {
461 v.hash(hasher)
462 }
463 H::I8(v) => v.hash(hasher),
464 H::U32(v) => v.hash(hasher),
465 H::I16(v) => v.hash(hasher),
466 H::I32(v) => v.hash(hasher),
467 H::I64(v) => v.hash(hasher),
468 H::BF16(v) => {
469 for x in v {
470 x.to_bits().hash(hasher);
471 }
472 }
473 H::F16(v) => {
474 for x in v {
475 x.to_bits().hash(hasher);
476 }
477 }
478 H::F32(v) => {
479 for x in v {
480 x.to_bits().hash(hasher);
481 }
482 }
483 H::F64(v) => {
484 for x in v {
485 x.to_bits().hash(hasher);
486 }
487 }
488 H::F8E4M3(v) => {
489 for x in v {
490 x.to_bits().hash(hasher);
491 }
492 }
493 }
494 }
495
496 fn go(graph: &Graph, id: NodeId, memo: &mut HashMap<NodeId, u64>) -> u64 {
497 if let Some(&h) = memo.get(&id) {
498 return h;
499 }
500 let n = graph.node(id);
501 let mut hasher = DefaultHasher::new();
502 match op_key(&n.op) {
503 Some(k) => k.hash(&mut hasher),
504 None => {
505 std::mem::discriminant(&n.op).hash(&mut hasher);
506 n.shape.dims().hash(&mut hasher);
507 n.dtype.hash(&mut hasher);
508 if matches!(n.op, Op::Const) {
509 fold_const_bytes(graph, id, &mut hasher);
510 }
511 }
512 }
513 let mut child_hashes: Vec<u64> = n.inputs.iter().map(|&c| go(graph, c, memo)).collect();
514 if is_commutative(&n.op) {
515 child_hashes.sort_unstable();
516 }
517 child_hashes.hash(&mut hasher);
518 let h = hasher.finish();
519 memo.insert(id, h);
520 h
521 }
522
523 go(graph, root, &mut HashMap::new())
524}
525
526// ---- Auto-generated lowering + fusion rules from FusedOpRegistry ----------
527//
528// Phase 7.6 step 3: PR 3's hand-written `SoftmaxLastDimLowerRule` and
529// `SoftmaxLastDimFuseRule` are replaced by [`LoweringRule`] +
530// [`FusionRule`] generic abstractions that read the per-fused-op
531// metadata (decompose function, pattern matcher) from a
532// [`FusedOpEntry`] in `crate::registry::default_registry`. Each rule
533// fires on `Op::Fused(id, _)` for lowering or on the pattern's
534// canonical primitive subgraph for fusion.
535//
536// Lowered form (7 nodes for SoftmaxLastDim, symmetric across max/sum):
537//
538// m = ReduceMaxTo([..., 1])(x) # max-keepdim in one node
539// mb = BroadcastTo([..., last])(m)
540// s = Sub(x, mb) # numerically-stable shift
541// e = Exp(s)
542// d = ReduceSumTo([..., 1])(e) # sum-keepdim in one node
543// db = BroadcastTo([..., last])(d)
544// out = Div(e, db)
545//
546// The compile_one auto-Contiguize at the executor catches any backend
547// that doesn't support the strided BroadcastTo output as a kernel
548// input. PR 2-wide lets CUDA binary F32 kernels consume the broadcast
549// strides directly without materialization.
550
551/// Auto-generated lowering rule from a [`FusedOpEntry`]. Matches
552/// `Op::Fused(id, _)` and decomposes via the entry's `decompose`
553/// function. Each rule corresponds to one fused-op id in the registry.
554///
555/// Step 3 also fires on the legacy per-fused-op `Op` variants (e.g.
556/// `Op::SoftmaxLastDim`) so emission sites that haven't yet migrated
557/// to `Op::Fused` continue to lower correctly. Step 5 deletes those
558/// variants and the legacy match arm with them.
559pub struct LoweringRule {
560 id: FusedOpId,
561 decompose: fn(&mut Graph, NodeId, &FusedOpParams) -> NodeId,
562}
563
564impl LoweringRule {
565 pub fn from_entry(entry: &FusedOpEntry) -> Self {
566 Self {
567 id: entry.id,
568 decompose: entry.decompose,
569 }
570 }
571
572 /// Lowering rule for a runtime-registered fused op: decompose by re-emitting
573 /// its `runtime_fused` sidecar region as primitives. One per runtime id.
574 pub fn runtime(id: FusedOpId) -> Self {
575 Self {
576 id,
577 decompose: crate::runtime_fused::runtime_lowering_decompose,
578 }
579 }
580}
581
582impl Rule for LoweringRule {
583 fn name(&self) -> &'static str {
584 // Step 3 ships only SoftmaxLastDim; step 4 generalizes per-id.
585 // Until then, all auto-generated lowering rules share one name —
586 // the registry id distinguishes them at the structural level.
587 "FusedOpLowering"
588 }
589
590 fn family(&self) -> RuleFamily {
591 RuleFamily::Lowering
592 }
593
594 fn matches(&self, graph: &Graph, id: NodeId) -> bool {
595 let node = graph.node(id);
596 // Arity is per-fused-op; the decompose function is the
597 // authority. We don't gate on `node.inputs.len()` here.
598 match &node.op {
599 Op::Fused(fid, _) if *fid == self.id => true,
600 // Phase 7.6 step 5 (2026-05-11): legacy-variant
601 // fallthroughs have been dropped — every migrated fused
602 // op now flows through `Op::Fused(id, _)`. Step 5 dropped
603 // the per-fused-op `Op` variants together with these
604 // matcher arms.
605 _ => false,
606 }
607 }
608
609 fn rewrite(&self, graph: &mut Graph, id: NodeId, remap: &mut HashMap<NodeId, NodeId>) {
610 // Read params from the matched `Op::Fused(_, p)` node; the
611 // legacy primitive-variant fallthroughs were dropped in
612 // step 5.
613 let params = match &graph.node(id).op {
614 Op::Fused(_, p) => p.clone(),
615 other => unreachable!(
616 "LoweringRule::rewrite reached with non-matching op {other:?} \
617 — matcher contract violated"
618 ),
619 };
620 let new_id = (self.decompose)(graph, id, ¶ms);
621 // G2 (2026-06-20): a `decompose` that returns the SAME node is at
622 // its fixpoint — the op can't decompose further (a primitive, or a
623 // coarse op whose recipe/primitives don't exist yet, which returns
624 // self). That is not a rewrite, so we record no remap entry; the
625 // driver (`run_pass`) then sees the unchanged remap and does not
626 // count it as progress, so the lowering fixpoint terminates instead
627 // of oscillating. The node is left as `Op::Fused` — a surfaced
628 // opaque-op gap that an inventory pass can find.
629 if new_id != id {
630 remap.insert(id, new_id);
631 }
632 }
633}
634
635/// Auto-generated fusion rule from a [`FusedOpEntry`]. Matches the
636/// canonical primitive-subgraph pattern carried by the entry's
637/// [`SubgraphPattern`] and emits a single `Op::Fused(id, params)`
638/// node. Round-trips with [`LoweringRule`] for the same id.
639///
640/// Currently params are reconstructed by-id: SoftmaxLastDim's variant
641/// is parameterless, so the fusion rule emits
642/// `FusedOpParams::SoftmaxLastDim`. Step 4 extends [`crate::registry::PatternMatch`]
643/// (or adds a sibling `extract_params` field on `FusedOpEntry`) so
644/// param-bearing fused ops can recover their parameters from the
645/// matched subgraph.
646pub struct FusionRule {
647 id: FusedOpId,
648 pattern: PatternKind,
649}
650
651/// Internal: the runtime-flavor of a registry entry's pattern. We
652/// keep a function-pointer copy here rather than holding a reference
653/// into the (process-wide) registry so the rule is `'static`.
654#[expect(
655 clippy::large_enum_variant,
656 reason = "matcher pattern enum; boxing the large variant is deferred to the same follow-up as PatternNode (docs/gaps.md GAP-215) -- a cross-cutting change to construction/match sites, not a lint-commit change"
657)]
658enum PatternKind {
659 Callable(fn(&Graph, NodeId) -> Option<crate::registry::PatternMatch>),
660 /// The declarative form (fkc-fusion-patterns §3): carries the owned
661 /// `PatternTree` (the §3 grammar root + the params to stamp) so the rule is
662 /// `'static`. `matches`/`rewrite` walk it via [`crate::jit::match_region`].
663 Declarative(crate::registry::PatternTree),
664}
665
666impl FusionRule {
667 pub fn from_entry(entry: &FusedOpEntry) -> Self {
668 let pattern = match &entry.pattern {
669 SubgraphPattern::Callable(f) => PatternKind::Callable(*f),
670 SubgraphPattern::Declarative(tree) => PatternKind::Declarative(tree.clone()),
671 };
672 Self {
673 id: entry.id,
674 pattern,
675 }
676 }
677
678 /// Fusion rule from a declarative pattern (the runtime path): match the
679 /// region and stamp `tree.params` on the emitted `Op::Fused(id, _)`.
680 pub fn declarative(id: FusedOpId, tree: crate::registry::PatternTree) -> Self {
681 Self {
682 id,
683 pattern: PatternKind::Declarative(tree),
684 }
685 }
686}
687
688impl Rule for FusionRule {
689 fn name(&self) -> &'static str {
690 "FusedOpFusion"
691 }
692 fn family(&self) -> RuleFamily {
693 RuleFamily::Fusion
694 }
695
696 fn matches(&self, graph: &Graph, id: NodeId) -> bool {
697 match &self.pattern {
698 PatternKind::Callable(f) => (*f)(graph, id).is_some(),
699 PatternKind::Declarative(tree) => {
700 let consumers = |n: NodeId| {
701 (0..graph.len())
702 .filter(|&i| graph.node(NodeId(i)).inputs.contains(&n))
703 .count()
704 };
705 crate::jit::match_region(graph, id, &tree.root, &consumers).is_some()
706 }
707 }
708 }
709
710 fn rewrite(&self, graph: &mut Graph, id: NodeId, remap: &mut HashMap<NodeId, NodeId>) {
711 let pattern_match = match &self.pattern {
712 PatternKind::Callable(f) => (*f)(graph, id)
713 .expect("rewrite called with non-matching id — matcher contract violated"),
714 PatternKind::Declarative(tree) => {
715 // match_region_extract returns the region's external inputs in
716 // bind-index order PLUS the live scalar values its open slots
717 // matched (the `extract:` layer). Wrap them as a PatternMatch
718 // so the shared reconstruction path below applies. For a
719 // Runtime params template the extracted scalars REPLACE the
720 // template's (fusing a scalar region with the template's empty
721 // scalars would silently lose the live values); non-Runtime
722 // templates stamp as-is (their matchers are value-agnostic).
723 let consumers = |n: NodeId| {
724 (0..graph.len())
725 .filter(|&i| graph.node(NodeId(i)).inputs.contains(&n))
726 .count()
727 };
728 let (inputs, extracted) =
729 crate::jit::match_region_extract(graph, id, &tree.root, &consumers)
730 .expect("rewrite called with non-matching declarative pattern");
731 let params = match &tree.params {
732 FusedOpParams::Runtime { .. } => FusedOpParams::Runtime { scalars: extracted },
733 other => other.clone(),
734 };
735 crate::registry::PatternMatch {
736 bindings: inputs.iter().enumerate().map(|(i, n)| (i, *n)).collect(),
737 params,
738 }
739 }
740 };
741 // General-arity input reconstruction: bindings are (index, NodeId)
742 // pairs; sorting by index yields the emitted node's inputs in
743 // canonical order. SoftmaxLastDim binds {0 → x_id}; FusedLinear
744 // binds {0 → a, 1 → b, 2 → bias}. Missing index in the [0, N)
745 // range is a matcher contract violation.
746 let mut sorted = pattern_match.bindings.clone();
747 sorted.sort_by_key(|(idx, _)| *idx);
748 for (expected_idx, (got_idx, _)) in sorted.iter().enumerate() {
749 debug_assert_eq!(
750 *got_idx, expected_idx,
751 "FusionRule: bindings must be dense [0..N); pattern for id \
752 {:?} returned non-contiguous indices",
753 self.id,
754 );
755 }
756 let inputs: Vec<NodeId> = sorted.iter().map(|(_, n)| *n).collect();
757 // Params come from the matcher — it's authoritative on what
758 // variant the recognized subgraph represents.
759 let params = pattern_match.params;
760 let dtype = graph.node(id).dtype;
761 let shape = graph.node(id).shape.clone();
762 let new_id = graph.push(Node {
763 op: Op::Fused(self.id, params),
764 inputs,
765 shape,
766 dtype,
767 });
768 remap.insert(id, new_id);
769 }
770}
771
772// ---- Cast-fusion algebraic rule ------------------------------------------
773//
774// `CastFusionRule` fires on `Cast(src→dst) → Op(dst, ...)` chains
775// where:
776// - the Cast (or chain of casts) has exactly one consumer at
777// each link of the chain,
778// - the consumer is a **type-preserving op** (its output dtype
779// equals the cast-fed input dtype — true for unary/binary
780// arithmetic, reductions, MatMul; *false* for compare ops,
781// Where, Cast),
782// - the consumer has a kernel for the cast's *source* dtype on
783// all input slots (capability predicate says yes).
784//
785// On fire: a new consumer node is appended with the cast input
786// replaced by the cast's source. **The new consumer's output dtype
787// is the cast-source dtype, not the original output dtype.** Any
788// downstream consumers reading from the rewritten node now see the
789// new dtype. This is by design — the rule fires aggressively under
790// the assumption that downstream consumers either (a) are
791// type-tolerant or (b) will have their own cast-fusion applied,
792// collapsing the chain.
793//
794// **Caveat — semantic change**: in graphs where the downstream
795// expects the original dst_dtype (e.g. a sink that returns BF16),
796// aggressive cast-fusion silently changes the dtype the user
797// observes. Wire this rule opt-in until a downstream-tolerance
798// framework (precision filter, op-tolerance metadata) is in place
799// to guard against unsafe rewrites.
800//
801// Chain cancellation is the always-safe sub-case: when the chain
802// is a net no-op (`Cast(F32→BF16) → Cast(BF16→F32) → Op(F32)`),
803// the final source dtype already matches the consumer's input
804// slot, so neither output dtype nor kernel selection changes.
805//
806// Architecture v1.0: this is the first "algebraic" family rule.
807// Future algebraic rewrites (constant folding, identity
808// elimination, CSE hoisting) join the same family and run in the
809// same Phase 3 loop.
810//
811// Decoupling: `fuel-graph::opt` doesn't know about binding tables,
812// PrecisionGuarantee, or BackendId. The capability check is
813// injected at rule construction via [`CapabilityPredicate`]; the
814// consumer (typically the route-picker layer in fuel-storage or a
815// higher crate) is responsible for closing over a binding-table
816// reference and answering the predicate.
817
818use std::sync::Arc;
819
820/// Predicate answering "is there a registered kernel for this op
821/// with these input/output dtypes?" The dtypes vector is the
822/// consumer's full input-dtype list (after the cast-source
823/// substitution) followed by the consumer's output dtype. Format
824/// matches the binding-table key shape used by `fuel-storage`.
825///
826/// Returns `true` if at least one backend has a kernel registered
827/// for the proposed (op, dtypes) combination — the route picker
828/// later decides which backend to use.
829pub type CapabilityPredicate = Arc<dyn Fn(&Op, &[DType]) -> bool + Send + Sync>;
830
831/// `Cast(src→dst) → Op(dst) ≡ Op(src)` when the consumer has a
832/// kernel for `src`.
833///
834/// Only fires when the cast has exactly one consumer (MVP: avoids
835/// the DAG complication of partial fan-out elimination). Multi-
836/// consumer Cast handling is a follow-up: rewrite one consumer and
837/// keep the cast alive for the others.
838pub struct CastFusionRule {
839 capabilities: CapabilityPredicate,
840}
841
842impl CastFusionRule {
843 /// Build the rule with a capability predicate. The predicate
844 /// closes over whatever capability source the caller has (a
845 /// binding table, a route picker, a static manifest). The rule
846 /// itself stays decoupled from those internals.
847 pub fn new(capabilities: CapabilityPredicate) -> Self {
848 Self { capabilities }
849 }
850
851 /// Walk `node.inputs` looking for an edge ending at a Cast (or
852 /// a chain of Casts) where every link in the chain has exactly
853 /// one consumer (the next link). Returns `Some((input_index,
854 /// final_src_id, consumer_dtypes))` where `final_src_id` is the
855 /// deepest reachable source after walking through the chain.
856 /// `consumer_dtypes` is the dtypes vector to ask the predicate
857 /// about. Returns `None` if no input qualifies.
858 ///
859 /// Walking through chained casts in one pass avoids a subtle
860 /// fixpoint trap: after the outer Cast is eliminated, the inner
861 /// Cast's other consumer (the now-orphan outer Cast) is still
862 /// in the arena and would make `count_consumers != 1` on the
863 /// next iteration. Collapsing the whole chain at once sidesteps
864 /// this because we never re-examine intermediate links.
865 ///
866 /// Shared by `matches` (which discards the index) and `rewrite`
867 /// (which uses the index to point the new consumer's input edge
868 /// at the deepest source). Computed twice intentionally — the
869 /// alternative is mutable state across `matches`/`rewrite`,
870 /// which conflicts with the trait's pure-matcher contract.
871 fn find_eligible_cast_input(
872 &self,
873 graph: &Graph,
874 id: NodeId,
875 ) -> Option<(usize, NodeId, Vec<fuel_ir::DType>)> {
876 let node = graph.node(id);
877 // Don't fire on a Cast node itself — fire on the consumer.
878 if matches!(node.op, Op::Cast(_)) {
879 return None;
880 }
881 for (idx, &input_id) in node.inputs.iter().enumerate() {
882 // Walk through chained casts. `current` is the cast (or
883 // chain of casts) currently being examined; `prev` is
884 // the consumer at this link of the chain (initially `id`,
885 // then the outer-most cast as we descend).
886 if !matches!(graph.node(input_id).op, Op::Cast(_)) {
887 continue;
888 }
889 // Type-preserving filter: the consumer's output dtype
890 // must equal the cast'd input slot's dtype. This is the
891 // family of ops where rewriting the input dtype is
892 // equivalent to rewriting the output dtype — Neg/Add/
893 // Mul/Div, reductions, MatMul, etc. Excludes Compare
894 // (T→U8), Where (U8+T+T→T), and Cast itself (which we
895 // already filtered above). For non-type-preserving
896 // consumers, swapping the input dtype would produce an
897 // output we can't predict without per-op knowledge.
898 if node.dtype != graph.node(input_id).dtype {
899 continue;
900 }
901 let mut prev = id;
902 let mut current = input_id;
903 let final_src_id = loop {
904 let current_node = graph.node(current);
905 if !matches!(current_node.op, Op::Cast(_)) {
906 break current;
907 }
908 // Single-consumer constraint at this link: the cast
909 // at `current` must feed only `prev`. Otherwise we
910 // can't eliminate it (would orphan the other consumer's
911 // input edge).
912 if !is_only_consumer(graph, current, prev) {
913 // The chain stops here; we treat the current node
914 // (a Cast) as the source. The consumer takes the
915 // dst dtype of this Cast.
916 break current;
917 }
918 let Some(&next) = current_node.inputs.first() else {
919 break current;
920 };
921 prev = current;
922 current = next;
923 };
924 // If the walk didn't move past the first cast (e.g.
925 // because the first cast has multiple consumers), there's
926 // nothing to fuse for this input.
927 if final_src_id == input_id {
928 continue;
929 }
930 let final_src_dtype = graph.node(final_src_id).dtype;
931 // Build the proposed input-dtypes + output-dtype vector.
932 // Type-preserving filter above guarantees output dtype
933 // tracks the cast-fed input, so the new output dtype is
934 // `final_src_dtype` (not the original `node.dtype`).
935 let mut dtypes: Vec<fuel_ir::DType> = node
936 .inputs
937 .iter()
938 .map(|&iid| {
939 if iid == input_id {
940 final_src_dtype
941 } else {
942 graph.node(iid).dtype
943 }
944 })
945 .collect();
946 dtypes.push(final_src_dtype);
947 if (self.capabilities)(&node.op, &dtypes) {
948 return Some((idx, final_src_id, dtypes));
949 }
950 }
951 None
952 }
953}
954
955/// Returns `true` if `expected_consumer` is the only consumer of
956/// `target` in the arena. Used to guard chained-cast elimination.
957fn is_only_consumer(graph: &Graph, target: NodeId, expected_consumer: NodeId) -> bool {
958 let mut found_others = false;
959 for i in 0..graph.len() {
960 let nid = NodeId(i);
961 if nid == expected_consumer {
962 continue;
963 }
964 if graph.node(nid).inputs.contains(&target) {
965 found_others = true;
966 break;
967 }
968 }
969 !found_others
970}
971
972impl Rule for CastFusionRule {
973 fn name(&self) -> &'static str {
974 "CastFusion"
975 }
976 fn family(&self) -> RuleFamily {
977 RuleFamily::Algebraic
978 }
979
980 fn matches(&self, graph: &Graph, id: NodeId) -> bool {
981 self.find_eligible_cast_input(graph, id).is_some()
982 }
983
984 fn rewrite(&self, graph: &mut Graph, id: NodeId, remap: &mut HashMap<NodeId, NodeId>) {
985 let (idx, final_src_id, _dtypes) = self
986 .find_eligible_cast_input(graph, id)
987 .expect("CastFusionRule::rewrite called on non-matching node");
988 let node = graph.node(id).clone();
989 let final_src_dtype = graph.node(final_src_id).dtype;
990 let mut new_inputs = node.inputs.clone();
991 new_inputs[idx] = final_src_id;
992 let new_id = graph.push(Node {
993 op: node.op.clone(),
994 inputs: new_inputs,
995 shape: node.shape.clone(),
996 // Type-preserving op (matcher's invariant): the new
997 // output dtype tracks the new input dtype. Downstream
998 // consumers seeing this node may observe a different
999 // dtype than before the rewrite — see the module
1000 // docstring on the aggressive-semantics caveat.
1001 dtype: final_src_dtype,
1002 });
1003 remap.insert(id, new_id);
1004 }
1005}
1006
1007/// A HashMap-friendly encoding of `Op`. Needed because `Op` carries
1008/// `f64` (in `AddScalar`, `MulScalar`, `Clamp`, `LayerNormLastDim`,
1009/// `LayerNormLastDimBackward`) and `ConstData` (in `Const`), neither
1010/// of which is `Hash + Eq`. We keep `Const` out of CSE entirely
1011/// (identity-dedup happens via the executor's Arc-pointer const pool
1012/// already) and encode scalar payloads as their bit patterns.
1013#[derive(Debug, Hash, PartialEq, Eq)]
1014struct OpKey {
1015 tag: u16,
1016 ints: Vec<i64>,
1017 bits: Vec<u64>,
1018 // Sparse payloads we don't need to index into for equality are
1019 // serialized via their Debug repr as a fallback. Cheap and
1020 // correct; not used on the hot path outside simplification.
1021 dims: Vec<usize>,
1022 shape: Option<Vec<usize>>,
1023 dtype: Option<u32>,
1024}
1025
1026fn op_key(op: &Op) -> Option<OpKey> {
1027 // We deliberately refuse to CSE `Const`. Rationale above.
1028 let (tag, ints, bits, dims, shape, dtype) = match op {
1029 Op::Const => return None,
1030
1031 Op::Add => (1, vec![], vec![], vec![], None, None),
1032 Op::Sub => (2, vec![], vec![], vec![], None, None),
1033 Op::Mul => (3, vec![], vec![], vec![], None, None),
1034 Op::Div => (4, vec![], vec![], vec![], None, None),
1035
1036 Op::Neg => (10, vec![], vec![], vec![], None, None),
1037 Op::Sqr => (11, vec![], vec![], vec![], None, None),
1038 Op::Sqrt => (12, vec![], vec![], vec![], None, None),
1039 Op::Exp => (13, vec![], vec![], vec![], None, None),
1040 Op::Log => (14, vec![], vec![], vec![], None, None),
1041 Op::Sin => (15, vec![], vec![], vec![], None, None),
1042 Op::Cos => (16, vec![], vec![], vec![], None, None),
1043 Op::Tanh => (17, vec![], vec![], vec![], None, None),
1044 Op::Sigmoid => (18, vec![], vec![], vec![], None, None),
1045 Op::Silu => (19, vec![], vec![], vec![], None, None),
1046 Op::GeluTanh => (20, vec![], vec![], vec![], None, None),
1047 Op::Relu => (21, vec![], vec![], vec![], None, None),
1048 Op::Step => (22, vec![], vec![], vec![], None, None),
1049 Op::Recip => (23, vec![], vec![], vec![], None, None),
1050 Op::Abs => (24, vec![], vec![], vec![], None, None),
1051
1052 // --- comparison family (output dtype is U8) ---
1053 Op::Equal => (25, vec![], vec![], vec![], None, None),
1054 Op::Ne => (26, vec![], vec![], vec![], None, None),
1055 Op::Lt => (27, vec![], vec![], vec![], None, None),
1056 Op::Le => (28, vec![], vec![], vec![], None, None),
1057 Op::Gt => (29, vec![], vec![], vec![], None, None),
1058 // Tag 30 already taken by Op::MatMul below; the comparison
1059 // family wraps to 33 (the next free slot above linear-algebra)
1060 // for Op::Ge. The family sits at 25-29 + 33 — a small
1061 // contiguity break tolerated rather than renumbering existing
1062 // ops opportunistically.
1063 Op::Ge => (33, vec![], vec![], vec![], None, None),
1064
1065 // Ternary select. Slot 34 (next free after the comparison
1066 // family's wrap to 33).
1067 Op::Where => (34, vec![], vec![], vec![], None, None),
1068
1069 // --- rounding family (non-differentiable) ---
1070 Op::Floor => (35, vec![], vec![], vec![], None, None),
1071 Op::Ceil => (36, vec![], vec![], vec![], None, None),
1072 Op::Round => (37, vec![], vec![], vec![], None, None),
1073 Op::Sign => (38, vec![], vec![], vec![], None, None),
1074 Op::Erf => (39, vec![], vec![], vec![], None, None),
1075 // Tag 40 was Op::Cast; the unary fanout wraps to 47 (next
1076 // free slot above the 40-46 cast/shape/reduce cluster).
1077 Op::Gelu => (47, vec![], vec![], vec![], None, None),
1078 Op::Pow => (49, vec![], vec![], vec![], None, None),
1079 Op::Rsqrt => (54, vec![], vec![], vec![], None, None),
1080 Op::Rem => (55, vec![], vec![], vec![], None, None),
1081 Op::Flip { dim } => (56, vec![*dim as i64], vec![], vec![], None, None),
1082 Op::Roll { dim, shift } => (57, vec![*dim as i64, *shift], vec![], vec![], None, None),
1083 Op::CumSum { dim } => (58, vec![*dim as i64], vec![], vec![], None, None),
1084 Op::PadBackward {
1085 in_shape,
1086 padding,
1087 mode,
1088 } => {
1089 let mode_tag = match mode {
1090 crate::PadMode::Constant => 0_i64,
1091 crate::PadMode::Reflect => 1,
1092 crate::PadMode::Replicate => 2,
1093 };
1094 let mut ints: Vec<i64> = Vec::with_capacity(padding.len() * 2 + 1);
1095 for &(b, a) in padding {
1096 ints.push(b as i64);
1097 ints.push(a as i64);
1098 }
1099 ints.push(mode_tag);
1100 (
1101 60,
1102 ints,
1103 vec![],
1104 vec![],
1105 Some(in_shape.dims().to_vec()),
1106 None,
1107 )
1108 }
1109 Op::Pad {
1110 padding,
1111 mode,
1112 value,
1113 } => {
1114 let mode_tag = match mode {
1115 crate::PadMode::Constant => 0_i64,
1116 crate::PadMode::Reflect => 1,
1117 crate::PadMode::Replicate => 2,
1118 };
1119 // Flatten padding pairs into [b0, a0, b1, a1, ...] + the
1120 // mode tag. Multi-dim padding keys uniquely off this vec.
1121 let mut ints: Vec<i64> = Vec::with_capacity(padding.len() * 2 + 1);
1122 for &(b, a) in padding {
1123 ints.push(b as i64);
1124 ints.push(a as i64);
1125 }
1126 ints.push(mode_tag);
1127 (59, ints, vec![value.to_bits()], vec![], None, None)
1128 }
1129
1130 Op::MatMul => (30, vec![], vec![], vec![], None, None),
1131 Op::Transpose => (31, vec![], vec![], vec![], None, None),
1132 Op::Permute(axes) => (32, vec![], vec![], axes.clone(), None, None),
1133
1134 Op::Cast(dt) => (40, vec![], vec![], vec![], None, Some(dtype_key(*dt))),
1135 Op::BroadcastTo(s) => (41, vec![], vec![], vec![], Some(s.dims().to_vec()), None),
1136 Op::Reshape(s) => (42, vec![], vec![], vec![], Some(s.dims().to_vec()), None),
1137 Op::Contiguize => (46, vec![], vec![], vec![], None, None),
1138 Op::Unsqueeze { dim } => (45, vec![*dim as i64], vec![], vec![], None, None),
1139 Op::Squeeze { dim } => (48, vec![*dim as i64], vec![], vec![], None, None),
1140 Op::ReduceSumTo(s) => (43, vec![], vec![], vec![], Some(s.dims().to_vec()), None),
1141 Op::ReduceMaxTo(s) => (44, vec![], vec![], vec![], Some(s.dims().to_vec()), None),
1142 // Phase 7.6 step 5 (2026-05-11): tags 46/70/71/72/73/74/75/76
1143 // formerly assigned to SoftmaxLastDim, LayerNormLastDim,
1144 // RmsNormLastDim, Rope and the four backward helpers have
1145 // been retired with their `Op` variants. Those ops now flow
1146 // through the `Op::Fused(fid, fparams)` arm below (tag 200,
1147 // with id + params encoded into the int/bit slots) and CSE
1148 // dedupes correctly because each entry produces a distinct
1149 // FusedOpId.
1150 Op::SumAll => (50, vec![], vec![], vec![], None, None),
1151 Op::MaxAll => (51, vec![], vec![], vec![], None, None),
1152 Op::MinAll => (52, vec![], vec![], vec![], None, None),
1153 Op::MeanAll => (53, vec![], vec![], vec![], None, None),
1154
1155 Op::SumDim(d) => (60, vec![*d as i64], vec![], vec![], None, None),
1156 Op::MaxDim(d) => (61, vec![*d as i64], vec![], vec![], None, None),
1157 Op::MinDim(d) => (62, vec![*d as i64], vec![], vec![], None, None),
1158 Op::MeanDim(d) => (63, vec![*d as i64], vec![], vec![], None, None),
1159 Op::ArgMaxDim(d) => (64, vec![*d as i64], vec![], vec![], None, None),
1160 Op::ArgMinDim(d) => (65, vec![*d as i64], vec![], vec![], None, None),
1161
1162 Op::Concat { dim } => (80, vec![*dim as i64], vec![], vec![], None, None),
1163 Op::Slice { dim, start, len } => (
1164 81,
1165 vec![*dim as i64, *start as i64, *len as i64],
1166 vec![],
1167 vec![],
1168 None,
1169 None,
1170 ),
1171
1172 Op::AddScalar(c) => (90, vec![], vec![c.to_bits()], vec![], None, None),
1173 Op::MulScalar(c) => (91, vec![], vec![c.to_bits()], vec![], None, None),
1174 Op::PowI(n) => (92, vec![*n as i64], vec![], vec![], None, None),
1175 Op::Clamp { min, max } => (
1176 93,
1177 vec![],
1178 vec![min.to_bits(), max.to_bits()],
1179 vec![],
1180 None,
1181 None,
1182 ),
1183
1184 Op::Maximum => (100, vec![], vec![], vec![], None, None),
1185 Op::Minimum => (101, vec![], vec![], vec![], None, None),
1186
1187 // Phase 7.6 step 2: registry-extended fused ops. CSE folds two
1188 // Op::Fused nodes with identical (id, params) to one. Encoding:
1189 // base tag 200; ints = [id.0, params.tag, ...params.ints]; bits
1190 // = params.bits. Mirrors the FusedOpParamsKey shape from
1191 // crate::registry so the encoding tracks any future param
1192 // variants without rewriting this arm.
1193 Op::Fused(fid, fparams) => {
1194 let pk = fparams.key();
1195 let mut ints: Vec<i64> = Vec::with_capacity(2 + pk.ints.len());
1196 ints.push(fid.0 as i64);
1197 ints.push(pk.tag as i64);
1198 ints.extend_from_slice(&pk.ints);
1199 (200, ints, pk.bits, vec![], None, None)
1200 }
1201
1202 // Op::Scan folds ONLY its own params (n_xs, bound, emit tag,
1203 // early_exit-present flag). The BODY is hashed automatically by
1204 // base_map_hash's child-recursion, because the body-exit NodeIds are
1205 // the last two entries of the node's `inputs` (the lax.scan encoding).
1206 // Two scans with identical params but different bodies therefore get
1207 // different child-hashes and different base_map_hash. Tag 210.
1208 Op::Scan {
1209 n_carries,
1210 n_xs,
1211 bound,
1212 emit,
1213 early_exit,
1214 } => {
1215 let emit_tag: i64 = match emit {
1216 ScanEmit::All => 0,
1217 ScanEmit::Final => 1,
1218 };
1219 let exit_flag: i64 = if early_exit.is_some() { 1 } else { 0 };
1220 (
1221 210,
1222 // ⚠️ GAP-303: `n_carries` MUST be folded here. This is Fuel's own
1223 // recipe identity (base_map_hash) -- omit it and a 1-carry and a
1224 // 3-carry Scan with the same n_xs/bound/emit hash IDENTICALLY, so
1225 // cover-finding and the plan cache treat them as one recipe. Same
1226 // defect class as KISS-OPS-6.19 (an arm reading too few attrs made
1227 // Gather-on-axis-2 and axis-0 emit identical bytes), and a SECOND
1228 // site beside the seam's canonical body.
1229 //
1230 // Appended LAST so existing single-carry hashes are the only ones
1231 // that move, and they move uniformly.
1232 vec![
1233 *n_xs as i64,
1234 *bound as i64,
1235 emit_tag,
1236 exit_flag,
1237 *n_carries as i64,
1238 ],
1239 vec![],
1240 vec![],
1241 None,
1242 None,
1243 )
1244 }
1245 // Op::ScanPlaceholder folds role tag + index so Carry/0, Elem/0,
1246 // Carry/1 are all distinct in the body hash. Tag 211.
1247 Op::ScanPlaceholder { role, index } => {
1248 let role_tag: i64 = match role {
1249 ScanRole::Carry => 0,
1250 ScanRole::Elem => 1,
1251 };
1252 (
1253 211,
1254 vec![role_tag, *index as i64],
1255 vec![],
1256 vec![],
1257 None,
1258 None,
1259 )
1260 }
1261
1262 // Indexing and anything else we haven't explicitly listed:
1263 // fall back to a unique tag that includes a structural
1264 // discriminant. These ops rarely appear more than once with
1265 // identical inputs, so we just mark them non-CSE-able by
1266 // returning None. Conservative; safe.
1267 _ => return None,
1268 };
1269 Some(OpKey {
1270 tag,
1271 ints,
1272 bits,
1273 dims,
1274 shape,
1275 dtype,
1276 })
1277}
1278
1279fn dtype_key(dt: DType) -> u32 {
1280 // Cheap injection: the Debug form is stable.
1281 // For tiny enums this compiles to a jump table.
1282 format!("{dt:?}")
1283 .as_bytes()
1284 .iter()
1285 .fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(*b as u32))
1286}
1287
1288/// `Maximum`/`Minimum` are DELIBERATELY ABSENT. They are commutative in
1289/// value and NOT in bits: `chassis/binary.rs` returns the FIRST NaN operand
1290/// (`if a.is_nan() { a } else if b.is_nan() { b }`), a documented lhs-first
1291/// tie-break matching `torch.maximum`. Two distinct NaN payloads therefore
1292/// swap with operand order -- measured, `max(0x7FC00001, 0x7FC00002)` gives
1293/// `0x7FC00001` and the reverse gives `0x7FC00002` -- so folding `min(a,b)`
1294/// with `min(b,a)` is an INVALID rewrite. See GAP-271.
1295///
1296/// The +-0 tie ALSO makes the fold invalid, but do not state it as a fixed
1297/// bias: the +-0 answer is NOT A PROPERTY OF THIS SOURCE. It is decided per
1298/// CALL SITE by the optimizer, because we delegate to Rust's `f32::max`,
1299/// which disclaims the zero tie. Measured in ONE binary at opt-level 3:
1300/// const-folded literals give +0 for both orders; `black_box` operands give
1301/// -0 for both; `#[inline(never)]` gives -0 and +0 -- ORDER-DEPENDENT.
1302/// Three answers, same source. That is itself a reason the fold is invalid:
1303/// folding two call sites merges forms the compiler may resolve differently.
1304///
1305/// Rely on the NaN-payload argument, because it is STABLE -- verified
1306/// identical at opt-level 0 and 3, under `black_box` and under
1307/// `#[inline(never)]`. It is stable precisely because that tie-break is
1308/// EXPLICIT IN OUR SOURCE, where the +-0 tie is delegated to the compiler.
1309fn is_commutative(op: &Op) -> bool {
1310 matches!(op, Op::Add | Op::Mul)
1311}
1312
1313/// Run CSE + algebraic simplification on the graph reachable from
1314/// `roots`. Returns the (possibly-rewritten) roots the caller should
1315/// use afterward.
1316///
1317/// The pass runs to a fixed point on a single topological walk: each
1318/// node is visited once, canonicalized (inputs remapped, constants
1319/// folded where trivial), then either matched to an existing canonical
1320/// node (CSE) or appended as fresh. No multi-pass iteration — the
1321/// simplification rules are chosen so one forward pass suffices.
1322pub fn optimize(graph: &SharedGraph, roots: &[NodeId]) -> Vec<NodeId> {
1323 let order = {
1324 let g = graph.read().unwrap();
1325 topo_order_multi(&g, roots)
1326 };
1327
1328 let mut g = graph.write().unwrap();
1329 let mut remap: HashMap<NodeId, NodeId> = HashMap::new();
1330 let mut cse: HashMap<(OpKey, Vec<NodeId>), NodeId> = HashMap::new();
1331
1332 for id in order {
1333 let (op, inputs, shape, dtype) = {
1334 let node = g.node(id);
1335 (
1336 node.op.clone(),
1337 node.inputs.clone(),
1338 node.shape.clone(),
1339 node.dtype,
1340 )
1341 };
1342 let mapped_inputs: Vec<NodeId> = inputs
1343 .iter()
1344 .map(|input_id| *remap.get(input_id).unwrap_or(input_id))
1345 .collect();
1346 let inputs_unchanged = mapped_inputs == inputs;
1347
1348 // 1. Algebraic simplifications that produce an alias (no new
1349 // node). If a rule fires, we remap this id to an existing
1350 // node and skip CSE for it entirely.
1351 //
1352 // Identity Cast(d) on a dtype-d input is a no-op: dispatch keys
1353 // casts on [src, dst] and intentionally registers no [d, d]
1354 // kernel, so this elision (not a kernel) is what makes a same-dtype
1355 // cast disappear before realize. Needs the input node's dtype, so
1356 // it lives here rather than in `try_simplify` (which sees only
1357 // op + inputs).
1358 if let Op::Cast(target) = &op {
1359 if g.node(mapped_inputs[0]).dtype == *target {
1360 remap.insert(id, mapped_inputs[0]);
1361 continue;
1362 }
1363 // GAP-183: lower `Cast(float -> Bool)` to `Ne(x, x*0)`.
1364 //
1365 // WHY A LOWERING RATHER THAN A KERNEL: CUDA already ships the
1366 // `!= 0` test — `ne_{f32,f64,f16,bf16}_u8` in
1367 // `cuda/compare.fkc.md` — but a STORAGE-level cast cannot compose
1368 // graph ops, so those kernels are unreachable from `to_dtype`.
1369 // Rewriting here reaches them. The INTEGER half is deliberately
1370 // left alone: CUDA has no integer compare kernel, so lowering it
1371 // would relocate the same decline while bypassing CPU's real
1372 // int->Bool cast kernels.
1373 //
1374 // WHY `MulScalar(0.0)` FOR THE ZEROS: its own doc says it exists
1375 // "to avoid building a full-shape const just to add or multiply by
1376 // a constant" — so the zeros inherit shape AND dtype from the
1377 // input by construction, with nothing materialized. `Op::ZeroFill`
1378 // is NOT usable here: it is DESTRUCTIVE (writes through input 0).
1379 //
1380 // ⚠️ THE IEEE REASONING, because a reader will stop at `x*0` and
1381 // wonder about non-finite inputs:
1382 // finite x != 0 : x*0 = ±0 -> x != 0 -> true
1383 // x = ±0.0 : x*0 = ±0 -> equal -> false
1384 // (IEEE: -0.0 == 0.0, so -0.0 is FALSE)
1385 // x = NaN : NaN*0 = NaN -> NaN != NaN -> TRUE
1386 // x = ±inf : inf*0 = NaN -> x != NaN -> true
1387 // NaN being truthy matches PyTorch's `.bool()` and the CPU cast
1388 // kernels, so both paths agree.
1389 //
1390 // NOTE the zeros operand is NaN (not 0) whenever x is NaN or ±inf,
1391 // and that is HARMLESS rather than load-bearing: for every
1392 // non-finite x, `x != NaN` and `x != 0` are both true, so the two
1393 // spellings agree everywhere. A future "x*0 => zeros" fold would
1394 // therefore be SAFE here — recorded because the opposite is the
1395 // intuitive guess, and an earlier draft of this comment asserted
1396 // it as a hazard. Only `AddScalar(0)`/`MulScalar(1)` fold today.
1397 if *target == fuel_ir::DType::Bool {
1398 let src_dtype = g.node(mapped_inputs[0]).dtype;
1399 if src_dtype.is_float() {
1400 let src_shape = g.node(mapped_inputs[0]).shape.clone();
1401 let zeros = g.push(Node {
1402 op: Op::MulScalar(0.0),
1403 inputs: vec![mapped_inputs[0]],
1404 shape: src_shape.clone(),
1405 dtype: src_dtype,
1406 });
1407 let ne = g.push(Node {
1408 op: Op::Ne,
1409 inputs: vec![mapped_inputs[0], zeros],
1410 shape: src_shape,
1411 dtype: fuel_ir::DType::Bool,
1412 });
1413 remap.insert(id, ne);
1414 continue;
1415 }
1416 }
1417 }
1418 if let Some(aliased) = try_simplify(&op, &mapped_inputs) {
1419 remap.insert(id, aliased);
1420 continue;
1421 }
1422
1423 // 2. CSE: if a structurally-identical canonical node already
1424 // exists, reuse it. Commutative ops use sorted inputs as
1425 // the key so `a+b` and `b+a` match. If no match exists and
1426 // nothing about this node needs rewriting (inputs unchanged,
1427 // no simplification fired), keep the original node in place
1428 // to avoid polluting the arena with identical copies.
1429 if let Some(key) = op_key(&op) {
1430 let key_inputs = if is_commutative(&op) {
1431 let mut v = mapped_inputs.clone();
1432 v.sort();
1433 v
1434 } else {
1435 mapped_inputs.clone()
1436 };
1437 let full_key = (key, key_inputs);
1438 if let Some(&existing) = cse.get(&full_key) {
1439 remap.insert(id, existing);
1440 continue;
1441 }
1442 let canonical_id = if inputs_unchanged {
1443 id
1444 } else {
1445 g.push(Node {
1446 op: op.clone(),
1447 inputs: mapped_inputs.clone(),
1448 shape: shape.clone(),
1449 dtype,
1450 })
1451 };
1452 cse.insert(full_key, canonical_id);
1453 remap.insert(id, canonical_id);
1454 } else {
1455 // Const or other non-CSE-able op. Keep the original if
1456 // unchanged; otherwise append a rewritten copy (Const
1457 // never has inputs, so this branch effectively keeps
1458 // Const originals).
1459 let canonical_id = if inputs_unchanged {
1460 id
1461 } else {
1462 g.push(Node {
1463 op: op.clone(),
1464 inputs: mapped_inputs,
1465 shape,
1466 dtype,
1467 })
1468 };
1469 remap.insert(id, canonical_id);
1470 }
1471 }
1472
1473 roots.iter().map(|r| *remap.get(r).unwrap_or(r)).collect()
1474}
1475
1476/// Insert `Op::Copy` nodes so every op's inputs live on the same
1477/// device as the op itself. Returns the rewritten roots.
1478///
1479/// This is the Phase-3.5 pass that lifts the Router from "explicit
1480/// copies only" to "auto-insert where needed."
1481///
1482/// ## Device inference rules
1483///
1484/// For each node N, walked in topological order:
1485///
1486/// 1. **Target device** = first match wins:
1487/// - If N has a placement hint via `graph.set_placement(n, d)`, use `d`.
1488/// - Else, inherit from N's first input's inferred device.
1489/// - Else (N is a Const or the first input has no inferred device
1490/// either), the target is `None` — N is "placeless" and will be
1491/// placed by its consumer's demands.
1492///
1493/// 2. **Input reconciliation**: for each input I of N, if I's inferred
1494/// device differs from N's target device, insert an
1495/// `Op::Copy { target }` node and redirect N's input edge to the
1496/// new Copy.
1497///
1498/// ## What it doesn't do
1499///
1500/// - **Backward ops:** the pass currently treats all ops the same
1501/// way — no special reasoning about what device a backward node
1502/// should run on. For pure inference this is fine; for training
1503/// through the Router we'd want a reverse-mode pass that mirrors
1504/// forward placement. Tracked as a follow-up TODO.
1505/// - **Const placement lowering:** if a Const flows into a single
1506/// consumer on device D, we emit `Copy(Const, D)` instead of
1507/// tagging the Const itself as on-device-D. A future cost-lowering
1508/// pass can fuse the two — tracked as a TODO alongside Phase 4's
1509/// scheduler.
1510/// - **Redundant copy elision:** `Copy(X, A) -> Copy(_, A)` when X is
1511/// already on A is dropped via the idempotent check in step 2, but
1512/// there's no pass that merges `Copy(X, A) -> Copy(_, B) -> Copy(_, A)`
1513/// (cross-device round-trips that cancel). Also future.
1514pub fn insert_copies(graph: &SharedGraph, roots: &[NodeId]) -> Vec<NodeId> {
1515 let order = {
1516 let g = graph.read().unwrap();
1517 topo_order_multi(&g, roots)
1518 };
1519
1520 // Inferred output device per node. `None` = placeless (e.g., an
1521 // unplaced Const). Read on the input side to decide whether a
1522 // Copy is needed.
1523 let mut inferred: HashMap<NodeId, Option<DeviceLocation>> = HashMap::new();
1524 // Rewritten (old → new) node ID map. A node gets rewritten when
1525 // any of its inputs needed a Copy interposed.
1526 let mut remap: HashMap<NodeId, NodeId> = HashMap::new();
1527
1528 let mut g = graph.write().unwrap();
1529
1530 for id in order {
1531 // Snapshot the node — all subsequent reads need the old id's
1532 // metadata, not whatever we're about to rewrite.
1533 let (op, inputs, shape, dtype) = {
1534 let node = g.node(id);
1535 (
1536 node.op.clone(),
1537 node.inputs.clone(),
1538 node.shape.clone(),
1539 node.dtype,
1540 )
1541 };
1542 let placement_hint = g.placement(id);
1543
1544 // Target device for this node: explicit hint > first input's
1545 // inferred device > None (placeless).
1546 let target_device: Option<DeviceLocation> = match placement_hint {
1547 Some(d) => Some(d),
1548 None => inputs.first().and_then(|i| {
1549 let mapped = *remap.get(i).unwrap_or(i);
1550 inferred.get(&mapped).copied().flatten()
1551 }),
1552 };
1553
1554 // Walk inputs, inserting Copy where needed.
1555 let mut new_inputs: Vec<NodeId> = Vec::with_capacity(inputs.len());
1556 let mut any_changed = false;
1557 for input_id in &inputs {
1558 let mapped_in = *remap.get(input_id).unwrap_or(input_id);
1559 let in_device = inferred.get(&mapped_in).copied().flatten();
1560
1561 let needs_copy = match (in_device, target_device) {
1562 // Both known and disagree → copy.
1563 (Some(src), Some(tgt)) if src != tgt => true,
1564 // Input is placeless and we have a target → copy
1565 // (Const gets Copy'd onto the target device; the
1566 // future cost-lowering pass can fuse this).
1567 (None, Some(_)) => true,
1568 // Everything else (match, or no target) → no copy.
1569 _ => false,
1570 };
1571
1572 if needs_copy {
1573 let tgt = target_device.expect("needs_copy implies target is Some");
1574 // A Copy preserves the INPUT's shape/dtype, not the
1575 // outer consumer's. Read them from the source node.
1576 let (in_shape, in_dtype) = {
1577 let n = g.node(mapped_in);
1578 (n.shape.clone(), n.dtype)
1579 };
1580 let copy_id = g.push(Node {
1581 op: Op::Copy { target: tgt },
1582 inputs: vec![mapped_in],
1583 shape: in_shape,
1584 dtype: in_dtype,
1585 });
1586 inferred.insert(copy_id, Some(tgt));
1587 new_inputs.push(copy_id);
1588 any_changed = true;
1589 } else {
1590 if mapped_in != *input_id {
1591 any_changed = true;
1592 }
1593 new_inputs.push(mapped_in);
1594 }
1595 }
1596
1597 // If nothing changed and this isn't a node whose inference
1598 // output device we need to record fresh, keep the original.
1599 let canonical_id = if any_changed {
1600 let new_id = g.push(Node {
1601 op: op.clone(),
1602 inputs: new_inputs,
1603 shape,
1604 dtype,
1605 });
1606 // Carry placement hint to the rewritten node so downstream
1607 // consumers see the same target.
1608 if let Some(d) = placement_hint {
1609 g.set_placement(new_id, d);
1610 }
1611 new_id
1612 } else {
1613 id
1614 };
1615
1616 // Record this node's inferred output device:
1617 // - Op::Copy / Op::Move: output is the transfer target.
1618 // - Else if we have a target_device, that's the output
1619 // device (it matches all inputs post-reconciliation).
1620 // - Else None (placeless forward).
1621 let out_device = match &op {
1622 Op::Copy { target } | Op::Move { target } => Some(*target),
1623 _ => target_device,
1624 };
1625 inferred.insert(canonical_id, out_device);
1626 remap.insert(id, canonical_id);
1627 }
1628
1629 roots.iter().map(|r| *remap.get(r).unwrap_or(r)).collect()
1630}
1631
1632/// Lower `Const` placements: if every consumer of an unplaced Const
1633/// has the same placement hint, tag the Const with that placement.
1634///
1635/// This is a pre-pass for [`insert_copies`]. Without it, a model's
1636/// weight Consts (unplaced at graph-build time) flow into ops tagged
1637/// for a specific device; insert_copies then emits a Copy for each
1638/// such Const every forward. Lowering the Const's placement directly
1639/// tells the Router to upload it straight to the target device, and
1640/// insert_copies skips the Copy.
1641///
1642/// Conservative: a Const whose consumers disagree on device stays
1643/// unplaced. A future replication pass could clone the Const to each
1644/// target, but that needs to weigh replication cost against transfer
1645/// cost — scheduler territory (Phase 4).
1646///
1647/// Returns the number of Const placements set. Mutates `graph`
1648/// placements in place.
1649pub fn lower_const_placement(graph: &SharedGraph, roots: &[NodeId]) -> usize {
1650 // Reverse edges: for each node, list its consumers. Only walks
1651 // nodes reachable from `roots`, so unused Consts don't waste work.
1652 let order = {
1653 let g = graph.read().unwrap();
1654 topo_order_multi(&g, roots)
1655 };
1656
1657 let mut consumers: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
1658 {
1659 let g = graph.read().unwrap();
1660 for &nid in &order {
1661 let node = g.node(nid);
1662 for &input in &node.inputs {
1663 consumers.entry(input).or_default().push(nid);
1664 }
1665 }
1666 }
1667
1668 let mut lowered = 0;
1669 let mut g = graph.write().unwrap();
1670 for &nid in &order {
1671 // Only consider Const nodes without an explicit placement.
1672 let is_const = matches!(g.node(nid).op, Op::Const);
1673 if !is_const || g.placement(nid).is_some() {
1674 continue;
1675 }
1676 let Some(cs) = consumers.get(&nid) else {
1677 continue; // Const is a root — no consumers to infer from.
1678 };
1679 // Walk consumers; collect their placements. If all are
1680 // Some and agree, adopt that device.
1681 let mut target: Option<DeviceLocation> = None;
1682 let mut unanimous = true;
1683 for &c in cs {
1684 match g.placement(c) {
1685 Some(d) => match target {
1686 None => target = Some(d),
1687 Some(prev) if prev == d => {}
1688 Some(_) => {
1689 unanimous = false;
1690 break;
1691 }
1692 },
1693 None => {
1694 unanimous = false;
1695 break;
1696 }
1697 }
1698 }
1699 if unanimous && let Some(d) = target {
1700 g.set_placement(nid, d);
1701 lowered += 1;
1702 }
1703 }
1704 lowered
1705}
1706
1707/// Try to produce an aliasing simplification: "this op is equivalent
1708/// to one of its inputs." Returns the aliased NodeId if so.
1709fn try_simplify(op: &Op, inputs: &[NodeId]) -> Option<NodeId> {
1710 match op {
1711 // AddScalar(0) and MulScalar(1) are no-ops. Route consumers
1712 // straight to the input and drop the op.
1713 Op::AddScalar(c) if *c == 0.0 => Some(inputs[0]),
1714 Op::MulScalar(c) if *c == 1.0 => Some(inputs[0]),
1715 // PowI(1) is a no-op. PowI(0) would be "1" but that needs a
1716 // new const node with the right shape — skip here, let the
1717 // executor handle or another rule add it.
1718 Op::PowI(1) => Some(inputs[0]),
1719 _ => None,
1720 }
1721}
1722
1723// ---- Ordering analysis for destructive ops ---------------------------------
1724//
1725// A destructive op (one whose `Op::destructive_input()` is `Some(i)`)
1726// invalidates its i-th input when it runs. For correctness, every
1727// non-destructive reader of that input must complete BEFORE the
1728// destructive op runs. The graph's data-flow edges don't express this
1729// — they'd let a backend schedule the destructive op any time after
1730// its input is produced, including AHEAD of sibling readers.
1731//
1732// `derive_ordering` analyzes the graph and returns a map of
1733// destructive-op → sibling-readers. The [`execution_plan`] below
1734// consumes this map alongside the data graph in a Kahn's-algorithm
1735// walk to produce an execution order that respects both constraint
1736// kinds.
1737
1738/// Derived ordering edges beyond the data-flow graph. Each entry
1739/// `(nid, deps)` means `nid` must run after every node in `deps`.
1740///
1741/// Produced by [`derive_ordering`] from destructive-op metadata
1742/// ([`Op::destructive_input`]). Consumed by [`execution_plan`].
1743#[derive(Debug, Clone, Default)]
1744pub struct OrderingEdges(pub HashMap<NodeId, Vec<NodeId>>);
1745
1746impl OrderingEdges {
1747 pub fn new() -> Self {
1748 Self(HashMap::new())
1749 }
1750
1751 /// True if the map is empty — no destructive ops in the analyzed
1752 /// subgraph, so the execution plan is just the plain topo order.
1753 pub fn is_empty(&self) -> bool {
1754 self.0.is_empty()
1755 }
1756
1757 /// Get the set of must-run-before nodes for `nid`, if any.
1758 pub fn deps_of(&self, nid: NodeId) -> &[NodeId] {
1759 self.0.get(&nid).map(|v| v.as_slice()).unwrap_or(&[])
1760 }
1761}
1762
1763/// Fuse `MatMul → Add(rank-1 bias)` patterns into `Op::FusedLinear`
1764/// (Phase 6d Track 3). Walks the graph; for each `Add` whose LHS is
1765/// a `MatMul` and whose RHS is a rank-1 bias whose length equals the
1766/// matmul output's last dim, emits a fresh `FusedLinear` node and
1767/// remaps consumers of the `Add` to it.
1768///
1769/// Conservative: only fires when the `Add` is the sole consumer of
1770/// the `MatMul`. Otherwise we'd be creating a duplicate matmul
1771/// computation. CSE doesn't help here because `MatMul` and
1772/// `MatMul-inside-FusedLinear` aren't structurally equal at the IR
1773/// level — backends with truly fused kernels are the ones that
1774/// benefit, so we'd rather skip the fusion than waste the work.
1775///
1776/// Returns the count of fusions applied.
1777pub fn fuse_linear(graph: &SharedGraph, roots: &[NodeId]) -> usize {
1778 let order = {
1779 let g = graph.read().unwrap();
1780 topo_order_multi(&g, roots)
1781 };
1782 // Count consumers of each node (so we can guard "single consumer of matmul").
1783 let mut consumer_count: HashMap<NodeId, usize> = HashMap::new();
1784 {
1785 let g = graph.read().unwrap();
1786 for &nid in &order {
1787 for &input in &g.node(nid).inputs {
1788 *consumer_count.entry(input).or_insert(0) += 1;
1789 }
1790 }
1791 // Also count root references — a root is implicitly a consumer.
1792 for &r in roots {
1793 *consumer_count.entry(r).or_insert(0) += 1;
1794 }
1795 }
1796
1797 let mut g = graph.write().unwrap();
1798 let mut remap: HashMap<NodeId, NodeId> = HashMap::new();
1799 let mut fused = 0usize;
1800
1801 for nid in order {
1802 // Apply already-known remappings to inputs.
1803 let (op, inputs, shape, dtype) = {
1804 let n = g.node(nid);
1805 (n.op.clone(), n.inputs.clone(), n.shape.clone(), n.dtype)
1806 };
1807 let mapped: Vec<NodeId> = inputs.iter().map(|i| *remap.get(i).unwrap_or(i)).collect();
1808 // Pattern: Op::Add { inputs[0]=matmul_node, inputs[1]=rank-1 bias }.
1809 if !matches!(op, Op::Add) || mapped.len() != 2 {
1810 continue;
1811 }
1812 let lhs = mapped[0];
1813 let rhs = mapped[1];
1814 // LHS must be a MatMul.
1815 let lhs_op = g.node(lhs).op.clone();
1816 if !matches!(lhs_op, Op::MatMul) {
1817 continue;
1818 }
1819 // LHS matmul must have only THIS Add as a consumer (otherwise
1820 // fusing would duplicate the matmul computation).
1821 // Note: consumer_count counts pre-remapping references; remap
1822 // only happens for skipped nodes here so it's still valid.
1823 if consumer_count.get(&lhs).copied().unwrap_or(0) != 1 {
1824 continue;
1825 }
1826 // RHS must be a BroadcastTo of a rank-1 bias whose length
1827 // equals the matmul output's last dim. The build-time `Add`
1828 // requires same-shape inputs, so user code typically does:
1829 // bias[N].broadcast_to([..., M, N]).add(matmul_out)
1830 // Walk through that BroadcastTo to find the rank-1 source.
1831 let mm_dims = g.node(lhs).shape.dims().to_vec();
1832 if mm_dims.is_empty() {
1833 continue;
1834 }
1835 let last_dim = mm_dims[mm_dims.len() - 1];
1836 let rhs_node = g.node(rhs);
1837 let bias_src_id = if matches!(rhs_node.op, Op::BroadcastTo(_)) && rhs_node.inputs.len() == 1
1838 {
1839 *remap
1840 .get(&rhs_node.inputs[0])
1841 .unwrap_or(&rhs_node.inputs[0])
1842 } else {
1843 // Bias broadcast may also have been pre-shaped; allow rank-1
1844 // direct (rare with build-time shape checks but cheap to
1845 // recognize).
1846 rhs
1847 };
1848 let bias_dims = g.node(bias_src_id).shape.dims().to_vec();
1849 if bias_dims.len() != 1 || bias_dims[0] != last_dim {
1850 continue;
1851 }
1852 // Pull the matmul's a, b inputs (apply remap to those too).
1853 let mm_inputs = g.node(lhs).inputs.clone();
1854 if mm_inputs.len() != 2 {
1855 continue;
1856 }
1857 let a = *remap.get(&mm_inputs[0]).unwrap_or(&mm_inputs[0]);
1858 let b = *remap.get(&mm_inputs[1]).unwrap_or(&mm_inputs[1]);
1859 // Phase 7.6 step 4: emit the registry-extended shape. The
1860 // executor's `Op::Fused(FUSED_LINEAR, _)` arm dispatches to
1861 // the same fused-linear kernel as the legacy variant. Step 5
1862 // drops the legacy `Op::FusedLinear` variant entirely.
1863 let new_id = g.push(Node {
1864 op: Op::Fused(
1865 crate::registry::FusedOps::FUSED_LINEAR,
1866 crate::registry::FusedOpParams::FusedLinear,
1867 ),
1868 // FusedLinear takes the *original* rank-1 bias, not the
1869 // BroadcastTo'd one — the kernel broadcasts it internally
1870 // to the matmul output shape.
1871 inputs: vec![a, b, bias_src_id],
1872 shape,
1873 dtype,
1874 });
1875 remap.insert(nid, new_id);
1876 fused += 1;
1877 }
1878
1879 // Apply remap by rewriting any consumer that still references an
1880 // un-fused Add. Using the existing `rewrite_input` helper.
1881 if !remap.is_empty() {
1882 // Collect all node ids; iterate to update inputs that point at
1883 // remapped nodes. We need the mutable borrow; clone the set of
1884 // nodes to iterate without borrow conflicts.
1885 let n_nodes = g.nodes.len();
1886 for nid in 0..n_nodes {
1887 let node = &mut g.nodes[nid];
1888 for input in node.inputs.iter_mut() {
1889 if let Some(&new) = remap.get(input) {
1890 *input = new;
1891 }
1892 }
1893 }
1894 }
1895 fused
1896}
1897
1898/// Derive ordering edges for every destructive op reachable from
1899/// `roots`. Result: `nid` → list of other readers of `nid`'s
1900/// destroyed input.
1901///
1902/// **Bundle-aware aliasing** (Option C, Session 3): the alias-set
1903/// computation treats `Op::View { slot }` as alias-extending — every
1904/// View of a multi-output producer shares the bundle's storage Arc, so
1905/// a destructive op on the producer (or on any sibling View) must
1906/// run after every reader of every other View of the same producer.
1907/// `Op::ViewOwned` is NOT alias-extending: after its forward memcpy,
1908/// the ViewOwned's output is an independent Storage; it is still
1909/// pinned-after destructive ops on the producer via the regular
1910/// data-dependency edge (`inputs[0] == producer`), which falls out
1911/// of the standard reader analysis below without needing the
1912/// alias-set extension. The full alias-extension rule covers
1913/// `Op::Reshape` / `Op::Contiguize`
1914/// (conditionally-zero-copy contiguize ops), added to close the MLA
1915/// decode multi-round `WriteSlice` ordering defect: a destructive op
1916/// on `X` only pins the readers of nodes actually IN the alias set,
1917/// so any alias-extending op excluded from that set is a hole a
1918/// reader can fall through unpinned. `Op::Reshape`/`Op::Contiguize`
1919/// were exactly that hole (see `collect_alias_set`'s doc for the
1920/// full mechanism and the shipped workaround this makes unnecessary
1921/// for correctness).
1922///
1923/// O(V + E). Does not mutate the graph.
1924pub fn derive_ordering(graph: &crate::Graph, roots: &[NodeId]) -> OrderingEdges {
1925 let order = topo_order_multi(graph, roots);
1926
1927 // Consumer index: tensor NodeId → list of consumers of that tensor.
1928 let mut consumers: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
1929 for &nid in &order {
1930 for &input in &graph.node(nid).inputs {
1931 consumers.entry(input).or_default().push(nid);
1932 }
1933 }
1934
1935 // Alias groups, built ONCE for the whole graph instead of re-derived
1936 // per destructive op (`collect_alias_set` used to re-walk the entire
1937 // `order` from scratch for every one of them — O(destructive_ops *
1938 // order.len())). Every alias-extending node (view/bundle-projection/
1939 // reshape-contiguize — see `alias_group_of`'s doc) points at the SAME
1940 // group id as whatever it extends, in a single forward pass; querying
1941 // a group's members is then an O(1) map lookup. See `alias_group_of`
1942 // for why this produces the identical partition `collect_alias_set`
1943 // did, including the sibling-bundle case.
1944 let (alias_group_of, alias_members) = alias_groups(graph, &order);
1945
1946 let mut ordering: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
1947 for &nid in &order {
1948 let node = graph.node(nid);
1949 let Some(d_idx) = node.op.destructive_input() else {
1950 continue;
1951 };
1952 if d_idx >= node.inputs.len() {
1953 continue;
1954 }
1955 let destroyed = node.inputs[d_idx];
1956 // The alias set of `destroyed`: the destructive op writes
1957 // through `destroyed`'s Storage Arc, and every view-op node
1958 // transitively derived from `destroyed` shares that same Arc
1959 // (executor's view-op materialization wraps the source's Arc
1960 // in a fresh Layout — see `is_view_op` in `lib.rs`). So a
1961 // reader of any alias-set member sees `destroyed`'s bytes
1962 // and must run BEFORE the destructive op. Without this
1963 // walk, `y = x.transpose(); z = relu(y); x.relu_inplace()`
1964 // would not pin `relu(y)` before `relu_inplace(x)`, even
1965 // though they share storage.
1966 let group = alias_group_of.get(&destroyed).copied().unwrap_or(destroyed);
1967 // `destroyed` itself is always a member, even when nothing
1968 // aliases it (the common case: no entry in `alias_members` at
1969 // all) — mirrors `collect_alias_set`'s unconditional seed
1970 // `alias.insert(root)`. Without this fallback a non-aliased
1971 // target's own direct readers would never get visited below.
1972 let singleton = vec![group];
1973 let alias_set = alias_members.get(&group).unwrap_or(&singleton);
1974 for &alias in alias_set {
1975 let Some(readers) = consumers.get(&alias) else {
1976 continue;
1977 };
1978 for &reader in readers {
1979 // Skip the destructive op itself + readers that are
1980 // themselves alias members (those are view ops; their
1981 // own consumers are what we actually need to pin, and
1982 // they show up in subsequent iterations).
1983 if reader != nid && !alias_set.contains(&reader) {
1984 ordering.entry(nid).or_default().push(reader);
1985 }
1986 }
1987 }
1988 }
1989 OrderingEdges(ordering)
1990}
1991
1992/// Collect the alias set of `root`: every node whose realized Storage
1993/// is the same `Arc<RwLock<Storage>>` as `root`'s.
1994///
1995/// Three families of alias-extending op share the input's Storage Arc
1996/// at realize time:
1997///
1998/// 1. **Single-input view ops** ([`Op::is_view_op`]): the executor
1999/// wraps the source's Arc with a fresh Layout — no copy. Examples:
2000/// `Op::Transpose`, `Op::Permute`, `Op::Slice`, `Op::Unsqueeze`,
2001/// `Op::Squeeze`, `Op::BroadcastTo`, `Op::Flip`.
2002/// 2. **Multi-output projection** (`Op::View { slot }`): the
2003/// realized View clones the producer's bundled storage Arc and
2004/// exposes one slot's window — bytes are shared, the bundle's Arc
2005/// refcount tracks the consumer's lifetime (Session 1 of the
2006/// multi-output Option C design — see
2007/// [`fuel_ir::storage::OutputView`]).
2008/// 3. **Conditionally zero-copy contiguize ops** (`Op::Reshape`,
2009/// `Op::Contiguize`): `fuel-dispatch`'s `WorkItemKind::ContiguizeOf`
2010/// execution arm ADOPTS the input's Storage Arc verbatim — no
2011/// allocation, no copy — whenever the input layout is already
2012/// contiguous at offset 0; only a non-contiguous or offset input
2013/// forces a fresh `auto_contiguize` allocation. Because that check
2014/// is a RUNTIME layout fact `collect_alias_set` cannot evaluate at
2015/// graph-build time (offset-0-contiguity depends on the layouts
2016/// realized upstream), Reshape/Contiguize are treated as
2017/// UNCONDITIONALLY alias-extending here — conservative in the
2018/// (rarer) case they materialize a fresh copy, since an extra
2019/// ordering pin only serializes two ops that didn't actually need
2020/// it; never unsafe, since the alternative (treating them as
2021/// alias-opaque) is exactly the ordering defect this variant
2022/// fixes: a destructive op's OWN direct alias-set members were
2023/// already pinned-after-their-readers correctly, but a
2024/// Reshape/Contiguize hop is a graph-visible node in its own right
2025/// — if it's excluded from the alias set, its consumers are never
2026/// visited by the pin loop below, so a reader on the OTHER side of
2027/// an adopted-Arc Reshape/Contiguize has no ordering guarantee
2028/// against a destructive op reusing the SAME storage. This is the
2029/// MLA decode multi-round `WriteSlice` defect: layer `L`'s
2030/// post-append cache buffer is read once (non-destructively)
2031/// through a `Slice` → `Reshape` (head-split-style) → kernel chain
2032/// that becomes an ancestor of layer `L+1`'s write, and again as
2033/// the destination of layer `L`'s NEXT round's append — see
2034/// `fuel-core/src/lazy_deepseek2.rs`'s
2035/// `DeepSeek2Model::rebind_latent_cache_fresh_graph` doc, whose
2036/// graph-rebinding workaround this fix makes unnecessary for
2037/// CORRECTNESS (rebinding remains a legitimate memory/perf choice —
2038/// it also caps unbounded graph growth across steps — but a
2039/// same-graph multi-step decode no longer silently corrupts or
2040/// hard-errors without it).
2041///
2042/// `Op::ViewOwned` is explicitly NOT alias-extending: at execution
2043/// time it allocates a fresh standalone Storage and memcpys the
2044/// slot's bytes in. After the memcpy runs, no Arc handle on the
2045/// producer's bundle remains in the ViewOwned's chain. ViewOwned is
2046/// still pinned-after destructive readers via the regular
2047/// data-dependency edge (its `inputs[0]` is the producer), which the
2048/// caller already accounts for outside this function.
2049///
2050/// **Sibling-bundle rule**: when `root` is itself an `Op::View`,
2051/// every other `Op::View` of the same producer shares the same
2052/// bundle Arc. To capture them, the function pre-seeds the producer
2053/// into the alias set so the forward walk picks up siblings the same
2054/// way it picks up downstream `is_view_op` derivatives.
2055///
2056/// Implementation: O(|order|) forward walk after a constant-time
2057/// pre-seed. A node enters the alias set iff its op is in the
2058/// alias-extending union AND its `inputs[0]` is already in the set.
2059/// Builds the alias-equivalence partition over the WHOLE `order` in a
2060/// single forward pass, replacing the old `collect_alias_set`'s
2061/// per-destructive-op walk of the entire `order` (O(destructive_ops *
2062/// order.len()) — a second, independent quadratic cost alongside
2063/// `insert_safety_copies`' own, confirmed as the dominant cost in a live
2064/// decode-step profile: ~55% of total step time went to this family of
2065/// per-call graph rebuilds).
2066///
2067/// Every alias-extending node (the same three families as before: single-
2068/// input view ops, `Op::View{slot}` bundle projections, and the
2069/// conservatively-always-aliasing `Reshape`/`Contiguize`) is mapped to the
2070/// SAME group id as whatever it extends; a group's id is always its
2071/// earliest (topologically first) non-alias-extending member. Returns
2072/// `(group_of, members)`: `group_of[n]` is `n`'s group id (only present
2073/// for alias-extending nodes — absent means `n` is its own trivial
2074/// singleton group), and `members[g]` is every node in group `g`,
2075/// `g` itself included.
2076///
2077/// This produces the IDENTICAL partition the old per-call
2078/// `collect_alias_set(root, ...)` computed when called with `root` equal
2079/// to the group's EARLIEST member — the case every existing call site
2080/// exercises (`derive_ordering` always queries with a destructive op's
2081/// direct input, and a destructive op's direct input was never itself
2082/// observed, in this codebase's test suite, to be a multi-hop view
2083/// chain) — INCLUDING the sibling-bundle case (`Op::View{slot}`'s shared
2084/// producer `P`): the first `Op::View` of `P` resolves to group `P`
2085/// (since `P` is never itself inserted as a key, so `.unwrap_or(&P)`
2086/// defaults to `P`); every later `Op::View` of the same `P` resolves to
2087/// the identical group, landing all of them — and `P` itself — in one
2088/// membership list, exactly what `collect_alias_set`'s explicit "root is
2089/// a View ⇒ also seed its producer" special case achieved per-call.
2090///
2091/// ⚠️ NOT proven byte-identical for a query against a NON-earliest member
2092/// of a multi-hop alias chain (e.g. `destroyed` being a `Slice` of a
2093/// `Transpose` of a plain producer): the old per-call walk only ever
2094/// extended FORWARD from its seed, so querying from a late chain member
2095/// could under-report versus querying from the chain's root — a
2096/// pre-existing asymmetry in the old code, not introduced here. This
2097/// version computes the full, symmetric equivalence class regardless of
2098/// which member is queried (matching the function's own doc contract:
2099/// "every node whose realized Storage is the same Arc as root's"), which
2100/// can only ADD members relative to the old behavior, never drop any —
2101/// so if the two ever disagree, this version is the more conservative
2102/// (more copies/pins, never fewer) of the two. All existing
2103/// `insert_safety_copies`/`derive_ordering` tests pass unchanged.
2104fn alias_groups(
2105 graph: &crate::Graph,
2106 order: &[NodeId],
2107) -> (HashMap<NodeId, NodeId>, HashMap<NodeId, Vec<NodeId>>) {
2108 use crate::Op;
2109
2110 let mut group_of: HashMap<NodeId, NodeId> = HashMap::new();
2111 let mut members: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
2112
2113 for &nid in order {
2114 let node = graph.node(nid);
2115 // Op::View shares the producer's bundle Arc — it extends the
2116 // alias set even though it isn't an `is_view_op` in the
2117 // narrower single-storage-strided-view sense. Op::Reshape /
2118 // Op::Contiguize are CONDITIONALLY zero-copy (adopt the input
2119 // Arc iff contiguous at offset 0 — a runtime fact); treated
2120 // unconditionally alias-extending here is the conservative,
2121 // always-safe direction (see the function doc).
2122 let extends_alias = node.op.is_view_op()
2123 || matches!(node.op, Op::View { .. } | Op::Reshape(_) | Op::Contiguize);
2124 if !extends_alias {
2125 continue;
2126 }
2127 let Some(&inp) = node.inputs.first() else {
2128 continue;
2129 };
2130 let group = group_of.get(&inp).copied().unwrap_or(inp);
2131 group_of.insert(nid, group);
2132 members
2133 .entry(group)
2134 .or_insert_with(|| vec![group])
2135 .push(nid);
2136 }
2137 (group_of, members)
2138}
2139
2140/// Insert defensive `Op::Copy` snapshots ahead of destructive ops
2141/// that would otherwise produce a data-flow ↔ ordering-edge cycle.
2142///
2143/// Phase 5 of the in-place ops infrastructure
2144/// (`docs/session-prompts/in-place-ops-infrastructure.md`). The
2145/// view-aware [`derive_ordering`] (Phase 4a) handles the common case
2146/// where a destructive op's target is read only by upstream consumers
2147/// — pinning the destructive op after those reads is enough. The
2148/// case Phase 5 addresses is when a single op reads BOTH the
2149/// in-place target X AND a downstream consumer of the in-place op
2150/// (e.g., residual connections: `y = x.relu_inplace(); z = y + x`).
2151///
2152/// In that case, the data-flow says the reader (`z`) must run after
2153/// the destructive op (because it depends on `y`), and the ordering
2154/// edge says the destructive op must run after the reader (because
2155/// the reader reads `x`). Without intervention, [`execution_plan`]
2156/// detects the cycle and panics.
2157///
2158/// ## Conflict predicate (dependency-based, NOT topo-position-based)
2159///
2160/// A reader `R` of the destructive op `D`'s target needs a safety
2161/// copy iff `R` is not **provably ordered before** `D`. The proof
2162/// obligation is grounded in the executor's actual contract:
2163/// [`execution_plan`] emits a linear extension of the *combined*
2164/// precedence graph — data edges (`input → consumer`) plus
2165/// [`derive_ordering`]'s ordering edges (`reader → destructive op`) —
2166/// and the executor dispatches work items strictly sequentially in
2167/// that order. Therefore:
2168///
2169/// - `R` has a path to `D` in the combined graph AND `D` has no
2170/// path back to `R` → every valid plan runs `R` before `D`'s
2171/// mutation. Safe; **no copy**. (This covers every reader
2172/// `derive_ordering` pins directly, plus view-op readers whose
2173/// consumers are pinned — the pre-gap readers of an evict-chain
2174/// `Op::Move`, for example.)
2175/// - `D` has a path to `R` (e.g., the residual pattern: `R`
2176/// consumes `D`'s output while also reading `D`'s target) → the
2177/// pin edge `R → D` would close a cycle; the executor cannot
2178/// order `R` first. **Copy needed.**
2179/// - Neither direction → no ordering guarantee exists (possible for
2180/// alias-member view readers, which `derive_ordering` deliberately
2181/// does not pin). Safety cannot be proven, so **copy needed** —
2182/// the conservative default: a spurious copy costs bytes, a
2183/// missing copy is silent data corruption.
2184///
2185/// Historical note: this pass originally inferred conflicts from DFS
2186/// topo-order *position* (any reader appearing after `D` in
2187/// `topo_order_multi` order was treated as conflicting). Position in
2188/// a DFS walk carries no dependency information — an independent
2189/// pre-gap reader of a `Op::Move`d tensor could land after the Move
2190/// purely because of root visit order, earning a spurious copy
2191/// stamped onto the destructive op's device (live-GPU failure,
2192/// 2026-06-11).
2193///
2194/// This pass rewrites each conflict by:
2195/// 1. Inserting an `Op::Copy { target: same_device }(X) → X_safe` —
2196/// a same-device byte snapshot independent of `X`'s storage.
2197/// 2. Rewiring the conflicting reader's input from `X` to `X_safe`.
2198///
2199/// After the rewrite, the reader reads the snapshot (pre-mutation
2200/// bytes) while the destructive op proceeds on `X`. No cycle.
2201///
2202/// Scope: detects DIRECT conflicts (reader's input IS the
2203/// destructive op's target). View-mediated conflicts (reader reads
2204/// `view(X)`) fall through and produce the cycle panic from
2205/// `execution_plan` — a clear-but-not-friendly error pointing the
2206/// user at the pattern. View-mediated auto-resolution is a follow-up
2207/// (would need to insert `Copy(X)` then re-derive the view from the
2208/// snapshot for each conflicting reader).
2209///
2210/// Idempotent: calling this twice on the same graph inserts copies
2211/// at most once per conflict (the rewrite removes the conflict by
2212/// redirecting the reader's input edge to the copy).
2213///
2214/// Returns the number of safety copies inserted (for telemetry /
2215/// testing).
2216pub fn insert_safety_copies(graph: &mut crate::Graph, roots: &[NodeId]) -> usize {
2217 /// Default [`DeviceLocation`] for a `BackendId` when the graph has
2218 /// no per-node `placement` set. Single-GPU convention (gpu_id 0) —
2219 /// mirrors `fuel_dispatch::plan::default_device_for`, duplicated
2220 /// here because `fuel-graph` doesn't depend on `fuel-dispatch`.
2221 /// `insert_safety_copies`' own `target_location` fallback is this
2222 /// function's sole caller.
2223 fn device_location_for_backend(backend: fuel_ir::probe::BackendId) -> DeviceLocation {
2224 use fuel_ir::probe::BackendId;
2225 match backend {
2226 BackendId::Cpu => DeviceLocation::Cpu,
2227 BackendId::Cuda => DeviceLocation::Cuda { gpu_id: 0 },
2228 BackendId::Vulkan => DeviceLocation::Vulkan { gpu_id: 0 },
2229 BackendId::Metal => DeviceLocation::Metal { gpu_id: 0 },
2230 _ => DeviceLocation::Cpu,
2231 }
2232 }
2233
2234 let order = topo_order_multi(graph, roots);
2235
2236 // Combined precedence graph: data edges (input → consumer) plus
2237 // derive_ordering's edges (reader → destructive op). This is
2238 // exactly the edge set execution_plan linearizes, so path
2239 // queries here answer "does every valid plan order A before B?".
2240 let ordering = derive_ordering(graph, roots);
2241 let node_set: std::collections::HashSet<NodeId> = order.iter().copied().collect();
2242 let mut succ: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
2243 let mut pred: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
2244 for &nid in &order {
2245 for &input in &graph.node(nid).inputs {
2246 if node_set.contains(&input) {
2247 succ.entry(input).or_default().push(nid);
2248 pred.entry(nid).or_default().push(input);
2249 }
2250 }
2251 for &dep in ordering.deps_of(nid) {
2252 if node_set.contains(&dep) {
2253 succ.entry(dep).or_default().push(nid);
2254 pred.entry(nid).or_default().push(dep);
2255 }
2256 }
2257 }
2258
2259 // All nodes reachable from `start` over `adj` (excluding `start`
2260 // itself unless it sits on a cycle). Iterative DFS; the visited
2261 // set terminates cyclic combined graphs (the very cycles this
2262 // pass exists to break).
2263 fn reach(
2264 start: NodeId,
2265 adj: &HashMap<NodeId, Vec<NodeId>>,
2266 ) -> std::collections::HashSet<NodeId> {
2267 let mut seen: std::collections::HashSet<NodeId> = std::collections::HashSet::new();
2268 let mut stack = vec![start];
2269 while let Some(n) = stack.pop() {
2270 if let Some(next) = adj.get(&n) {
2271 for &m in next {
2272 if seen.insert(m) {
2273 stack.push(m);
2274 }
2275 }
2276 }
2277 }
2278 seen
2279 }
2280
2281 // Snapshot conflicts before mutating the graph: collect
2282 // (destructive_op_id, target, conflicting_readers) tuples.
2283 struct Conflict {
2284 destructive_nid: NodeId,
2285 target: NodeId,
2286 target_shape: crate::Shape,
2287 target_dtype: DType,
2288 conflicting_readers: Vec<NodeId>,
2289 }
2290 let mut conflicts: Vec<Conflict> = Vec::new();
2291
2292 for &nid in &order {
2293 let node = graph.node(nid);
2294 let Some(d_idx) = node.op.destructive_input() else {
2295 continue;
2296 };
2297 if d_idx >= node.inputs.len() {
2298 continue;
2299 }
2300 let target = node.inputs[d_idx];
2301
2302 // Cheap pre-check: skip BOTH `reach()` walks and the
2303 // `order.len()`-sized readers scan below entirely when `target`
2304 // provably has no OTHER reader at all — the common case in a real
2305 // decode graph (KV-cache writes, release markers: nothing besides
2306 // the destructive op itself touches their target). `succ` was
2307 // already built above from EVERY node's `.inputs` edges (plus
2308 // ordering deps) for every node whose INPUT is in `node_set`, so
2309 // when `target` is in `node_set`, `succ[target]` is the COMPLETE
2310 // set of nodes with `target` as a direct input or an ordering
2311 // dependent — a superset of what the scan below finds (ordering
2312 // deps can appear here without a matching direct-input reader, but
2313 // never the reverse). Empty there (besides `nid` itself) therefore
2314 // guarantees the scan would find nothing either, so this cannot
2315 // change which copies get inserted — it only skips redundant work.
2316 // Falls through to the full computation when `target` isn't in
2317 // `node_set` (rare; `succ` doesn't cover it there).
2318 if node_set.contains(&target) {
2319 let any_other_reader = succ
2320 .get(&target)
2321 .is_some_and(|rs| rs.iter().any(|&r| r != nid));
2322 if !any_other_reader {
2323 continue;
2324 }
2325 }
2326
2327 // `forced_before`: nodes with a combined-graph path TO the
2328 // destructive op. `forced_after`: nodes the destructive op
2329 // has a combined-graph path to. A reader is provably safe
2330 // iff it is forced-before AND not forced-after (the latter
2331 // would mean its pin edge closes a cycle the executor cannot
2332 // satisfy). Anything else — cycle members, descendants, or
2333 // readers with no ordering guarantee in either direction —
2334 // gets a safety copy. False positives cost a snapshot;
2335 // false negatives are silent data corruption.
2336 let forced_before = reach(nid, &pred);
2337 let forced_after = reach(nid, &succ);
2338
2339 let mut readers: Vec<NodeId> = Vec::new();
2340 for &maybe_reader in &order {
2341 if maybe_reader == nid {
2342 continue;
2343 }
2344 if !graph.node(maybe_reader).inputs.contains(&target) {
2345 continue;
2346 }
2347 let provably_before =
2348 forced_before.contains(&maybe_reader) && !forced_after.contains(&maybe_reader);
2349 if !provably_before {
2350 readers.push(maybe_reader);
2351 }
2352 }
2353 if readers.is_empty() {
2354 continue;
2355 }
2356
2357 let target_node = graph.node(target);
2358 conflicts.push(Conflict {
2359 destructive_nid: nid,
2360 target,
2361 target_shape: target_node.shape.clone(),
2362 target_dtype: target_node.dtype,
2363 conflicting_readers: readers,
2364 });
2365 }
2366
2367 let inserted = conflicts.len();
2368 for Conflict {
2369 destructive_nid,
2370 target,
2371 target_shape,
2372 target_dtype,
2373 conflicting_readers,
2374 } in conflicts
2375 {
2376 // Pick the copy's target_location. Prefer the target's own
2377 // placement (if any) — that's the device the data lives on.
2378 // Fall back to the destructive op's placement. Most graphs
2379 // populate NEITHER `placement` (a rarely-used explicit pin,
2380 // `Graph::set_placement` / `.on_device(..)`) — ordinary
2381 // single-device `optimize_graph` only stamps the coarser
2382 // `target_backend` (`BackendId`, no gpu_id) — so fall back
2383 // further to a same-device location DERIVED from
2384 // `target_backend` before ever reaching for `Cpu`. Getting
2385 // this wrong silently strands a same-device CUDA/Vulkan
2386 // safety snapshot on the host: the (unrelated) reader this
2387 // copy exists to protect keeps its original device, so the
2388 // optimizer's own backend-follows-data logic then drags the
2389 // reader onto CPU too, and — the capture-specific failure
2390 // mode a same-device CUDA decode graph hits — an ordinary
2391 // same-device snapshot becomes a genuinely cross-device D2H
2392 // that can never be part of a CUDA graph capture (see
2393 // `fuel_dispatch::pipelined::capture_decode`, which only
2394 // ever accepts `Op::Copy` when `target` is same-device Cuda).
2395 // Only truly unplaced AND unbackended nodes (neither hint
2396 // available anywhere) fall all the way through to `Cpu`.
2397 let target_location = graph
2398 .placement(target)
2399 .or_else(|| graph.placement(destructive_nid))
2400 .or_else(|| {
2401 graph
2402 .target_backend(target)
2403 .or_else(|| graph.target_backend(destructive_nid))
2404 .map(device_location_for_backend)
2405 })
2406 .unwrap_or(DeviceLocation::Cpu);
2407 let copy_id = graph.push(crate::Node {
2408 op: crate::Op::Copy {
2409 target: target_location,
2410 },
2411 inputs: vec![target],
2412 shape: target_shape,
2413 dtype: target_dtype,
2414 });
2415 // Propagate target_backend so the executor can look up the
2416 // copy's wrapper. Inherit from the target (the source of the
2417 // copy), falling back to the destructive op's target_backend.
2418 if let Some(backend) = graph
2419 .target_backend(target)
2420 .or_else(|| graph.target_backend(destructive_nid))
2421 {
2422 graph.set_target_backend(copy_id, backend);
2423 }
2424 for reader_id in conflicting_readers {
2425 graph.rewrite_input(reader_id, target, copy_id);
2426 }
2427 }
2428 inserted
2429}
2430
2431/// Walk every multi-output producer in the graph and promote some of
2432/// its `Op::View` consumers to `Op::ViewOwned` when slot lifetimes
2433/// are sufficiently asymmetric that keeping the whole bundle alive
2434/// for the longest-lived slot is wasteful.
2435///
2436/// v1 heuristic (Option C, Session 2): for each multi-output producer
2437/// P, compute the per-slot "last use" position in topo order (the
2438/// max position over each slot's Views and their transitive
2439/// consumers). Slots whose last use is strictly later than at least
2440/// one other slot's last use get all their Views promoted to
2441/// `Op::ViewOwned`. The remaining short-lived slots stay as
2442/// `Op::View` so the bundle drops naturally when their last consumer
2443/// finishes.
2444///
2445/// Concrete example: SelectiveScan's `y` (slot 0) consumed at the
2446/// next layer; `last_state` (slot 1) retained across a barrier into
2447/// the next autoregressive step. `last_state`'s last use is much
2448/// later → it gets promoted to `ViewOwned`, the bundle drops once
2449/// `y`'s consumer finishes, and `last_state`'s standalone copy
2450/// survives across the gap.
2451///
2452/// Implementation: builds a consumer index over the topo order,
2453/// computes `last_use[N] = max(pos[N], last_use[consumers])` by
2454/// reverse-iterating, then per producer makes a per-slot rollup
2455/// and rewrites edges from the promoted Views to fresh
2456/// `Op::ViewOwned` nodes. Returns the number of promotions made.
2457/// Idempotent on already-promoted Views (an `Op::ViewOwned` is a
2458/// fixpoint).
2459///
2460/// **Where this plugs in:** today the multi-output infra has no
2461/// production consumer (Session 2 ships the authoring contract and
2462/// this pass; consumers light up in the
2463/// `selective-scan-ssd-chunk-multi-output-followup` session). The
2464/// pass is a no-op on graphs without any `Op::View` consumers of a
2465/// multi-output producer — the common case until consumers migrate.
2466/// Long-term it likely runs after the ranker/picker pass and before
2467/// `compile_plan`; placement is the picker session's call.
2468pub fn promote_views_for_liveness(graph: &mut crate::Graph, roots: &[NodeId]) -> usize {
2469 use crate::Op;
2470
2471 let order = topo_order_multi(graph, roots);
2472 if order.is_empty() {
2473 return 0;
2474 }
2475 // Forward consumer index: producer NodeId → list of consumer NodeIds.
2476 let mut consumers: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
2477 for &nid in &order {
2478 for &input in &graph.node(nid).inputs {
2479 consumers.entry(input).or_default().push(nid);
2480 }
2481 }
2482
2483 // `depth[N]`: longest-input-chain length feeding into N. Two
2484 // sibling consumers in the same conceptual layer get the same
2485 // depth, so the lifetime comparison below treats them symmetrically
2486 // even when topo *position* puts them in different slots of the
2487 // walk (which depends on roots[] order and traversal direction —
2488 // unstable for our purposes).
2489 let mut depth: HashMap<NodeId, usize> = HashMap::with_capacity(order.len());
2490 for &nid in &order {
2491 let mut d = 0usize;
2492 for &inp in &graph.node(nid).inputs {
2493 if let Some(&di) = depth.get(&inp)
2494 && di + 1 > d
2495 {
2496 d = di + 1;
2497 }
2498 }
2499 depth.insert(nid, d);
2500 }
2501
2502 // `last_use[N]`: max downstream depth reachable from N (including
2503 // N itself). Computed by reverse-iterating the topo order and
2504 // propagating each node's depth back to its inputs.
2505 let mut last_use: HashMap<NodeId, usize> = HashMap::with_capacity(order.len());
2506 for &nid in order.iter().rev() {
2507 let mut lu = depth[&nid];
2508 if let Some(downstream) = consumers.get(&nid) {
2509 for &c in downstream {
2510 if let Some(&cl) = last_use.get(&c)
2511 && cl > lu
2512 {
2513 lu = cl;
2514 }
2515 }
2516 }
2517 last_use.insert(nid, lu);
2518 }
2519
2520 // Find every multi-output producer that has any View / ViewOwned
2521 // consumers in this graph. Per producer: bucket the Views by slot
2522 // and compute the per-slot last-use position.
2523 struct ViewEntry {
2524 view_id: NodeId,
2525 slot: u32,
2526 is_owned: bool,
2527 }
2528 let mut by_producer: HashMap<NodeId, Vec<ViewEntry>> = HashMap::new();
2529 for &nid in &order {
2530 let node = graph.node(nid);
2531 let (slot, is_owned) = match node.op {
2532 Op::View { slot } => (slot, false),
2533 Op::ViewOwned { slot } => (slot, true),
2534 _ => continue,
2535 };
2536 // View / ViewOwned have a single input (the producer).
2537 let Some(&producer) = node.inputs.first() else {
2538 continue;
2539 };
2540 // Only meaningful if producer was declared multi-output;
2541 // otherwise it's an authoring bug that the NodeHandle::view
2542 // builder already rejected. Defensive skip.
2543 if !graph.is_multi_output(producer) {
2544 continue;
2545 }
2546 by_producer.entry(producer).or_default().push(ViewEntry {
2547 view_id: nid,
2548 slot,
2549 is_owned,
2550 });
2551 }
2552
2553 // For each producer, decide which slots get promoted and collect
2554 // the per-View promotions. Defer the actual graph mutations to a
2555 // second pass so we can iterate the analysis with `&Graph`.
2556 struct Promotion {
2557 view_id: NodeId,
2558 slot: u32,
2559 view_shape: crate::Shape,
2560 view_dtype: DType,
2561 // Consumers of `view_id` at analysis time — every edge gets
2562 // rewired to the new ViewOwned node.
2563 view_consumers: Vec<NodeId>,
2564 }
2565 let mut promotions: Vec<Promotion> = Vec::new();
2566 for views in by_producer.values() {
2567 // Per-slot last use rollup.
2568 let mut slot_last_use: HashMap<u32, usize> = HashMap::new();
2569 for v in views {
2570 let lu = last_use[&v.view_id];
2571 slot_last_use
2572 .entry(v.slot)
2573 .and_modify(|cur| {
2574 if lu > *cur {
2575 *cur = lu;
2576 }
2577 })
2578 .or_insert(lu);
2579 }
2580 // If every slot in this producer has the same last_use, the
2581 // bundle's natural drop position is fine — no promotions.
2582 let min_lu = match slot_last_use.values().copied().min() {
2583 Some(v) => v,
2584 None => continue,
2585 };
2586 for v in views {
2587 if v.is_owned {
2588 // Already owned — nothing to do (idempotent).
2589 continue;
2590 }
2591 let lu = slot_last_use[&v.slot];
2592 if lu <= min_lu {
2593 // This slot is among the shortest-lived → stays View.
2594 continue;
2595 }
2596 let v_node = graph.node(v.view_id);
2597 promotions.push(Promotion {
2598 view_id: v.view_id,
2599 slot: v.slot,
2600 view_shape: v_node.shape.clone(),
2601 view_dtype: v_node.dtype,
2602 view_consumers: consumers.get(&v.view_id).cloned().unwrap_or_default(),
2603 });
2604 }
2605 }
2606
2607 // Apply mutations: each promoted Op::View becomes a fresh
2608 // Op::ViewOwned that consumers point at instead. The old Op::View
2609 // node stays in the arena (no in-place op mutation) but is
2610 // unreachable from the roots — execution_plan + topo skip it
2611 // naturally. This mirrors the insert_safety_copies pattern of
2612 // "add new nodes + rewrite_input" rather than mutating ops in
2613 // place.
2614 let count = promotions.len();
2615 for p in promotions {
2616 let producer = graph.node(p.view_id).inputs[0];
2617 let owned_id = graph.push(crate::Node {
2618 op: Op::ViewOwned { slot: p.slot },
2619 inputs: vec![producer],
2620 shape: p.view_shape,
2621 dtype: p.view_dtype,
2622 });
2623 // Inherit target_backend from the original View when set, so
2624 // the executor can look up ViewOwned's wrapper without a
2625 // re-resolution pass.
2626 if let Some(backend) = graph.target_backend(p.view_id) {
2627 graph.set_target_backend(owned_id, backend);
2628 }
2629 for consumer in p.view_consumers {
2630 graph.rewrite_input(consumer, p.view_id, owned_id);
2631 }
2632 }
2633 count
2634}
2635
2636/// Build an execution plan that respects both data-flow edges (via
2637/// [`topo_order_multi`]) and ordering edges (via [`derive_ordering`]).
2638/// Returns a `Vec<NodeId>` in an order the executor can walk linearly,
2639/// evaluating each node's dependencies before it.
2640///
2641/// **Fast path:** when the graph has no destructive ops, this returns
2642/// the same order `topo_order_multi` does — no extra cost beyond the
2643/// analysis pass.
2644///
2645/// **Cycle detection:** if the combined (data + ordering) graph has a
2646/// cycle — the residency rule emitted a destructive op that
2647/// transitively depends on itself — this panics with a clear error
2648/// message. That panic indicates a bug in the rule that emitted the
2649/// destructive op, not user code.
2650///
2651/// **Stability:** among nodes that are concurrently ready, this
2652/// picks the one whose `topo_order_multi` index is smallest. Result:
2653/// when there are no ordering edges, the output matches
2654/// `topo_order_multi` exactly.
2655pub fn execution_plan(graph: &crate::Graph, roots: &[NodeId]) -> Vec<NodeId> {
2656 let base_order = topo_order_multi(graph, roots);
2657 let ordering = derive_ordering(graph, roots);
2658 if ordering.is_empty() {
2659 return base_order;
2660 }
2661
2662 // Position of each node in base_order — used as the stable tiebreaker.
2663 let pos: HashMap<NodeId, usize> = base_order
2664 .iter()
2665 .enumerate()
2666 .map(|(i, &n)| (n, i))
2667 .collect();
2668 let node_set: std::collections::HashSet<NodeId> = base_order.iter().copied().collect();
2669
2670 // Build in-degree + reverse adjacency for Kahn's.
2671 let mut in_degree: HashMap<NodeId, usize> = HashMap::with_capacity(base_order.len());
2672 let mut forward: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
2673 for &nid in &base_order {
2674 let node = graph.node(nid);
2675 let mut d = 0usize;
2676 for &input in &node.inputs {
2677 if node_set.contains(&input) {
2678 d += 1;
2679 forward.entry(input).or_default().push(nid);
2680 }
2681 }
2682 for &dep in ordering.deps_of(nid) {
2683 if node_set.contains(&dep) {
2684 d += 1;
2685 forward.entry(dep).or_default().push(nid);
2686 }
2687 }
2688 in_degree.insert(nid, d);
2689 }
2690
2691 // Stable ready-set keyed by base_order position. BTreeSet pops the
2692 // smallest → output matches topo order when ordering edges allow it.
2693 let mut ready: std::collections::BTreeSet<(usize, NodeId)> = base_order
2694 .iter()
2695 .copied()
2696 .filter(|n| in_degree[n] == 0)
2697 .map(|n| (pos[&n], n))
2698 .collect();
2699
2700 let mut plan = Vec::with_capacity(base_order.len());
2701 while let Some(&(_, n)) = ready.iter().next() {
2702 ready.remove(&(pos[&n], n));
2703 plan.push(n);
2704 if let Some(succs) = forward.get(&n) {
2705 for &s in succs {
2706 let d = in_degree.get_mut(&s).expect("in_degree covers all nodes");
2707 *d -= 1;
2708 if *d == 0 {
2709 ready.insert((pos[&s], s));
2710 }
2711 }
2712 }
2713 }
2714
2715 if plan.len() != base_order.len() {
2716 panic!(
2717 "execution_plan: cycle in ordering edges (plan={}, base={}) — \
2718 a destructive op transitively depends on itself. This is a \
2719 bug in whatever rule emitted the destructive op.",
2720 plan.len(),
2721 base_order.len(),
2722 );
2723 }
2724 plan
2725}
2726
2727// ---- Evict + reload graph surgery -----------------------------------------
2728
2729/// Insert an evict-chain around `candidate` to free its device storage
2730/// during a gap between uses, with automatic reload before the post-gap
2731/// consumers. Returns `(move_id, reload_id)`.
2732///
2733/// ## What it does
2734///
2735/// Inserts two new nodes:
2736/// 1. `mv = Op::Move { target: Cpu }` reading `candidate` — stages the
2737/// data to host memory AND destructively releases `candidate`'s
2738/// device-resident storage once it runs ([`Op::Move`] is the fused
2739/// Copy + Release: `destructive_input() == Some(0)`). The
2740/// [`derive_ordering`] pass pins it to run AFTER every
2741/// non-destructive reader of `candidate` (the pre-gap consumers the
2742/// caller left untouched).
2743/// 2. `reload = Op::Copy { target: src_device }` reading `mv` —
2744/// restages the data to the device for the post-gap consumers.
2745///
2746/// Then rewrites every `post_gap_consumer`'s input edge from `candidate`
2747/// to `reload`. Pre-gap consumers keep reading `candidate` directly.
2748///
2749/// Historical note: the original (legacy-executor era) chain was three
2750/// nodes — `Op::Copy{Cpu}` + side-effect-root `Op::Release` +
2751/// `Op::Copy{device}`. `Op::Move` collapsed the first two; the fused
2752/// node's output is consumed by `reload`, so no side-effect root is
2753/// needed.
2754///
2755/// ## Caller's responsibility
2756///
2757/// - `candidate` is a NodeId currently in the graph whose device
2758/// residency is `src_device`.
2759/// - `post_gap_consumers` are NodeIds currently in the graph that each
2760/// have `candidate` in their `inputs`. Typically these come from the
2761/// residency analyzer's gap-positioning logic.
2762/// - The caller stamps placement/backend metadata on the new nodes
2763/// (`mv` runs on `src_device`'s backend, `reload` on Cpu — the
2764/// staged copy's residency) before realizing on the pipelined
2765/// executor. `fuel-dispatch::residency::insert_residency_evictions`
2766/// does this.
2767pub fn insert_evict_reload(
2768 graph: &SharedGraph,
2769 candidate: NodeId,
2770 src_device: DeviceLocation,
2771 post_gap_consumers: &[NodeId],
2772) -> (NodeId, NodeId) {
2773 let mut g = graph.write().unwrap();
2774 let (shape, dtype) = {
2775 let n = g.node(candidate);
2776 (n.shape.clone(), n.dtype)
2777 };
2778
2779 let move_id = g.push(Node {
2780 op: Op::Move {
2781 target: DeviceLocation::Cpu,
2782 },
2783 inputs: vec![candidate],
2784 shape: shape.clone(),
2785 dtype,
2786 });
2787
2788 let reload_id = g.push(Node {
2789 op: Op::Copy { target: src_device },
2790 inputs: vec![move_id],
2791 shape,
2792 dtype,
2793 });
2794
2795 // Rewrite each post-gap consumer's `candidate` input to `reload_id`.
2796 // Pre-gap consumers (not in this list) continue reading `candidate`
2797 // directly, which is why derive_ordering needs `move_id` to run
2798 // after them.
2799 for &consumer in post_gap_consumers {
2800 g.rewrite_input(consumer, candidate, reload_id);
2801 }
2802
2803 (move_id, reload_id)
2804}
2805
2806// ===========================================================================
2807// Phase 2.1 — cross-device `Op::Copy` insertion
2808// ===========================================================================
2809//
2810// When the picker (or a user pin) commits a kernel-bearing node to a
2811// `DeviceLocation` that doesn't share a storage substrate with one of
2812// its inputs' resident `DeviceLocation`, the data needs to actually
2813// move. The executor handles the FINAL output via `pipelined_bridge`'s
2814// realize-root splicing; cross-device edges INSIDE the graph are
2815// handled here.
2816//
2817// `insert_cross_device_copies` is the pre-execute pass that handles
2818// internal cross-device edges. Architecture v1.0 §04 names the
2819// optimizer as the owner of transfer-op insertion — this is that
2820// owner. Wired into `fuel-tensor::pipelined_bridge::prepare()` (picker
2821// arc step 2): the bridge derives `placement_for` from the monolithic
2822// `target_backend` pinning + the StorageCache's resident locations,
2823// and `shares_storage` from `SystemTopology`.
2824//
2825// The pass deliberately doesn't decide placements itself — callers
2826// (the optimizer ranker / picker) commit to placements first, then
2827// hand the pass `(graph, placement_for_node, shares_storage)` and
2828// it materializes the implied transfers. Same pattern as the
2829// JudgeOracle / CapabilitiesLookup callbacks in
2830// `fuel-dispatch::ranker`: keep `fuel-graph` ignorant of how
2831// placements / topology are derived; the closure is the seam.
2832
2833/// Insert `Op::Copy { target }` nodes on every edge where the
2834/// consumer's intended `DeviceLocation` doesn't share a storage
2835/// substrate with the producer's. Returns a remap of original
2836/// `NodeId`s to their post-rewrite equivalents (most NodeIds map to
2837/// themselves; only consumers of cross-device edges acquire new
2838/// inputs in-place via `rewrite_input`).
2839///
2840/// # Arguments
2841///
2842/// - `graph` — the graph to rewrite, mutably. New `Op::Copy` nodes
2843/// are appended via `Graph::push`; consumer edges are rewired via
2844/// the internal `rewrite_input` helper.
2845/// - `roots` — the realize roots the caller cares about. The pass
2846/// walks `topo_order_multi(roots)` and considers every node it
2847/// visits.
2848/// - `placement_for` — `Fn(NodeId) -> Option<DeviceLocation>`.
2849/// Returns the picker's committed placement for the node, or
2850/// `None` if the node has no placement (view ops, structural ops,
2851/// user-unpinned ops in a single-backend realize). When either
2852/// the producer or consumer has no placement, the pass leaves
2853/// the edge alone — the executor falls back to its existing
2854/// handling (auto_contiguize or the realize-root splice).
2855/// - `shares_storage` — `Fn(DeviceLocation, DeviceLocation) -> bool`.
2856/// The topology query. Typical implementation is
2857/// `SystemTopology::shares_storage((b1, dev1), (b2, dev2))` after
2858/// the caller resolves backends to devices; we pass just the
2859/// `DeviceLocation` pair here because the substrate question is
2860/// device-level (`Cpu` shares with `Cpu`, `Cuda{0}` shares with
2861/// `Cuda{0}` but not `Cuda{1}`, etc.). Same-device queries should
2862/// return `true`.
2863/// - `needs_host_staging` — `Fn(DeviceLocation, DeviceLocation) -> bool`.
2864/// For a non-shared edge that this pass IS bridging, returns `true`
2865/// when the transfer cannot be done as a SINGLE `Op::Copy` hop and
2866/// must instead route through host memory as TWO hops
2867/// (producer-GPU → CPU, then CPU → consumer-GPU). This is the
2868/// cross-VENDOR GPU↔GPU case (CUDA↔Vulkan): there is no direct
2869/// device-to-device copy kernel between distinct GPU substrates, so
2870/// the source-backend Copy kernel only accepts a CPU or same-substrate
2871/// output. The single-hop cases — CPU↔CUDA, CPU↔Vulkan, and any edge
2872/// touching CPU on either end — return `false` and get exactly ONE
2873/// `Op::Copy` (byte-identical to the pre-two-hop behavior). Typical
2874/// wiring derives this from `SystemTopology::transfer_path(src, dst)
2875/// == TransferPath::HostStaging` for GPU↔GPU pairs. When `true`, the
2876/// pass inserts a CPU intermediate `Op::Copy { target: Cpu }` (placed
2877/// on CPU) and a second `Op::Copy { target: consumer }` reading it;
2878/// the consumer is rewired to the second hop.
2879///
2880/// # Returns
2881///
2882/// The `NodeId`s of the inserted `Op::Copy` nodes, in insertion
2883/// order. Callers typically need these to stamp executor metadata
2884/// on the new nodes — e.g. `fuel-tensor::pipelined_bridge` sets
2885/// `target_backend` = the SOURCE backend (the pipelined executor's
2886/// Op::Copy kernel-lookup convention: the transfer kernel runs on
2887/// the backend the bytes come FROM). `.len()` is the transfer-count
2888/// metric the optimizer reports when deciding whether to pursue
2889/// alternative placements that minimize transfers. A two-hop
2890/// (host-staged) edge contributes BOTH inserted copies (the CPU
2891/// intermediate and the consumer-side hop) to this vec; the SOURCE-
2892/// backend re-stamp the caller applies to `inputs.first()` is
2893/// correct for each hop independently (the CPU hop's source is the
2894/// producer GPU; the consumer hop's source is the CPU intermediate).
2895///
2896/// # Idempotence
2897///
2898/// The pass is idempotent — re-running on an already-rewritten
2899/// graph adds zero new copies (the inserted `Op::Copy` nodes
2900/// carry `placement` matching the consumer's, so subsequent
2901/// passes see same-device edges from `Op::Copy` to consumer).
2902///
2903/// # CSE on inserted copies
2904///
2905/// When two consumers share an input AND both need to bring it to
2906/// the same target device, the pass deduplicates: one `Op::Copy`
2907/// node serves both consumers. This matches the existing CSE
2908/// pattern in `optimize_to_fixpoint`.
2909pub fn insert_cross_device_copies<P, S, H>(
2910 graph: &mut Graph,
2911 roots: &[NodeId],
2912 placement_for: P,
2913 shares_storage: S,
2914 needs_host_staging: H,
2915) -> Vec<NodeId>
2916where
2917 P: Fn(NodeId) -> Option<DeviceLocation>,
2918 S: Fn(DeviceLocation, DeviceLocation) -> bool,
2919 H: Fn(DeviceLocation, DeviceLocation) -> bool,
2920{
2921 let order = topo_order_multi(graph, roots);
2922 // CSE on inserted copies: `(producer, target_device) → Op::Copy NodeId`.
2923 // For a two-hop edge the CPU intermediate is itself CSE-keyed at
2924 // `(producer, Cpu)` and the consumer hop at `(cpu_intermediate, target)`,
2925 // so a producer feeding several cross-vendor consumers stages to CPU
2926 // exactly once and each distinct target gets one consumer-side hop.
2927 let mut copy_cache: HashMap<(NodeId, DeviceLocation), NodeId> = HashMap::new();
2928 let mut inserted: Vec<NodeId> = Vec::new();
2929
2930 // Two passes: first compute the rewires we want, then apply
2931 // them. Splitting avoids borrow issues with graph mutation
2932 // mid-iteration and keeps the iteration order stable.
2933 let mut rewires: Vec<(NodeId, NodeId, NodeId)> = Vec::new();
2934
2935 for &consumer_id in &order {
2936 let consumer_placement = match placement_for(consumer_id) {
2937 Some(p) => p,
2938 None => continue,
2939 };
2940 // Skip ops that are themselves transfers — `Op::Copy` /
2941 // `Op::Move` exist precisely TO bridge devices; inserting
2942 // another copy on their input would be infinite-regress.
2943 let consumer_op = &graph.node(consumer_id).op;
2944 if matches!(consumer_op, Op::Copy { .. } | Op::Move { .. }) {
2945 continue;
2946 }
2947
2948 // Snapshot inputs so we don't reborrow during the loop.
2949 let inputs: Vec<NodeId> = graph.node(consumer_id).inputs.clone();
2950 for producer_id in inputs {
2951 let producer_placement = match placement_for(producer_id) {
2952 Some(p) => p,
2953 None => continue,
2954 };
2955 if shares_storage(producer_placement, consumer_placement) {
2956 continue;
2957 }
2958
2959 // Decide hop count. A cross-VENDOR GPU↔GPU edge (e.g.
2960 // CUDA↔Vulkan) can't be done as one `Op::Copy` — there's no
2961 // direct device-to-device copy kernel between distinct GPU
2962 // substrates, so the source-backend Copy kernel rejects the
2963 // foreign-GPU output. Route it through host memory as TWO hops:
2964 // producer-GPU → CPU (intermediate), then CPU → consumer-GPU.
2965 // Single-hop edges (anything touching CPU on either end, or a
2966 // same-vendor pair the topology can copy directly) stay exactly
2967 // one `Op::Copy` — byte-identical to the pre-two-hop behavior.
2968 let two_hop = needs_host_staging(producer_placement, consumer_placement);
2969
2970 // `source_for_consumer_hop` is the node the consumer-side
2971 // `Op::Copy { target: consumer_placement }` reads FROM: the
2972 // producer directly for a single hop, or the CPU intermediate
2973 // for a two-hop edge.
2974 let source_for_consumer_hop = if two_hop {
2975 // Hop 1: producer-GPU → CPU intermediate. CSE-keyed at
2976 // `(producer, Cpu)` so a producer feeding several cross-
2977 // vendor consumers stages to CPU exactly once.
2978 match copy_cache.get(&(producer_id, DeviceLocation::Cpu)) {
2979 Some(&id) => id,
2980 None => {
2981 let producer = graph.node(producer_id);
2982 let shape = producer.shape.clone();
2983 let dtype = producer.dtype;
2984 let cpu_id = graph.push(Node {
2985 op: Op::Copy {
2986 target: DeviceLocation::Cpu,
2987 },
2988 inputs: vec![producer_id],
2989 shape,
2990 dtype,
2991 });
2992 // The CPU intermediate bridges the two hops; place
2993 // it on CPU.
2994 graph.set_placement(cpu_id, DeviceLocation::Cpu);
2995 copy_cache.insert((producer_id, DeviceLocation::Cpu), cpu_id);
2996 inserted.push(cpu_id);
2997 cpu_id
2998 }
2999 }
3000 } else {
3001 producer_id
3002 };
3003
3004 // Hop 2 (or the single hop): `source_for_consumer_hop` →
3005 // consumer device. CSE-keyed at `(source, consumer_placement)`.
3006 let copy_id = match copy_cache.get(&(source_for_consumer_hop, consumer_placement)) {
3007 Some(&id) => id,
3008 None => {
3009 let src = graph.node(source_for_consumer_hop);
3010 let shape = src.shape.clone();
3011 let dtype = src.dtype;
3012 let id = graph.push(Node {
3013 op: Op::Copy {
3014 target: consumer_placement,
3015 },
3016 inputs: vec![source_for_consumer_hop],
3017 shape,
3018 dtype,
3019 });
3020 // The inserted copy itself sits on the
3021 // consumer's device — its output is what the
3022 // consumer reads.
3023 graph.set_placement(id, consumer_placement);
3024 copy_cache.insert((source_for_consumer_hop, consumer_placement), id);
3025 inserted.push(id);
3026 id
3027 }
3028 };
3029 rewires.push((consumer_id, producer_id, copy_id));
3030 }
3031 }
3032
3033 for (consumer, old_input, new_input) in rewires {
3034 graph.rewrite_input(consumer, old_input, new_input);
3035 }
3036
3037 inserted
3038}
3039
3040/// Phase 2.2 of the picker arc — insert `Op::Contiguize` before any
3041/// kernel-bearing node whose chosen kernel doesn't accept strided
3042/// inputs but whose live input layout is non-contiguous (strided,
3043/// offset, or both).
3044///
3045/// # When to call
3046///
3047/// After Phase 2.1's `insert_cross_device_copies` and after the
3048/// picker has committed to per-node kernel choices. The picker's
3049/// commitment determines the `KernelCaps::strided_input` flag for
3050/// each node; this pass reads that flag via the `kernel_accepts_strided`
3051/// callback and inserts layout-fixups where the flag is false and
3052/// the input is strided.
3053///
3054/// # Callback contract
3055///
3056/// `kernel_accepts_strided(consumer_id) -> bool` returns `true` if
3057/// the kernel resolved for that node tolerates strided / offset
3058/// inputs (i.e. its `KernelCaps::strided_input == true`). The
3059/// callback is opaque to this pass — typical wiring reads the
3060/// picker's commit-to-winner alternative set and queries the
3061/// winner's caps. When `false`, this pass inserts `Op::Contiguize`
3062/// on every non-contiguous input.
3063///
3064/// # Skips
3065///
3066/// - View ops (`Transpose`, `Permute`, `BroadcastTo`, `Slice`,
3067/// `Unsqueeze`, `Squeeze`, `Flip`) — they preserve strided
3068/// layouts intentionally.
3069/// - Structural ops (`Const`, `Release`, `Reshape`, `Contiguize`,
3070/// `Copy`, `Move`, `Alloc`, `ZeroFill`, `WriteSlice`) — their
3071/// executor arms handle strided naturally or are not kernel-
3072/// bearing.
3073///
3074/// # CSE
3075///
3076/// When two consumers share a non-contiguous input AND both kernels
3077/// reject strided, one `Op::Contiguize` node serves both. The
3078/// per-pass cache is keyed on producer NodeId (no target-device
3079/// dimension — Contiguize is device-inheriting).
3080///
3081/// # Idempotence
3082///
3083/// Running this pass on an already-rewritten graph adds zero new
3084/// nodes: an inserted `Op::Contiguize` has a contiguous + zero-offset
3085/// output layout, so the subsequent fixup check (`is_contiguous &&
3086/// start_offset == 0`) succeeds and the input is skipped.
3087///
3088/// Returns the number of `Op::Contiguize` nodes inserted.
3089pub fn insert_layout_fixups<F>(
3090 graph: &mut Graph,
3091 roots: &[NodeId],
3092 kernel_accepts_strided: F,
3093) -> usize
3094where
3095 F: Fn(NodeId) -> bool,
3096{
3097 let order = topo_order_multi(graph, roots);
3098 // CSE: producer NodeId → Contiguize NodeId. No device dim since
3099 // Contiguize inherits the producer's residency.
3100 let mut fixup_cache: HashMap<NodeId, NodeId> = HashMap::new();
3101 let mut inserted = 0usize;
3102 let mut rewires: Vec<(NodeId, NodeId, NodeId)> = Vec::new();
3103
3104 for &consumer_id in &order {
3105 // Snapshot the consumer to release the borrow before
3106 // mutation.
3107 let (consumer_op, consumer_inputs) = {
3108 let node = graph.node(consumer_id);
3109 (node.op.clone(), node.inputs.clone())
3110 };
3111
3112 // Skip non-kernel-bearing nodes — their executor arms
3113 // either preserve strided layouts (view ops, Reshape) or
3114 // are structural with no kernel to mismatch on (Const,
3115 // Release, Copy, Move, Alloc, ZeroFill, WriteSlice,
3116 // WriteSliceDoff, Contiguize). WriteSlice/WriteSliceDoff
3117 // adopt `dest` in place + auto-contiguize their source in the
3118 // execute arm, so a pass-inserted contiguize on `dest` would
3119 // break the in-place adoption — skip them here.
3120 if consumer_op.is_view_op()
3121 || matches!(
3122 consumer_op,
3123 Op::Const
3124 | Op::Release
3125 | Op::Reshape(_)
3126 | Op::Contiguize
3127 | Op::Copy { .. }
3128 | Op::Move { .. }
3129 | Op::Alloc { .. }
3130 | Op::ZeroFill
3131 | Op::WriteSlice { .. }
3132 | Op::WriteSliceDoff { .. }
3133 )
3134 {
3135 continue;
3136 }
3137
3138 // The kernel accepts strided inputs directly — no fixup.
3139 if kernel_accepts_strided(consumer_id) {
3140 continue;
3141 }
3142
3143 for input_id in consumer_inputs {
3144 let input_layout = graph.layout(input_id);
3145 if input_layout.is_contiguous() && input_layout.start_offset() == 0 {
3146 continue;
3147 }
3148
3149 let fixup_id = match fixup_cache.get(&input_id) {
3150 Some(&id) => id,
3151 None => {
3152 let (shape, dtype) = {
3153 let p = graph.node(input_id);
3154 (p.shape.clone(), p.dtype)
3155 };
3156 let id = graph.push(Node {
3157 op: Op::Contiguize,
3158 inputs: vec![input_id],
3159 shape,
3160 dtype,
3161 });
3162 fixup_cache.insert(input_id, id);
3163 inserted += 1;
3164 id
3165 }
3166 };
3167 rewires.push((consumer_id, input_id, fixup_id));
3168 }
3169 }
3170
3171 for (consumer, old_input, new_input) in rewires {
3172 graph.rewrite_input(consumer, old_input, new_input);
3173 }
3174
3175 inserted
3176}
3177
3178/// Insert `Op::Cast` fixups that CHANGE a consumer's input dtype — the sibling of
3179/// [`insert_layout_fixups`], for dtype rather than layout. Given precomputed
3180/// `(consumer, input, target_dtype)` edges (an input whose dtype should be
3181/// promoted so the consumer's `(op, per-operand-dtypes)` key becomes servable),
3182/// insert one `Op::Cast(target)` per distinct `(input, target)` — CSE-shared
3183/// across consumers — and rewire each consumer's edge to it.
3184///
3185/// Unlike `Graph::rewrite_input` (whose contract is a *same-dtype* redirect),
3186/// this DELIBERATELY changes the consumer's input dtype — that is the point: it
3187/// is the graph-side half of the optimizer's dtype-reconciliation pass
3188/// (`fuel_dispatch::optimize::insert_dtype_fixups`), which decides WHICH edges to
3189/// promote from backend kernel coverage. Edges apply in the given order
3190/// (deterministic when the caller passes topo order). Returns the rewires applied.
3191pub fn insert_cast_fixups(graph: &mut Graph, casts: &[(NodeId, NodeId, DType)]) -> usize {
3192 let mut cache: HashMap<(NodeId, DType), NodeId> = HashMap::new();
3193 let mut applied = 0;
3194 for &(consumer, input, target) in casts {
3195 if graph.node(input).dtype == target {
3196 continue; // already the target dtype — nothing to cast
3197 }
3198 let cast_id = *cache.entry((input, target)).or_insert_with(|| {
3199 let shape = graph.node(input).shape.clone();
3200 graph.push(Node {
3201 op: Op::Cast(target),
3202 inputs: vec![input],
3203 shape,
3204 dtype: target,
3205 })
3206 });
3207 graph.rewrite_input(consumer, input, cast_id);
3208 applied += 1;
3209 }
3210 applied
3211}
3212
3213#[cfg(test)]
3214mod tests {
3215 use super::*;
3216 use crate::NodeHandle;
3217 // `FusedOps` is referenced only in these tests; the lib-level `use` above
3218 // correctly dropped it as unused, so import it here where it's used.
3219 use crate::registry::FusedOps;
3220 use fuel_ir::{DeviceLocation, Shape};
3221 use std::sync::Arc;
3222
3223 /// Phase 7.5 G2: tests need a real device for slot-populating
3224 /// constructors. Singleton CpuBackendDevice via OnceLock.
3225 fn cpu_dev() -> &'static Arc<dyn fuel_backend_contract::DynBackendDevice> {
3226 static D: std::sync::OnceLock<Arc<dyn fuel_backend_contract::DynBackendDevice>> =
3227 std::sync::OnceLock::new();
3228 D.get_or_init(|| Arc::new(fuel_cpu_backend::dyn_impl::CpuBackendDevice))
3229 }
3230
3231 fn make_scalar_graph() -> (SharedGraph, NodeHandle) {
3232 let t = NodeHandle::from_f32(vec![1.0, 2.0, 3.0, 4.0], Shape::from_dims(&[4]), cpu_dev())
3233 .unwrap();
3234 (t.graph().clone(), t)
3235 }
3236
3237 fn count_copy_nodes(graph: &SharedGraph) -> usize {
3238 let g = graph.read().unwrap();
3239 (0..g.len())
3240 .filter(|i| matches!(g.node(NodeId(*i)).op, Op::Copy { .. }))
3241 .count()
3242 }
3243
3244 #[test]
3245 fn insert_copies_no_placement_no_copies() {
3246 // Graph with no placement hints: pass should be a no-op, no
3247 // Copies inserted, roots unchanged.
3248 let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
3249 let b = a
3250 .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
3251 .unwrap();
3252 let c = a.add(&b);
3253 let graph = c.graph().clone();
3254 let before = count_copy_nodes(&graph);
3255 let new_roots = insert_copies(&graph, &[c.id()]);
3256 assert_eq!(new_roots, vec![c.id()]);
3257 assert_eq!(count_copy_nodes(&graph), before);
3258 }
3259
3260 #[test]
3261 fn insert_copies_tagged_node_pulls_inputs_to_its_device() {
3262 // Const a, Const b, Add(a, b) placed on Vulkan.
3263 // Expected: two Copy(a, Vulkan) and Copy(b, Vulkan) inserted,
3264 // Add's inputs rewritten to reference the Copies.
3265 let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
3266 let b = a
3267 .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
3268 .unwrap();
3269 let c = a.add(&b).on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3270 let graph = c.graph().clone();
3271
3272 let before_copies = count_copy_nodes(&graph);
3273 let new_roots = insert_copies(&graph, &[c.id()]);
3274
3275 // Should be exactly 2 new Copies (one per input).
3276 assert_eq!(count_copy_nodes(&graph) - before_copies, 2);
3277
3278 // Rewritten Add should reference two Copy nodes.
3279 let g = graph.read().unwrap();
3280 let new_add = g.node(new_roots[0]);
3281 assert_eq!(new_add.inputs.len(), 2);
3282 for input in &new_add.inputs {
3283 let node = g.node(*input);
3284 assert!(matches!(
3285 node.op,
3286 Op::Copy {
3287 target: DeviceLocation::Vulkan { gpu_id: 0 }
3288 }
3289 ));
3290 }
3291 }
3292
3293 #[test]
3294 fn insert_copies_matching_device_no_copies_inserted() {
3295 // Const a and Add both placed on Vulkan. Const flows into
3296 // Add, both want Vulkan — no Copy needed.
3297 let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev())
3298 .unwrap()
3299 .on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3300 let b = a
3301 .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
3302 .unwrap()
3303 .on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3304 let c = a.add(&b).on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3305 let graph = c.graph().clone();
3306
3307 let before = count_copy_nodes(&graph);
3308 insert_copies(&graph, &[c.id()]);
3309 assert_eq!(count_copy_nodes(&graph), before);
3310 }
3311
3312 #[test]
3313 fn insert_copies_handles_backward_graph() {
3314 // Forward: y = sum(x * x) with x placed on Vulkan.
3315 // Call backward — this appends gradient nodes to the same graph.
3316 // Then run insert_copies on BOTH the forward root and the
3317 // gradient root. Every backward op should end up on Vulkan
3318 // (inherited from its inputs, which trace back to Vulkan-placed x).
3319 let x = NodeHandle::from_f32(vec![1.0, 2.0, 3.0, 4.0], Shape::from_dims(&[4]), cpu_dev())
3320 .unwrap()
3321 .on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3322 let sq = x.mul(&x).on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3323 let y = sq.sum_all().on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3324 let graph = y.graph().clone();
3325
3326 let grads = y.backward();
3327 let grad_x = grads.get(&x).expect("dL/dx should exist");
3328
3329 // Run insert_copies on both roots.
3330 let before_copies = count_copy_nodes(&graph);
3331 let _new = insert_copies(&graph, &[y.id(), grad_x.id()]);
3332 let after_copies = count_copy_nodes(&graph);
3333
3334 // With everything on Vulkan, no Copies should be needed —
3335 // this verifies the pass doesn't spuriously insert Copies into
3336 // the backward graph when placements are consistent.
3337 assert_eq!(
3338 after_copies, before_copies,
3339 "insert_copies should leave a uniformly-Vulkan forward+backward graph alone"
3340 );
3341 }
3342
3343 #[test]
3344 fn insert_copies_backward_inherits_forward_device() {
3345 // Forward: y = sum(x * x) with NO explicit placement.
3346 // Compute backward. Then place JUST the forward root on Vulkan
3347 // and re-run insert_copies. The Copies that get inserted should
3348 // pull x to Vulkan; the backward path should follow suit when
3349 // we ask for both roots.
3350 let x = NodeHandle::from_f32(vec![1.0, 2.0, 3.0, 4.0], Shape::from_dims(&[4]), cpu_dev())
3351 .unwrap();
3352 let sq = x.mul(&x);
3353 let y = sq.sum_all().on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3354 let graph = y.graph().clone();
3355
3356 let grads = y.backward();
3357 let grad_x = grads.get(&x).expect("dL/dx should exist");
3358
3359 // Before: only y is tagged. The backward graph has no tags.
3360 // After insert_copies([y, grad_x]): y pulls its inputs (sq)
3361 // toward Vulkan → sq pulls x → Copies inserted.
3362 // Backward inherits device from its inputs transitively.
3363 let new_roots = insert_copies(&graph, &[y.id(), grad_x.id()]);
3364 assert_eq!(new_roots.len(), 2);
3365 // At least one Copy should have been inserted (the unplaced
3366 // forward consts need to get to Vulkan).
3367 assert!(
3368 count_copy_nodes(&graph) > 0,
3369 "expected Copies to be inserted for unplaced Const inputs"
3370 );
3371 }
3372
3373 #[test]
3374 fn lower_const_placement_single_vulkan_consumer() {
3375 // Const a → Add(a, b) placed on Vulkan. lower_const_placement
3376 // should tag a with Vulkan since Add is its only consumer.
3377 let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
3378 let b = a
3379 .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
3380 .unwrap();
3381 let c = a.add(&b).on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3382 let graph = c.graph().clone();
3383
3384 let lowered = lower_const_placement(&graph, &[c.id()]);
3385 assert_eq!(lowered, 2); // both a and b tagged
3386 assert_eq!(
3387 graph.read().unwrap().placement(a.id()),
3388 Some(DeviceLocation::Vulkan { gpu_id: 0 })
3389 );
3390 assert_eq!(
3391 graph.read().unwrap().placement(b.id()),
3392 Some(DeviceLocation::Vulkan { gpu_id: 0 })
3393 );
3394
3395 // After lowering, insert_copies should emit NO Copies (the
3396 // Consts are now on the target device).
3397 let before_copies = count_copy_nodes(&graph);
3398 insert_copies(&graph, &[c.id()]);
3399 assert_eq!(count_copy_nodes(&graph), before_copies);
3400 }
3401
3402 #[test]
3403 fn lower_const_placement_consumers_disagree_stays_unplaced() {
3404 // Const a flows into two consumers on different devices.
3405 // Without replication support, lowering has to leave a unplaced.
3406 let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
3407 let b = a
3408 .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
3409 .unwrap();
3410 let cpu_sum = a.add(&b).on_device(DeviceLocation::Cpu);
3411 let vulkan_sum = a.add(&b).on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3412 let graph = a.graph().clone();
3413
3414 lower_const_placement(&graph, &[cpu_sum.id(), vulkan_sum.id()]);
3415 assert_eq!(
3416 graph.read().unwrap().placement(a.id()),
3417 None,
3418 "const with disagreeing consumers stays unplaced"
3419 );
3420 assert_eq!(graph.read().unwrap().placement(b.id()), None);
3421 }
3422
3423 #[test]
3424 fn lower_const_placement_skips_already_placed() {
3425 // An explicitly-placed Const should not be overridden.
3426 let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev())
3427 .unwrap()
3428 .on_device(DeviceLocation::Cpu);
3429 let b = a
3430 .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
3431 .unwrap();
3432 let c = a.add(&b).on_device(DeviceLocation::Vulkan { gpu_id: 0 });
3433 let graph = c.graph().clone();
3434
3435 lower_const_placement(&graph, &[c.id()]);
3436 // a keeps its explicit Cpu placement even though its consumer is Vulkan.
3437 assert_eq!(
3438 graph.read().unwrap().placement(a.id()),
3439 Some(DeviceLocation::Cpu)
3440 );
3441 // b had no hint; it gets lowered to Vulkan.
3442 assert_eq!(
3443 graph.read().unwrap().placement(b.id()),
3444 Some(DeviceLocation::Vulkan { gpu_id: 0 })
3445 );
3446 }
3447
3448 #[test]
3449 fn insert_copies_idempotent() {
3450 let a = NodeHandle::from_f32(vec![1.0], Shape::from_dims(&[1]), cpu_dev()).unwrap();
3451 let b = a.const_f32_like(vec![2.0], Shape::from_dims(&[1])).unwrap();
3452 let c = a.add(&b).on_device(DeviceLocation::Cpu);
3453 let graph = c.graph().clone();
3454
3455 let roots1 = insert_copies(&graph, &[c.id()]);
3456 let after_first = count_copy_nodes(&graph);
3457 let _roots2 = insert_copies(&graph, &roots1);
3458 assert_eq!(
3459 count_copy_nodes(&graph),
3460 after_first,
3461 "insert_copies should be idempotent on already-reconciled graphs"
3462 );
3463 }
3464
3465 #[test]
3466 fn cse_folds_identical_add() {
3467 let (graph, a) = make_scalar_graph();
3468 let b = a.add(&a);
3469 let c = a.add(&a);
3470 let pre_len = graph.read().unwrap().len();
3471 let new_roots = optimize(&graph, &[b.id(), c.id()]);
3472 assert_eq!(
3473 new_roots[0], new_roots[1],
3474 "CSE should map both to same node"
3475 );
3476 assert!(graph.read().unwrap().len() >= pre_len);
3477 }
3478
3479 #[test]
3480 fn cse_folds_commutative() {
3481 // Build two tensors inside the same graph so `a + b` and
3482 // `b + a` share NodeIds for the inputs. Use add_scalar on `a`
3483 // as a simple way to get a second tensor handle sharing a's graph.
3484 let (_graph, a) = make_scalar_graph();
3485 let b = a.add_scalar(5.0);
3486 let ab = a.add(&b);
3487 let ba = b.add(&a);
3488 let graph = a.graph().clone();
3489 let new_roots = optimize(&graph, &[ab.id(), ba.id()]);
3490 assert_eq!(
3491 new_roots[0], new_roots[1],
3492 "commutative CSE should fold a+b and b+a to one node"
3493 );
3494 }
3495
3496 /// GAP-271: `Maximum`/`Minimum` must NOT be folded across operand order.
3497 ///
3498 /// They are commutative in VALUE and not in BITS. `chassis/binary.rs`
3499 /// returns the FIRST NaN operand -- a documented lhs-first tie-break
3500 /// matching `torch.maximum` -- so two distinct NaN payloads swap with
3501 /// operand order: `max(0x7FC00001, 0x7FC00002)` is `0x7FC00001` and the
3502 /// reverse is `0x7FC00002`. Folding the two orderings is therefore an
3503 /// invalid rewrite.
3504 ///
3505 /// A +-0 FIXTURE WOULD BE UNSOUND HERE, and not because we canonicalise:
3506 /// the +-0 answer is decided PER CALL SITE by the optimizer (we delegate
3507 /// to `f32::max`, which disclaims the tie). One binary at opt-level 3
3508 /// gives three answers -- const-folded +0/+0, `black_box` -0/-0,
3509 /// `#[inline(never)]` -0/+0. A +-0 assertion would therefore be testing
3510 /// the optimizer, not us. The NaN tie-break is explicit in our source and
3511 /// is stable in all four contexts, which is why it is the fixture.
3512 #[test]
3513 fn cse_does_not_fold_minmax_across_operand_order() {
3514 let (_graph, a) = make_scalar_graph();
3515 let b = a.add_scalar(5.0);
3516
3517 // CONTROL, and it is load-bearing: `Add` IS commutative in bits, so it
3518 // must STILL fold. Without this arm, a change that broke CSE entirely
3519 // would pass the assertion below for the wrong reason.
3520 let add_ab = a.add(&b);
3521 let add_ba = b.add(&a);
3522 let graph = a.graph().clone();
3523 let add_roots = optimize(&graph, &[add_ab.id(), add_ba.id()]);
3524 assert_eq!(
3525 add_roots[0], add_roots[1],
3526 "control: Add is bitwise commutative and must still fold -- if this fails, CSE is broken generally and the Maximum arm below proves nothing"
3527 );
3528
3529 let max_ab = a.maximum(&b);
3530 let max_ba = b.maximum(&a);
3531 let max_roots = optimize(&graph, &[max_ab.id(), max_ba.id()]);
3532 assert_ne!(
3533 max_roots[0], max_roots[1],
3534 "max(a,b) and max(b,a) were folded to one node, but they differ in BITS on distinct NaN payloads (lhs-first tie-break). See GAP-271."
3535 );
3536
3537 let min_ab = a.minimum(&b);
3538 let min_ba = b.minimum(&a);
3539 let min_roots = optimize(&graph, &[min_ab.id(), min_ba.id()]);
3540 assert_ne!(
3541 min_roots[0], min_roots[1],
3542 "min(a,b) and min(b,a) were folded to one node -- same defect as Maximum, see GAP-271"
3543 );
3544 }
3545
3546 #[test]
3547 fn simplifies_add_scalar_zero() {
3548 let (graph, a) = make_scalar_graph();
3549 let b = a.add_scalar(0.0);
3550 let new_roots = optimize(&graph, &[b.id()]);
3551 assert_eq!(new_roots[0], a.id(), "AddScalar(0) should alias to input");
3552 }
3553
3554 #[test]
3555 fn simplifies_mul_scalar_one() {
3556 let (graph, a) = make_scalar_graph();
3557 let b = a.mul_scalar(1.0);
3558 let new_roots = optimize(&graph, &[b.id()]);
3559 assert_eq!(new_roots[0], a.id(), "MulScalar(1) should alias to input");
3560 }
3561
3562 #[test]
3563 fn simplifies_identity_cast() {
3564 let (graph, a) = make_scalar_graph();
3565 // Cast to the same dtype is a no-op; the optimizer aliases it away.
3566 // (Dispatch keys casts on [src, dst] and registers no [d, d] kernel,
3567 // so this elision is what removes a same-dtype cast before realize.)
3568 let same = a.cast(a.dtype());
3569 let new_roots = optimize(&graph, &[same.id()]);
3570 assert_eq!(
3571 new_roots[0],
3572 a.id(),
3573 "Cast(d) on a dtype-d input should alias to input",
3574 );
3575 }
3576
3577 /// GAP-183: `Cast(float -> Bool)` lowers to `Ne(x, x*0)` at the GRAPH level.
3578 ///
3579 /// WHY A LOWERING AND NOT A KERNEL: CUDA already ships the `!= 0` test —
3580 /// `ne_{f32,f64,f16,bf16}_u8` in `cuda/compare.fkc.md` — but those kernels are
3581 /// UNREACHABLE from a storage-level cast, which cannot compose graph ops. So
3582 /// the fix belongs here, not in the storage layer.
3583 ///
3584 /// BORN-RED: before the rewrite this root is an `Op::Cast(Bool)`.
3585 #[test]
3586 fn lowers_float_cast_to_bool_into_ne_against_zero() {
3587 let (graph, a) = make_scalar_graph(); // F32
3588 let as_bool = a.cast(fuel_ir::DType::Bool);
3589 let new_roots = optimize(&graph, &[as_bool.id()]);
3590 let g = graph.read().unwrap();
3591 let root = g.node(new_roots[0]);
3592 assert!(
3593 matches!(root.op, Op::Ne),
3594 "float -> Bool must lower to Op::Ne, got {:?}",
3595 root.op,
3596 );
3597 assert_eq!(
3598 root.dtype,
3599 fuel_ir::DType::Bool,
3600 "the lowered root still yields Bool"
3601 );
3602 // RHS is zeros from `MulScalar(0.0)` over the SAME input — not a
3603 // materialized const, so shape and dtype match by construction.
3604 let rhs = g.node(root.inputs[1]);
3605 assert!(
3606 matches!(rhs.op, Op::MulScalar(c) if c == 0.0),
3607 "rhs must be MulScalar(0.0) (shape-preserving zeros), got {:?}",
3608 rhs.op,
3609 );
3610 assert_eq!(
3611 rhs.inputs[0], root.inputs[0],
3612 "zeros must derive from the cast's own input",
3613 );
3614 }
3615
3616 /// GAP-183 companion: the INTEGER half is deliberately NOT lowered — there is
3617 /// no CUDA integer compare kernel, so rewriting would move the same decline
3618 /// one layer out while bypassing CPU's real int->Bool cast kernels.
3619 #[test]
3620 fn does_not_lower_integer_cast_to_bool() {
3621 let (graph, a) = make_scalar_graph();
3622 let as_u8 = a.cast(fuel_ir::DType::U8);
3623 let as_bool = as_u8.cast(fuel_ir::DType::Bool);
3624 let new_roots = optimize(&graph, &[as_bool.id()]);
3625 let g = graph.read().unwrap();
3626 let root = g.node(new_roots[0]);
3627 assert!(
3628 matches!(root.op, Op::Cast(fuel_ir::DType::Bool)),
3629 "int -> Bool stays an Op::Cast (genuine decline on CUDA), got {:?}",
3630 root.op,
3631 );
3632 }
3633
3634 #[test]
3635 fn keeps_real_cast() {
3636 let (graph, a) = make_scalar_graph();
3637 // A genuine dtype change (F32→F64) is NOT elided.
3638 let cast = a.cast(fuel_ir::DType::F64);
3639 let new_roots = optimize(&graph, &[cast.id()]);
3640 assert_ne!(
3641 new_roots[0],
3642 a.id(),
3643 "a real Cast must survive optimization"
3644 );
3645 }
3646
3647 #[test]
3648 fn declarative_pattern_matches_and_fuses_relu_add() {
3649 use crate::jit::{OpAttrs, OpTag, PatternNode};
3650 use crate::registry::{
3651 BackwardKind, FusedOpEntry, FusedOpFamily, FusedOpParams, FusedOps, PatternTree,
3652 SubgraphPattern,
3653 };
3654 fn shape_rule(s: &[Shape], _: &FusedOpParams) -> Shape {
3655 s[0].clone()
3656 }
3657 fn dtype_rule(d: &[DType], _: &FusedOpParams) -> DType {
3658 d[0]
3659 }
3660 fn no_decompose(_: &mut Graph, id: NodeId, _: &FusedOpParams) -> NodeId {
3661 id
3662 }
3663 // A declaratively-registered fused op for relu(add(a, b)).
3664 let entry = FusedOpEntry {
3665 destructive_input: None,
3666 id: FusedOps::SOFTMAX_LAST_DIM, // a real parameterless id (plumbing test)
3667 name: "test_declarative_relu_add",
3668 family: FusedOpFamily::Forward,
3669 pattern: SubgraphPattern::Declarative(PatternTree {
3670 root: PatternNode::Op {
3671 op: OpTag::Relu,
3672 attrs: OpAttrs::default(),
3673 operands: vec![PatternNode::Op {
3674 op: OpTag::Add,
3675 attrs: OpAttrs::default(),
3676 operands: vec![
3677 PatternNode::Bind { index: 0 },
3678 PatternNode::Bind { index: 1 },
3679 ],
3680 }],
3681 },
3682 params: FusedOpParams::SoftmaxLastDim,
3683 }),
3684 decompose: no_decompose,
3685 backward: BackwardKind::NotDifferentiable,
3686 shape_rule,
3687 dtype_rule,
3688 output_views: None,
3689 };
3690 let rule = FusionRule::from_entry(&entry);
3691
3692 // Build relu(add(a, b)).
3693 let mut g = Graph::new();
3694 let s = Shape::from_dims(&[2]);
3695 let f32 = DType::F32;
3696 let a = g.push(Node {
3697 op: Op::Const,
3698 inputs: vec![],
3699 shape: s.clone(),
3700 dtype: f32,
3701 });
3702 let b = g.push(Node {
3703 op: Op::Const,
3704 inputs: vec![],
3705 shape: s.clone(),
3706 dtype: f32,
3707 });
3708 let sum = g.push(Node {
3709 op: Op::Add,
3710 inputs: vec![a, b],
3711 shape: s.clone(),
3712 dtype: f32,
3713 });
3714 let r = g.push(Node {
3715 op: Op::Relu,
3716 inputs: vec![sum],
3717 shape: s.clone(),
3718 dtype: f32,
3719 });
3720
3721 // The declarative rule fires at the relu sink and fuses to Op::Fused.
3722 assert!(rule.matches(&g, r), "declarative pattern matches relu(add)");
3723 let mut remap = HashMap::new();
3724 rule.rewrite(&mut g, r, &mut remap);
3725 let fused_id = remap[&r];
3726 match &g.node(fused_id).op {
3727 Op::Fused(id, FusedOpParams::SoftmaxLastDim) => {
3728 assert_eq!(*id, FusedOps::SOFTMAX_LAST_DIM)
3729 }
3730 other => panic!("expected Op::Fused(SOFTMAX_LAST_DIM, _), got {other:?}"),
3731 }
3732 assert_eq!(
3733 g.node(fused_id).inputs,
3734 vec![a, b],
3735 "fused inputs = the region's bound external inputs",
3736 );
3737 }
3738
3739 #[test]
3740 fn runtime_op_fuses_and_round_trips_through_the_pass() {
3741 use crate::jit::{OpAttrs, OpTag, PatternNode};
3742 use crate::runtime_fused::register_runtime_fused;
3743 // A region no other test registers — tanh(sub(a, b)) — so the fuse is
3744 // deterministic (only our runtime rule matches it; sub is non-commutative).
3745 let region = PatternNode::Op {
3746 op: OpTag::Tanh,
3747 attrs: OpAttrs::default(),
3748 operands: vec![PatternNode::Op {
3749 op: OpTag::Sub,
3750 attrs: OpAttrs::default(),
3751 operands: vec![
3752 PatternNode::Bind { index: 0 },
3753 PatternNode::Bind { index: 1 },
3754 ],
3755 }],
3756 };
3757 let rid = register_runtime_fused("test::tanh_sub", region).unwrap();
3758
3759 // Build tanh(sub(a, b)) on primitives (b shares a's graph).
3760 let a = NodeHandle::from_f32(
3761 vec![0.5, -0.5, 1.0, -1.0],
3762 Shape::from_dims(&[4]),
3763 cpu_dev(),
3764 )
3765 .unwrap();
3766 let b = a
3767 .const_f32_like(vec![0.1, 0.2, 0.3, 0.4], Shape::from_dims(&[4]))
3768 .unwrap();
3769 let y = a.sub(&b).tanh();
3770 let graph = y.graph().clone();
3771
3772 // default_rules: lower (no-op here) then fuse — the runtime declarative
3773 // FusionRule folds tanh(sub) → Op::Fused(rid, Runtime).
3774 let fused = RuleRegistry::default_rules().optimize_to_fixpoint(&graph, &[y.id()]);
3775 {
3776 let g = graph.read().unwrap();
3777 match &g.node(fused[0]).op {
3778 Op::Fused(fid, FusedOpParams::Runtime { scalars }) => {
3779 assert_eq!(*fid, rid, "fused to our runtime op");
3780 assert!(fid.is_runtime());
3781 assert!(scalars.is_empty(), "parameterless region");
3782 }
3783 other => panic!("expected runtime Op::Fused, got {other:?}"),
3784 }
3785 }
3786
3787 // lowering_only: the kernel-absent path — decompose the runtime op back
3788 // to its region on primitives (tanh(sub(a, b))).
3789 let lowered = RuleRegistry::lowering_only().optimize_to_fixpoint(&graph, &fused);
3790 let g = graph.read().unwrap();
3791 assert!(
3792 matches!(g.node(lowered[0]).op, Op::Tanh),
3793 "re-emitted sink is Tanh"
3794 );
3795 let sub_id = g.node(lowered[0]).inputs[0];
3796 assert!(matches!(g.node(sub_id).op, Op::Sub), "Tanh's input is Sub");
3797 assert_eq!(
3798 g.node(sub_id).inputs.len(),
3799 2,
3800 "Sub over the two bound inputs"
3801 );
3802 }
3803
3804 #[test]
3805 fn capability_gate_blocks_fusion_without_a_kernel() {
3806 use crate::jit::{OpAttrs, OpTag, PatternNode};
3807 use crate::runtime_fused::register_runtime_fused;
3808 // Unique region — sigmoid(div(a, b)) — so the gate decision is ours alone.
3809 let region = PatternNode::Op {
3810 op: OpTag::Sigmoid,
3811 attrs: OpAttrs::default(),
3812 operands: vec![PatternNode::Op {
3813 op: OpTag::Div,
3814 attrs: OpAttrs::default(),
3815 operands: vec![
3816 PatternNode::Bind { index: 0 },
3817 PatternNode::Bind { index: 1 },
3818 ],
3819 }],
3820 };
3821 let rid = register_runtime_fused("test::sigmoid_div", region).unwrap();
3822
3823 let a = NodeHandle::from_f32(vec![1.0, 2.0, 3.0, 4.0], Shape::from_dims(&[4]), cpu_dev())
3824 .unwrap();
3825 let b = a
3826 .const_f32_like(vec![2.0, 2.0, 2.0, 2.0], Shape::from_dims(&[4]))
3827 .unwrap();
3828 let y = a.div(&b).sigmoid();
3829 let graph = y.graph().clone();
3830
3831 // No kernel for our op → it is gated out of fusion (reported) and its
3832 // region stays primitive — the miss is caught at match time, not after.
3833 let (rules, gated_out) = RuleRegistry::capability_gated_rules(|id| id != rid);
3834 assert!(
3835 gated_out.contains(&rid),
3836 "kernel-absent op is reported as a miss candidate"
3837 );
3838 let roots = rules.optimize_to_fixpoint(&graph, &[y.id()]);
3839 {
3840 let g = graph.read().unwrap();
3841 assert!(
3842 matches!(g.node(roots[0]).op, Op::Sigmoid),
3843 "kernel-absent runtime op stays primitive (Sigmoid sink), never fuses",
3844 );
3845 }
3846
3847 // With a kernel available, the same op fuses.
3848 let (rules2, _) = RuleRegistry::capability_gated_rules(|_| true);
3849 let roots2 = rules2.optimize_to_fixpoint(&graph, &[roots[0]]);
3850 let g = graph.read().unwrap();
3851 assert!(
3852 matches!(g.node(roots2[0]).op, Op::Fused(fid, _) if fid == rid),
3853 "with a kernel, the runtime op fuses",
3854 );
3855 }
3856
3857 #[test]
3858 fn cse_does_not_fold_distinct_ops() {
3859 // Add and Mul on the same inputs are not equivalent.
3860 let (graph, a) = make_scalar_graph();
3861 let sum = a.add(&a);
3862 let prod = a.mul(&a);
3863 let new_roots = optimize(&graph, &[sum.id(), prod.id()]);
3864 assert_ne!(new_roots[0], new_roots[1], "Add and Mul must stay distinct");
3865 }
3866
3867 #[test]
3868 fn cse_does_not_fold_distinct_scalars() {
3869 let (graph, a) = make_scalar_graph();
3870 let b = a.add_scalar(1.0);
3871 let c = a.add_scalar(2.0);
3872 let new_roots = optimize(&graph, &[b.id(), c.id()]);
3873 assert_ne!(
3874 new_roots[0], new_roots[1],
3875 "AddScalar with different c must stay distinct"
3876 );
3877 }
3878
3879 #[test]
3880 fn cse_nested_chain_deduplicates() {
3881 // (a + a) + a appearing twice should dedupe both subexpressions.
3882 let (graph, a) = make_scalar_graph();
3883 let p1 = a.add(&a);
3884 let p2 = a.add(&a); // duplicates p1
3885 let q1 = p1.add(&a);
3886 let q2 = p2.add(&a); // structurally identical to q1 via CSE
3887 let new_roots = optimize(&graph, &[q1.id(), q2.id()]);
3888 assert_eq!(new_roots[0], new_roots[1], "nested duplicates must fold");
3889 }
3890
3891 // ---- derive_ordering + execution_plan -----------------------------------
3892
3893 #[test]
3894 fn derive_ordering_empty_for_non_destructive_graph() {
3895 let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
3896 let b = a
3897 .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
3898 .unwrap();
3899 let c = a.add(&b);
3900 let ord = derive_ordering(&c.graph().read().unwrap(), &[c.id()]);
3901 assert!(
3902 ord.is_empty(),
3903 "graph without destructive ops → no ordering edges"
3904 );
3905 }
3906
3907 #[test]
3908 fn derive_ordering_release_must_run_after_sibling_readers() {
3909 // Graph:
3910 // a (producer)
3911 // b = relu(a) (non-destructive reader of a)
3912 // r = release(a) (destructive reader of a)
3913 // Expected ordering: r must run after b.
3914 let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
3915 let b = a.relu();
3916 let r = a.release();
3917 let ord = derive_ordering(&a.graph().read().unwrap(), &[b.id(), r.id()]);
3918 let deps = ord.deps_of(r.id());
3919 assert_eq!(
3920 deps.len(),
3921 1,
3922 "release should have one ordering dep (the relu)"
3923 );
3924 assert_eq!(deps[0], b.id(), "release must run after relu");
3925 }
3926
3927 #[test]
3928 fn derive_ordering_follows_view_chains_for_destructive_op() {
3929 // Phase 4a of in-place ops infrastructure: a destructive op
3930 // on `x` must run after any reader of any view-of-x, because
3931 // views share `x`'s Storage Arc at realize time. Without the
3932 // alias-set walk in derive_ordering, this case would not pin
3933 // `reader(view)` before `destructive(x)`.
3934 //
3935 // Graph:
3936 // x (producer; rank-2)
3937 // v = x.transpose() (view of x)
3938 // z = v.relu() (reader of v, transitively reads x's bytes)
3939 // r = x.release() (destructive on x)
3940 // Expected: r must run after z (not just after v).
3941 let x = NodeHandle::from_f32(
3942 vec![1.0_f32, 2.0, 3.0, 4.0],
3943 Shape::from_dims(&[2, 2]),
3944 cpu_dev(),
3945 )
3946 .unwrap();
3947 let v = x.transpose();
3948 let z = v.relu();
3949 let r = x.release();
3950 let ord = derive_ordering(&x.graph().read().unwrap(), &[z.id(), r.id()]);
3951 let deps = ord.deps_of(r.id());
3952 assert!(
3953 deps.contains(&z.id()),
3954 "release(x) must be pinned after relu(transpose(x)) (view-aware alias set); got deps = {deps:?}",
3955 );
3956 }
3957
3958 #[test]
3959 fn derive_ordering_inplace_unary_through_view_chain() {
3960 // Specifically for the in-place ops Phase 4 use case:
3961 // `y = x.transpose(); z = y.relu(); x.relu_inplace()` —
3962 // ReluInplace must be pinned after the read through the
3963 // transpose view chain.
3964 let x = NodeHandle::from_f32(
3965 vec![-1.0_f32, 2.0, -3.0, 4.0],
3966 Shape::from_dims(&[2, 2]),
3967 cpu_dev(),
3968 )
3969 .unwrap();
3970 let y = x.transpose();
3971 let z = y.relu();
3972 // Capture x's shape + dtype + id BEFORE acquiring the write
3973 // lock — otherwise the args evaluate inside the locked region
3974 // and self-deadlock (RwLock doesn't grant a read lock to the
3975 // writer-holding thread).
3976 let x_shape = x.shape();
3977 let x_dtype = x.dtype();
3978 let x_id = x.id();
3979 let inplace_id = x.graph().write().unwrap().push(crate::Node {
3980 op: crate::Op::ReluInplace,
3981 inputs: vec![x_id],
3982 shape: x_shape,
3983 dtype: x_dtype,
3984 });
3985 let ord = derive_ordering(&x.graph().read().unwrap(), &[z.id(), inplace_id]);
3986 let deps = ord.deps_of(inplace_id);
3987 assert!(
3988 deps.contains(&z.id()),
3989 "ReluInplace(x) must be pinned after Relu(Transpose(x)) via view-aware alias set; got deps = {deps:?}",
3990 );
3991 }
3992
3993 // ---- Ordering-defect repros (MLA decode multi-round WriteSlice) ----
3994
3995 /// Control: a single-hop `Slice` reader of a `WriteSlice`
3996 /// destination is already correctly pinned before a SECOND
3997 /// `WriteSlice` that reuses the same destination —
3998 /// `collect_alias_set` finds the `Slice` via `is_view_op`, and its
3999 /// reader (`relu`) is not itself an alias member, so
4000 /// `derive_ordering` pins the second `WriteSlice` after it
4001 /// directly. Baseline for the Reshape-gap test below (confirms the
4002 /// gap is Reshape-specific, not a general multi-round WriteSlice
4003 /// failure).
4004 #[test]
4005 fn derive_ordering_write_slice_round2_plain_slice_reader_pinned() {
4006 let dest =
4007 NodeHandle::from_f32(vec![0.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
4008 let src1 = dest
4009 .const_f32_like(vec![1.0_f32, 2.0], Shape::from_dims(&[2]))
4010 .unwrap();
4011 let src2 = dest
4012 .const_f32_like(vec![9.0_f32, 8.0], Shape::from_dims(&[2]))
4013 .unwrap();
4014 let x = dest
4015 .write_slice(&src1, vec![(0, 2)])
4016 .expect("write_slice round 1");
4017 let s = x.slice(0, 0, 2);
4018 let k = s.relu();
4019 let y = x
4020 .write_slice(&src2, vec![(0, 2)])
4021 .expect("write_slice round 2");
4022 let ord = derive_ordering(&x.graph().read().unwrap(), &[k.id(), y.id()]);
4023 let deps = ord.deps_of(y.id());
4024 assert!(
4025 deps.contains(&k.id()),
4026 "round-2 write_slice must be pinned after the round-1 slice's \
4027 reader; got deps = {deps:?}",
4028 );
4029 }
4030
4031 /// CRITICAL SUSPECT #2 (MLA decode multi-round `WriteSlice`
4032 /// ordering defect): same shape as the control above, but the
4033 /// `Slice`'s reader is behind an `Op::Reshape` hop first.
4034 /// `Op::Reshape` is NOT in `collect_alias_set`'s alias-extending
4035 /// predicate (only `Op::is_view_op` + `Op::View`) even though its
4036 /// `WorkItemKind::ContiguizeOf` execution zero-copy ADOPTS the
4037 /// input's Storage Arc whenever the input is contiguous at offset
4038 /// 0 (`fuel-dispatch/src/pipelined.rs`'s `ContiguizeOf` arm) — so
4039 /// the alias walk stops at the `Slice`, and the Reshape's own
4040 /// consumer (`relu`) is never scanned for pinning against the
4041 /// round-2 `WriteSlice`. Born-red: fails before the fix (Reshape
4042 /// missing from the alias-extending set), green after.
4043 #[test]
4044 fn derive_ordering_write_slice_round2_reshape_reader_pinned() {
4045 let dest =
4046 NodeHandle::from_f32(vec![0.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
4047 let src1 = dest
4048 .const_f32_like(vec![1.0_f32, 2.0], Shape::from_dims(&[2]))
4049 .unwrap();
4050 let src2 = dest
4051 .const_f32_like(vec![9.0_f32, 8.0], Shape::from_dims(&[2]))
4052 .unwrap();
4053 let x = dest
4054 .write_slice(&src1, vec![(0, 2)])
4055 .expect("write_slice round 1");
4056 let s = x.slice(0, 0, 2);
4057 let r = s.reshape(Shape::from_dims(&[2]));
4058 let k = r.relu();
4059 let y = x
4060 .write_slice(&src2, vec![(0, 2)])
4061 .expect("write_slice round 2");
4062 let ord = derive_ordering(&x.graph().read().unwrap(), &[k.id(), y.id()]);
4063 let deps = ord.deps_of(y.id());
4064 assert!(
4065 deps.contains(&k.id()),
4066 "round-2 write_slice must be pinned after the reshape-adopted-arc \
4067 reader (CRITICAL SUSPECT #2 gap — Reshape excluded from \
4068 collect_alias_set's alias-extending predicate); got deps = {deps:?}",
4069 );
4070 }
4071
4072 // ---- Phase 5: insert_safety_copies ----
4073
4074 #[test]
4075 fn insert_safety_copies_residual_connection_breaks_cycle() {
4076 // Canonical residual pattern: `y = x.relu_inplace(); z = y + x`.
4077 // Before Phase 5: derive_ordering pins ReluInplace after Add
4078 // (Add reads x), but data flow pins Add after ReluInplace (Add
4079 // reads y, y depends on ReluInplace) → cycle. After Phase 5:
4080 // a Copy(x) → x_safe is inserted and Add's x input rewires to
4081 // x_safe.
4082 let x = NodeHandle::from_f32(
4083 vec![1.0_f32, -2.0, 3.0, -4.0],
4084 Shape::from_dims(&[4]),
4085 cpu_dev(),
4086 )
4087 .unwrap();
4088 let x_shape = x.shape();
4089 let x_dtype = x.dtype();
4090 let x_id = x.id();
4091
4092 // Manually build: y = ReluInplace(x); z = Add(y, x).
4093 let (y_id, z_id) = {
4094 let mut g = x.graph().write().unwrap();
4095 let y_id = g.push(crate::Node {
4096 op: crate::Op::ReluInplace,
4097 inputs: vec![x_id],
4098 shape: x_shape.clone(),
4099 dtype: x_dtype,
4100 });
4101 let z_id = g.push(crate::Node {
4102 op: crate::Op::Add,
4103 inputs: vec![y_id, x_id],
4104 shape: x_shape,
4105 dtype: x_dtype,
4106 });
4107 (y_id, z_id)
4108 };
4109
4110 // Before the pass: cycle would exist. Run insert_safety_copies.
4111 let inserted = insert_safety_copies(&mut x.graph().write().unwrap(), &[z_id]);
4112 assert_eq!(inserted, 1, "should insert exactly one safety copy");
4113
4114 // Verify Add's inputs were rewired: still [y_id, <something>],
4115 // but the second input is NOT x_id anymore — it's the new
4116 // Op::Copy node.
4117 let copy_id = {
4118 let g = x.graph().read().unwrap();
4119 let z_node = g.node(z_id);
4120 assert_eq!(z_node.inputs.len(), 2, "Add still has 2 inputs");
4121 assert_eq!(z_node.inputs[0], y_id, "Add's y input unchanged");
4122 assert_ne!(z_node.inputs[1], x_id, "Add's x input was rewired");
4123 let copy_node_id = z_node.inputs[1];
4124 let copy_node = g.node(copy_node_id);
4125 assert!(
4126 matches!(copy_node.op, crate::Op::Copy { .. }),
4127 "rewired input is Op::Copy; got {:?}",
4128 copy_node.op
4129 );
4130 assert_eq!(copy_node.inputs, vec![x_id], "Op::Copy reads x");
4131 copy_node_id
4132 };
4133
4134 // Verify execution_plan succeeds (no cycle).
4135 let plan = execution_plan(&x.graph().read().unwrap(), &[z_id]);
4136 // Plan must contain x, the copy, ReluInplace, and Add.
4137 // The copy must come before ReluInplace (it's a reader of x).
4138 let pos: HashMap<NodeId, usize> = plan.iter().enumerate().map(|(i, n)| (*n, i)).collect();
4139 let copy_pos = pos[©_id];
4140 let inplace_pos = pos[&y_id];
4141 assert!(
4142 copy_pos < inplace_pos,
4143 "Copy must run before ReluInplace; copy={copy_pos} inplace={inplace_pos}"
4144 );
4145 }
4146
4147 /// CapturedRun executor build-out (4b-ζ follow-up): when a graph has
4148 /// `target_backend` stamped (ordinary single-device `optimize_graph`
4149 /// output — the common case) but no explicit `placement` (a rarely-
4150 /// used pin), the inserted safety copy's `target_location` must be
4151 /// derived from `target_backend`, NOT default straight to `Cpu`. A
4152 /// same-device CUDA safety snapshot silently landing on `Cpu` strands
4153 /// its (unrelated) reader off-device — and, in a CUDA decode graph
4154 /// bound for `capture_decode`, turns an ordinary same-device
4155 /// snapshot into a genuinely-cross-device D2H that capture must
4156 /// reject (capture only ever accepts a same-device CUDA `Op::Copy`).
4157 /// Born-red without the `target_backend` fallback (old code:
4158 /// `.unwrap_or(DeviceLocation::Cpu)` fires immediately since
4159 /// `placement` is never populated here); green with it.
4160 #[test]
4161 fn insert_safety_copies_derives_target_location_from_target_backend_when_unplaced() {
4162 // Same residual shape as `insert_safety_copies_residual_connection_breaks_cycle`
4163 // (`y = x.relu_inplace(); z = y + x`), but every node is stamped
4164 // `target_backend = Cuda` (the ordinary optimize_graph output for a
4165 // single-device CUDA decode graph) and NO node has an explicit
4166 // `placement` — the realistic shape of a real Llama decode graph.
4167 let x = NodeHandle::from_f32(
4168 vec![1.0_f32, -2.0, 3.0, -4.0],
4169 Shape::from_dims(&[4]),
4170 cpu_dev(),
4171 )
4172 .unwrap();
4173 let x_shape = x.shape();
4174 let x_dtype = x.dtype();
4175 let x_id = x.id();
4176
4177 let (_y_id, z_id) = {
4178 let mut g = x.graph().write().unwrap();
4179 let y_id = g.push(crate::Node {
4180 op: crate::Op::ReluInplace,
4181 inputs: vec![x_id],
4182 shape: x_shape.clone(),
4183 dtype: x_dtype,
4184 });
4185 let z_id = g.push(crate::Node {
4186 op: crate::Op::Add,
4187 inputs: vec![y_id, x_id],
4188 shape: x_shape,
4189 dtype: x_dtype,
4190 });
4191 g.set_target_backend(y_id, fuel_ir::probe::BackendId::Cuda);
4192 g.set_target_backend(z_id, fuel_ir::probe::BackendId::Cuda);
4193 (y_id, z_id)
4194 };
4195 assert!(
4196 x.graph().read().unwrap().placement(x_id).is_none(),
4197 "precondition: x has no explicit placement",
4198 );
4199
4200 let inserted = insert_safety_copies(&mut x.graph().write().unwrap(), &[z_id]);
4201 assert_eq!(inserted, 1, "should insert exactly one safety copy");
4202
4203 let g = x.graph().read().unwrap();
4204 let z_node = g.node(z_id);
4205 let copy_id = z_node.inputs[1];
4206 let copy_node = g.node(copy_id);
4207 assert_eq!(
4208 copy_node.op,
4209 crate::Op::Copy {
4210 target: DeviceLocation::Cuda { gpu_id: 0 }
4211 },
4212 "same-device CUDA safety copy must target Cuda (derived from \
4213 target_backend), NOT fall back to Cpu when placement is unset",
4214 );
4215 }
4216
4217 #[test]
4218 fn insert_safety_copies_noop_when_no_destructive_conflicts() {
4219 // Pure functional graph — no in-place ops. The pass should
4220 // insert zero copies.
4221 let x = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4222 let y = x.relu();
4223 let z = y.sum_all();
4224 let inserted = insert_safety_copies(&mut x.graph().write().unwrap(), &[z.id()]);
4225 assert_eq!(inserted, 0);
4226 }
4227
4228 #[test]
4229 fn insert_safety_copies_noop_when_inplace_target_only_consumed_via_alias_path() {
4230 // `y = x.relu_inplace(); loss = y.sum_all()` — the only
4231 // downstream consumer of x reads it through y (the in-place
4232 // node), not x directly. derive_ordering pins ReluInplace
4233 // after... only ReluInplace itself (no other readers of x).
4234 // No conflict, no copy needed.
4235 let x =
4236 NodeHandle::from_f32(vec![1.0_f32, -1.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4237 let x_shape = x.shape();
4238 let x_dtype = x.dtype();
4239 let x_id = x.id();
4240 let y_id = x.graph().write().unwrap().push(crate::Node {
4241 op: crate::Op::ReluInplace,
4242 inputs: vec![x_id],
4243 shape: x_shape,
4244 dtype: x_dtype,
4245 });
4246 // Build a chain that only reads y (not x).
4247 let inserted = insert_safety_copies(&mut x.graph().write().unwrap(), &[y_id]);
4248 assert_eq!(inserted, 0);
4249 }
4250
4251 /// Regression for the 2026-06-11 live-GPU failure: a reader with
4252 /// NO dependency relationship to a destructive `Op::Move` must
4253 /// not get a safety copy just because DFS visit order happened to
4254 /// place it after the Move.
4255 ///
4256 /// Graph: `a; b = relu(a); m = move(a)`. derive_ordering pins
4257 /// `m` after `b` (reader-before-mutator), so every execution_plan
4258 /// runs `b` before `m`'s release — no copy is needed in EITHER
4259 /// roots order. The old positional predicate inserted a spurious
4260 /// copy when roots ordering made `b` land after `m` in
4261 /// `topo_order_multi` (which visits the LAST root's subtree
4262 /// first), and that copy then inherited the Move's device.
4263 #[test]
4264 fn insert_safety_copies_no_spurious_copy_for_independent_reader_of_move() {
4265 for flip_roots in [false, true] {
4266 let a = NodeHandle::from_f32(vec![1.0_f32, -2.0], Shape::from_dims(&[2]), cpu_dev())
4267 .unwrap();
4268 let b = a.relu();
4269 let a_shape = a.shape();
4270 let a_dtype = a.dtype();
4271 let a_id = a.id();
4272 let m_id = a.graph().write().unwrap().push(crate::Node {
4273 op: crate::Op::Move {
4274 target: DeviceLocation::Cpu,
4275 },
4276 inputs: vec![a_id],
4277 shape: a_shape,
4278 dtype: a_dtype,
4279 });
4280 let roots = if flip_roots {
4281 [m_id, b.id()]
4282 } else {
4283 [b.id(), m_id]
4284 };
4285 // Sanity: in the `[b, m]` roots order, DFS visits m's
4286 // subtree first, so b lands positionally AFTER the Move —
4287 // the exact shape the old predicate misclassified.
4288 let inserted = insert_safety_copies(&mut a.graph().write().unwrap(), &roots);
4289 assert_eq!(
4290 inserted, 0,
4291 "relu(a) is pinned before move(a) by derive_ordering; \
4292 no copy regardless of roots order (flip_roots={flip_roots})",
4293 );
4294 let g = a.graph().read().unwrap();
4295 assert_eq!(
4296 g.node(b.id()).inputs,
4297 vec![a_id],
4298 "reader must keep reading `a` directly",
4299 );
4300 drop(g);
4301 // The plan must still order the reader before the Move.
4302 let plan = execution_plan(&a.graph().read().unwrap(), &roots);
4303 let b_pos = plan.iter().position(|&n| n == b.id()).unwrap();
4304 let m_pos = plan.iter().position(|&n| n == m_id).unwrap();
4305 assert!(b_pos < m_pos, "relu must precede move: {plan:?}");
4306 }
4307 }
4308
4309 /// A reader that TRANSITIVELY depends on the destructive op (not
4310 /// just via a direct input) still conflicts: the pin edge would
4311 /// close a cycle through the intermediate node. The
4312 /// dependency-based predicate must use reachability, not direct
4313 /// input inspection.
4314 ///
4315 /// Graph: `y = relu_inplace(x); w = relu(y); z = add(w, x)`.
4316 #[test]
4317 fn insert_safety_copies_transitive_descendant_reader_gets_copy() {
4318 let x = NodeHandle::from_f32(
4319 vec![1.0_f32, -2.0, 3.0, -4.0],
4320 Shape::from_dims(&[4]),
4321 cpu_dev(),
4322 )
4323 .unwrap();
4324 let x_shape = x.shape();
4325 let x_dtype = x.dtype();
4326 let x_id = x.id();
4327 let (y_id, z_id) = {
4328 let mut g = x.graph().write().unwrap();
4329 let y_id = g.push(crate::Node {
4330 op: crate::Op::ReluInplace,
4331 inputs: vec![x_id],
4332 shape: x_shape.clone(),
4333 dtype: x_dtype,
4334 });
4335 let w_id = g.push(crate::Node {
4336 op: crate::Op::Relu,
4337 inputs: vec![y_id],
4338 shape: x_shape.clone(),
4339 dtype: x_dtype,
4340 });
4341 let z_id = g.push(crate::Node {
4342 op: crate::Op::Add,
4343 inputs: vec![w_id, x_id],
4344 shape: x_shape,
4345 dtype: x_dtype,
4346 });
4347 (y_id, z_id)
4348 };
4349 let inserted = insert_safety_copies(&mut x.graph().write().unwrap(), &[z_id]);
4350 assert_eq!(inserted, 1, "transitive residual cycle needs one copy");
4351 let g = x.graph().read().unwrap();
4352 let z_node = g.node(z_id);
4353 assert_ne!(z_node.inputs[1], x_id, "z's x input must be rewired");
4354 assert!(matches!(
4355 g.node(z_node.inputs[1]).op,
4356 crate::Op::Copy { .. }
4357 ));
4358 drop(g);
4359 let plan = execution_plan(&x.graph().read().unwrap(), &[z_id]);
4360 assert!(plan.contains(&y_id), "plan still contains the in-place op");
4361 }
4362
4363 /// A genuinely-parallel reader — no ordering guarantee in either
4364 /// direction — keeps its safety copy (conservative default).
4365 ///
4366 /// The only readers derive_ordering does NOT pin before the
4367 /// mutator are alias-set members (view ops of the target). A view
4368 /// with consumers is transitively forced before the mutator via
4369 /// its pinned consumers; a view that is itself a realize root has
4370 /// no ordering guarantee at all. Safety cannot be proven, so the
4371 /// pass must copy. Note the old positional predicate got this
4372 /// case WRONG in the `[m, v]` roots order (v lands positionally
4373 /// before m → no copy; only the plan's stable tiebreak saved it).
4374 #[test]
4375 fn insert_safety_copies_parallel_view_root_reader_keeps_copy() {
4376 let x = NodeHandle::from_f32(
4377 vec![1.0_f32, 2.0, 3.0, 4.0],
4378 Shape::from_dims(&[2, 2]),
4379 cpu_dev(),
4380 )
4381 .unwrap();
4382 let v = x.transpose();
4383 let x_shape = x.shape();
4384 let x_dtype = x.dtype();
4385 let x_id = x.id();
4386 let m_id = x.graph().write().unwrap().push(crate::Node {
4387 op: crate::Op::Move {
4388 target: DeviceLocation::Cpu,
4389 },
4390 inputs: vec![x_id],
4391 shape: x_shape,
4392 dtype: x_dtype,
4393 });
4394 // Roots [m, v]: DFS visits v's subtree first, so v sits
4395 // positionally BEFORE the Move — the shape the positional
4396 // predicate silently skipped.
4397 let inserted = insert_safety_copies(&mut x.graph().write().unwrap(), &[m_id, v.id()]);
4398 assert_eq!(
4399 inserted, 1,
4400 "unpinned view-root reader has no ordering guarantee → copy",
4401 );
4402 let g = x.graph().read().unwrap();
4403 let v_node = g.node(v.id());
4404 assert_ne!(
4405 v_node.inputs[0], x_id,
4406 "view must be rewired to the snapshot"
4407 );
4408 assert!(matches!(
4409 g.node(v_node.inputs[0]).op,
4410 crate::Op::Copy { .. }
4411 ));
4412 }
4413
4414 /// Cycles through ANOTHER destructive op's ordering edge are
4415 /// still conflicts — the predicate must walk the combined
4416 /// (data + ordering) graph, not just data edges.
4417 ///
4418 /// Graph:
4419 /// m1 = move(t1); m2 = move(t2)
4420 /// x = add(m1, t2) (pin x→m2; data m1→x)
4421 /// r = add(m2, t1) (pin r→m1; data m2→r)
4422 /// Combined cycle: m1 → x → m2 → r → m1. Neither x nor r is a
4423 /// pure-data descendant of the op whose target it reads, yet
4424 /// both pins are unsatisfiable. Both need copies; afterwards
4425 /// execution_plan must succeed.
4426 #[test]
4427 fn insert_safety_copies_breaks_cycle_through_other_ordering_edges() {
4428 let t1 =
4429 NodeHandle::from_f32(vec![1.0_f32, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4430 let t2 = t1
4431 .const_f32_like(vec![3.0_f32, 4.0], Shape::from_dims(&[2]))
4432 .unwrap();
4433 let shape = t1.shape();
4434 let dtype = t1.dtype();
4435 let (t1_id, t2_id) = (t1.id(), t2.id());
4436 let (x_id, r_id) = {
4437 let mut g = t1.graph().write().unwrap();
4438 let m1 = g.push(crate::Node {
4439 op: crate::Op::Move {
4440 target: DeviceLocation::Cpu,
4441 },
4442 inputs: vec![t1_id],
4443 shape: shape.clone(),
4444 dtype,
4445 });
4446 let m2 = g.push(crate::Node {
4447 op: crate::Op::Move {
4448 target: DeviceLocation::Cpu,
4449 },
4450 inputs: vec![t2_id],
4451 shape: shape.clone(),
4452 dtype,
4453 });
4454 let x = g.push(crate::Node {
4455 op: crate::Op::Add,
4456 inputs: vec![m1, t2_id],
4457 shape: shape.clone(),
4458 dtype,
4459 });
4460 let r = g.push(crate::Node {
4461 op: crate::Op::Add,
4462 inputs: vec![m2, t1_id],
4463 shape,
4464 dtype,
4465 });
4466 (x, r)
4467 };
4468 let inserted = insert_safety_copies(&mut t1.graph().write().unwrap(), &[x_id, r_id]);
4469 assert_eq!(inserted, 2, "both cross-readers sit on the combined cycle");
4470 // The rewritten graph must be plannable (no cycle panic).
4471 let plan = execution_plan(&t1.graph().read().unwrap(), &[x_id, r_id]);
4472 assert!(plan.contains(&x_id) && plan.contains(&r_id));
4473 }
4474
4475 /// Idempotence under the dependency-based predicate: a second run
4476 /// sees the inserted Copy as a pinned, acyclic reader of the
4477 /// target and inserts nothing new.
4478 #[test]
4479 fn insert_safety_copies_idempotent_after_rewrite() {
4480 let x =
4481 NodeHandle::from_f32(vec![1.0_f32, -2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4482 let x_shape = x.shape();
4483 let x_dtype = x.dtype();
4484 let x_id = x.id();
4485 let z_id = {
4486 let mut g = x.graph().write().unwrap();
4487 let y_id = g.push(crate::Node {
4488 op: crate::Op::ReluInplace,
4489 inputs: vec![x_id],
4490 shape: x_shape.clone(),
4491 dtype: x_dtype,
4492 });
4493 g.push(crate::Node {
4494 op: crate::Op::Add,
4495 inputs: vec![y_id, x_id],
4496 shape: x_shape,
4497 dtype: x_dtype,
4498 })
4499 };
4500 let first = insert_safety_copies(&mut x.graph().write().unwrap(), &[z_id]);
4501 assert_eq!(first, 1);
4502 let second = insert_safety_copies(&mut x.graph().write().unwrap(), &[z_id]);
4503 assert_eq!(second, 0, "second run must be a no-op");
4504 }
4505
4506 /// Performance regression: a destructive op whose target has NO other
4507 /// reader is the COMMON case in a real decode graph (KV-cache writes,
4508 /// release markers). Two INDEPENDENT quadratic costs used to fire on
4509 /// every one of the `N` destructive ops regardless: (1)
4510 /// `insert_safety_copies`' own pair of `reach()` graph walks plus an
4511 /// `order.len()`-sized linear readers-scan, and (2) `derive_ordering`'s
4512 /// `collect_alias_set` (now `alias_groups`), which re-walked the ENTIRE
4513 /// `order` per destructive op to find alias members. `N` independent
4514 /// 2-node chains (a `Const` leaf read ONLY by its own `ReluInplace`)
4515 /// make BOTH scale as `N * order.len()` under the old code. Measured on
4516 /// this box: unpatched (both costs present), `N=4000` took 18.2s;
4517 /// patched (both fixed — the `succ`-map pre-check here, plus
4518 /// `alias_groups`' single O(order.len()) pass shared across every
4519 /// destructive op instead of one pass each), `N=4000` takes ~62ms —
4520 /// a ~294x improvement. The bound below is generous in the PATCHED
4521 /// direction (keeps the test fast and non-flaky under shared-box
4522 /// contention) while still being far below what either quadratic old
4523 /// path could ever hit at this `N` — born red reverting either fix.
4524 #[test]
4525 fn insert_safety_copies_skips_reach_when_target_has_no_other_reader() {
4526 const N: usize = 4000;
4527 let shape = Shape::from_dims(&[4]);
4528 let mut g = crate::Graph::new();
4529 let mut roots: Vec<NodeId> = Vec::with_capacity(N);
4530 for _ in 0..N {
4531 let leaf = g.push(crate::Node {
4532 op: crate::Op::Const,
4533 inputs: vec![],
4534 shape: shape.clone(),
4535 dtype: DType::F32,
4536 });
4537 let relu = g.push(crate::Node {
4538 op: crate::Op::ReluInplace,
4539 inputs: vec![leaf],
4540 shape: shape.clone(),
4541 dtype: DType::F32,
4542 });
4543 roots.push(relu);
4544 }
4545
4546 let start = std::time::Instant::now();
4547 let inserted = insert_safety_copies(&mut g, &roots);
4548 let elapsed = start.elapsed();
4549
4550 assert_eq!(
4551 inserted, 0,
4552 "no leaf has a reader besides its own ReluInplace; nothing to copy"
4553 );
4554 assert!(
4555 elapsed < std::time::Duration::from_millis(500),
4556 "insert_safety_copies({N} independent no-reader chains) took {elapsed:?} — \
4557 the no-other-reader pre-check should make this O(N), not O(N^2)"
4558 );
4559 }
4560
4561 // ===== Differential safety-analysis harness (docs/design/incremental-consumer-index.md
4562 // §4 step 1) =====
4563 //
4564 // Step 1 of the staged redesign plan: build the comparison machinery and a
4565 // corpus of representative graphs BEFORE any new incremental-index
4566 // production code exists (that's step 2). The "new" side below calls the
4567 // SAME old pass on an INDEPENDENTLY-constructed graph instance (not a
4568 // clone of the old one -- `Graph` deliberately isn't `Clone`, and this
4569 // harness doesn't need it to be: it never touches production code to get
4570 // there). That makes today's differential assertion validate the thing
4571 // step 1 actually needs validated — the comparison logic itself, and that
4572 // every corpus factory is deterministic (same NodeId assignments on every
4573 // call, since NodeId is push-order, not globally counted) — ready for
4574 // step 2 to swap `run_new_safety_analysis` for the real incremental index
4575 // without touching this file again.
4576 //
4577 // NOT done here (tracked, not silently skipped): the design doc's step 1
4578 // also calls for the live decode harness's ACTUAL graph in the corpus.
4579 // That needs a graph-capture hook in the live CUDA harness; out of scope
4580 // for this PR, which is corpus-of-unit-test-shapes + harness plumbing
4581 // only. Also not done: porting ALL nine existing insert_safety_copies/
4582 // derive_ordering tests into the corpus (design doc open item #2) — five
4583 // representative shapes are ported below; full porting is tracked for
4584 // the step-4 PR (view/alias resolution), where open item #2 is due.
4585
4586 /// One differential-corpus entry: builds a FRESH graph + its realize
4587 /// roots. Called twice per comparison (once per side) rather than
4588 /// cloned, so the harness never needs `Graph: Clone`.
4589 type SafetyCorpusFactory = fn() -> (SharedGraph, Vec<NodeId>);
4590
4591 /// A snapshotted, comparable view of "what insert_safety_copies (which
4592 /// internally calls derive_ordering) did to this graph" — the
4593 /// observable contract the differential test checks. `nodes` compares
4594 /// the WHOLE resulting graph structurally (op + inputs, Debug-formatted
4595 /// since `Op` has no blanket `PartialEq`) rather than just the inserted
4596 /// count, so a divergence that inserts the SAME NUMBER of copies in a
4597 /// DIFFERENT place is still caught.
4598 #[derive(Debug, PartialEq)]
4599 struct SafetyAnalysisResult {
4600 inserted: usize,
4601 node_count: usize,
4602 nodes: Vec<(String, Vec<NodeId>)>,
4603 }
4604
4605 fn run_old_safety_analysis(graph: &SharedGraph, roots: &[NodeId]) -> SafetyAnalysisResult {
4606 let inserted = insert_safety_copies(&mut graph.write().unwrap(), roots);
4607 let g = graph.read().unwrap();
4608 let nodes = (0..g.len())
4609 .map(|i| {
4610 let n = g.node(NodeId(i));
4611 (format!("{:?}", n.op), n.inputs.clone())
4612 })
4613 .collect();
4614 SafetyAnalysisResult {
4615 inserted,
4616 node_count: g.len(),
4617 nodes,
4618 }
4619 }
4620
4621 /// STEP 1 PLACEHOLDER — see the module note above. Step 2 (`consumers`
4622 /// side-table, push-only) replaces this body with the real incremental
4623 /// logic; nothing else in this harness needs to change when it does.
4624 fn run_new_safety_analysis(graph: &SharedGraph, roots: &[NodeId]) -> SafetyAnalysisResult {
4625 run_old_safety_analysis(graph, roots)
4626 }
4627
4628 fn assert_safety_analysis_matches(name: &str, factory: SafetyCorpusFactory) {
4629 let (old_graph, old_roots) = factory();
4630 let (new_graph, new_roots) = factory();
4631 assert_eq!(
4632 old_roots, new_roots,
4633 "{name}: factory produced different roots across two calls -- corpus factory \
4634 must be deterministic (same pushes, same order) or this harness can't compare"
4635 );
4636 let old = run_old_safety_analysis(&old_graph, &old_roots);
4637 let new = run_new_safety_analysis(&new_graph, &new_roots);
4638 assert_eq!(old, new, "{name}: old vs new safety analysis diverged");
4639 }
4640
4641 fn corpus_no_destructive_conflicts() -> (SharedGraph, Vec<NodeId>) {
4642 // a + b, no in-place op anywhere -- insert_safety_copies must be a
4643 // pure no-op (mirrors insert_safety_copies_noop_when_no_destructive_conflicts).
4644 let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4645 let b = a
4646 .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
4647 .unwrap();
4648 let c = a.add(&b);
4649 (c.graph().clone(), vec![c.id()])
4650 }
4651
4652 fn corpus_residual_connection() -> (SharedGraph, Vec<NodeId>) {
4653 // y = x.relu_inplace(); z = y + x -- the canonical cycle-breaking
4654 // case (mirrors insert_safety_copies_residual_connection_breaks_cycle).
4655 let x = NodeHandle::from_f32(
4656 vec![1.0_f32, -2.0, 3.0, -4.0],
4657 Shape::from_dims(&[4]),
4658 cpu_dev(),
4659 )
4660 .unwrap();
4661 let x_shape = x.shape();
4662 let x_dtype = x.dtype();
4663 let x_id = x.id();
4664 let z_id = {
4665 let mut g = x.graph().write().unwrap();
4666 let y_id = g.push(crate::Node {
4667 op: crate::Op::ReluInplace,
4668 inputs: vec![x_id],
4669 shape: x_shape.clone(),
4670 dtype: x_dtype,
4671 });
4672 g.push(crate::Node {
4673 op: crate::Op::Add,
4674 inputs: vec![y_id, x_id],
4675 shape: x_shape,
4676 dtype: x_dtype,
4677 })
4678 };
4679 (x.graph().clone(), vec![z_id])
4680 }
4681
4682 fn corpus_independent_reader_of_move_needs_no_copy() -> (SharedGraph, Vec<NodeId>) {
4683 // relu(a) pinned before move(a); an unrelated reader needs no copy
4684 // (mirrors insert_safety_copies_no_spurious_copy_for_independent_reader_of_move,
4685 // flip_roots=false arm: roots=[b, m], DFS visits m's subtree first
4686 // so b lands positionally after the Move).
4687 let a =
4688 NodeHandle::from_f32(vec![1.0_f32, -2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4689 let b = a.relu();
4690 let a_shape = a.shape();
4691 let a_dtype = a.dtype();
4692 let a_id = a.id();
4693 let m_id = a.graph().write().unwrap().push(crate::Node {
4694 op: crate::Op::Move {
4695 target: DeviceLocation::Cpu,
4696 },
4697 inputs: vec![a_id],
4698 shape: a_shape,
4699 dtype: a_dtype,
4700 });
4701 (a.graph().clone(), vec![b.id(), m_id])
4702 }
4703
4704 fn corpus_many_independent_no_reader_chains() -> (SharedGraph, Vec<NodeId>) {
4705 // A small-N version of the perf regression shape above: many
4706 // independent 2-node chains, none with an extra reader. Exercises
4707 // the common case (most destructive ops in a real decode graph have
4708 // no other reader at all) at a size small enough that the
4709 // differential comparison's O(graph) structural equality check
4710 // stays cheap in a correctness-focused test, not a perf one.
4711 const N: usize = 25;
4712 let shape = Shape::from_dims(&[4]);
4713 let mut g = crate::Graph::new();
4714 let mut roots: Vec<NodeId> = Vec::with_capacity(N);
4715 for _ in 0..N {
4716 let leaf = g.push(crate::Node {
4717 op: crate::Op::Const,
4718 inputs: vec![],
4719 shape: shape.clone(),
4720 dtype: DType::F32,
4721 });
4722 let relu = g.push(crate::Node {
4723 op: crate::Op::ReluInplace,
4724 inputs: vec![leaf],
4725 shape: shape.clone(),
4726 dtype: DType::F32,
4727 });
4728 roots.push(relu);
4729 }
4730 (Arc::new(std::sync::RwLock::new(g)), roots)
4731 }
4732
4733 const SAFETY_CORPUS: &[(&str, SafetyCorpusFactory)] = &[
4734 ("no_destructive_conflicts", corpus_no_destructive_conflicts),
4735 ("residual_connection", corpus_residual_connection),
4736 (
4737 "independent_reader_of_move_needs_no_copy",
4738 corpus_independent_reader_of_move_needs_no_copy,
4739 ),
4740 (
4741 "many_independent_no_reader_chains",
4742 corpus_many_independent_no_reader_chains,
4743 ),
4744 ];
4745
4746 #[test]
4747 fn differential_safety_analysis_matches_on_corpus() {
4748 for &(name, factory) in SAFETY_CORPUS {
4749 assert_safety_analysis_matches(name, factory);
4750 }
4751 }
4752
4753 #[test]
4754 fn derive_ordering_release_of_multi_reader_input() {
4755 // a read by relu AND neg, then released. Both relu and neg
4756 // must precede the release.
4757 let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4758 let b = a.relu();
4759 let c = a.neg();
4760 let r = a.release();
4761 let ord = derive_ordering(&a.graph().read().unwrap(), &[b.id(), c.id(), r.id()]);
4762 let mut deps = ord.deps_of(r.id()).to_vec();
4763 deps.sort_by_key(|n| n.0);
4764 let mut expected = vec![b.id(), c.id()];
4765 expected.sort_by_key(|n| n.0);
4766 assert_eq!(deps, expected);
4767 }
4768
4769 #[test]
4770 fn execution_plan_matches_topo_when_no_destructive_ops() {
4771 let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4772 let b = a
4773 .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
4774 .unwrap();
4775 let c = a.add(&b);
4776 let graph = c.graph().read().unwrap();
4777 let plan = execution_plan(&graph, &[c.id()]);
4778 let topo = topo_order_multi(&graph, &[c.id()]);
4779 assert_eq!(plan, topo);
4780 }
4781
4782 #[test]
4783 fn execution_plan_pins_release_after_sibling_reader() {
4784 let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4785 let b = a.relu();
4786 let r = a.release();
4787 let plan = execution_plan(&a.graph().read().unwrap(), &[b.id(), r.id()]);
4788 let b_pos = plan.iter().position(|&n| n == b.id()).unwrap();
4789 let r_pos = plan.iter().position(|&n| n == r.id()).unwrap();
4790 assert!(
4791 b_pos < r_pos,
4792 "expected relu@{b_pos} to precede release@{r_pos}: {plan:?}"
4793 );
4794 }
4795
4796 #[test]
4797 fn execution_plan_handles_chain_of_destructive_ops() {
4798 // a -> relu -> b
4799 // a -> neg -> c
4800 // a -> release
4801 // b and c must come before release.
4802 let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4803 let b = a.relu();
4804 let c = a.neg();
4805 let r = a.release();
4806 let plan = execution_plan(&a.graph().read().unwrap(), &[b.id(), c.id(), r.id()]);
4807 let b_pos = plan.iter().position(|&n| n == b.id()).unwrap();
4808 let c_pos = plan.iter().position(|&n| n == c.id()).unwrap();
4809 let r_pos = plan.iter().position(|&n| n == r.id()).unwrap();
4810 assert!(
4811 b_pos < r_pos,
4812 "relu@{b_pos} must precede release@{r_pos}: {plan:?}"
4813 );
4814 assert!(
4815 c_pos < r_pos,
4816 "neg@{c_pos} must precede release@{r_pos}: {plan:?}"
4817 );
4818 }
4819
4820 #[test]
4821 fn insert_evict_reload_creates_expected_chain() {
4822 // Graph:
4823 // a (producer, device=Cpu default)
4824 // b = relu(a) (pre-gap consumer — stays wired to a)
4825 // c = neg(a) (post-gap consumer — should be rewired to reload)
4826 // After insert_evict_reload(a, Cpu, &[c]):
4827 // - 2 new nodes (move, reload) appended
4828 // - c's input list now has `reload_id` instead of `a.id()`
4829 // - b's input list STILL has `a.id()` (unchanged)
4830 let a =
4831 NodeHandle::from_f32(vec![1.0_f32, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4832 let b = a.relu();
4833 let c = a.neg();
4834
4835 let (move_id, reload_id) =
4836 insert_evict_reload(a.graph(), a.id(), DeviceLocation::Cpu, &[c.id()]);
4837
4838 let g = a.graph().read().unwrap();
4839 // mv is an Op::Move{Cpu} reading a — the fused stage-to-host +
4840 // destructive release of a's device storage.
4841 match &g.node(move_id).op {
4842 Op::Move {
4843 target: DeviceLocation::Cpu,
4844 } => {}
4845 other => panic!("expected Op::Move{{Cpu}}, got {other:?}"),
4846 }
4847 assert_eq!(g.node(move_id).inputs, vec![a.id()]);
4848 assert_eq!(
4849 g.node(move_id).op.destructive_input(),
4850 Some(0),
4851 "Move must destroy its source so the device storage frees",
4852 );
4853 // reload is an Op::Copy{Cpu} (src_device passed in) reading mv
4854 assert!(matches!(g.node(reload_id).op, Op::Copy { .. }));
4855 assert_eq!(g.node(reload_id).inputs, vec![move_id]);
4856 // c's input was rewired
4857 assert_eq!(
4858 g.node(c.id()).inputs,
4859 vec![reload_id],
4860 "post-gap consumer should read from reload, not candidate directly"
4861 );
4862 // b's input stays
4863 assert_eq!(
4864 g.node(b.id()).inputs,
4865 vec![a.id()],
4866 "pre-gap consumer should still read candidate directly"
4867 );
4868 }
4869
4870 #[test]
4871 fn execution_plan_respects_transitive_data_deps_after_release() {
4872 // Graph:
4873 // a
4874 // b = relu(a)
4875 // r = release(a) (destructive; runs after b)
4876 // sum = sum_all(b) (data-dependent on b, not on r)
4877 // Plan must have: b before r, b before sum; b before both is enough.
4878 let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
4879 let b = a.relu();
4880 let sum = b.sum_all();
4881 let r = a.release();
4882 let plan = execution_plan(&a.graph().read().unwrap(), &[sum.id(), r.id()]);
4883 let b_pos = plan.iter().position(|&n| n == b.id()).unwrap();
4884 let sum_pos = plan.iter().position(|&n| n == sum.id()).unwrap();
4885 let r_pos = plan.iter().position(|&n| n == r.id()).unwrap();
4886 assert!(b_pos < sum_pos);
4887 assert!(b_pos < r_pos);
4888 }
4889
4890 #[test]
4891 fn fuse_linear_collapses_matmul_plus_rank1_bias() {
4892 // Build [batch=1, m=2, k=3] @ [k=3, n=4] + bias[4].
4893 let a = crate::NodeHandle::from_f32(
4894 vec![1.0_f32, 2.0, 3.0, 4.0, 5.0, 6.0],
4895 crate::Shape::from_dims(&[2, 3]),
4896 cpu_dev(),
4897 )
4898 .unwrap();
4899 let b = a
4900 .const_f32_like(
4901 (0..12).map(|i| (i as f32) * 0.1).collect::<Vec<f32>>(),
4902 crate::Shape::from_dims(&[3, 4]),
4903 )
4904 .unwrap();
4905 let bias = a.const_f32_like(
4906 vec![0.5_f32, -0.5, 1.0, -1.0],
4907 crate::Shape::from_dims(&[4]),
4908 );
4909 let mm = a.matmul(&b);
4910 let bias_b = bias.unwrap().broadcast_to(crate::Shape::from_dims(&[2, 4]));
4911 let out = mm.add(&bias_b);
4912 // Note: real users would call broadcast_to first, then Add.
4913 // The fusion pass looks for `Add(MatMul, Const-shape-1-N)`
4914 // so we need to update it to also recognize the
4915 // BroadcastTo-then-Add pattern.
4916 let n_fused = fuse_linear(out.graph(), &[out.id()]);
4917 assert_eq!(n_fused, 1, "exactly one MatMul→Add(bias[N]) should fuse");
4918
4919 // The fused node should now be reachable as the canonical
4920 // root after remap. Its op is FusedLinear with three inputs.
4921 let g = out.graph().read().unwrap();
4922 // Walk consumers of the original Add: any leftover Add should
4923 // be unreferenced; the new FusedLinear should be present.
4924 // Phase 7.6 step 4: emission is the registry-extended shape.
4925 let any_fused = g.nodes.iter().any(|n| {
4926 matches!(
4927 n.op,
4928 Op::Fused(fid, _) if fid == crate::registry::FusedOps::FUSED_LINEAR,
4929 )
4930 });
4931 assert!(
4932 any_fused,
4933 "graph should contain an Op::Fused(FUSED_LINEAR) node"
4934 );
4935 }
4936
4937 #[test]
4938 fn fuse_linear_skips_when_matmul_has_other_consumers() {
4939 // If the matmul is consumed by both Add and something else,
4940 // fusing would duplicate the matmul. Pass should skip.
4941 let a = crate::NodeHandle::from_f32(
4942 vec![1.0_f32; 6],
4943 crate::Shape::from_dims(&[2, 3]),
4944 cpu_dev(),
4945 )
4946 .unwrap();
4947 let b = a
4948 .const_f32_like(vec![1.0_f32; 12], crate::Shape::from_dims(&[3, 4]))
4949 .unwrap();
4950 let bias = a
4951 .const_f32_like(vec![1.0_f32; 4], crate::Shape::from_dims(&[4]))
4952 .unwrap();
4953 let mm = a.matmul(&b);
4954 let bias_b = bias.broadcast_to(crate::Shape::from_dims(&[2, 4]));
4955 let with_bias = mm.add(&bias_b);
4956 let also_used = mm.relu(); // second consumer of mm
4957 // Both with_bias and also_used are roots.
4958 let n_fused = fuse_linear(a.graph(), &[with_bias.id(), also_used.id()]);
4959 assert_eq!(
4960 n_fused, 0,
4961 "MatMul has 2 consumers — fusion would duplicate work"
4962 );
4963 }
4964
4965 // ---- Rule registry: SoftmaxLastDim lower / fuse / round-trip ------------
4966
4967 fn count_op_in_reachable<F: Fn(&Op) -> bool>(
4968 graph: &SharedGraph,
4969 roots: &[NodeId],
4970 pred: F,
4971 ) -> usize {
4972 let g = graph.read().unwrap();
4973 topo_order_multi(&g, roots)
4974 .into_iter()
4975 .filter(|id| pred(&g.node(*id).op))
4976 .count()
4977 }
4978
4979 /// The lower rule, applied to a graph with one fused softmax,
4980 /// produces the canonical primitive subgraph reachable from the
4981 /// rewritten root, with auto-populated layout entries on the
4982 /// inserted BroadcastTo nodes.
4983 ///
4984 /// T5 (Increment C slice 1): the lowered spelling is the RECIPE's
4985 /// 9-node shrink-via-swap form — `MaxDim`/`SumDim` at the last axis +
4986 /// `Unsqueeze` append restoring keepdim, with the `e = Exp(..)`
4987 /// interior SHARED (identity-share) between the denominator reduce and
4988 /// the final Div — replacing the legacy 7-node
4989 /// `ReduceMaxTo`/`ReduceSumTo` form.
4990 #[test]
4991 fn softmax_last_dim_lower_rule_produces_canonical_subgraph() {
4992 let x = NodeHandle::from_f32(vec![1.0_f32; 12], Shape::from_dims(&[2, 3, 2]), cpu_dev())
4993 .unwrap();
4994 let sm = x.softmax_last_dim();
4995 let graph = sm.graph().clone();
4996
4997 let registry = RuleRegistry::lowering_only();
4998 let new_roots = registry.optimize_to_fixpoint(&graph, &[sm.id()]);
4999 assert_eq!(new_roots.len(), 1);
5000 let new_root = new_roots[0];
5001
5002 // Reachable subgraph from the new root should contain exactly
5003 // the 9 lowered nodes (plus the `x` Const leaf — that's 10).
5004 // Op composition: 1 MaxDim, 2 Unsqueeze, 2 BroadcastTo, 1 Sub,
5005 // 1 Exp, 1 SumDim, 1 Div, 1 Const = 10 reachable.
5006 let g = graph.read().unwrap();
5007 let reachable = topo_order_multi(&g, &[new_root]);
5008 assert_eq!(
5009 reachable.len(),
5010 10,
5011 "lowered subgraph: 9 ops + 1 Const = 10 reachable nodes"
5012 );
5013 drop(g);
5014
5015 // Op-shape sanity checks.
5016 let n_reduce_max =
5017 count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::ReduceMaxTo(_)));
5018 let n_max_dim =
5019 count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::MaxDim(_)));
5020 let n_sum_dim =
5021 count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::SumDim(_)));
5022 let n_unsqueeze =
5023 count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::Unsqueeze { .. }));
5024 let n_reshape =
5025 count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::Reshape(_)));
5026 let n_broadcast =
5027 count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::BroadcastTo(_)));
5028 let n_sub = count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::Sub));
5029 let n_exp = count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::Exp));
5030 let n_reduce_sum =
5031 count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::ReduceSumTo(_)));
5032 let n_div = count_op_in_reachable(&graph, &[new_root], |op| matches!(op, Op::Div));
5033 // Phase 7.6 step 5: post-migration the builder emits
5034 // `Op::Fused(SOFTMAX_LAST_DIM, _)` only — the legacy
5035 // `Op::SoftmaxLastDim` variant was retired.
5036 let n_softmax = count_op_in_reachable(
5037 &graph,
5038 &[new_root],
5039 |op| matches!(op, Op::Fused(fid, _) if *fid == FusedOps::SOFTMAX_LAST_DIM),
5040 );
5041 assert_eq!(
5042 n_max_dim, 1,
5043 "T5 max side = MaxDim(last) + Unsqueeze append"
5044 );
5045 assert_eq!(
5046 n_sum_dim, 1,
5047 "T5 sum side = SumDim(last) + Unsqueeze append"
5048 );
5049 assert_eq!(n_unsqueeze, 2, "one keepdim-restore per reduce");
5050 assert_eq!(
5051 n_reduce_max, 0,
5052 "the legacy ReduceMaxTo spelling is retired from the lowering"
5053 );
5054 assert_eq!(
5055 n_reduce_sum, 0,
5056 "the legacy ReduceSumTo spelling is retired from the lowering"
5057 );
5058 assert_eq!(n_reshape, 0, "keepdim restores via Unsqueeze, not Reshape");
5059 assert_eq!(n_broadcast, 2);
5060 assert_eq!(n_sub, 1);
5061 assert_eq!(
5062 n_exp, 1,
5063 "the shared `e` interior emits ONCE (identity-share), not per consumer"
5064 );
5065 assert_eq!(n_div, 1);
5066 assert_eq!(
5067 n_softmax, 0,
5068 "no SoftmaxLastDim/Fused(SOFTMAX_LAST_DIM) should remain reachable post-lowering"
5069 );
5070
5071 // Layout side-table entries on the inserted BroadcastTo nodes
5072 // must be populated and have the expected broadcast strides.
5073 let g = graph.read().unwrap();
5074 for id in topo_order_multi(&g, &[new_root]) {
5075 if let Op::BroadcastTo(_) = g.node(id).op {
5076 assert!(
5077 g.has_explicit_layout(id),
5078 "lowered BroadcastTo at {id:?} missing explicit Layout entry — \
5079 Graph::push auto-populate should have set it",
5080 );
5081 // The inserted BroadcastTo broadcasts shape [2,3,1] -> [2,3,2]
5082 // with last-dim stride 0.
5083 let l = g.layout(id);
5084 assert_eq!(l.shape().dims(), &[2, 3, 2]);
5085 assert_eq!(
5086 l.stride().last().copied(),
5087 Some(0),
5088 "BroadcastTo from [2,3,1] to [2,3,2] should have stride 0 on last dim"
5089 );
5090 }
5091 }
5092 }
5093
5094 /// The fuse rule, applied to the canonical 7-node lowered subgraph,
5095 /// collapses it back to a single `Op::SoftmaxLastDim` reachable
5096 /// from the rewritten root.
5097 #[test]
5098 fn softmax_last_dim_fuse_rule_collapses_canonical_subgraph() {
5099 // Build the canonical subgraph by-hand — start from a Const
5100 // and apply the same op sequence the lower rule would emit.
5101 let x =
5102 NodeHandle::from_f32(vec![1.0_f32; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
5103 let m = x.reduce_max_to(Shape::from_dims(&[2, 1]));
5104 let mb = m.broadcast_to(Shape::from_dims(&[2, 3]));
5105 let s = x.sub(&mb);
5106 let e = s.exp();
5107 let d = e.reduce_sum_to(Shape::from_dims(&[2, 1]));
5108 let db = d.broadcast_to(Shape::from_dims(&[2, 3]));
5109 let out = e.div(&db);
5110 let graph = out.graph().clone();
5111
5112 // Phase 7.6 step 3: fuse rule comes from the registry entry.
5113 let registry = RuleRegistry::new().with_rule(Box::new(FusionRule::from_entry(
5114 &crate::registry::softmax_last_dim::entry(),
5115 )));
5116 let new_roots = registry.optimize_to_fixpoint(&graph, &[out.id()]);
5117 assert_eq!(new_roots.len(), 1);
5118 let new_root = new_roots[0];
5119
5120 // Reachable from the new root: 1 fused-softmax + 1 Const x = 2.
5121 let g = graph.read().unwrap();
5122 let reachable = topo_order_multi(&g, &[new_root]);
5123 assert_eq!(
5124 reachable.len(),
5125 2,
5126 "fused subgraph: 1 Fused(SOFTMAX_LAST_DIM) + 1 Const = 2 reachable nodes"
5127 );
5128 assert!(
5129 matches!(
5130 g.node(new_root).op,
5131 Op::Fused(fid, _) if fid == FusedOps::SOFTMAX_LAST_DIM
5132 ),
5133 "post-migration fusion produces Op::Fused(SOFTMAX_LAST_DIM, _)",
5134 );
5135 // Fused-softmax's input should be the original Const x.
5136 assert_eq!(g.node(new_root).inputs, vec![x.id()]);
5137 }
5138
5139 /// Lower then fuse to fixpoint: a graph with one `Op::SoftmaxLastDim`
5140 /// returns to a graph that's structurally identical to the input
5141 /// (modulo a re-canonicalized root NodeId — the original
5142 /// SoftmaxLastDim node remains in the arena but is unreachable
5143 /// from the rewritten root).
5144 #[test]
5145 fn softmax_last_dim_lower_then_fuse_round_trips() {
5146 let x =
5147 NodeHandle::from_f32(vec![1.0_f32; 6], Shape::from_dims(&[2, 3]), cpu_dev()).unwrap();
5148 let sm = x.softmax_last_dim();
5149 let graph = sm.graph().clone();
5150 let original_root = sm.id();
5151
5152 let registry = RuleRegistry::default_rules();
5153 let new_roots = registry.optimize_to_fixpoint(&graph, &[original_root]);
5154 assert_eq!(new_roots.len(), 1);
5155 let new_root = new_roots[0];
5156
5157 // Reachable from the new root must look like the original
5158 // graph: one fused-softmax consuming one Const. Phase 7.6
5159 // step 3: the post-migration builder + fuse cycle produces
5160 // `Op::Fused(SOFTMAX_LAST_DIM, _)`, not `Op::SoftmaxLastDim`.
5161 let g = graph.read().unwrap();
5162 let reachable = topo_order_multi(&g, &[new_root]);
5163 assert_eq!(
5164 reachable.len(),
5165 2,
5166 "round-tripped graph: 1 Fused(SOFTMAX_LAST_DIM) + 1 Const = 2 reachable nodes"
5167 );
5168 assert!(
5169 matches!(
5170 g.node(new_root).op,
5171 Op::Fused(fid, _) if fid == FusedOps::SOFTMAX_LAST_DIM
5172 ),
5173 "round-tripped node should be Op::Fused(SOFTMAX_LAST_DIM, _)",
5174 );
5175 assert_eq!(
5176 g.node(new_root).inputs,
5177 vec![x.id()],
5178 "round-tripped Fused(SOFTMAX_LAST_DIM) should consume the original input"
5179 );
5180 // Shape and dtype preserved.
5181 assert_eq!(g.node(new_root).shape, g.node(original_root).shape);
5182 assert_eq!(g.node(new_root).dtype, g.node(original_root).dtype);
5183 }
5184
5185 /// Empty registry is a no-op: roots come back unchanged.
5186 #[test]
5187 fn empty_registry_is_noop() {
5188 let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5189 let y = x.add(&x);
5190 let graph = y.graph().clone();
5191 let pre_len = graph.read().unwrap().len();
5192 let new_roots = RuleRegistry::new().optimize_to_fixpoint(&graph, &[y.id()]);
5193 assert_eq!(new_roots, vec![y.id()]);
5194 assert_eq!(
5195 graph.read().unwrap().len(),
5196 pre_len,
5197 "empty registry should add no nodes"
5198 );
5199 }
5200
5201 /// Lowering rule does not fire on a graph that has no
5202 /// SoftmaxLastDim nodes — the registry returns the input
5203 /// roots unchanged.
5204 #[test]
5205 fn lower_rule_does_not_fire_without_softmax() {
5206 let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5207 let y = x.relu();
5208 let graph = y.graph().clone();
5209 let pre_len = graph.read().unwrap().len();
5210 let new_roots = RuleRegistry::lowering_only().optimize_to_fixpoint(&graph, &[y.id()]);
5211 assert_eq!(new_roots, vec![y.id()]);
5212 assert_eq!(graph.read().unwrap().len(), pre_len);
5213 }
5214
5215 /// `lower_to_base_map` is a named wrapper over
5216 /// `RuleRegistry::lowering_only().optimize_to_fixpoint` — a fused ROPE
5217 /// node dissolves to its primitive base map; no `Op::Fused(ROPE, _)`
5218 /// remains reachable from the (possibly-remapped) roots.
5219 #[test]
5220 fn lower_to_base_map_dissolves_a_fused_op() {
5221 use std::sync::RwLock;
5222 // Build a graph: three Const leaves (x, cos, sin) + one Op::Fused(ROPE).
5223 let graph = Arc::new(RwLock::new(Graph::new()));
5224 let sink = {
5225 let mut g = graph.write().unwrap();
5226 let shape = Shape::from_dims(&[1, 4, 8]); // (batch, seq, head_dim=8, even)
5227 let x = g.push(Node {
5228 op: Op::Const,
5229 inputs: vec![],
5230 shape: shape.clone(),
5231 dtype: DType::F32,
5232 });
5233 let cshape = Shape::from_dims(&[4, 8]);
5234 let cos = g.push(Node {
5235 op: Op::Const,
5236 inputs: vec![],
5237 shape: cshape.clone(),
5238 dtype: DType::F32,
5239 });
5240 let sin = g.push(Node {
5241 op: Op::Const,
5242 inputs: vec![],
5243 shape: cshape,
5244 dtype: DType::F32,
5245 });
5246 g.push(Node {
5247 op: Op::Fused(FusedOps::ROPE, FusedOpParams::Rope),
5248 inputs: vec![x, cos, sin],
5249 shape,
5250 dtype: DType::F32,
5251 })
5252 };
5253 let roots = lower_to_base_map(&graph, &[sink]);
5254 let g = graph.read().unwrap();
5255 // No Op::Fused(ROPE) remains reachable; the base map is primitives.
5256 let reachable = topo_order_multi(&g, &roots);
5257 let has_rope = reachable
5258 .iter()
5259 .any(|&n| matches!(g.node(n).op, Op::Fused(fid, _) if fid == FusedOps::ROPE));
5260 assert!(!has_rope, "ROPE must be lowered to its primitive base map");
5261 }
5262
5263 /// `base_map_hash` canonicalizes commutative-operand order: two
5264 /// independently-built graphs computing `a + b` and `b + a` (same
5265 /// consts, swapped operand order on the `Add`) hash equal.
5266 #[test]
5267 fn base_map_hash_commutative_reorder_is_equal() {
5268 let mk = |swap: bool| {
5269 let mut g = Graph::new();
5270 let s = Shape::from_dims(&[4]);
5271 let a = g.push(Node {
5272 op: Op::Const,
5273 inputs: vec![],
5274 shape: s.clone(),
5275 dtype: DType::F32,
5276 });
5277 let b = g.push(Node {
5278 op: Op::Const,
5279 inputs: vec![],
5280 shape: s.clone(),
5281 dtype: DType::F32,
5282 });
5283 let ins = if swap { vec![b, a] } else { vec![a, b] };
5284 let sink = g.push(Node {
5285 op: Op::Add,
5286 inputs: ins,
5287 shape: s,
5288 dtype: DType::F32,
5289 });
5290 (g, sink)
5291 };
5292 let (g0, r0) = mk(false);
5293 let (g1, r1) = mk(true);
5294 assert_eq!(base_map_hash(&g0, r0), base_map_hash(&g1, r1), "a+b == b+a");
5295 }
5296
5297 /// `base_map_hash` distinguishes distinct ops over the same operands:
5298 /// `Add(a, b)` and `Mul(a, b)` must hash differently.
5299 #[test]
5300 fn base_map_hash_distinct_ops_differ() {
5301 let mk = |op: Op| {
5302 let mut g = Graph::new();
5303 let s = Shape::from_dims(&[4]);
5304 let a = g.push(Node {
5305 op: Op::Const,
5306 inputs: vec![],
5307 shape: s.clone(),
5308 dtype: DType::F32,
5309 });
5310 let b = g.push(Node {
5311 op: Op::Const,
5312 inputs: vec![],
5313 shape: s.clone(),
5314 dtype: DType::F32,
5315 });
5316 let sink = g.push(Node {
5317 op,
5318 inputs: vec![a, b],
5319 shape: s,
5320 dtype: DType::F32,
5321 });
5322 (g, sink)
5323 };
5324 let (ga, ra) = mk(Op::Add);
5325 let (gm, rm) = mk(Op::Mul);
5326 assert_ne!(base_map_hash(&ga, ra), base_map_hash(&gm, rm), "add != mul");
5327 }
5328
5329 /// `op_key` already carries explicit arms for `Op::Slice` and
5330 /// `Op::Concat` (tags 81/80) — the two ops rope's rotate-half base map
5331 /// relies on. This test forces that path: a tiny `Slice`-then-`Concat`
5332 /// fragment (the first half of rope's rotate-half idiom) hashes
5333 /// deterministically across two independently-built, structurally
5334 /// identical copies.
5335 #[test]
5336 fn base_map_hash_slice_concat_fragment_is_deterministic() {
5337 let mk = || {
5338 let mut g = Graph::new();
5339 let s = Shape::from_dims(&[4, 8]);
5340 let half = Shape::from_dims(&[4, 4]);
5341 let x = g.push(Node {
5342 op: Op::Const,
5343 inputs: vec![],
5344 shape: s.clone(),
5345 dtype: DType::F32,
5346 });
5347 let lo = g.push(Node {
5348 op: Op::Slice {
5349 dim: 1,
5350 start: 0,
5351 len: 4,
5352 },
5353 inputs: vec![x],
5354 shape: half.clone(),
5355 dtype: DType::F32,
5356 });
5357 let hi = g.push(Node {
5358 op: Op::Slice {
5359 dim: 1,
5360 start: 4,
5361 len: 4,
5362 },
5363 inputs: vec![x],
5364 shape: half,
5365 dtype: DType::F32,
5366 });
5367 let sink = g.push(Node {
5368 op: Op::Concat { dim: 1 },
5369 inputs: vec![hi, lo],
5370 shape: s,
5371 dtype: DType::F32,
5372 });
5373 (g, sink)
5374 };
5375 let (g0, r0) = mk();
5376 let (g1, r1) = mk();
5377 assert_eq!(
5378 base_map_hash(&g0, r0),
5379 base_map_hash(&g1, r1),
5380 "two independently-built Slice->Concat fragments must hash equal"
5381 );
5382 }
5383
5384 /// `base_map_hash` folds a `Const`'s real bytes when its storage slot
5385 /// is populated (unlike the bare `Node { op: Op::Const, .. }` pushes
5386 /// used above, `NodeHandle::from_f32` populates real storage via
5387 /// `set_storage`). Two consts with equal bytes hash equal; two consts
5388 /// of the same shape/dtype but different bytes hash differently — this
5389 /// is the behavior that distinguishes real recipe identity from the
5390 /// shape/dtype-only fallback (documented on `fold_const_bytes`).
5391 #[test]
5392 fn base_map_hash_folds_real_const_bytes() {
5393 let shape = Shape::from_dims(&[4]);
5394 let a = NodeHandle::from_f32(vec![1.0, 2.0, 3.0, 4.0], shape.clone(), cpu_dev()).unwrap();
5395 let b = NodeHandle::from_f32(vec![1.0, 2.0, 3.0, 4.0], shape.clone(), cpu_dev()).unwrap();
5396 let c = NodeHandle::from_f32(vec![9.0, 9.0, 9.0, 9.0], shape, cpu_dev()).unwrap();
5397 let ga = a.graph().read().unwrap();
5398 let gb = b.graph().read().unwrap();
5399 let gc = c.graph().read().unwrap();
5400 assert_eq!(
5401 base_map_hash(&ga, a.id()),
5402 base_map_hash(&gb, b.id()),
5403 "identical const bytes must hash equal across independently-built graphs"
5404 );
5405 assert_ne!(
5406 base_map_hash(&ga, a.id()),
5407 base_map_hash(&gc, c.id()),
5408 "different const bytes (same shape/dtype) must hash differently"
5409 );
5410 }
5411
5412 #[test]
5413 fn scan_base_map_hash_differs_for_different_bodies() {
5414 use crate::{Graph, Node, Op, ScanEmit, ScanRole};
5415 use fuel_ir::{DType, Shape};
5416 // Two scans, IDENTICAL carry shape / n_xs / bound / emit, but STRUCTURALLY
5417 // DIFFERENT bodies: body A = carry*2 (MulScalar), body B = carry+1
5418 // (AddScalar). Without a dedicated op_key arm they fall to the None branch
5419 // (discriminant+shape+dtype only) and collide -> silent CSE / recipe-identity
5420 // corruption. THIS red run is the evidence the trap is real.
5421 let mk = |mul: bool| {
5422 let mut g = Graph::new();
5423 let s = Shape::from_dims(&[1]);
5424 let carry = g.push(Node {
5425 op: Op::Const,
5426 inputs: vec![],
5427 shape: s.clone(),
5428 dtype: DType::F32,
5429 });
5430 let hole = g.push(Node {
5431 op: Op::ScanPlaceholder {
5432 role: ScanRole::Carry,
5433 index: 0,
5434 },
5435 inputs: vec![],
5436 shape: s.clone(),
5437 dtype: DType::F32,
5438 });
5439 let new_carry = g.push(Node {
5440 op: if mul {
5441 Op::MulScalar(2.0)
5442 } else {
5443 Op::AddScalar(1.0)
5444 },
5445 inputs: vec![hole],
5446 shape: s.clone(),
5447 dtype: DType::F32,
5448 });
5449 // inputs = [init_carry, body_new_carry, body_y] (n_xs = 0, no consts)
5450 let scan = g.push(Node {
5451 op: Op::Scan {
5452 n_carries: 1,
5453 n_xs: 0,
5454 bound: 3,
5455 emit: ScanEmit::All,
5456 early_exit: None,
5457 },
5458 inputs: vec![carry, new_carry, new_carry],
5459 shape: s,
5460 dtype: DType::F32,
5461 });
5462 (g, scan)
5463 };
5464 let (ga, ra) = mk(true);
5465 let (gb, rb) = mk(false);
5466 assert_ne!(
5467 base_map_hash(&ga, ra),
5468 base_map_hash(&gb, rb),
5469 "scans with structurally different bodies must hash differently",
5470 );
5471 }
5472
5473 #[test]
5474 fn scan_base_map_hash_equal_for_same_body_cross_graph() {
5475 use crate::{Graph, Node, Op, ScanEmit, ScanRole};
5476 use fuel_ir::{DType, Shape};
5477 // Same body built independently in two graphs -> equal hash (the
5478 // recipe-identity positive case). base_map_hash is NodeId-independent.
5479 let mk = || {
5480 let mut g = Graph::new();
5481 let s = Shape::from_dims(&[1]);
5482 let carry = g.push(Node {
5483 op: Op::Const,
5484 inputs: vec![],
5485 shape: s.clone(),
5486 dtype: DType::F32,
5487 });
5488 let hole = g.push(Node {
5489 op: Op::ScanPlaceholder {
5490 role: ScanRole::Carry,
5491 index: 0,
5492 },
5493 inputs: vec![],
5494 shape: s.clone(),
5495 dtype: DType::F32,
5496 });
5497 let new_carry = g.push(Node {
5498 op: Op::MulScalar(2.0),
5499 inputs: vec![hole],
5500 shape: s.clone(),
5501 dtype: DType::F32,
5502 });
5503 let scan = g.push(Node {
5504 op: Op::Scan {
5505 n_carries: 1,
5506 n_xs: 0,
5507 bound: 3,
5508 emit: ScanEmit::All,
5509 early_exit: None,
5510 },
5511 inputs: vec![carry, new_carry, new_carry],
5512 shape: s,
5513 dtype: DType::F32,
5514 });
5515 (g, scan)
5516 };
5517 let (g0, r0) = mk();
5518 let (g1, r1) = mk();
5519 assert_eq!(
5520 base_map_hash(&g0, r0),
5521 base_map_hash(&g1, r1),
5522 "same body -> equal hash"
5523 );
5524 }
5525
5526 /// GAP-303 DISCRIMINATING FIXTURE for the `n_carries` fold in `op_key`.
5527 ///
5528 /// ⚠️ WITHOUT THIS TEST THE FIX IS INVISIBLE IN ITS OWN METRIC. Every other
5529 /// scan test in this workspace uses ONE carry, and `NodeHandle::scan` pins
5530 /// `n_carries: 1`, so folding the field or not folding it produces identical
5531 /// hashes across the entire suite. Ask "if this became a no-op, which number
5532 /// moves?" — before this test, none.
5533 ///
5534 /// What it protects: `op_key` is Fuel's own recipe identity. Two scans that
5535 /// differ ONLY in carry count must not key the same, or cover-finding and the
5536 /// plan cache treat a 1-carry and a 3-carry scan as one recipe. Same defect
5537 /// class as KISS-OPS-6.19, where an arm reading too few attrs made
5538 /// Gather-on-axis-2 and Gather-on-axis-0 emit IDENTICAL bytes.
5539 #[test]
5540 fn gap303_n_carries_participates_in_op_key() {
5541 use crate::{Op, ScanEmit, ScanRole};
5542 // Every other field held EQUAL — n_carries is the only axis that moves.
5543 let mk = |n_carries: usize| {
5544 op_key(&Op::Scan {
5545 n_carries,
5546 n_xs: 2,
5547 bound: 3,
5548 emit: ScanEmit::All,
5549 early_exit: None,
5550 })
5551 };
5552 let one = mk(1);
5553 assert!(one.is_some(), "Op::Scan must produce an op_key, not None");
5554 for n in [2usize, 3, 7] {
5555 assert_ne!(
5556 mk(n),
5557 one,
5558 "n_carries={n} must not key identically to n_carries=1 (all other fields held equal)"
5559 );
5560 }
5561 // ...and distinct counts stay distinct from each OTHER, not merely from 1.
5562 assert_ne!(mk(2), mk(3), "distinct carry counts must key distinctly");
5563
5564 // The SIBLING claim, asserted rather than only commented: the placeholder
5565 // arm's own doc says "Carry/0, Elem/0, Carry/1 are all distinct in the
5566 // body hash". It had no test. It does now.
5567 let c0 = op_key(&Op::ScanPlaceholder {
5568 role: ScanRole::Carry,
5569 index: 0,
5570 });
5571 let c1 = op_key(&Op::ScanPlaceholder {
5572 role: ScanRole::Carry,
5573 index: 1,
5574 });
5575 let e0 = op_key(&Op::ScanPlaceholder {
5576 role: ScanRole::Elem,
5577 index: 0,
5578 });
5579 assert!(c0.is_some(), "ScanPlaceholder must produce an op_key");
5580 assert_ne!(c0, c1, "Carry/0 vs Carry/1 must differ in the body hash");
5581 assert_ne!(c0, e0, "Carry/0 vs Elem/0 must differ in the body hash");
5582 }
5583
5584 #[test]
5585 fn scan_emit_and_placeholder_role_participate_in_op_key() {
5586 use crate::{Op, ScanEmit, ScanRole};
5587 // emit=All vs emit=Final differ.
5588 let all = op_key(&Op::Scan {
5589 n_carries: 1,
5590 n_xs: 0,
5591 bound: 3,
5592 emit: ScanEmit::All,
5593 early_exit: None,
5594 });
5595 let fin = op_key(&Op::Scan {
5596 n_carries: 1,
5597 n_xs: 0,
5598 bound: 3,
5599 emit: ScanEmit::Final,
5600 early_exit: None,
5601 });
5602 assert!(
5603 all.is_some() && fin.is_some(),
5604 "Op::Scan must produce an op_key, not None"
5605 );
5606 assert_ne!(all, fin, "emit=All vs emit=Final must differ in op_key");
5607 // n_xs / bound participate.
5608 assert_ne!(
5609 op_key(&Op::Scan {
5610 n_carries: 1,
5611 n_xs: 1,
5612 bound: 3,
5613 emit: ScanEmit::All,
5614 early_exit: None
5615 }),
5616 all
5617 );
5618 assert_ne!(
5619 op_key(&Op::Scan {
5620 n_carries: 1,
5621 n_xs: 0,
5622 bound: 4,
5623 emit: ScanEmit::All,
5624 early_exit: None
5625 }),
5626 all
5627 );
5628 // ScanPlaceholder Carry/0 vs Elem/0 vs Carry/1 all differ.
5629 let c0 = op_key(&Op::ScanPlaceholder {
5630 role: ScanRole::Carry,
5631 index: 0,
5632 });
5633 let e0 = op_key(&Op::ScanPlaceholder {
5634 role: ScanRole::Elem,
5635 index: 0,
5636 });
5637 let c1 = op_key(&Op::ScanPlaceholder {
5638 role: ScanRole::Carry,
5639 index: 1,
5640 });
5641 assert!(c0.is_some());
5642 assert_ne!(c0, e0, "Carry vs Elem must differ");
5643 assert_ne!(c0, c1, "index must participate");
5644 }
5645
5646 /// G2 (2026-06-20): FlashAttn IS decomposable — the vanilla (non-causal,
5647 /// MHA) case lowers to its materialized scaled-dot-product-attention
5648 /// subgraph. Born-red before the fix (`flash_attn::decompose` panicked).
5649 #[test]
5650 fn flash_attn_vanilla_decomposes_to_sdpa() {
5651 let s = Shape::from_dims(&[1, 1, 1, 1]);
5652 let q = NodeHandle::from_f32(vec![0.0_f32; 1], s.clone(), cpu_dev()).unwrap();
5653 let k = q.const_f32_like(vec![0.0_f32; 1], s.clone()).unwrap();
5654 let v = q.const_f32_like(vec![0.0_f32; 1], s.clone()).unwrap();
5655 // Vanilla: non-causal, Hq==Hkv==1, no alibi/window/softcap.
5656 let attn = q.flash_attn(&k, &v, None, 1.0_f32, false, None, None, None);
5657 let graph = attn.graph().clone();
5658
5659 let new_roots = RuleRegistry::lowering_only().optimize_to_fixpoint(&graph, &[attn.id()]);
5660 assert_eq!(new_roots.len(), 1);
5661 let root = new_roots[0];
5662 let g = graph.read().unwrap();
5663 // The fused FlashAttn is gone from the root, replaced by the SDPA
5664 // subgraph — which contains MatMul nodes.
5665 assert!(
5666 !matches!(g.node(root).op, Op::Fused(fid, _) if fid == crate::registry::FusedOps::FLASH_ATTN),
5667 "vanilla FlashAttn must decompose, not stay fused",
5668 );
5669 let has_matmul = (0..g.len()).any(|i| matches!(g.node(NodeId(i)).op, Op::MatMul));
5670 assert!(has_matmul, "decomposed FlashAttn must contain a MatMul");
5671 }
5672
5673 /// G2: causal FlashAttn decomposes too — the causal mask is an additive
5674 /// `-inf` `Triu` band (no new primitive needed), so the node lowers
5675 /// rather than staying fused. (The decomposed *numerics* are verified
5676 /// separately by a parity test vs. the reference attention.)
5677 #[test]
5678 fn flash_attn_causal_decomposes_with_triu_mask() {
5679 let s = Shape::from_dims(&[1, 1, 2, 1]); // Sq=Sk=2 so the mask is non-trivial
5680 let q = NodeHandle::from_f32(vec![0.0_f32; 2], s.clone(), cpu_dev()).unwrap();
5681 let k = q.const_f32_like(vec![0.0_f32; 2], s.clone()).unwrap();
5682 let v = q.const_f32_like(vec![0.0_f32; 2], s.clone()).unwrap();
5683 let attn = q.flash_attn(&k, &v, None, 1.0_f32, true, None, None, None); // causal
5684 let graph = attn.graph().clone();
5685
5686 let new_roots = RuleRegistry::lowering_only().optimize_to_fixpoint(&graph, &[attn.id()]);
5687 assert_eq!(new_roots.len(), 1);
5688 let root = new_roots[0];
5689 let g = graph.read().unwrap();
5690 assert!(
5691 !matches!(g.node(root).op, Op::Fused(fid, _) if fid == crate::registry::FusedOps::FLASH_ATTN),
5692 "causal FlashAttn must decompose (Triu mask), not stay fused",
5693 );
5694 let has_triu = (0..g.len()).any(|i| matches!(g.node(NodeId(i)).op, Op::Triu { .. }));
5695 assert!(
5696 has_triu,
5697 "causal decomposition must contain a Triu mask band"
5698 );
5699 }
5700
5701 /// Fuse rule does not fire on a non-canonical Div pattern (e.g.,
5702 /// a plain `a / b` with no upstream softmax structure).
5703 #[test]
5704 fn fuse_rule_does_not_fire_on_plain_div() {
5705 let a = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5706 let b = a
5707 .const_f32_like(vec![2.0_f32; 4], Shape::from_dims(&[4]))
5708 .unwrap();
5709 let c = a.div(&b);
5710 let graph = c.graph().clone();
5711 let pre_len = graph.read().unwrap().len();
5712 let new_roots = RuleRegistry::new()
5713 .with_rule(Box::new(FusionRule::from_entry(
5714 &crate::registry::softmax_last_dim::entry(),
5715 )))
5716 .optimize_to_fixpoint(&graph, &[c.id()]);
5717 assert_eq!(new_roots, vec![c.id()]);
5718 assert_eq!(graph.read().unwrap().len(), pre_len);
5719 }
5720
5721 // -------- Cast-fusion rule -------------------------------------------
5722
5723 /// Build a "yes to everything" capability predicate. Useful when
5724 /// the test wants to confirm the matcher/rewriter mechanics work;
5725 /// orthogonal tests cover the predicate-says-no path.
5726 fn allow_all_predicate() -> CapabilityPredicate {
5727 Arc::new(|_op: &Op, _dtypes: &[fuel_ir::DType]| true)
5728 }
5729
5730 /// Predicate that says no for everything. Useful for confirming
5731 /// the rule respects the capability gate.
5732 fn deny_all_predicate() -> CapabilityPredicate {
5733 Arc::new(|_op: &Op, _dtypes: &[fuel_ir::DType]| false)
5734 }
5735
5736 /// Happy path: `x:f32 → Cast(BF16) → Neg(BF16)` collapses to
5737 /// `x:f32 → Neg(f32)` when the predicate says Neg supports f32.
5738 #[test]
5739 fn cast_fusion_drops_cast_when_consumer_supports_source_dtype() {
5740 let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5741 let xc = x.cast(fuel_ir::DType::BF16);
5742 let y = xc.neg();
5743 let graph = y.graph().clone();
5744 let pre_len = graph.read().unwrap().len();
5745
5746 let registry =
5747 RuleRegistry::new().with_rule(Box::new(CastFusionRule::new(allow_all_predicate())));
5748 let new_roots = registry.optimize_to_fixpoint(&graph, &[y.id()]);
5749 assert_eq!(new_roots.len(), 1);
5750 let new_root = new_roots[0];
5751
5752 let g = graph.read().unwrap();
5753 // The new root is a fresh Neg whose input is the original
5754 // f32 Const x, not the Cast.
5755 assert!(matches!(g.node(new_root).op, Op::Neg));
5756 assert_eq!(g.node(new_root).inputs, vec![x.id()]);
5757 // Cast node remains in the arena but is no longer reachable
5758 // from the new root.
5759 let reachable = topo_order_multi(&g, &[new_root]);
5760 assert!(
5761 !reachable
5762 .iter()
5763 .any(|&n| matches!(g.node(n).op, Op::Cast(_))),
5764 "no Cast node should be reachable post-fusion"
5765 );
5766 // One new Neg node was appended; the original Neg + Cast are
5767 // unreachable but still in the arena.
5768 assert!(g.len() > pre_len);
5769 }
5770
5771 /// Capability gate: when the predicate says no, the rule must
5772 /// not fire. Graph structure is unchanged.
5773 #[test]
5774 fn cast_fusion_no_fire_when_predicate_denies() {
5775 let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5776 let xc = x.cast(fuel_ir::DType::BF16);
5777 let y = xc.neg();
5778 let graph = y.graph().clone();
5779 let pre_len = graph.read().unwrap().len();
5780
5781 let registry =
5782 RuleRegistry::new().with_rule(Box::new(CastFusionRule::new(deny_all_predicate())));
5783 let new_roots = registry.optimize_to_fixpoint(&graph, &[y.id()]);
5784 assert_eq!(new_roots, vec![y.id()]);
5785 assert_eq!(
5786 graph.read().unwrap().len(),
5787 pre_len,
5788 "predicate-says-no should append no nodes"
5789 );
5790 // The original Cast → Neg chain is intact.
5791 let g = graph.read().unwrap();
5792 assert!(matches!(g.node(y.id()).op, Op::Neg));
5793 assert_eq!(g.node(y.id()).inputs, vec![xc.id()]);
5794 assert!(matches!(g.node(xc.id()).op, Op::Cast(_)));
5795 }
5796
5797 /// Single-consumer constraint: if the Cast feeds two consumers,
5798 /// the rule must not fire (eliminating the cast for one consumer
5799 /// would orphan it for the other).
5800 #[test]
5801 fn cast_fusion_no_fire_when_cast_has_multiple_consumers() {
5802 let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5803 let xc = x.cast(fuel_ir::DType::BF16);
5804 // Two consumers of the same Cast.
5805 let y1 = xc.neg();
5806 let y2 = xc.relu();
5807 let graph = y1.graph().clone();
5808 let pre_len = graph.read().unwrap().len();
5809
5810 let registry =
5811 RuleRegistry::new().with_rule(Box::new(CastFusionRule::new(allow_all_predicate())));
5812 let new_roots = registry.optimize_to_fixpoint(&graph, &[y1.id(), y2.id()]);
5813 // Roots unchanged; cast survives because rule didn't fire.
5814 assert_eq!(new_roots, vec![y1.id(), y2.id()]);
5815 assert_eq!(graph.read().unwrap().len(), pre_len);
5816 let g = graph.read().unwrap();
5817 assert!(matches!(g.node(xc.id()).op, Op::Cast(_)));
5818 }
5819
5820 /// Output dtype tracks the new input: in aggressive mode the
5821 /// rewritten consumer's output dtype is the cast-source dtype,
5822 /// not the original consumer's output dtype. Downstream
5823 /// consumers reading from the rewritten node will see the new
5824 /// dtype — by design (see module docstring's
5825 /// aggressive-semantics caveat).
5826 #[test]
5827 fn cast_fusion_rewrites_output_dtype_to_source_dtype() {
5828 let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5829 let xc = x.cast(fuel_ir::DType::BF16);
5830 let y = xc.neg();
5831 // Pre-fusion the original Neg produces BF16.
5832 assert_eq!(y.dtype(), fuel_ir::DType::BF16);
5833
5834 let graph = y.graph().clone();
5835 let registry =
5836 RuleRegistry::new().with_rule(Box::new(CastFusionRule::new(allow_all_predicate())));
5837 let new_roots = registry.optimize_to_fixpoint(&graph, &[y.id()]);
5838 let new_root = new_roots[0];
5839
5840 let g = graph.read().unwrap();
5841 // The rewritten Neg consumes the F32 source directly and
5842 // produces F32. This differs from the original BF16
5843 // output; the rule's aggressive semantics commit to this.
5844 assert_eq!(g.node(new_root).dtype, fuel_ir::DType::F32);
5845 }
5846
5847 /// Multi-input op: only the qualifying input fuses; the others
5848 /// are left alone.
5849 #[test]
5850 fn cast_fusion_multi_input_op_fuses_only_qualifying_edge() {
5851 let a = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5852 // a:f32 → Cast(BF16) → Add(bf16, b:bf16) where b is already bf16.
5853 let ac = a.cast(fuel_ir::DType::BF16);
5854 let b = a
5855 .const_bf16_like(vec![half::bf16::from_f32(2.0); 4], Shape::from_dims(&[4]))
5856 .unwrap();
5857 let sum = ac.add(&b);
5858 let graph = sum.graph().clone();
5859
5860 // Build a predicate that says yes only for Add[f32, bf16, bf16].
5861 // (Realistic kernels typically wouldn't have mixed-dtype Add;
5862 // this isolates the matcher's per-input behavior. If the
5863 // predicate said no, the rule wouldn't fire and `b` would
5864 // stay paired with the Cast.)
5865 let predicate: CapabilityPredicate =
5866 Arc::new(|op: &Op, _dtypes: &[fuel_ir::DType]| matches!(op, Op::Add));
5867
5868 let registry = RuleRegistry::new().with_rule(Box::new(CastFusionRule::new(predicate)));
5869 let new_roots = registry.optimize_to_fixpoint(&graph, &[sum.id()]);
5870 let new_root = new_roots[0];
5871
5872 let g = graph.read().unwrap();
5873 assert!(matches!(g.node(new_root).op, Op::Add));
5874 // First input of new Add: the original f32 const (cast eliminated).
5875 // Second input: still `b` (a bf16 const, no cast was there).
5876 assert_eq!(g.node(new_root).inputs, vec![a.id(), b.id()]);
5877 }
5878
5879 /// Chained casts: `x:f32 → Cast(BF16) → Cast(F32) → Op` should
5880 /// reduce to `x:f32 → Op` via the fixpoint loop firing the rule
5881 /// twice (once per consumer-side cast).
5882 #[test]
5883 fn cast_fusion_handles_chained_casts_via_fixpoint() {
5884 let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5885 let x_bf16 = x.cast(fuel_ir::DType::BF16);
5886 let x_f32 = x_bf16.cast(fuel_ir::DType::F32);
5887 let y = x_f32.neg();
5888 let graph = y.graph().clone();
5889
5890 let registry =
5891 RuleRegistry::new().with_rule(Box::new(CastFusionRule::new(allow_all_predicate())));
5892 let new_roots = registry.optimize_to_fixpoint(&graph, &[y.id()]);
5893 let new_root = new_roots[0];
5894
5895 let g = graph.read().unwrap();
5896 // After two iterations: the outer Cast is gone (Neg consumes
5897 // x_bf16 directly), then the inner Cast is gone (Neg consumes
5898 // x directly). No Cast nodes are reachable.
5899 let reachable = topo_order_multi(&g, &[new_root]);
5900 let cast_count = reachable
5901 .iter()
5902 .filter(|&&n| matches!(g.node(n).op, Op::Cast(_)))
5903 .count();
5904 assert_eq!(
5905 cast_count, 0,
5906 "fixpoint should eliminate both casts in the chain"
5907 );
5908 assert!(matches!(g.node(new_root).op, Op::Neg));
5909 assert_eq!(g.node(new_root).inputs, vec![x.id()]);
5910 }
5911
5912 /// The rule must not fire on the Cast node itself — only on the
5913 /// consumer of a Cast.
5914 #[test]
5915 fn cast_fusion_does_not_fire_on_cast_node_itself() {
5916 let x = NodeHandle::from_f32(vec![1.0_f32; 4], Shape::from_dims(&[4]), cpu_dev()).unwrap();
5917 let xc = x.cast(fuel_ir::DType::BF16);
5918 let graph = xc.graph().clone();
5919 let pre_len = graph.read().unwrap().len();
5920
5921 let registry =
5922 RuleRegistry::new().with_rule(Box::new(CastFusionRule::new(allow_all_predicate())));
5923 // Root the optimizer at the Cast itself. The rule should look
5924 // at the Cast and decline (it's not the consumer's role).
5925 let new_roots = registry.optimize_to_fixpoint(&graph, &[xc.id()]);
5926 assert_eq!(new_roots, vec![xc.id()]);
5927 assert_eq!(graph.read().unwrap().len(), pre_len);
5928 }
5929
5930 // ===== Phase 2.1: insert_cross_device_copies tests =====
5931
5932 use std::collections::HashMap as StdHashMap;
5933
5934 fn add_node(g: &mut Graph, op: Op, inputs: Vec<NodeId>) -> NodeId {
5935 g.push(Node {
5936 op,
5937 inputs,
5938 shape: Shape::from_dims(&[4]),
5939 dtype: DType::F32,
5940 })
5941 }
5942
5943 /// Snapshot every node's `Graph::placement` into an external
5944 /// map. Needed because `&mut Graph` and `|id| g.placement(id)`
5945 /// closure can't co-exist (overlapping borrows). The closure-
5946 /// based oracle is the production shape (the picker hands in a
5947 /// `Fn(NodeId) -> Option<DeviceLocation>` derived from its own
5948 /// commit map); tests just precompute the snapshot from the
5949 /// graph.
5950 fn snapshot_placements(g: &Graph) -> StdHashMap<NodeId, DeviceLocation> {
5951 let mut m = StdHashMap::new();
5952 for id in 0..g.len() {
5953 let nid = NodeId(id);
5954 if let Some(loc) = g.placement(nid) {
5955 m.insert(nid, loc);
5956 }
5957 }
5958 m
5959 }
5960
5961 fn build_two_node_graph_with_placements(
5962 a: DeviceLocation,
5963 b: DeviceLocation,
5964 ) -> (Graph, NodeId, NodeId) {
5965 let mut g = Graph::new();
5966 let n1 = add_node(&mut g, Op::Const, vec![]);
5967 let n2 = add_node(&mut g, Op::Neg, vec![n1]);
5968 g.set_placement(n1, a);
5969 g.set_placement(n2, b);
5970 (g, n1, n2)
5971 }
5972
5973 fn shares_storage_cpu_devices_only(a: DeviceLocation, b: DeviceLocation) -> bool {
5974 // Test stub topology: CPU shares with CPU only (the AOCL/MKL/
5975 // portable-CPU substrate story). CUDA shares with same-gpu_id
5976 // CUDA. Vulkan shares with same-gpu_id Vulkan. Cross device
5977 // is never shared.
5978 match (a, b) {
5979 (DeviceLocation::Cpu, DeviceLocation::Cpu) => true,
5980 (DeviceLocation::Cuda { gpu_id: x }, DeviceLocation::Cuda { gpu_id: y }) => x == y,
5981 (DeviceLocation::Vulkan { gpu_id: x }, DeviceLocation::Vulkan { gpu_id: y }) => x == y,
5982 (DeviceLocation::Metal { gpu_id: x }, DeviceLocation::Metal { gpu_id: y }) => x == y,
5983 _ => false,
5984 }
5985 }
5986
5987 /// Single-hop everywhere: the pre-two-hop behavior. Every bridged
5988 /// edge gets exactly one `Op::Copy`. Used by the legacy single-hop
5989 /// tests so they stay byte-identical to before the two-hop param.
5990 fn no_host_staging(_a: DeviceLocation, _b: DeviceLocation) -> bool {
5991 false
5992 }
5993
5994 /// Realistic topology predicate: a cross-VENDOR GPU↔GPU pair (CUDA↔
5995 /// Vulkan, or any two distinct GPU substrates) needs host staging
5996 /// (two hops). Any edge touching CPU, or a same-vendor pair, is a
5997 /// single hop.
5998 fn host_staging_cross_vendor_gpu(a: DeviceLocation, b: DeviceLocation) -> bool {
5999 use DeviceLocation::*;
6000 match (a, b) {
6001 // CPU on either end → single hop.
6002 (Cpu, _) | (_, Cpu) => false,
6003 // Same vendor (both CUDA, both Vulkan, both Metal) → single
6004 // hop (the topology can copy directly / shares_storage may
6005 // even short-circuit it first).
6006 (Cuda { .. }, Cuda { .. }) => false,
6007 (Vulkan { .. }, Vulkan { .. }) => false,
6008 (Metal { .. }, Metal { .. }) => false,
6009 // Cross-vendor GPU↔GPU → two hops through host.
6010 _ => true,
6011 }
6012 }
6013
6014 /// No cross-device edges → no copies inserted.
6015 #[test]
6016 fn insert_copies_noop_when_all_same_device() {
6017 let (mut g, _n1, n2) =
6018 build_two_node_graph_with_placements(DeviceLocation::Cpu, DeviceLocation::Cpu);
6019 let pre_len = g.len();
6020 let placements = snapshot_placements(&g);
6021 let inserted = insert_cross_device_copies(
6022 &mut g,
6023 &[n2],
6024 |id| placements.get(&id).copied(),
6025 shares_storage_cpu_devices_only,
6026 no_host_staging,
6027 );
6028 assert_eq!(inserted.len(), 0, "same-device edge → no copy needed");
6029 assert_eq!(g.len(), pre_len, "graph unchanged");
6030 }
6031
6032 /// Cross-device edge (CPU → CUDA) → one `Op::Copy { target: Cuda }`
6033 /// inserted; consumer's input rewired to point at it.
6034 #[test]
6035 fn insert_copies_cuda_consumer_of_cpu_input() {
6036 let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6037 let (mut g, n1, n2) = build_two_node_graph_with_placements(DeviceLocation::Cpu, cuda);
6038 let pre_len = g.len();
6039 let placements = snapshot_placements(&g);
6040 let inserted = insert_cross_device_copies(
6041 &mut g,
6042 &[n2],
6043 |id| placements.get(&id).copied(),
6044 shares_storage_cpu_devices_only,
6045 no_host_staging,
6046 );
6047 assert_eq!(inserted.len(), 1);
6048 assert_eq!(g.len(), pre_len + 1, "one new Op::Copy node appended");
6049 // Consumer's input now points at the new Op::Copy (not n1).
6050 let consumer_inputs = &g.node(n2).inputs;
6051 assert_eq!(consumer_inputs.len(), 1);
6052 let copy_id = consumer_inputs[0];
6053 assert_ne!(copy_id, n1, "consumer's input was rewired");
6054 let copy_node = g.node(copy_id);
6055 assert!(
6056 matches!(copy_node.op, Op::Copy { target } if target == cuda),
6057 "rewired input is Op::Copy targeting CUDA; got {:?}",
6058 copy_node.op,
6059 );
6060 assert_eq!(copy_node.inputs, vec![n1], "Op::Copy reads from n1");
6061 assert_eq!(g.placement(copy_id), Some(cuda), "Op::Copy lands on CUDA");
6062 }
6063
6064 /// Two consumers share an input AND both need it on the same target
6065 /// device → ONE Op::Copy serves both (CSE on inserted copies).
6066 #[test]
6067 fn insert_copies_dedupes_when_two_consumers_share_input_and_target() {
6068 let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6069 let mut g = Graph::new();
6070 let cpu_src = add_node(&mut g, Op::Const, vec![]);
6071 g.set_placement(cpu_src, DeviceLocation::Cpu);
6072 let cuda_a = add_node(&mut g, Op::Neg, vec![cpu_src]);
6073 let cuda_b = add_node(&mut g, Op::Sqr, vec![cpu_src]);
6074 g.set_placement(cuda_a, cuda);
6075 g.set_placement(cuda_b, cuda);
6076
6077 let pre_len = g.len();
6078 let placements = snapshot_placements(&g);
6079 let inserted = insert_cross_device_copies(
6080 &mut g,
6081 &[cuda_a, cuda_b],
6082 |id| placements.get(&id).copied(),
6083 shares_storage_cpu_devices_only,
6084 no_host_staging,
6085 );
6086 assert_eq!(
6087 inserted.len(),
6088 1,
6089 "CSE — one Op::Copy serves both consumers"
6090 );
6091 assert_eq!(g.len(), pre_len + 1);
6092 // Both consumers point at the SAME Op::Copy node.
6093 let a_input = g.node(cuda_a).inputs[0];
6094 let b_input = g.node(cuda_b).inputs[0];
6095 assert_eq!(a_input, b_input, "both consumers share the rewired input");
6096 }
6097
6098 /// Two consumers share an input but want it on DIFFERENT target
6099 /// devices → TWO distinct Op::Copy nodes inserted.
6100 #[test]
6101 fn insert_copies_does_not_dedupe_across_distinct_targets() {
6102 let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6103 let vulkan = DeviceLocation::Vulkan { gpu_id: 0 };
6104 let mut g = Graph::new();
6105 let cpu_src = add_node(&mut g, Op::Const, vec![]);
6106 g.set_placement(cpu_src, DeviceLocation::Cpu);
6107 let cuda_c = add_node(&mut g, Op::Neg, vec![cpu_src]);
6108 let vk_c = add_node(&mut g, Op::Sqr, vec![cpu_src]);
6109 g.set_placement(cuda_c, cuda);
6110 g.set_placement(vk_c, vulkan);
6111
6112 let placements = snapshot_placements(&g);
6113
6114 let inserted = insert_cross_device_copies(
6115 &mut g,
6116 &[cuda_c, vk_c],
6117 |id| placements.get(&id).copied(),
6118 shares_storage_cpu_devices_only,
6119 no_host_staging,
6120 );
6121 assert_eq!(inserted.len(), 2, "distinct targets → distinct copies");
6122 let cuda_input = g.node(cuda_c).inputs[0];
6123 let vk_input = g.node(vk_c).inputs[0];
6124 assert_ne!(cuda_input, vk_input);
6125 }
6126
6127 /// Producer with no placement → pass leaves the edge alone (the
6128 /// executor handles via fallback). Same for consumer with no
6129 /// placement.
6130 #[test]
6131 fn insert_copies_skips_when_placement_absent() {
6132 let mut g = Graph::new();
6133 let n1 = add_node(&mut g, Op::Const, vec![]);
6134 let n2 = add_node(&mut g, Op::Neg, vec![n1]);
6135 // Only consumer has placement; producer doesn't.
6136 g.set_placement(n2, DeviceLocation::Cuda { gpu_id: 0 });
6137 let placements = snapshot_placements(&g);
6138 let inserted = insert_cross_device_copies(
6139 &mut g,
6140 &[n2],
6141 |id| placements.get(&id).copied(),
6142 shares_storage_cpu_devices_only,
6143 no_host_staging,
6144 );
6145 assert_eq!(inserted.len(), 0, "producer with no placement → skip");
6146 }
6147
6148 /// `Op::Copy` and `Op::Move` are themselves transfers — the pass
6149 /// must not try to insert ANOTHER copy on their inputs (would
6150 /// infinite-regress).
6151 #[test]
6152 fn insert_copies_skips_transfer_ops_themselves() {
6153 let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6154 let mut g = Graph::new();
6155 let cpu_src = add_node(&mut g, Op::Const, vec![]);
6156 g.set_placement(cpu_src, DeviceLocation::Cpu);
6157 // A user-authored Op::Copy from CPU to CUDA. Should not be
6158 // re-wrapped.
6159 let copy = g.push(Node {
6160 op: Op::Copy { target: cuda },
6161 inputs: vec![cpu_src],
6162 shape: Shape::from_dims(&[4]),
6163 dtype: DType::F32,
6164 });
6165 g.set_placement(copy, cuda);
6166
6167 let pre_len = g.len();
6168 let placements = snapshot_placements(&g);
6169 let inserted = insert_cross_device_copies(
6170 &mut g,
6171 &[copy],
6172 |id| placements.get(&id).copied(),
6173 shares_storage_cpu_devices_only,
6174 no_host_staging,
6175 );
6176 assert_eq!(inserted.len(), 0, "the existing Op::Copy is not re-wrapped");
6177 assert_eq!(g.len(), pre_len);
6178 }
6179
6180 /// Pass is idempotent — re-running on a graph already rewritten
6181 /// adds zero new copies. Phase 2.1 variant.
6182 #[test]
6183 fn insert_cross_device_copies_idempotent() {
6184 let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6185 let (mut g, _n1, n2) = build_two_node_graph_with_placements(DeviceLocation::Cpu, cuda);
6186 let placements_before = snapshot_placements(&g);
6187 let first = insert_cross_device_copies(
6188 &mut g,
6189 &[n2],
6190 |id| placements_before.get(&id).copied(),
6191 shares_storage_cpu_devices_only,
6192 no_host_staging,
6193 );
6194 let placements_after = snapshot_placements(&g);
6195 let second = insert_cross_device_copies(
6196 &mut g,
6197 &[n2],
6198 |id| placements_after.get(&id).copied(),
6199 shares_storage_cpu_devices_only,
6200 no_host_staging,
6201 );
6202 assert_eq!(first.len(), 1);
6203 assert_eq!(
6204 second.len(),
6205 0,
6206 "re-run sees inserted-Copy as on-target; no new copies"
6207 );
6208 }
6209
6210 /// Cross-GPU edge (CUDA gpu_id 0 → CUDA gpu_id 1) → copy inserted
6211 /// (devices don't share storage even though both are CUDA).
6212 #[test]
6213 fn insert_copies_across_gpu_ids_within_one_backend() {
6214 let cuda0 = DeviceLocation::Cuda { gpu_id: 0 };
6215 let cuda1 = DeviceLocation::Cuda { gpu_id: 1 };
6216 let (mut g, _n1, n2) = build_two_node_graph_with_placements(cuda0, cuda1);
6217 let placements = snapshot_placements(&g);
6218 let inserted = insert_cross_device_copies(
6219 &mut g,
6220 &[n2],
6221 |id| placements.get(&id).copied(),
6222 shares_storage_cpu_devices_only,
6223 no_host_staging,
6224 );
6225 assert_eq!(inserted.len(), 1, "cross-gpu_id is cross-device too");
6226 let copy_id = g.node(n2).inputs[0];
6227 let copy_node = g.node(copy_id);
6228 assert!(matches!(copy_node.op, Op::Copy { target } if target == cuda1));
6229 }
6230
6231 /// Cross-VENDOR GPU↔GPU edge (Vulkan → CUDA) with `needs_host_staging`
6232 /// returning true → TWO `Op::Copy` hops through a CPU intermediate:
6233 /// the consumer reads `Op::Copy{target:Cuda}`, which reads
6234 /// `Op::Copy{target:Cpu}`, which reads the Vulkan producer.
6235 #[test]
6236 fn insert_copies_cross_vendor_gpu_inserts_two_hops_through_cpu() {
6237 let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6238 let vulkan = DeviceLocation::Vulkan { gpu_id: 0 };
6239 let (mut g, vk_src, cuda_consumer) = build_two_node_graph_with_placements(vulkan, cuda);
6240 let pre_len = g.len();
6241 let placements = snapshot_placements(&g);
6242 let inserted = insert_cross_device_copies(
6243 &mut g,
6244 &[cuda_consumer],
6245 |id| placements.get(&id).copied(),
6246 shares_storage_cpu_devices_only,
6247 host_staging_cross_vendor_gpu,
6248 );
6249 assert_eq!(inserted.len(), 2, "cross-vendor GPU↔GPU → two hops");
6250 assert_eq!(g.len(), pre_len + 2, "two new Op::Copy nodes appended");
6251
6252 // Consumer reads the consumer-side hop (Copy → Cuda).
6253 let hop2 = g.node(cuda_consumer).inputs[0];
6254 let hop2_node = g.node(hop2);
6255 assert!(
6256 matches!(hop2_node.op, Op::Copy { target } if target == cuda),
6257 "consumer-side hop targets CUDA; got {:?}",
6258 hop2_node.op,
6259 );
6260 assert_eq!(
6261 g.placement(hop2),
6262 Some(cuda),
6263 "consumer-side hop lands on CUDA"
6264 );
6265
6266 // The consumer-side hop reads the CPU intermediate (Copy → Cpu).
6267 let hop1 = hop2_node.inputs[0];
6268 let hop1_node = g.node(hop1);
6269 assert!(
6270 matches!(hop1_node.op, Op::Copy { target } if target == DeviceLocation::Cpu),
6271 "CPU-intermediate hop targets Cpu; got {:?}",
6272 hop1_node.op,
6273 );
6274 assert_eq!(
6275 g.placement(hop1),
6276 Some(DeviceLocation::Cpu),
6277 "CPU intermediate is placed on CPU",
6278 );
6279
6280 // The CPU intermediate reads the original Vulkan producer.
6281 assert_eq!(
6282 hop1_node.inputs,
6283 vec![vk_src],
6284 "CPU hop reads the Vulkan producer"
6285 );
6286 }
6287
6288 /// Two-hop edge is idempotent: re-running adds zero new copies. The
6289 /// consumer now reads a same-device `Op::Copy`; the CPU intermediate's
6290 /// consumer is also an `Op::Copy` (skipped as a transfer op).
6291 #[test]
6292 fn insert_copies_cross_vendor_two_hop_idempotent() {
6293 let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6294 let vulkan = DeviceLocation::Vulkan { gpu_id: 0 };
6295 let (mut g, _vk_src, cuda_consumer) = build_two_node_graph_with_placements(vulkan, cuda);
6296 let p1 = snapshot_placements(&g);
6297 let first = insert_cross_device_copies(
6298 &mut g,
6299 &[cuda_consumer],
6300 |id| p1.get(&id).copied(),
6301 shares_storage_cpu_devices_only,
6302 host_staging_cross_vendor_gpu,
6303 );
6304 let p2 = snapshot_placements(&g);
6305 let second = insert_cross_device_copies(
6306 &mut g,
6307 &[cuda_consumer],
6308 |id| p2.get(&id).copied(),
6309 shares_storage_cpu_devices_only,
6310 host_staging_cross_vendor_gpu,
6311 );
6312 assert_eq!(first.len(), 2, "two hops on first run");
6313 assert_eq!(second.len(), 0, "re-run adds no new copies");
6314 }
6315
6316 /// CSE on a two-hop edge: one Vulkan producer feeding TWO CUDA
6317 /// consumers stages to CPU exactly once (one CPU intermediate), then
6318 /// one consumer-side CUDA hop shared by both → 2 inserted copies
6319 /// total, not 4.
6320 #[test]
6321 fn insert_copies_cross_vendor_two_hop_cse_shares_cpu_intermediate() {
6322 let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6323 let vulkan = DeviceLocation::Vulkan { gpu_id: 0 };
6324 let mut g = Graph::new();
6325 let vk_src = add_node(&mut g, Op::Const, vec![]);
6326 g.set_placement(vk_src, vulkan);
6327 let cuda_a = add_node(&mut g, Op::Neg, vec![vk_src]);
6328 let cuda_b = add_node(&mut g, Op::Sqr, vec![vk_src]);
6329 g.set_placement(cuda_a, cuda);
6330 g.set_placement(cuda_b, cuda);
6331
6332 let placements = snapshot_placements(&g);
6333 let inserted = insert_cross_device_copies(
6334 &mut g,
6335 &[cuda_a, cuda_b],
6336 |id| placements.get(&id).copied(),
6337 shares_storage_cpu_devices_only,
6338 host_staging_cross_vendor_gpu,
6339 );
6340 // One CPU intermediate + one shared CUDA hop = 2.
6341 assert_eq!(
6342 inserted.len(),
6343 2,
6344 "CSE — one CPU intermediate + one CUDA hop"
6345 );
6346 let a_input = g.node(cuda_a).inputs[0];
6347 let b_input = g.node(cuda_b).inputs[0];
6348 assert_eq!(
6349 a_input, b_input,
6350 "both CUDA consumers share the consumer-side hop"
6351 );
6352 // That shared hop reads the single CPU intermediate.
6353 let cpu_hop = g.node(a_input).inputs[0];
6354 assert!(matches!(
6355 g.node(cpu_hop).op,
6356 Op::Copy {
6357 target: DeviceLocation::Cpu
6358 }
6359 ));
6360 assert_eq!(g.node(cpu_hop).inputs, vec![vk_src]);
6361 }
6362
6363 /// A CPU↔GPU edge stays SINGLE hop even when the host-staging
6364 /// predicate is the realistic cross-vendor one (which returns false
6365 /// for any CPU-touching edge) — byte-identical to the legacy path.
6366 #[test]
6367 fn insert_copies_cpu_gpu_edge_stays_single_hop_under_cross_vendor_predicate() {
6368 let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6369 let (mut g, n1, n2) = build_two_node_graph_with_placements(DeviceLocation::Cpu, cuda);
6370 let pre_len = g.len();
6371 let placements = snapshot_placements(&g);
6372 let inserted = insert_cross_device_copies(
6373 &mut g,
6374 &[n2],
6375 |id| placements.get(&id).copied(),
6376 shares_storage_cpu_devices_only,
6377 host_staging_cross_vendor_gpu,
6378 );
6379 assert_eq!(inserted.len(), 1, "CPU↔CUDA is a single hop");
6380 assert_eq!(g.len(), pre_len + 1);
6381 let copy_id = g.node(n2).inputs[0];
6382 assert!(matches!(g.node(copy_id).op, Op::Copy { target } if target == cuda));
6383 assert_eq!(g.node(copy_id).inputs, vec![n1]);
6384 }
6385
6386 /// Per-node `placement_for` can be backed by an external map, not
6387 /// just `Graph::placement`. Smoke test of the closure shape.
6388 #[test]
6389 fn insert_copies_takes_external_placement_oracle() {
6390 let cuda = DeviceLocation::Cuda { gpu_id: 0 };
6391 let (mut g, n1, n2) = build_two_node_graph_with_placements(DeviceLocation::Cpu, cuda);
6392 // Clear graph-side placements; back the oracle with an
6393 // external map instead.
6394 let mut external: StdHashMap<NodeId, DeviceLocation> = StdHashMap::new();
6395 external.insert(n1, DeviceLocation::Cpu);
6396 external.insert(n2, cuda);
6397 // Strip graph-side placements so we PROVE the closure is the
6398 // authoritative source.
6399 // (No public setter to clear; instead override by leaving
6400 // graph.placement untouched and using only the external map.)
6401 let _placements = snapshot_placements(&g);
6402 let inserted = insert_cross_device_copies(
6403 &mut g,
6404 &[n2],
6405 |id| external.get(&id).copied(),
6406 shares_storage_cpu_devices_only,
6407 no_host_staging,
6408 );
6409 assert_eq!(inserted.len(), 1);
6410 }
6411
6412 // ===== Phase 2.2 — insert_layout_fixups =====
6413
6414 fn count_contiguize_nodes(graph: &SharedGraph) -> usize {
6415 let g = graph.read().unwrap();
6416 (0..g.len())
6417 .filter(|i| matches!(g.node(NodeId(*i)).op, Op::Contiguize))
6418 .count()
6419 }
6420
6421 /// All-contiguous-input graph: pass adds nothing regardless of
6422 /// the kernel_accepts_strided predicate.
6423 #[test]
6424 fn insert_fixups_no_strided_inputs_no_fixups() {
6425 let a = NodeHandle::from_f32(vec![1.0, 2.0], Shape::from_dims(&[2]), cpu_dev()).unwrap();
6426 let b = a
6427 .const_f32_like(vec![3.0, 4.0], Shape::from_dims(&[2]))
6428 .unwrap();
6429 let c = a.add(&b);
6430 let graph = c.graph().clone();
6431
6432 let before = count_contiguize_nodes(&graph);
6433 let inserted = {
6434 let mut g = graph.write().unwrap();
6435 insert_layout_fixups(&mut g, &[c.id()], |_| false)
6436 };
6437 assert_eq!(inserted, 0);
6438 assert_eq!(count_contiguize_nodes(&graph), before);
6439 }
6440
6441 /// Strided input (Transpose) feeding a kernel that rejects
6442 /// strided: one Contiguize gets inserted between them and the
6443 /// consumer's input gets rewired.
6444 #[test]
6445 fn insert_fixups_strided_input_rejecting_kernel_inserts_contiguize() {
6446 // 2x3 const, transpose to 3x2, then "consumer" reads the
6447 // transposed view. The consumer kernel claims it can't
6448 // handle strided.
6449 let a = NodeHandle::from_f32(
6450 vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
6451 Shape::from_dims(&[2, 3]),
6452 cpu_dev(),
6453 )
6454 .unwrap();
6455 // Transpose produces a strided view via the layout side-table.
6456 let at = a.transpose();
6457 // A binary op as our "consumer" (Op::Add is kernel-bearing,
6458 // not a view op).
6459 let b = at
6460 .const_f32_like(vec![0.0; 6], Shape::from_dims(&[3, 2]))
6461 .unwrap();
6462 let c = at.add(&b);
6463 let graph = c.graph().clone();
6464
6465 let before = count_contiguize_nodes(&graph);
6466 let inserted = {
6467 let mut g = graph.write().unwrap();
6468 // Kernel rejects strided for every node.
6469 insert_layout_fixups(&mut g, &[c.id()], |_| false)
6470 };
6471 // One Contiguize for the transposed input of `c`. (`b` is a
6472 // contiguous Const → no fixup.)
6473 assert_eq!(inserted, 1);
6474 assert_eq!(count_contiguize_nodes(&graph) - before, 1);
6475
6476 // c's first input was `at`; after rewrite it should point
6477 // to the new Op::Contiguize node.
6478 let g = graph.read().unwrap();
6479 let c_node = g.node(c.id());
6480 let new_input = g.node(c_node.inputs[0]);
6481 assert!(
6482 matches!(new_input.op, Op::Contiguize),
6483 "consumer's strided input must be rewired through Op::Contiguize, \
6484 got {:?}",
6485 new_input.op,
6486 );
6487 // The Contiguize's input is the original transposed view.
6488 assert_eq!(new_input.inputs, vec![at.id()]);
6489 }
6490
6491 /// Strided input feeding a kernel that accepts strided: pass
6492 /// skips the input, no Contiguize inserted.
6493 #[test]
6494 fn insert_fixups_strided_input_accepting_kernel_no_fixup() {
6495 let a = NodeHandle::from_f32(
6496 vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
6497 Shape::from_dims(&[2, 3]),
6498 cpu_dev(),
6499 )
6500 .unwrap();
6501 let at = a.transpose();
6502 let b = at
6503 .const_f32_like(vec![0.0; 6], Shape::from_dims(&[3, 2]))
6504 .unwrap();
6505 let c = at.add(&b);
6506 let graph = c.graph().clone();
6507
6508 let before = count_contiguize_nodes(&graph);
6509 let inserted = {
6510 let mut g = graph.write().unwrap();
6511 // Every kernel accepts strided — no fixup.
6512 insert_layout_fixups(&mut g, &[c.id()], |_| true)
6513 };
6514 assert_eq!(inserted, 0);
6515 assert_eq!(count_contiguize_nodes(&graph), before);
6516 }
6517
6518 /// CSE: two non-strided-tolerant consumers reading the same
6519 /// transposed view share one inserted Contiguize node.
6520 #[test]
6521 fn insert_fixups_cse_dedupes_shared_strided_input() {
6522 let a = NodeHandle::from_f32(
6523 vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
6524 Shape::from_dims(&[2, 3]),
6525 cpu_dev(),
6526 )
6527 .unwrap();
6528 let at = a.transpose();
6529 // Two consumers of the same transposed view.
6530 let b = at
6531 .const_f32_like(vec![0.0; 6], Shape::from_dims(&[3, 2]))
6532 .unwrap();
6533 let c1 = at.add(&b);
6534 let c2 = at.mul(&b);
6535 let graph = c1.graph().clone();
6536
6537 let inserted = {
6538 let mut g = graph.write().unwrap();
6539 insert_layout_fixups(&mut g, &[c1.id(), c2.id()], |_| false)
6540 };
6541 // Exactly ONE Contiguize, shared between the two consumers.
6542 assert_eq!(inserted, 1);
6543
6544 let g = graph.read().unwrap();
6545 let c1_input = g.node(c1.id()).inputs[0];
6546 let c2_input = g.node(c2.id()).inputs[0];
6547 assert_eq!(
6548 c1_input, c2_input,
6549 "CSE: both consumers must share the same Contiguize node",
6550 );
6551 assert!(matches!(g.node(c1_input).op, Op::Contiguize));
6552 }
6553
6554 /// Idempotency: running the pass twice on the same graph adds
6555 /// 0 new nodes the second time.
6556 #[test]
6557 fn insert_fixups_idempotent() {
6558 let a = NodeHandle::from_f32(
6559 vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
6560 Shape::from_dims(&[2, 3]),
6561 cpu_dev(),
6562 )
6563 .unwrap();
6564 let at = a.transpose();
6565 let b = at
6566 .const_f32_like(vec![0.0; 6], Shape::from_dims(&[3, 2]))
6567 .unwrap();
6568 let c = at.add(&b);
6569 let graph = c.graph().clone();
6570
6571 let first = {
6572 let mut g = graph.write().unwrap();
6573 insert_layout_fixups(&mut g, &[c.id()], |_| false)
6574 };
6575 assert_eq!(first, 1, "first run inserts one Contiguize");
6576
6577 let second = {
6578 let mut g = graph.write().unwrap();
6579 insert_layout_fixups(&mut g, &[c.id()], |_| false)
6580 };
6581 assert_eq!(second, 0, "second run is a no-op");
6582 }
6583
6584 /// View ops in the consumer position do NOT get Contiguize
6585 /// inserted before them — they preserve strided layouts
6586 /// intentionally as part of their semantics.
6587 #[test]
6588 fn insert_fixups_skips_view_op_consumers() {
6589 let a = NodeHandle::from_f32(
6590 vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
6591 Shape::from_dims(&[2, 3]),
6592 cpu_dev(),
6593 )
6594 .unwrap();
6595 let at = a.transpose();
6596 // Permute on a transposed view: both are view ops, the
6597 // strided layout flows through unchanged.
6598 let ap = at.permute(&[1, 0]);
6599 let graph = ap.graph().clone();
6600
6601 let inserted = {
6602 let mut g = graph.write().unwrap();
6603 insert_layout_fixups(&mut g, &[ap.id()], |_| false)
6604 };
6605 assert_eq!(
6606 inserted, 0,
6607 "view ops preserve strided layouts; no Contiguize should be inserted",
6608 );
6609 }
6610}