fuel_graph/run.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Run extraction + the transient `lower_run` view + the fewness gate.
3//!
4//! Phase A PR-A2 of the "plan IS the graph" rebuild.
5//!
6//! A **run** is the maximal straight-line op-sequence between two
7//! *decision points* — the future dispatch / CUDA-graph-capture unit
8//! (see [`../docs/architecture/06-runtime.md`](../../docs/architecture/06-runtime.md)
9//! §"Dispatch: runs, not single ops"). This module *derives* runs from
10//! the graph structure; it does **not** execute them (Phase C) and does
11//! **not** add an optimizer (Phase A3+). A run is a single-device
12//! contiguous chain with one `entry` and one `exit`; its members are
13//! [`NodeId`]s in topological order.
14//!
15//! Run boundaries (a new run starts at a node when *any* hold):
16//! - **(a) graph entry** — a node with no predecessor inside the
17//! reachable set (a source / root input of the walk).
18//! - **(b) reconverge** — a node named as some [`Op::Branch`]'s
19//! `reconverge_at` (the post-merge region is a fresh run).
20//! - **(c) arm entry** — a node whose sole predecessor is a Branch's
21//! *diverge* point (the first node of each candidate route).
22//! - **(d) residency seam** — the node's [`Graph::target_backend`]
23//! differs from its sole predecessor's, **or** the node is an
24//! [`Op::Copy`] / [`Op::Move`] device-transfer.
25//! - **(e) fan-in** — a node with more than one predecessor inside the
26//! reachable set.
27//!
28//! Consequently a run never spans a `Branch` boundary or a device
29//! change. A graph with **zero** `Op::Branch` nodes and a single
30//! residency extracts to exactly **one** run covering all reachable
31//! nodes — exactly today's single-route graph.
32
33use crate::{Graph, NodeId, Op, topo_order_multi};
34use fuel_ir::probe::BackendId;
35use std::collections::{HashMap, HashSet};
36
37/// A resolved route through the multi-path graph: for each `Op::Branch`
38/// decision point (keyed by the Branch node's [`NodeId`]) the **arm
39/// index** the runtime route picker (Picker 2) chose. Arm `i` is the
40/// branch's `inputs[i]` (arm-0 = `inputs[0]` = the cost-vector winner).
41///
42/// A branch **absent** from the map defaults to **arm 0** — so the empty
43/// route is exactly the arm-0-everywhere lowering ([`lower_runs_arm0`]),
44/// which keeps the no-pressure / no-telemetry behavior identical to
45/// Phase B. The picker in `fuel-dispatch` produces one of these by
46/// walking the branches in topological order and consulting a runtime
47/// selector over each branch's arms; `fuel-graph` only *consumes* it to
48/// lower the chosen route (it has no selector / telemetry knowledge).
49pub type PickedRoute = HashMap<NodeId, usize>;
50
51/// A maximal straight-line, single-device op-sequence between two
52/// decision points — the dispatch / command-buffer-capture unit.
53///
54/// `members` are in topological order; `entry == members[0]` and
55/// `exit == *members.last()`. `device` is the resolved
56/// [`BackendId`] shared by every member (via
57/// [`Graph::target_backend`]); `None` means "inherit the executor
58/// default" (no member carries an explicit backend) — a run is
59/// single-device, so every member agrees on this value by construction
60/// (a residency change is itself a run boundary).
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Run {
63 /// First node of the run (a decision-point boundary).
64 pub entry: NodeId,
65 /// Last node of the run (the node the next decision point reads,
66 /// or a root output).
67 pub exit: NodeId,
68 /// Every node of the run, in topological order, `entry` first.
69 pub members: Vec<NodeId>,
70 /// The single resolved backend shared by all members, or `None`
71 /// when no member carries an explicit `target_backend` (executor
72 /// default).
73 pub device: Option<BackendId>,
74}
75
76/// Extract the runs of the graph reachable from `root`, in
77/// topological order of their entries.
78///
79/// Walks the single existing [`topo_order_multi`] order once and cuts
80/// a new run at every boundary (see the module docs). Each emitted run
81/// is single-entry / single-exit and single-device.
82pub fn extract_runs(graph: &Graph, root: NodeId) -> Vec<Run> {
83 extract_runs_multi(graph, &[root])
84}
85
86/// Multi-root variant of [`extract_runs`] — the reachable set is the
87/// union over `roots` (mirrors [`topo_order_multi`]) **plus the arms of
88/// every reachable [`Op::Branch`]**. A Branch's non-arm-0 arms are read
89/// only by the Branch node itself (arm 0 is the runnability fallback the
90/// `reconverge_at` node reads directly, per PR-A1), so seeding the walk
91/// from the Branch nodes pulls every candidate route into the reachable
92/// set.
93pub fn extract_runs_multi(graph: &Graph, roots: &[NodeId]) -> Vec<Run> {
94 let eff_roots = effective_roots(graph, roots);
95 let order = topo_order_multi(graph, &eff_roots);
96 let reachable: HashSet<NodeId> = order.iter().copied().collect();
97
98 // Nodes named as some Branch's `reconverge_at` (the post-merge region
99 // opens a fresh run).
100 let reconverge_points: HashSet<NodeId> = order
101 .iter()
102 .filter_map(|&id| match graph.node(id).op {
103 Op::Branch { reconverge_at } => Some(reconverge_at),
104 _ => None,
105 })
106 .collect();
107
108 // Arm-entry nodes: the first node of each candidate route, departing
109 // from the shared diverge point. A run never spans into an arm.
110 let arm_entries = compute_arm_entries(graph, &order, &reachable);
111
112 // Predecessors inside the reachable set, per node (for fan-in + the
113 // residency-seam sole-predecessor test).
114 let mut pred_inside: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
115 for &id in &order {
116 let preds: Vec<NodeId> = graph
117 .node(id)
118 .inputs
119 .iter()
120 .copied()
121 .filter(|p| reachable.contains(p))
122 .collect();
123 pred_inside.insert(id, preds);
124 }
125
126 let mut runs: Vec<Run> = Vec::new();
127 let mut current: Vec<NodeId> = Vec::new();
128 let mut current_device: Option<BackendId> = None;
129
130 for &id in &order {
131 // The `Op::Branch` (phi/merge) node is structural bookkeeping —
132 // the decision point *between* runs, not executable work. It is
133 // never a run member; once a route is picked it is invisible to
134 // the hot path. Skipping it also means a consumer that reads a
135 // Branch (rather than the `reconverge_at` node) finds its
136 // predecessor absent from the open run and so opens a fresh run.
137 if matches!(graph.node(id).op, Op::Branch { .. }) {
138 continue;
139 }
140 let preds = &pred_inside[&id];
141 let device = graph.target_backend(id);
142
143 let starts_new = {
144 // (a) graph entry — no predecessor in the reachable set.
145 let is_entry = preds.is_empty();
146 // (b) reconverge point.
147 let is_reconverge = reconverge_points.contains(&id);
148 // (c) arm entry.
149 let is_arm = arm_entries.contains(&id);
150 // (e) fan-in — more than one reachable predecessor.
151 let is_fan_in = preds.len() > 1;
152 // (d) residency seam: a device-transfer op, or a backend that
153 // differs from the sole predecessor's backend.
154 let is_transfer = matches!(graph.node(id).op, Op::Copy { .. } | Op::Move { .. });
155 let is_seam = preds.len() == 1 && graph.target_backend(preds[0]) != device;
156 // Contiguity: a run is a straight-line chain, so a node only
157 // *extends* the current run when its sole predecessor IS the
158 // current run's last member. Otherwise it begins a new run
159 // even if no other rule fired (guards against topo interleave
160 // and the Branch node itself, whose inputs are the arm exits).
161 let breaks_chain = match current.last() {
162 None => true,
163 Some(&last) => !(preds.len() == 1 && preds[0] == last),
164 };
165 // A node whose backend differs from the open run's device
166 // cannot extend it (single-device runs).
167 let device_mismatch = !current.is_empty() && device != current_device;
168 is_entry
169 || is_reconverge
170 || is_arm
171 || is_fan_in
172 || is_transfer
173 || is_seam
174 || breaks_chain
175 || device_mismatch
176 };
177
178 if starts_new && !current.is_empty() {
179 runs.push(finish_run(¤t, current_device));
180 current = Vec::new();
181 }
182 if current.is_empty() {
183 current_device = device;
184 }
185 current.push(id);
186 }
187 if !current.is_empty() {
188 runs.push(finish_run(¤t, current_device));
189 }
190 runs
191}
192
193/// The effective root set for a run/density walk: the caller's `roots`
194/// plus every [`Op::Branch`] node *participating in* the reachable
195/// computation. A finalized `Op::Branch` is typically **orphaned** —
196/// nothing downstream reads it (the `reconverge_at` node reads arm 0
197/// directly, per PR-A1's runnability invariant), so it would never be
198/// found by a plain forward walk. We therefore scan the whole arena and
199/// seed any Branch whose `reconverge_at` (or any arm exit) is already
200/// reachable; seeding it pulls its candidate arms into the walk. Run to
201/// a fixpoint so a Branch reached only through another Branch's arm is
202/// also covered.
203///
204/// Public so the optimizer's residency pass (`fuel-dispatch`) can stitch
205/// inbound cross-device copies for EVERY surviving arm — a plain
206/// `topo_order_multi(roots)` walk misses orphaned Branch nodes (and thus
207/// their non-arm-0 arms), which would leave a re-pickable arm's device-
208/// inputs un-bridged (Step E Phase C, PR C-0).
209pub fn effective_roots(graph: &Graph, roots: &[NodeId]) -> Vec<NodeId> {
210 let mut seeds: Vec<NodeId> = roots.to_vec();
211 let mut seen: HashSet<NodeId> = seeds.iter().copied().collect();
212 loop {
213 let reachable: HashSet<NodeId> = topo_order_multi(graph, &seeds).into_iter().collect();
214 let mut added = false;
215 for idx in 0..graph.len() {
216 let id = NodeId(idx);
217 if seen.contains(&id) {
218 continue;
219 }
220 let Op::Branch { reconverge_at } = graph.node(id).op else {
221 continue;
222 };
223 // The branch participates if its merge target or any arm exit
224 // is already part of the reachable computation.
225 let participates = reachable.contains(&reconverge_at)
226 || graph.node(id).inputs.iter().any(|a| reachable.contains(a));
227 if participates {
228 seen.insert(id);
229 seeds.push(id);
230 added = true;
231 }
232 }
233 if !added {
234 break;
235 }
236 }
237 seeds
238}
239
240/// The reachable [`Op::Branch`] decision points over `roots`, in
241/// **topological order** of their `reconverge_at` merge points — the
242/// order the runtime route picker (Picker 2) resolves them in, so that
243/// coupled upstream decisions are committed before a downstream branch
244/// is reached (06-runtime §"Resolution order matters when decisions are
245/// coupled").
246///
247/// A finalized `Op::Branch` is typically orphaned (the `reconverge_at`
248/// node reads arm 0 directly, per PR-A1), so a plain forward walk would
249/// miss it; we therefore order by each branch's `reconverge_at` position
250/// in the [`effective_roots`] walk — the merge point is downstream of the
251/// arms, so its topo position is a faithful "where this decision sits"
252/// key. Branches whose merge point is unreachable are dropped.
253pub fn branches_in_topo_order(graph: &Graph, roots: &[NodeId]) -> Vec<NodeId> {
254 let eff_roots = effective_roots(graph, roots);
255 let order = topo_order_multi(graph, &eff_roots);
256 let position: HashMap<NodeId, usize> =
257 order.iter().enumerate().map(|(i, &id)| (id, i)).collect();
258
259 let mut branches: Vec<(usize, NodeId)> = Vec::new();
260 for idx in 0..graph.len() {
261 let id = NodeId(idx);
262 let Op::Branch { reconverge_at } = graph.node(id).op else {
263 continue;
264 };
265 // Only branches whose merge point participates in this realize.
266 if let Some(&pos) = position.get(&reconverge_at) {
267 branches.push((pos, id));
268 }
269 }
270 branches.sort_by_key(|&(pos, _)| pos);
271 branches.into_iter().map(|(_, id)| id).collect()
272}
273
274fn finish_run(members: &[NodeId], device: Option<BackendId>) -> Run {
275 Run {
276 entry: members[0],
277 exit: *members.last().expect("a run has at least one member"),
278 members: members.to_vec(),
279 device,
280 }
281}
282
283/// For every [`Op::Branch`] reachable in `order`, find the arm-entry
284/// nodes: the first node on each candidate route after the shared
285/// diverge point. These are run boundaries (rule (c)).
286///
287/// The op carries `reconverge_at` but not the diverge, so we recover the
288/// branch's shared prefix structurally: intersect the backward cones of
289/// all arm exits. Every node in that intersection is shared (the diverge
290/// point and its ancestors); the per-arm interior is everything else in
291/// the cone. An interior node is an *arm entry* when one of its
292/// predecessors lies in the shared prefix — i.e. it is the point where
293/// the route departs from the diverge region.
294fn compute_arm_entries(
295 graph: &Graph,
296 order: &[NodeId],
297 reachable: &HashSet<NodeId>,
298) -> HashSet<NodeId> {
299 let mut arm_entries: HashSet<NodeId> = HashSet::new();
300 for &id in order {
301 let Op::Branch { .. } = graph.node(id).op else {
302 continue;
303 };
304 let arm_exits: Vec<NodeId> = graph.node(id).inputs.clone();
305 if arm_exits.len() < 2 {
306 continue;
307 }
308 // Backward cone of each arm exit, bounded to the reachable set.
309 let cones: Vec<HashSet<NodeId>> = arm_exits
310 .iter()
311 .map(|&e| backward_cone(graph, e, reachable))
312 .collect();
313 // The diverge point is shared by every arm — it lies in the
314 // intersection of all cones. The arm interiors are the
315 // per-cone nodes NOT in that shared prefix. The arm entry is the
316 // interior node whose sole predecessor lies in the shared prefix.
317 let mut shared: HashSet<NodeId> = cones[0].clone();
318 for c in &cones[1..] {
319 shared = shared.intersection(c).copied().collect();
320 }
321 for cone in &cones {
322 for &n in cone {
323 if shared.contains(&n) {
324 continue;
325 }
326 // Arm-interior node. It is an arm *entry* if any of its
327 // predecessors is in the shared prefix (it departs from
328 // the diverge region).
329 let departs = graph.node(n).inputs.iter().any(|p| shared.contains(p));
330 if departs {
331 arm_entries.insert(n);
332 }
333 }
334 }
335 }
336 arm_entries
337}
338
339/// Backward-reachable cone of `from` (the node and all transitive
340/// inputs), bounded to `reachable`.
341fn backward_cone(graph: &Graph, from: NodeId, reachable: &HashSet<NodeId>) -> HashSet<NodeId> {
342 let mut seen: HashSet<NodeId> = HashSet::new();
343 let mut stack = vec![from];
344 while let Some(n) = stack.pop() {
345 if !reachable.contains(&n) || !seen.insert(n) {
346 continue;
347 }
348 for &inp in &graph.node(n).inputs {
349 if !seen.contains(&inp) {
350 stack.push(inp);
351 }
352 }
353 }
354 seen
355}
356
357/// The transient executable view of a single run: its ordered member
358/// [`NodeId`]s, ready to be recorded into a command buffer (Phase C).
359///
360/// A **borrow / transient** view — it is NOT stored on the graph. This
361/// is the shape Phase C records; PR-A2 only derives it.
362pub fn lower_run(run: &Run) -> &[NodeId] {
363 &run.members
364}
365
366/// The **arm-0 single-route lowering** (Phase A PR-A4, architect-
367/// approved temporary fix). The flat executable dispatch order that
368/// **follows arm 0 through every [`Op::Branch`]** — the pre-run, arm 0's
369/// run, then the post-run — and **skips every non-arm-0 arm's run**.
370///
371/// Why this is needed: [`extract_runs_multi`] partitions *every* arm
372/// into its own run (so the future runtime picker can choose among
373/// them). A naive concatenation of all runs would execute every arm.
374/// Until the Phase-C route picker lands, realize must default to arm 0
375/// — the route a finalized-but-unpicked graph runs on (the A1 arm-0
376/// runnability invariant). This walk drops the runs that lie entirely
377/// inside a non-arm-0 arm and keeps the rest (pre / arm-0 / post), so a
378/// branched graph realizes to the same result as its arm-0 route.
379///
380/// A graph with **zero `Op::Branch` nodes** has no arm to skip, so this
381/// is byte-identical to concatenating [`lower_run`] over
382/// [`extract_runs_multi`] — today's single-route order.
383pub fn lower_runs_arm0(graph: &Graph, roots: &[NodeId]) -> Vec<NodeId> {
384 // Arm-0 everywhere is the empty-route special case of the general
385 // route-aware lowering (an unmentioned branch defaults to arm 0).
386 lower_picked_route(graph, roots, &PickedRoute::new())
387}
388
389/// **Route-aware lowering** (Phase C PR-C1) — the generalization of
390/// [`lower_runs_arm0`]. The flat executable dispatch order that follows
391/// the **chosen arm at each [`Op::Branch`]** (per `picked`, defaulting to
392/// arm 0 for any branch the picker did not resolve) and **skips every
393/// non-chosen arm's run**.
394///
395/// This is exactly [`lower_runs_arm0`] generalized from "arm 0 always"
396/// to "the picked arm per branch": it reuses the same run partition
397/// ([`extract_runs_multi`]) and the same single-contiguous-region
398/// property of a run (extract never spans a branch boundary, so a run is
399/// either wholly inside a non-chosen arm or wholly outside). The skip set
400/// is `non_chosen_arm_nodes` — `non_arm0_arm_nodes` generalized to "any
401/// arm but the chosen one."
402///
403/// Value-preserving contract: every arm is a valid kernel for the same
404/// op (cast-to-uniform at `reconverge_at` per PR-A1), so following any
405/// arm yields the same result within tolerance. With `picked` empty
406/// (no-pressure / no-telemetry route) this is byte-identical to
407/// [`lower_runs_arm0`], and a graph with **zero `Op::Branch` nodes** has
408/// no arm to skip, so it equals concatenating [`lower_run`] over
409/// [`extract_runs_multi`] — today's single-route order.
410pub fn lower_picked_route(graph: &Graph, roots: &[NodeId], picked: &PickedRoute) -> Vec<NodeId> {
411 let runs = extract_runs_multi(graph, roots);
412 let skip = non_chosen_arm_nodes(graph, roots, picked);
413 // The KEPT runs (chosen arms + pre/post/independent regions), in topo
414 // order — the runs the un-reordered lowering would concatenate.
415 let kept: Vec<Run> = runs
416 .into_iter()
417 // A run is a single contiguous arm/region (extract never spans a
418 // branch boundary), so it is either wholly inside a non-chosen
419 // arm or wholly outside. Keep iff its entry is not a skipped node.
420 .filter(|run| !skip.contains(&run.entry))
421 .collect();
422 // PR-C3: device-alternating reorder of the kept runs (auto-overlap).
423 // Single-device ⇒ identity ⇒ byte-identical to the prior concatenation.
424 // Multi-device ⇒ a valid topological reordering that interleaves
425 // devices so independent device sub-DAGs dispatch adjacently (the A4b
426 // overlap topology, for an arbitrary graph). The reorder is a hint: the
427 // emitted NodeIds are identical, only their order across independent
428 // runs changes.
429 let perm = device_alternating_order(graph, &kept);
430 let mut order = Vec::new();
431 for &ri in &perm {
432 order.extend_from_slice(lower_run(&kept[ri]));
433 }
434 order
435}
436
437/// **Device-overlap topological reorder** of a run list (Step E Phase C,
438/// PR C3 — the *auto-overlap* pass). Returns a permutation of
439/// `0..runs.len()` (indices into `runs`) that is a **valid topological
440/// reordering** — every run still appears after all the runs it depends
441/// on — chosen so independent cross-device sub-DAGs DISPATCH so they
442/// overlap: the longest compute chunk of each device is enqueued/recorded
443/// BEFORE the host-blocking cross-device drain that feeds the join.
444///
445/// # Why (the A4b caveat this removes)
446///
447/// [`extract_runs_multi`] emits runs in [`topo_order_multi`]'s fixed-DFS
448/// order. For two INDEPENDENT reconverging sub-DAGs — one on CUDA, one on
449/// Vulkan — that DFS emits one sub-DAG *fully* (including the cross-device
450/// `Op::Copy` that feeds the join) before the other is even recorded. The
451/// executor then blocks the host on that copy's producer (a D2H wait)
452/// while the OTHER device sits idle — the two devices serialize. A4b built
453/// the cross-device concurrency MECHANISM (eager Vulkan submit + in-flight
454/// CUDA handles) but it only overlaps when the auto-submitting device's
455/// chunk (CUDA, A3 stream-ordered) is enqueued — and thus running — BEFORE
456/// the host blocks on the deferred device's (Vulkan) drain. The A4b overlap
457/// benchmark gets that order by HAND (a specific reconverge input order +
458/// CPU-primary pinning so the DFS happens to pop the heavy CUDA chunk
459/// first). This pass produces that order for an ARBITRARY graph.
460///
461/// # The heuristic — longest-downstream-compute (critical-path) list scheduling
462///
463/// A Kahn pump that, among the runs whose producers are all emitted, picks
464/// the run with the **largest compute chunk reachable downstream**
465/// (including itself) — a classic critical-path / HLFET list-scheduling
466/// priority. Concretely, each run is weighted by `max over its downstream
467/// cone of compute_size(run)`, where a compute run's size is its node count
468/// and a cross-device **transfer** (`Op::Copy`/`Op::Move`) has size 0. The
469/// effect:
470///
471/// - The path to the **heaviest compute chunk** (e.g. the long CUDA chain)
472/// is emitted FIRST — so on the auto-submitting device it is enqueued and
473/// running earliest, maximizing the window the other device's work
474/// overlaps. (Empirically: enqueuing the heavy CUDA chunk first is what
475/// makes the eager-submit overlap; the inverse order serializes.)
476/// - The next-heaviest device chunk is emitted next (its device's chunk is
477/// recorded while the first device runs).
478/// - A cross-device **drain** (the D2H `Op::Copy` feeding the join) has
479/// near-zero downstream compute weight, so it sorts AFTER both device
480/// chunks — exactly the A4b overlap topology (record/enqueue both chunks,
481/// THEN the drain whose fence-wait the other device's in-flight work
482/// hides). Among equal-weight ready runs, ties break toward a device
483/// DIFFERENT from the last emitted (interleave), then the smallest
484/// original index (determinism).
485///
486/// # The invariant — a HINT, never a correctness lever
487///
488/// Reordering INDEPENDENT runs changes only *when* work dispatches, never
489/// the result: the emitted order is always a valid topological order (a
490/// run after its producers), every op is bit-deterministic, and every
491/// cross-device wait/copy keys on a node's *inputs*, not on dispatch
492/// position — so no reorder can drop a wait or change a value. The output
493/// is byte-identical regardless of the reorder (proved by the
494/// reorder-invariance tests).
495///
496/// # The multi-device gate (single-device = identity)
497///
498/// If every run shares a single resolved device (≤ 1 distinct
499/// [`Run::device`] across the list), there is nothing to overlap, so this
500/// returns the identity permutation `0..runs.len()` and the lowering is
501/// **byte-identical to today's** topo-DFS order. The pass only diverges
502/// from the input order when the run list genuinely spans two or more
503/// devices.
504///
505/// # Determinism
506///
507/// Deterministic for a given run list: the priority key is
508/// `(−downstream_weight, not_different_device, original_index)`, fully
509/// ordered, so the reorder is reproducible and a degenerate input (one
510/// device) reproduces the input order exactly.
511///
512/// `runs` must be the partition [`extract_runs_multi`] produced for the
513/// same `graph` (the node→run mapping is rebuilt from `runs`), and the
514/// dependency edges are derived from each member's graph inputs.
515pub fn device_alternating_order(graph: &Graph, runs: &[Run]) -> Vec<usize> {
516 let n = runs.len();
517 if n <= 1 {
518 return (0..n).collect();
519 }
520
521 // The multi-device gate: count distinct resolved devices. `None`
522 // (executor-default, no explicit backend) is its own bucket — a list
523 // that is all-`None` or all-one-backend has nothing to interleave.
524 let mut distinct: Vec<Option<BackendId>> = Vec::new();
525 for r in runs {
526 if !distinct.contains(&r.device) {
527 distinct.push(r.device);
528 }
529 }
530 if distinct.len() <= 1 {
531 // Single-device (or single executor-default) — identity. This is
532 // the byte-identical-to-today path (the headline single-device
533 // regression gate).
534 return (0..n).collect();
535 }
536
537 // node -> the index of the run that contains it (every reachable node
538 // is in exactly one run; the Branch node is not a member of any run,
539 // so a cross-run input that is a Branch node simply finds no producer
540 // run, which is correct — arms feed the reconverge, not the Branch).
541 let mut run_of: HashMap<NodeId, usize> = HashMap::new();
542 for (ri, r) in runs.iter().enumerate() {
543 for &m in &r.members {
544 run_of.insert(m, ri);
545 }
546 }
547
548 // Run-level dependency edges. Run `r` depends on run `s` (s != r) when
549 // some member of `r` reads a node that is a member of `s` (a cross-run
550 // input). `preds[r]` = the set of producer runs; `succs[s]` = the runs
551 // that read `s`; `indeg[r]` = count of not-yet-emitted producers.
552 let mut preds: Vec<HashSet<usize>> = vec![HashSet::new(); n];
553 let mut succs: Vec<Vec<usize>> = vec![Vec::new(); n];
554 for (ri, r) in runs.iter().enumerate() {
555 for &m in &r.members {
556 for &inp in &graph.node(m).inputs {
557 if let Some(&pi) = run_of.get(&inp)
558 && pi != ri
559 && preds[ri].insert(pi)
560 {
561 succs[pi].push(ri);
562 }
563 }
564 }
565 }
566 let mut indeg: Vec<usize> = preds.iter().map(|p| p.len()).collect();
567
568 // Per-run COMPUTE size: node count for a real compute chunk, 0 for a
569 // pure cross-device TRANSFER (`Op::Copy`/`Op::Move` — always its own
570 // single-member run, the residency-seam boundary). A transfer is the
571 // run that FORCES a cross-device wait at dispatch (the executor drains
572 // the source / eager-submits + fences the in-flight batch when it hits a
573 // cross-device copy); it does no compute, so it weighs 0 and a drain is
574 // never the heaviest-downstream pick — it sorts after the compute chunks.
575 let compute_size = |ri: usize| -> u64 {
576 if matches!(
577 graph.node(runs[ri].entry).op,
578 Op::Copy { .. } | Op::Move { .. }
579 ) {
580 0
581 } else {
582 runs[ri].members.len() as u64
583 }
584 };
585
586 // `down_weight[r]` = the LARGEST compute chunk reachable from `r`
587 // downstream, including `r` itself — the critical-path priority. A
588 // memoized DFS over `succs` (the run-DAG is acyclic, so the recursion
589 // terminates; memoization makes it linear). Computed with an explicit
590 // stack (never-panic on deep graphs — no recursion).
591 let mut down_weight: Vec<u64> = vec![u64::MAX; n]; // MAX = not yet computed
592 for start in 0..n {
593 if down_weight[start] != u64::MAX {
594 continue;
595 }
596 // Post-order: push (node, children_done?).
597 let mut stack: Vec<(usize, bool)> = vec![(start, false)];
598 while let Some((r, done)) = stack.pop() {
599 if done {
600 let mut w = compute_size(r);
601 for &s in &succs[r] {
602 w = w.max(down_weight[s]);
603 }
604 down_weight[r] = w;
605 continue;
606 }
607 if down_weight[r] != u64::MAX {
608 continue;
609 }
610 // Tentatively mark in-progress so a (DAG-impossible) revisit
611 // doesn't loop; finalized on the `done` pass.
612 stack.push((r, true));
613 for &s in &succs[r] {
614 if down_weight[s] == u64::MAX {
615 stack.push((s, false));
616 }
617 }
618 }
619 }
620
621 // Kahn pump with critical-path priority. `ready` is every run whose
622 // producers are all emitted. Among the ready runs we pick the one whose
623 // priority key is smallest:
624 //
625 // ( -down_weight , not_different_device , original_index )
626 //
627 // i.e. (1) the LARGEST downstream compute chunk first — emit the path to
628 // the heaviest device chunk earliest so the auto-submitting device
629 // (CUDA) is enqueued + running before the deferred device's (Vulkan)
630 // host-blocking drain, which is what the eager-submit mechanism turns
631 // into overlap (and a drain weighs 0, so it sorts after both chunks);
632 // (2) among equal-weight ready runs, a device DIFFERENT from the last
633 // emitted (interleave); (3) the SMALLEST original index (determinism +
634 // topo stability — the degenerate single-device case already returned
635 // identity above). `ready` is a plain Vec scanned each step; run counts
636 // are tiny (a run is a whole straight-line chunk; the fewness gate keeps
637 // decision points sparse), so the O(ready) scan is cheap. This is a
638 // HINT: every emitted order is a valid topological order regardless of
639 // the priority (Kahn guarantees producers-first), so the result is
640 // byte-identical — only the dispatch timing changes.
641 let mut emitted: Vec<bool> = vec![false; n];
642 let mut ready: Vec<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
643 let mut order: Vec<usize> = Vec::with_capacity(n);
644 let mut last_device: Option<Option<BackendId>> = None;
645
646 while !ready.is_empty() {
647 // Priority key (smaller wins): heaviest downstream compute first,
648 // then a different device than last (interleave), then smallest idx.
649 let key_of = |ri: usize| -> (std::cmp::Reverse<u64>, bool, usize) {
650 let same_device = last_device.is_some_and(|ld| runs[ri].device == ld);
651 (std::cmp::Reverse(down_weight[ri]), same_device, ri)
652 };
653 let mut best_pos = 0usize;
654 let mut best_key = key_of(ready[0]);
655 for (pos, &ri) in ready.iter().enumerate().skip(1) {
656 let key = key_of(ri);
657 if key < best_key {
658 best_key = key;
659 best_pos = pos;
660 }
661 }
662 let ri = ready.swap_remove(best_pos);
663 emitted[ri] = true;
664 order.push(ri);
665 last_device = Some(runs[ri].device);
666
667 for &si in &succs[ri] {
668 if emitted[si] {
669 continue;
670 }
671 indeg[si] -= 1;
672 if indeg[si] == 0 {
673 ready.push(si);
674 }
675 }
676 }
677
678 // A cyclic run graph is impossible (the node graph is a DAG and runs
679 // partition it), so the pump emits every run. Defend never-panic: if
680 // somehow a run was not emitted (a structural surprise), fall back to
681 // the input order for the remainder so the lowering stays total rather
682 // than dropping work.
683 if order.len() != n {
684 #[expect(
685 clippy::needless_range_loop,
686 reason = "the bound `n` is the node count checked above (order.len() != n), not `emitted.len()`; enumerate could change the iteration count -- a wrong-number bug"
687 )]
688 for i in 0..n {
689 if !emitted[i] {
690 order.push(i);
691 }
692 }
693 }
694 order
695}
696
697/// **Streaming route-aware lowering** (Step E Phase C, PR C1) — the
698/// incremental form of [`lower_picked_route`]. Instead of taking a
699/// fully-resolved [`PickedRoute`] up front, it walks the run partition in
700/// topological order and **resolves each branch lazily, the first time the
701/// walk reaches one of its arm-entry runs**, by calling `resolve(branch)`.
702/// It emits only the chosen arm's runs (skipping the non-chosen arms),
703/// exactly like [`lower_picked_route`] — but the arm decision happens *at
704/// the frontier*, not before the walk.
705///
706/// This is the substrate that lets the runtime picker re-pick a branch's
707/// arm **by the live device load at the moment the frontier reaches it**
708/// (C2): the compiler thread drives this walk while the executor drains
709/// the previous runs, so `resolve` sees the load current to that decision
710/// point. `resolve` returns the chosen arm index for a branch (a stale /
711/// out-of-range index is clamped to arm 0 by the same skip logic, so it
712/// never panics — never-panic on a production path).
713///
714/// **Byte-identity with the one-shot lowering (the C1 gate).** Because
715/// `resolve` is the same per-branch decision the eager picker makes (the
716/// production VRAM-pressure chain reads only free-memory state, not walk
717/// progress), the route this walk accumulates equals
718/// `fuel_graph::PickedRoute` the eager `pick_route` produces, and the
719/// emitted order therefore equals `lower_picked_route(graph, roots,
720/// pick_route(..))` on every input. C1 changes *when* a branch resolves,
721/// never *which* arm — and a branchless graph never enters this walk at
722/// all (the caller's branchless fast path returns before streaming).
723/// `resolve` is invoked **at most once per branch** (memoized in the
724/// accumulated route), so a coupled downstream branch that reads upstream
725/// picks still resolves upstream-first (arm-entries of an upstream branch
726/// precede a downstream branch's in run-topo order).
727pub fn lower_picked_route_streaming<F>(
728 graph: &Graph,
729 roots: &[NodeId],
730 mut resolve: F,
731) -> Vec<NodeId>
732where
733 F: FnMut(NodeId) -> usize,
734{
735 let mut order = Vec::new();
736 walk_picked_route_streaming(graph, roots, &mut resolve, |members| {
737 order.extend_from_slice(members);
738 });
739 order
740}
741
742/// The callback core of the streaming lowering — the form the compiler
743/// thread drives so resolution and emission genuinely interleave (the
744/// thread compiles + dispatches each chosen run as the walk reaches it,
745/// rather than first materializing a flat order). `resolve(branch)` yields
746/// the chosen arm index at the frontier; `emit(&[NodeId])` receives each
747/// kept run's members in topological order. [`lower_picked_route_streaming`]
748/// is the `Vec`-collecting wrapper over this (used by the byte-identity
749/// test); the executor's compiler thread passes an `emit` that
750/// `compile_one`'s + sends each node.
751///
752/// Walks the run partition once, in topological order of run entries
753/// (identical to [`extract_runs_multi`]); at the first run that opens an
754/// arm of a not-yet-resolved branch it calls `resolve`, folds that
755/// branch's non-chosen arms into the skip set, then emits the run iff its
756/// entry is not skipped. See [`lower_picked_route_streaming`] for the
757/// byte-identity argument.
758pub fn walk_picked_route_streaming<R, E>(
759 graph: &Graph,
760 roots: &[NodeId],
761 mut resolve: R,
762 mut emit: E,
763) where
764 R: FnMut(NodeId) -> usize,
765 E: FnMut(&[NodeId]),
766{
767 let runs = extract_runs_multi(graph, roots);
768
769 // Per-branch lowering metadata, precomputed once from the reachable
770 // structure (cones + the diverge/shared prefix + each arm's entry
771 // node). The *decision* — which arm — is still deferred to `resolve`
772 // at the frontier; only the structure is precomputed.
773 let branch_meta = branch_arm_meta(graph, roots);
774
775 // arm-entry node -> the branches it opens an arm of. A node can be the
776 // arm-entry of more than one branch only in degenerate shared-prefix
777 // overlaps; resolving every such branch on first encounter is correct
778 // (each resolves independently) and keeps the walk single-pass.
779 let mut entry_to_branches: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
780 for meta in &branch_meta {
781 for &(entry, _arm) in &meta.arm_entries {
782 entry_to_branches
783 .entry(entry)
784 .or_default()
785 .push(meta.branch);
786 }
787 }
788 let meta_of: HashMap<NodeId, &BranchArmMeta> =
789 branch_meta.iter().map(|m| (m.branch, m)).collect();
790
791 let mut skip: HashSet<NodeId> = HashSet::new();
792 let mut resolved: HashSet<NodeId> = HashSet::new();
793
794 // PR-C3 composition: the device-alternating reorder (auto-overlap) runs
795 // over the kept runs, but it must NOT reorder across an unresolved
796 // branch decision (a downstream run's keep/skip depends on the arm
797 // picked upstream, and the picker reads live load AT the frontier, so
798 // moving a run past an unresolved branch would change what is even
799 // resolvable). So we reorder within **branch-free segments**: buffer
800 // kept runs; when the frontier reaches a run that resolves a NEW branch,
801 // FLUSH the buffered segment (device-alternation-reordered) BEFORE
802 // resolving, then start a fresh segment. The flush is the only place a
803 // reorder happens, and it never crosses a `resolve` call — so arm
804 // resolution stays strictly upstream-first and the reorder is confined
805 // to runs already committed to their devices. Branchless graphs (the
806 // auto-overlap benchmark + every existing multi-device live suite) have
807 // exactly one segment = the whole run list, so this is identical to the
808 // one-shot `lower_picked_route` reorder.
809 let mut segment: Vec<&Run> = Vec::new();
810 let flush = |segment: &mut Vec<&Run>, emit: &mut E| {
811 if segment.is_empty() {
812 return;
813 }
814 // `device_alternating_order` wants `&[Run]`; collect the segment's
815 // runs (cheap — members are NodeId Vecs, but a segment is small).
816 let owned: Vec<Run> = segment.iter().map(|r| (*r).clone()).collect();
817 let perm = device_alternating_order(graph, &owned);
818 for &ri in &perm {
819 emit(lower_run(&owned[ri]));
820 }
821 segment.clear();
822 };
823
824 for run in &runs {
825 // Frontier reached this run's entry. If it opens an arm of one or
826 // more not-yet-resolved branches, the route past this point is about
827 // to be decided — flush the branch-free segment accumulated so far
828 // (reordered) BEFORE resolving, so no reorder crosses the decision.
829 if let Some(branches) = entry_to_branches.get(&run.entry) {
830 let opens_new = branches.iter().any(|b| !resolved.contains(b));
831 if opens_new {
832 flush(&mut segment, &mut emit);
833 }
834 for &branch in branches {
835 if !resolved.insert(branch) {
836 continue;
837 }
838 let meta = meta_of[&branch];
839 // Resolve NOW (reading live load via `resolve` once C2 lands)
840 // and fold this branch's non-chosen arms into the skip set —
841 // before we decide whether to keep this run.
842 let chosen = resolve(branch);
843 add_non_chosen_skip(meta, chosen, &mut skip);
844 }
845 }
846 // A run is a single contiguous arm/region (extract never spans a
847 // branch boundary), so it is either wholly inside a non-chosen arm
848 // or wholly outside. Skip iff its entry is a skipped node; otherwise
849 // buffer it into the current branch-free segment (emitted at the
850 // next flush, device-alternation-reordered).
851 if skip.contains(&run.entry) {
852 continue;
853 }
854 segment.push(run);
855 }
856 // Flush the final branch-free segment (the post-region + any tail).
857 flush(&mut segment, &mut emit);
858}
859
860/// Per-branch structural metadata for the streaming lowering: the branch
861/// node, its arms' `(arm-entry node, arm index)` pairs, the per-arm
862/// backward cones, and the shared diverge prefix. The arm *decision* is
863/// not here — only the structure the skip-set computation needs once an
864/// arm is chosen.
865struct BranchArmMeta {
866 branch: NodeId,
867 /// `(arm-entry node, arm index)` for each arm — the run entry that
868 /// opens that arm. Used to detect, while walking runs, which branch a
869 /// run opens an arm of.
870 arm_entries: Vec<(NodeId, usize)>,
871 /// Backward cone of each arm exit (`inputs[i]`), bounded to reachable.
872 cones: Vec<HashSet<NodeId>>,
873 /// Shared diverge prefix = intersection of every arm's cone.
874 shared: HashSet<NodeId>,
875}
876
877/// Precompute [`BranchArmMeta`] for every reachable ≥2-arm [`Op::Branch`].
878/// Mirrors `non_chosen_arm_nodes`'s cone/shared-prefix derivation and
879/// `compute_arm_entries`'s arm-entry recovery (the op carries only
880/// `reconverge_at`, so the diverge prefix is recovered as the cone
881/// intersection), so the streaming skip set is byte-identical to the
882/// one-shot `non_chosen_arm_nodes` for the same picks.
883fn branch_arm_meta(graph: &Graph, roots: &[NodeId]) -> Vec<BranchArmMeta> {
884 let eff_roots = effective_roots(graph, roots);
885 let order = topo_order_multi(graph, &eff_roots);
886 let reachable: HashSet<NodeId> = order.iter().copied().collect();
887
888 let mut metas = Vec::new();
889 for &id in &order {
890 let Op::Branch { .. } = graph.node(id).op else {
891 continue;
892 };
893 let arm_exits = graph.node(id).inputs.clone();
894 if arm_exits.len() < 2 {
895 continue;
896 }
897 let cones: Vec<HashSet<NodeId>> = arm_exits
898 .iter()
899 .map(|&e| backward_cone(graph, e, &reachable))
900 .collect();
901 let mut shared: HashSet<NodeId> = cones[0].clone();
902 for c in &cones[1..] {
903 shared = shared.intersection(c).copied().collect();
904 }
905 // Each arm's entry: the interior node (cone minus shared) whose
906 // sole/any predecessor lies in the shared prefix — where the arm
907 // departs from the diverge region (mirrors `compute_arm_entries`).
908 let mut arm_entries: Vec<(NodeId, usize)> = Vec::new();
909 for (arm_idx, cone) in cones.iter().enumerate() {
910 for &n in cone {
911 if shared.contains(&n) {
912 continue;
913 }
914 let departs = graph.node(n).inputs.iter().any(|p| shared.contains(p));
915 if departs {
916 arm_entries.push((n, arm_idx));
917 }
918 }
919 }
920 metas.push(BranchArmMeta {
921 branch: id,
922 arm_entries,
923 cones,
924 shared,
925 });
926 }
927 metas
928}
929
930/// Fold one branch's non-chosen arms into `skip`, given the chosen arm
931/// index. Identical to the per-branch body of [`non_chosen_arm_nodes`]: a
932/// node is skipped when it lies in a non-chosen arm's cone but is neither
933/// shared nor part of the chosen arm's cone. A stale / out-of-range
934/// `chosen` clamps to arm 0 (never panic).
935fn add_non_chosen_skip(meta: &BranchArmMeta, chosen: usize, skip: &mut HashSet<NodeId>) {
936 let chosen = if chosen < meta.cones.len() { chosen } else { 0 };
937 let chosen_cone = &meta.cones[chosen];
938 for (i, cone) in meta.cones.iter().enumerate() {
939 if i == chosen {
940 continue;
941 }
942 for &n in cone {
943 if !meta.shared.contains(&n) && !chosen_cone.contains(&n) {
944 skip.insert(n);
945 }
946 }
947 }
948}
949
950/// The set of nodes that belong to a **non-chosen arm** of some reachable
951/// [`Op::Branch`] — the nodes the route-aware lowering skips. The arm-0
952/// generalization: when `picked` is empty (or a branch is absent from
953/// it), the chosen arm defaults to arm 0, so this reduces to the former
954/// `non_arm0_arm_nodes`.
955///
956/// For each reachable branch, the chosen arm is `inputs[chosen]`
957/// (`chosen = picked[branch]`, default 0) and the non-chosen arms are
958/// every other input. The shared prefix (the diverge region) is the
959/// intersection of every arm exit's backward cone (PR-A2's
960/// `compute_arm_entries` recovers the diverge the same way — the op
961/// carries `reconverge_at`, not the diverge). A node is skipped when it
962/// lies in a non-chosen arm's cone but is neither shared nor part of the
963/// chosen arm's cone — i.e. it is interior to a route the picker did not
964/// take. Arms are internally disjoint by the PR-A1 build-time
965/// validation, so these sets don't overlap the chosen arm.
966fn non_chosen_arm_nodes(graph: &Graph, roots: &[NodeId], picked: &PickedRoute) -> HashSet<NodeId> {
967 let eff_roots = effective_roots(graph, roots);
968 let order = topo_order_multi(graph, &eff_roots);
969 let reachable: HashSet<NodeId> = order.iter().copied().collect();
970
971 let mut skip: HashSet<NodeId> = HashSet::new();
972 for &id in &order {
973 let Op::Branch { .. } = graph.node(id).op else {
974 continue;
975 };
976 let arm_exits = &graph.node(id).inputs;
977 if arm_exits.len() < 2 {
978 continue;
979 }
980 // The chosen arm index — default arm 0 (the winner) for any
981 // branch the picker did not resolve. Clamp to a valid arm so a
982 // stale/out-of-range pick degrades to arm 0 rather than panicking
983 // (never panic on a production path).
984 let chosen = picked
985 .get(&id)
986 .copied()
987 .filter(|&c| c < arm_exits.len())
988 .unwrap_or(0);
989
990 let cones: Vec<HashSet<NodeId>> = arm_exits
991 .iter()
992 .map(|&e| backward_cone(graph, e, &reachable))
993 .collect();
994 // Shared prefix = intersection of every arm's cone (the diverge
995 // region the arms depart from).
996 let mut shared: HashSet<NodeId> = cones[0].clone();
997 for c in &cones[1..] {
998 shared = shared.intersection(c).copied().collect();
999 }
1000 // Skip every non-chosen arm's interior: its cone minus the shared
1001 // prefix minus the chosen arm's cone.
1002 let chosen_cone = &cones[chosen];
1003 for (i, cone) in cones.iter().enumerate() {
1004 if i == chosen {
1005 continue;
1006 }
1007 for &n in cone {
1008 if !shared.contains(&n) && !chosen_cone.contains(&n) {
1009 skip.insert(n);
1010 }
1011 }
1012 }
1013 }
1014 skip
1015}
1016
1017/// Branch density: the share of reachable nodes that are decision
1018/// points ([`Op::Branch`] nodes), over `root`.
1019///
1020/// The **fewness gate** — deliberate-fork branching must stay sparse
1021/// (the granularity crux locked before Phase B). A real decode graph
1022/// branches only at "layer" boundaries and sits far below the threshold;
1023/// a synthetic per-op-branch graph blows past it.
1024pub fn branch_density(graph: &Graph, root: NodeId) -> f32 {
1025 branch_density_multi(graph, &[root])
1026}
1027
1028/// Multi-root variant of [`branch_density`].
1029pub fn branch_density_multi(graph: &Graph, roots: &[NodeId]) -> f32 {
1030 let eff_roots = effective_roots(graph, roots);
1031 let order = topo_order_multi(graph, &eff_roots);
1032 if order.is_empty() {
1033 return 0.0;
1034 }
1035 let branches = order
1036 .iter()
1037 .filter(|&&id| matches!(graph.node(id).op, Op::Branch { .. }))
1038 .count();
1039 branches as f32 / order.len() as f32
1040}
1041
1042/// The fewness-gate threshold: branch points must be at most ~5% of
1043/// reachable nodes. Deliberate-fork branching stays this sparse;
1044/// per-op branching does not.
1045pub const FEWNESS_THRESHOLD: f32 = 0.05;
1046
1047/// Whether the graph passes the fewness gate over `root` (branch
1048/// density strictly below [`FEWNESS_THRESHOLD`]).
1049pub fn passes_fewness_gate(graph: &Graph, root: NodeId) -> bool {
1050 branch_density(graph, root) < FEWNESS_THRESHOLD
1051}
1052
1053#[cfg(test)]
1054mod tests {
1055 use super::*;
1056 use crate::Node;
1057 use fuel_ir::{DType, Shape};
1058
1059 fn f32_node(g: &mut Graph, op: Op, inputs: Vec<NodeId>) -> NodeId {
1060 g.push(Node {
1061 op,
1062 inputs,
1063 shape: Shape::from_dims(&[2]),
1064 dtype: DType::F32,
1065 })
1066 }
1067
1068 /// Hand-build the same 2-arm diamond the branch-builder tests use,
1069 /// then finalize it into a real `Op::Branch`. Returns the graph and
1070 /// `(diverge, arm0, arm1, reconverge, branch)`.
1071 ///
1072 /// Topology: `diverge -> {arm0, arm1}`; `reconverge` reads arm0 (the
1073 /// runnability invariant); a `Branch` node merges {arm0, arm1} with
1074 /// `reconverge_at = reconverge`. `post` reads `reconverge` so the
1075 /// post-merge region is non-empty.
1076 fn diamond_with_branch() -> (Graph, NodeId, NodeId, NodeId, NodeId, NodeId, NodeId) {
1077 let mut g = Graph::new();
1078 let pre = f32_node(&mut g, Op::Const, vec![]);
1079 let diverge = f32_node(&mut g, Op::Relu, vec![pre]);
1080 let arm0 = f32_node(&mut g, Op::Silu, vec![diverge]);
1081 let arm1 = f32_node(&mut g, Op::GeluTanh, vec![diverge]);
1082 // reconverge reads arm0 (arm-0 runnability).
1083 let reconverge = f32_node(&mut g, Op::Relu, vec![arm0]);
1084 let mut b = g.open_branch(diverge);
1085 b.add_arm(arm0);
1086 b.add_arm(arm1);
1087 let branch = b
1088 .finalize_branches(&mut g, reconverge)
1089 .expect("well-formed 2-arm branch finalizes")
1090 .expect("2 arms survive");
1091 // A post node that reads the merge, so there is a real post-run.
1092 let post = f32_node(&mut g, Op::Tanh, vec![reconverge]);
1093 (g, diverge, arm0, arm1, reconverge, branch, post)
1094 }
1095
1096 /// (a) A straight-line graph with no branches extracts to exactly
1097 /// ONE run covering all reachable nodes, single-entry / single-exit.
1098 #[test]
1099 fn straight_line_extracts_to_one_run() {
1100 let mut g = Graph::new();
1101 let a = f32_node(&mut g, Op::Const, vec![]);
1102 let b = f32_node(&mut g, Op::Relu, vec![a]);
1103 let c = f32_node(&mut g, Op::Silu, vec![b]);
1104 let d = f32_node(&mut g, Op::Tanh, vec![c]);
1105
1106 let runs = extract_runs(&g, d);
1107 assert_eq!(runs.len(), 1, "a straight-line graph is exactly one run");
1108 let run = &runs[0];
1109 assert_eq!(run.entry, a, "the run enters at the source");
1110 assert_eq!(run.exit, d, "the run exits at the root");
1111 assert_eq!(
1112 run.members,
1113 vec![a, b, c, d],
1114 "the single run covers every reachable node in topo order",
1115 );
1116 // single-device: no explicit backend anywhere -> None.
1117 assert_eq!(run.device, None);
1118 // lower_run reproduces the ordered members.
1119 assert_eq!(lower_run(run), &[a, b, c, d]);
1120 }
1121
1122 /// (b) A finalized 2-arm branch extracts to exactly four runs —
1123 /// {pre-run, arm0-run, arm1-run, post-run} — each single-entry /
1124 /// single-exit.
1125 #[test]
1126 fn two_arm_branch_extracts_to_four_runs() {
1127 let (g, diverge, arm0, arm1, reconverge, _branch, post) = diamond_with_branch();
1128
1129 let runs = extract_runs(&g, post);
1130
1131 // Each run is single-entry/single-exit by construction.
1132 for r in &runs {
1133 assert_eq!(r.entry, r.members[0]);
1134 assert_eq!(r.exit, *r.members.last().unwrap());
1135 assert!(!r.members.is_empty());
1136 }
1137
1138 // Locate the run that contains each landmark node.
1139 let run_of = |node: NodeId| -> usize {
1140 runs.iter()
1141 .position(|r| r.members.contains(&node))
1142 .unwrap_or_else(|| panic!("no run contains Node#{}", node.0))
1143 };
1144 let pre_run = run_of(diverge);
1145 let arm0_run = run_of(arm0);
1146 let arm1_run = run_of(arm1);
1147 let post_run = run_of(reconverge);
1148
1149 // Four distinct runs: pre, arm0, arm1, post.
1150 let distinct: HashSet<usize> = [pre_run, arm0_run, arm1_run, post_run]
1151 .into_iter()
1152 .collect();
1153 assert_eq!(
1154 distinct.len(),
1155 4,
1156 "a 2-arm branch yields exactly {{pre, arm0, arm1, post}} = 4 runs; got runs={runs:?}",
1157 );
1158 assert_eq!(runs.len(), 4, "no run beyond the four; got {runs:?}");
1159
1160 // arm0 and arm1 are in *different* runs (a run never spans a
1161 // branch boundary).
1162 assert_ne!(arm0_run, arm1_run, "the two arms must be separate runs");
1163 // diverge is upstream of both arms and in neither arm's run.
1164 assert_ne!(pre_run, arm0_run);
1165 assert_ne!(pre_run, arm1_run);
1166 // the post region (reconverge + Branch + post) is its own run.
1167 assert!(runs[post_run].members.contains(&post));
1168 }
1169
1170 /// PR-A4 arm-0 single-route lowering: [`lower_runs_arm0`] follows
1171 /// arm 0 through the branch (pre, arm0, post) and SKIPS arm 1's run.
1172 /// arm 1's interior node never appears; arm 0's does. On a branchless
1173 /// graph it is identical to concatenating [`lower_run`] over the runs.
1174 #[test]
1175 fn lower_runs_arm0_follows_arm0_and_skips_other_arms() {
1176 let (g, diverge, arm0, arm1, reconverge, _branch, post) = diamond_with_branch();
1177
1178 let order = lower_runs_arm0(&g, &[post]);
1179
1180 // arm 1's interior is skipped; arm 0 + pre + post execute.
1181 assert!(
1182 !order.contains(&arm1),
1183 "arm-0 lowering skips arm 1's run; order={order:?} arm1={arm1:?}",
1184 );
1185 assert!(order.contains(&diverge), "the pre-run (diverge) executes");
1186 assert!(order.contains(&arm0), "arm 0 executes");
1187 assert!(order.contains(&reconverge), "the reconverge executes");
1188 assert!(order.contains(&post), "the post-run executes");
1189 // The phi/merge Branch node is structural — never an executable
1190 // member.
1191 for &id in &order {
1192 assert!(
1193 !matches!(g.node(id).op, Op::Branch { .. }),
1194 "the Branch node is never an executable member",
1195 );
1196 }
1197
1198 // Branchless: identical to concatenating lower_run over runs.
1199 let mut g2 = Graph::new();
1200 let a = f32_node(&mut g2, Op::Const, vec![]);
1201 let b = f32_node(&mut g2, Op::Relu, vec![a]);
1202 let c = f32_node(&mut g2, Op::Silu, vec![b]);
1203 let d = f32_node(&mut g2, Op::Tanh, vec![c]);
1204 let flat: Vec<NodeId> = extract_runs(&g2, d)
1205 .iter()
1206 .flat_map(|r| lower_run(r).to_vec())
1207 .collect();
1208 assert_eq!(
1209 lower_runs_arm0(&g2, &[d]),
1210 flat,
1211 "on a branchless graph the arm-0 lowering equals the flat concatenation",
1212 );
1213 }
1214
1215 /// PR-C1 (e) route-aware lowering: [`lower_picked_route`] follows the
1216 /// **chosen** arm and skips the non-chosen arms' runs, and equals
1217 /// [`lower_runs_arm0`] when every pick is arm 0 (the empty route).
1218 ///
1219 /// On the 2-arm diamond:
1220 /// - empty route (== arm 0 everywhere): arm 0 in, arm 1 out, and
1221 /// byte-identical to `lower_runs_arm0`.
1222 /// - pick arm 1 for the branch: arm 1 in, arm 0's interior out — the
1223 /// mirror image, proving the lowering follows the picked arm rather
1224 /// than hard-coding arm 0.
1225 #[test]
1226 fn lower_picked_route_follows_chosen_arm_and_skips_others() {
1227 let (g, _diverge, arm0, arm1, _reconverge, branch, post) = diamond_with_branch();
1228
1229 // (1) Empty route == arm-0 everywhere == lower_runs_arm0.
1230 let empty = PickedRoute::new();
1231 let route_order = lower_picked_route(&g, &[post], &empty);
1232 let arm0_order = lower_runs_arm0(&g, &[post]);
1233 assert_eq!(
1234 route_order, arm0_order,
1235 "the empty route lowers byte-identically to lower_runs_arm0",
1236 );
1237 assert!(route_order.contains(&arm0), "empty route follows arm 0");
1238 assert!(
1239 !route_order.contains(&arm1),
1240 "empty route skips arm 1's run; order={route_order:?}",
1241 );
1242
1243 // (2) Pick arm 1 for the branch — now arm 1 is followed, arm 0's
1244 // interior is skipped.
1245 let mut picked = PickedRoute::new();
1246 picked.insert(branch, 1);
1247 let order = lower_picked_route(&g, &[post], &picked);
1248 assert!(
1249 order.contains(&arm1),
1250 "picking arm 1 follows arm 1's run; order={order:?}",
1251 );
1252 assert!(
1253 !order.contains(&arm0),
1254 "picking arm 1 skips arm 0's interior run; order={order:?}",
1255 );
1256 // The Branch node is never an executable member regardless of pick.
1257 for &id in &order {
1258 assert!(
1259 !matches!(g.node(id).op, Op::Branch { .. }),
1260 "the Branch node is never an executable member",
1261 );
1262 }
1263
1264 // (3) A branch absent from the route still defaults to arm 0.
1265 let absent = PickedRoute::new();
1266 let absent_order = lower_picked_route(&g, &[post], &absent);
1267 assert_eq!(
1268 absent_order, arm0_order,
1269 "a branch the picker did not resolve defaults to arm 0",
1270 );
1271
1272 // (4) An out-of-range pick degrades to arm 0 (never panic).
1273 let mut bad = PickedRoute::new();
1274 bad.insert(branch, 99);
1275 let bad_order = lower_picked_route(&g, &[post], &bad);
1276 assert_eq!(
1277 bad_order, arm0_order,
1278 "an out-of-range arm index clamps to arm 0 rather than panicking",
1279 );
1280 }
1281
1282 /// **Optimize-time variant bake (collapse) — the graph-side primitive.**
1283 /// `Graph::collapse_variant_branch(branch, winner)` rewires the decision
1284 /// point so the chosen arm is the realized route WITHOUT any runtime pick
1285 /// (kernel-variant choice baked at optimize time):
1286 ///
1287 /// - collapse to arm 1 ⇒ `reconverge` reads arm 1, the branch is a single
1288 /// inert arm `[arm1]`, and `lower_runs_arm0` (the empty-route lowering)
1289 /// now follows arm 1 and drops arm 0's interior — the mirror image of the
1290 /// default, achieved structurally rather than via a `PickedRoute`;
1291 /// - collapse to arm 0 ⇒ the merge is unchanged, the branch reduces to
1292 /// `[arm0]`, and the lowering is byte-identical to today (arm 1 pruned).
1293 #[test]
1294 fn collapse_variant_branch_bakes_the_winner_into_the_route() {
1295 // --- (a) collapse to the variant arm (arm 1) ---
1296 let (mut g, _diverge, arm0, arm1, reconverge, branch, post) = diamond_with_branch();
1297 // Before: default lowering realizes arm 0 (the finding — a same-device
1298 // variant arm is never selected).
1299 let before = lower_runs_arm0(&g, &[post]);
1300 assert!(
1301 before.contains(&arm0) && !before.contains(&arm1),
1302 "before the bake the default route is arm 0"
1303 );
1304
1305 g.collapse_variant_branch(branch, 1)
1306 .expect("collapse to arm 1");
1307
1308 // The merge now reads the winner (arm 1); the branch is a single arm.
1309 assert!(
1310 g.node(reconverge).inputs.contains(&arm1),
1311 "reconverge rewired to read the baked winner (arm 1)"
1312 );
1313 assert!(
1314 !g.node(reconverge).inputs.contains(&arm0),
1315 "reconverge no longer reads the pruned arm 0"
1316 );
1317 assert_eq!(
1318 g.node(branch).inputs,
1319 vec![arm1],
1320 "the branch collapsed to the single winning arm"
1321 );
1322
1323 // The empty-route lowering now follows the baked winner (arm 1) and
1324 // drops arm 0's interior — with NO PickedRoute.
1325 let after = lower_runs_arm0(&g, &[post]);
1326 assert!(
1327 after.contains(&arm1),
1328 "after the bake the default route follows the winner (arm 1); order={after:?}"
1329 );
1330 assert!(
1331 !after.contains(&arm0),
1332 "after the bake arm 0's interior is pruned; order={after:?}"
1333 );
1334
1335 // --- (b) collapse to arm 0 is byte-identical to today (arm 1 pruned) ---
1336 let (mut g2, _d2, a0_2, a1_2, recon2, branch2, post2) = diamond_with_branch();
1337 let recon2_inputs_before = g2.node(recon2).inputs.clone();
1338 g2.collapse_variant_branch(branch2, 0)
1339 .expect("collapse to arm 0");
1340 assert_eq!(
1341 g2.node(recon2).inputs,
1342 recon2_inputs_before,
1343 "arm-0 collapse leaves the merge unchanged"
1344 );
1345 assert_eq!(
1346 g2.node(branch2).inputs,
1347 vec![a0_2],
1348 "arm-0 collapse reduces the branch to [arm0] (variant arm pruned)"
1349 );
1350 let order2 = lower_runs_arm0(&g2, &[post2]);
1351 assert!(
1352 order2.contains(&a0_2) && !order2.contains(&a1_2),
1353 "arm-0 collapse realizes arm 0 (byte-identical to today)"
1354 );
1355
1356 // --- (c) never-panic guards: bad node / out-of-range winner / non-branch ---
1357 let (mut g3, _d3, _a0_3, _a1_3, _r3, branch3, post3) = diamond_with_branch();
1358 assert!(
1359 g3.collapse_variant_branch(NodeId(9999), 0).is_err(),
1360 "out-of-bounds branch node ⇒ Err, not panic"
1361 );
1362 assert!(
1363 g3.collapse_variant_branch(branch3, 7).is_err(),
1364 "out-of-range winner_arm ⇒ Err, not panic"
1365 );
1366 assert!(
1367 g3.collapse_variant_branch(post3, 0).is_err(),
1368 "non-Op::Branch node ⇒ Err, not panic"
1369 );
1370 }
1371
1372 /// Build two coupled diamonds back-to-back (diamond-2 diverges off
1373 /// diamond-1's reconverge) so the streaming-walk equality test exercises
1374 /// upstream-first lazy resolution over more than one branch. Returns the
1375 /// graph + `(branch1, branch2, post, arm1_of_b1, arm1_of_b2)`.
1376 fn two_coupled_diamonds() -> (Graph, NodeId, NodeId, NodeId, NodeId, NodeId) {
1377 let mut g = Graph::new();
1378 let pre = f32_node(&mut g, Op::Const, vec![]);
1379 // diamond 1
1380 let div1 = f32_node(&mut g, Op::Relu, vec![pre]);
1381 let a0_1 = f32_node(&mut g, Op::Silu, vec![div1]);
1382 let a1_1 = f32_node(&mut g, Op::GeluTanh, vec![div1]);
1383 let recon1 = f32_node(&mut g, Op::Relu, vec![a0_1]);
1384 let mut b1 = g.open_branch(div1);
1385 b1.add_arm(a0_1);
1386 b1.add_arm(a1_1);
1387 let branch1 = b1
1388 .finalize_branches(&mut g, recon1)
1389 .expect("branch1 valid")
1390 .expect("2 arms");
1391 // diamond 2 (downstream of diamond 1's merge)
1392 let div2 = f32_node(&mut g, Op::Tanh, vec![recon1]);
1393 let a0_2 = f32_node(&mut g, Op::Silu, vec![div2]);
1394 let a1_2 = f32_node(&mut g, Op::GeluTanh, vec![div2]);
1395 let recon2 = f32_node(&mut g, Op::Relu, vec![a0_2]);
1396 let mut b2 = g.open_branch(div2);
1397 b2.add_arm(a0_2);
1398 b2.add_arm(a1_2);
1399 let branch2 = b2
1400 .finalize_branches(&mut g, recon2)
1401 .expect("branch2 valid")
1402 .expect("2 arms");
1403 let post = f32_node(&mut g, Op::Tanh, vec![recon2]);
1404 (g, branch1, branch2, post, a1_1, a1_2)
1405 }
1406
1407 /// **C1 byte-identity gate (fuel-graph layer).** The streaming lowering
1408 /// — which resolves each branch lazily, at the run-walk frontier, via a
1409 /// closure — emits the SAME `NodeId` order as the one-shot
1410 /// [`lower_picked_route`] over the route the same closure would produce,
1411 /// for every combination of arm picks across one and two branches. This
1412 /// is the structural proof that C1 changes *when* a branch resolves, not
1413 /// *which* arm it picks (or the order it emits).
1414 #[test]
1415 fn streaming_lowering_equals_one_shot_on_every_pick() {
1416 // --- single diamond: all four pick combinations ---
1417 let (g, _diverge, _arm0, _arm1, _reconverge, branch, post) = diamond_with_branch();
1418 for chosen in [0usize, 1, 99] {
1419 // One-shot: build the route then lower.
1420 let mut route = PickedRoute::new();
1421 if chosen != 0 {
1422 route.insert(branch, chosen);
1423 }
1424 let one_shot = lower_picked_route(&g, &[post], &route);
1425 // Streaming: resolve the branch lazily at its arm-entry.
1426 let streamed = lower_picked_route_streaming(&g, &[post], |b| {
1427 assert_eq!(b, branch, "only the diamond's branch is resolved");
1428 chosen
1429 });
1430 assert_eq!(
1431 streamed, one_shot,
1432 "streamed order must equal one-shot for branch pick {chosen}",
1433 );
1434 }
1435
1436 // --- two coupled diamonds: every (arm1, arm2) combination ---
1437 let (g2, branch1, branch2, post2, _a1_1, _a1_2) = two_coupled_diamonds();
1438 for c1 in [0usize, 1] {
1439 for c2 in [0usize, 1] {
1440 let mut route = PickedRoute::new();
1441 if c1 != 0 {
1442 route.insert(branch1, c1);
1443 }
1444 if c2 != 0 {
1445 route.insert(branch2, c2);
1446 }
1447 let one_shot = lower_picked_route(&g2, &[post2], &route);
1448
1449 // The streaming closure resolves each branch at most once
1450 // and upstream-first (branch1 before branch2). Assert that
1451 // discipline AND the resulting byte-identity.
1452 let mut seen: Vec<NodeId> = Vec::new();
1453 let streamed = lower_picked_route_streaming(&g2, &[post2], |b| {
1454 assert!(
1455 !seen.contains(&b),
1456 "each branch is resolved at most once; b={b:?} seen={seen:?}",
1457 );
1458 seen.push(b);
1459 if b == branch1 {
1460 c1
1461 } else if b == branch2 {
1462 c2
1463 } else {
1464 panic!("unexpected branch {b:?}")
1465 }
1466 });
1467 assert_eq!(
1468 seen,
1469 vec![branch1, branch2],
1470 "coupled branches resolve upstream-first at the frontier",
1471 );
1472 assert_eq!(
1473 streamed, one_shot,
1474 "streamed == one-shot for picks ({c1},{c2})",
1475 );
1476 }
1477 }
1478 }
1479
1480 /// (c) A `target_backend` change mid-chain (set via the existing
1481 /// accessor) starts a new run — a residency seam.
1482 #[test]
1483 fn residency_seam_starts_new_run() {
1484 let mut g = Graph::new();
1485 let a = f32_node(&mut g, Op::Const, vec![]);
1486 let b = f32_node(&mut g, Op::Relu, vec![a]);
1487 let c = f32_node(&mut g, Op::Silu, vec![b]);
1488 let d = f32_node(&mut g, Op::Tanh, vec![c]);
1489 // a,b live on CPU; c,d live on CUDA -> a seam between b and c.
1490 g.set_target_backend(a, BackendId::Cpu);
1491 g.set_target_backend(b, BackendId::Cpu);
1492 g.set_target_backend(c, BackendId::Cuda);
1493 g.set_target_backend(d, BackendId::Cuda);
1494
1495 let runs = extract_runs(&g, d);
1496 assert_eq!(
1497 runs.len(),
1498 2,
1499 "a residency change mid-chain cuts the chain into two runs; got {runs:?}",
1500 );
1501 assert_eq!(runs[0].members, vec![a, b]);
1502 assert_eq!(runs[0].device, Some(BackendId::Cpu));
1503 assert_eq!(runs[1].members, vec![c, d]);
1504 assert_eq!(runs[1].device, Some(BackendId::Cuda));
1505 }
1506
1507 /// (d) A multi-predecessor fan-in starts a fresh run: the joining
1508 /// node is the entry of a new run, distinct from either feeder run.
1509 #[test]
1510 fn fan_in_starts_new_run() {
1511 let mut g = Graph::new();
1512 // Two independent feeders that join at `sum`.
1513 let a = f32_node(&mut g, Op::Const, vec![]);
1514 let a1 = f32_node(&mut g, Op::Relu, vec![a]);
1515 let b = f32_node(&mut g, Op::Const, vec![]);
1516 let b1 = f32_node(&mut g, Op::Silu, vec![b]);
1517 let sum = f32_node(&mut g, Op::Add, vec![a1, b1]);
1518
1519 let runs = extract_runs(&g, sum);
1520 // The fan-in node `sum` must be the entry of its own run.
1521 let sum_run = runs
1522 .iter()
1523 .find(|r| r.members.contains(&sum))
1524 .expect("sum is in some run");
1525 assert_eq!(
1526 sum_run.entry, sum,
1527 "a fan-in node starts a fresh run (it is that run's entry)",
1528 );
1529 // `sum` shares a run with neither feeder chain.
1530 assert!(
1531 !sum_run.members.contains(&a1) && !sum_run.members.contains(&b1),
1532 "the fan-in run does not absorb either feeder; got {sum_run:?}",
1533 );
1534 }
1535
1536 /// (e) Fewness gate — PASS side: a graph that branches only at a
1537 /// "layer" boundary sits below the ~5% threshold.
1538 #[test]
1539 fn fewness_gate_passes_on_sparse_branching() {
1540 // A long straight-line "model" with a single deliberate branch.
1541 let mut g = Graph::new();
1542 let mut prev = f32_node(&mut g, Op::Const, vec![]);
1543 // ~40 ops of straight-line body.
1544 let mut body: Vec<NodeId> = vec![prev];
1545 for _ in 0..40 {
1546 prev = f32_node(&mut g, Op::Relu, vec![prev]);
1547 body.push(prev);
1548 }
1549 // One deliberate fork off `prev` (the layer boundary).
1550 let diverge = prev;
1551 let arm0 = f32_node(&mut g, Op::Silu, vec![diverge]);
1552 let arm1 = f32_node(&mut g, Op::GeluTanh, vec![diverge]);
1553 let reconverge = f32_node(&mut g, Op::Relu, vec![arm0]);
1554 let mut b = g.open_branch(diverge);
1555 b.add_arm(arm0);
1556 b.add_arm(arm1);
1557 b.finalize_branches(&mut g, reconverge)
1558 .expect("valid branch")
1559 .expect("2 arms survive");
1560 let root = f32_node(&mut g, Op::Tanh, vec![reconverge]);
1561
1562 let density = branch_density(&g, root);
1563 assert!(
1564 density < FEWNESS_THRESHOLD,
1565 "sparse layer-boundary branching must pass the fewness gate; density={density}",
1566 );
1567 assert!(passes_fewness_gate(&g, root));
1568 }
1569
1570 /// (e) Fewness gate — FAIL side: a synthetic graph that forks at
1571 /// (nearly) every op blows past the ~5% threshold.
1572 #[test]
1573 fn fewness_gate_fails_on_per_op_branching() {
1574 // Build a chain of N diamonds back-to-back, so a large fraction
1575 // of nodes are Op::Branch decision points.
1576 let mut g = Graph::new();
1577 let mut prev = f32_node(&mut g, Op::Const, vec![]);
1578 for _ in 0..12 {
1579 let diverge = prev;
1580 let arm0 = f32_node(&mut g, Op::Silu, vec![diverge]);
1581 let arm1 = f32_node(&mut g, Op::GeluTanh, vec![diverge]);
1582 let reconverge = f32_node(&mut g, Op::Relu, vec![arm0]);
1583 let mut b = g.open_branch(diverge);
1584 b.add_arm(arm0);
1585 b.add_arm(arm1);
1586 b.finalize_branches(&mut g, reconverge)
1587 .expect("valid branch")
1588 .expect("2 arms survive");
1589 prev = reconverge;
1590 }
1591 let root = prev;
1592
1593 let density = branch_density(&g, root);
1594 assert!(
1595 density >= FEWNESS_THRESHOLD,
1596 "per-op branching must FAIL the fewness gate; density={density}",
1597 );
1598 assert!(!passes_fewness_gate(&g, root));
1599 }
1600
1601 // ===== PR-C3: device-alternating reorder (auto-overlap) =====
1602
1603 /// Build the "arbitrary graph" the auto-overlap benchmark needs WITHOUT
1604 /// the hand-constructed crutch: two INDEPENDENT same-length device
1605 /// chains (one CUDA, one Vulkan) over distinct consts, reconverging at a
1606 /// final CUDA `add`. The reconverge inputs are `[cuda_root, vulkan_root]`
1607 /// — the "wrong" order that the un-reordered topo DFS pops CUDA-last, so
1608 /// it would emit the Vulkan chain + its cross-device copy FIRST (host
1609 /// blocks on Vulkan before CUDA is enqueued → serialized). One explicit
1610 /// `Op::Copy{target:Cuda}` bridges the Vulkan root to the CUDA
1611 /// reconverge (a run boundary). Returns `(graph, root, cuda_chain_nodes,
1612 /// vk_chain_nodes, copy_node)`.
1613 fn two_device_reconverge(
1614 cuda_len: usize,
1615 vk_len: usize,
1616 ) -> (Graph, NodeId, Vec<NodeId>, Vec<NodeId>, NodeId) {
1617 let mut g = Graph::new();
1618 // Distinct consts, one per device (CPU-resident sources H2D'd to
1619 // each device — modeled here as the chain heads carrying the device
1620 // stamp; the const itself is unplaced/default).
1621 let cc = f32_node(&mut g, Op::Const, vec![]);
1622 let vc = f32_node(&mut g, Op::Const, vec![]);
1623
1624 // CUDA chain.
1625 let mut cuda_nodes = Vec::new();
1626 let mut cprev = cc;
1627 for _ in 0..cuda_len {
1628 let n = f32_node(&mut g, Op::Relu, vec![cprev]);
1629 g.set_target_backend(n, BackendId::Cuda);
1630 cuda_nodes.push(n);
1631 cprev = n;
1632 }
1633 // Vulkan chain.
1634 let mut vk_nodes = Vec::new();
1635 let mut vprev = vc;
1636 for _ in 0..vk_len {
1637 let n = f32_node(&mut g, Op::Silu, vec![vprev]);
1638 g.set_target_backend(n, BackendId::Vulkan);
1639 vk_nodes.push(n);
1640 vprev = n;
1641 }
1642 // Cross-device copy: Vulkan root -> CUDA (a residency seam = its own
1643 // run). Reconverge on CUDA reads [cuda_root, copy].
1644 let copy = g.push(Node {
1645 op: Op::Copy {
1646 target: fuel_ir::DeviceLocation::Cuda { gpu_id: 0 },
1647 },
1648 inputs: vec![vprev],
1649 shape: Shape::from_dims(&[2]),
1650 dtype: DType::F32,
1651 });
1652 g.set_target_backend(copy, BackendId::Cuda);
1653 // Reconverge inputs in the "wrong" (cuda-first) order, so the DFS
1654 // pops the LAST input (the Vulkan-fed copy) first.
1655 let out = g.push(Node {
1656 op: Op::Add,
1657 inputs: vec![cprev, copy],
1658 shape: Shape::from_dims(&[2]),
1659 dtype: DType::F32,
1660 });
1661 g.set_target_backend(out, BackendId::Cuda);
1662 (g, out, cuda_nodes, vk_nodes, copy)
1663 }
1664
1665 /// Assert `order` is a valid topological reordering of `runs`: every run
1666 /// appears after all the runs it depends on (a member reads a member of
1667 /// the producer run).
1668 fn assert_valid_topo_run_order(graph: &Graph, runs: &[Run], order: &[usize]) {
1669 let mut run_of: HashMap<NodeId, usize> = HashMap::new();
1670 for (ri, r) in runs.iter().enumerate() {
1671 for &m in &r.members {
1672 run_of.insert(m, ri);
1673 }
1674 }
1675 let mut emitted: HashSet<usize> = HashSet::new();
1676 for &ri in order {
1677 for &m in &runs[ri].members {
1678 for &inp in &graph.node(m).inputs {
1679 if let Some(&pi) = run_of.get(&inp)
1680 && pi != ri
1681 {
1682 assert!(
1683 emitted.contains(&pi),
1684 "run {ri} emitted before its producer run {pi}",
1685 );
1686 }
1687 }
1688 }
1689 emitted.insert(ri);
1690 }
1691 assert_eq!(order.len(), runs.len(), "every run emitted exactly once");
1692 let distinct: HashSet<usize> = order.iter().copied().collect();
1693 assert_eq!(distinct.len(), runs.len(), "the order is a permutation");
1694 }
1695
1696 /// C3 core: a two-device run list is reordered into a valid topological
1697 /// order that INTERLEAVES the devices — the independent CUDA and Vulkan
1698 /// producer chunks are both emitted BEFORE the cross-device copy that
1699 /// feeds the reconverge (the A4b overlap topology), regardless of the
1700 /// reconverge's input order.
1701 #[test]
1702 fn device_alternating_reorder_interleaves_two_devices() {
1703 let (g, out, cuda_nodes, vk_nodes, copy) = two_device_reconverge(4, 4);
1704 let runs = extract_runs(&g, out);
1705 let perm = device_alternating_order(&g, &runs);
1706
1707 // (1) A valid topological reordering.
1708 assert_valid_topo_run_order(&g, &runs, &perm);
1709
1710 // Map each landmark node to its position in the FLATTENED reordered
1711 // order, so we can assert the overlap-relevant ordering.
1712 let flat: Vec<NodeId> = perm
1713 .iter()
1714 .flat_map(|&ri| runs[ri].members.clone())
1715 .collect();
1716 let pos = |n: NodeId| flat.iter().position(|&x| x == n).expect("node present");
1717
1718 let cuda_root = *cuda_nodes.last().unwrap();
1719 let vk_root = *vk_nodes.last().unwrap();
1720
1721 // (2) BOTH device producer chunks are emitted before the
1722 // cross-device copy (the host-blocking D2H). This is the
1723 // property the un-reordered cuda-last DFS violates and that the
1724 // A4b eager-submit mechanism turns into overlap.
1725 assert!(
1726 pos(cuda_root) < pos(copy),
1727 "the CUDA chunk must be enqueued before the cross-device copy; \
1728 flat={flat:?}",
1729 );
1730 assert!(
1731 pos(vk_root) < pos(copy),
1732 "the Vulkan chunk must be recorded before the cross-device copy; \
1733 flat={flat:?}",
1734 );
1735
1736 // (3) The devices INTERLEAVE rather than one draining fully first:
1737 // the run-device sequence has at least one CUDA<->Vulkan
1738 // transition before the copy. (With one chunk per device this is
1739 // just "both chunks precede the copy", already asserted; assert
1740 // the device sequence explicitly too.)
1741 let dev_seq: Vec<Option<BackendId>> = perm.iter().map(|&ri| runs[ri].device).collect();
1742 let switches = dev_seq.windows(2).filter(|w| w[0] != w[1]).count();
1743 assert!(
1744 switches >= 1,
1745 "a two-device run list must contain at least one device switch; \
1746 dev_seq={dev_seq:?}",
1747 );
1748 }
1749
1750 /// C3 gate: a SINGLE-device run list is the IDENTITY permutation — the
1751 /// reorder never diverges from the input order, so the lowering is
1752 /// byte-identical to today's (the primary single-device regression
1753 /// gate).
1754 #[test]
1755 fn device_alternating_reorder_single_device_is_identity() {
1756 // All-CUDA chain (one distinct device) → identity.
1757 let mut g = Graph::new();
1758 let mut prev = f32_node(&mut g, Op::Const, vec![]);
1759 for _ in 0..6 {
1760 prev = f32_node(&mut g, Op::Relu, vec![prev]);
1761 g.set_target_backend(prev, BackendId::Cuda);
1762 }
1763 let runs = extract_runs(&g, prev);
1764 let perm = device_alternating_order(&g, &runs);
1765 assert_eq!(
1766 perm,
1767 (0..runs.len()).collect::<Vec<_>>(),
1768 "a single-device run list reorders to the identity permutation",
1769 );
1770
1771 // A graph with NO explicit backend anywhere (all device == None) is
1772 // also a single bucket → identity (the branchless single-route CPU
1773 // graph — must stay byte-identical).
1774 let mut g2 = Graph::new();
1775 let a = f32_node(&mut g2, Op::Const, vec![]);
1776 let b = f32_node(&mut g2, Op::Relu, vec![a]);
1777 let c = f32_node(&mut g2, Op::Silu, vec![b]);
1778 let runs2 = extract_runs(&g2, c);
1779 assert_eq!(
1780 device_alternating_order(&g2, &runs2),
1781 (0..runs2.len()).collect::<Vec<_>>(),
1782 "an all-default (device=None) run list is identity",
1783 );
1784 }
1785
1786 /// C3 correctness: the reorder NEVER violates a data dependency, even
1787 /// when alternation "wants" to move a run earlier. A linear cross-device
1788 /// chain CPU→CUDA→Vulkan→CUDA (each a residency seam, so 4 runs, each
1789 /// depending strictly on the prior) must come out in dependency order —
1790 /// the device preference can't reorder a strict chain.
1791 #[test]
1792 fn device_alternating_reorder_respects_dependencies() {
1793 let mut g = Graph::new();
1794 let a = f32_node(&mut g, Op::Const, vec![]);
1795 g.set_target_backend(a, BackendId::Cpu);
1796 let b = f32_node(&mut g, Op::Relu, vec![a]);
1797 g.set_target_backend(b, BackendId::Cuda);
1798 let c = f32_node(&mut g, Op::Silu, vec![b]);
1799 g.set_target_backend(c, BackendId::Vulkan);
1800 let d = f32_node(&mut g, Op::Tanh, vec![c]);
1801 g.set_target_backend(d, BackendId::Cuda);
1802
1803 let runs = extract_runs(&g, d);
1804 let perm = device_alternating_order(&g, &runs);
1805 assert_valid_topo_run_order(&g, &runs, &perm);
1806 // A strict chain has exactly one valid topological order; the
1807 // alternation cannot change it.
1808 let flat: Vec<NodeId> = perm
1809 .iter()
1810 .flat_map(|&ri| runs[ri].members.clone())
1811 .collect();
1812 assert_eq!(
1813 flat,
1814 vec![a, b, c, d],
1815 "a strict cross-device chain keeps dependency order despite \
1816 device alternation",
1817 );
1818 }
1819
1820 /// C3 invariance (fuel-graph layer): the device-alternating lowering of
1821 /// a multi-device graph emits the SAME multiset of NodeIds as the
1822 /// un-reordered concatenation, in a VALID topological order — only the
1823 /// order across independent runs changes. (The byte-identical-OUTPUT
1824 /// proof is the live reorder-invariance test in `fuel-dispatch`; this is
1825 /// the structural guard that the reorder is a permutation, not a
1826 /// rewrite.)
1827 #[test]
1828 fn lower_picked_route_multidevice_is_a_valid_reordering() {
1829 let (g, out, _cuda, _vk, _copy) = two_device_reconverge(4, 4);
1830 let runs = extract_runs(&g, out);
1831
1832 // Un-reordered reference: concatenate runs in topo order.
1833 let reference: Vec<NodeId> = runs.iter().flat_map(|r| lower_run(r).to_vec()).collect();
1834 // The production lowering (now reorders multi-device).
1835 let lowered = lower_picked_route(&g, &[out], &PickedRoute::new());
1836
1837 // Same multiset of nodes (a permutation, no add/drop).
1838 let mut a = reference.clone();
1839 let mut b = lowered.clone();
1840 a.sort();
1841 b.sort();
1842 assert_eq!(a, b, "the reorder is a permutation of the same node set");
1843
1844 // The lowered order is a valid topological order of the graph: every
1845 // node appears after all of its (reachable, lowered) inputs.
1846 let lowered_pos: HashMap<NodeId, usize> =
1847 lowered.iter().enumerate().map(|(i, &n)| (n, i)).collect();
1848 for (i, &n) in lowered.iter().enumerate() {
1849 for &inp in &g.node(n).inputs {
1850 if let Some(&j) = lowered_pos.get(&inp) {
1851 assert!(j < i, "input {inp:?} of {n:?} must precede it");
1852 }
1853 }
1854 }
1855
1856 // And it genuinely diverged from the reference (the reorder fired) —
1857 // the cuda-last DFS reference puts the Vulkan-fed copy's chain before
1858 // the CUDA chain; the reorder interleaves them.
1859 assert_ne!(
1860 lowered, reference,
1861 "on this two-device graph the reorder must change the order \
1862 (else auto-overlap did nothing)",
1863 );
1864 }
1865
1866 /// C3 + C1 composition: the STREAMING lowering of a multi-device graph
1867 /// equals the one-shot `lower_picked_route` (both now reorder), and the
1868 /// streaming reorder is confined to branch-free segments. On a
1869 /// branchless multi-device graph there is exactly one segment, so the
1870 /// streamed order equals the one-shot reordered order byte-for-byte.
1871 #[test]
1872 fn streaming_multidevice_equals_one_shot_reordered() {
1873 let (g, out, _cuda, _vk, _copy) = two_device_reconverge(4, 4);
1874 let one_shot = lower_picked_route(&g, &[out], &PickedRoute::new());
1875 let streamed = lower_picked_route_streaming(&g, &[out], |_b| 0);
1876 assert_eq!(
1877 streamed, one_shot,
1878 "streamed multi-device order equals the one-shot reordered order",
1879 );
1880 }
1881
1882 /// C3 critical-path property (the live-overlap regression guard): when
1883 /// the two device chunks differ in size, the reorder emits the HEAVIER
1884 /// chunk's whole path FIRST — so the auto-submitting device (the one with
1885 /// the long chain) is enqueued and running before the lighter device's
1886 /// host-blocking drain. This mirrors the live benchmark (CUDA-768 vs
1887 /// Vulkan-120): the proven-overlap order is CUDA-chunk-first; the inverse
1888 /// (Vulkan-first) serialized at 0.0 efficiency. This test locks the
1889 /// heavy-first ordering that makes the arbitrary-graph benchmark
1890 /// auto-overlap.
1891 #[test]
1892 fn device_alternating_reorder_emits_heavier_chunk_first() {
1893 // CUDA chain HEAVY (8), Vulkan chain LIGHT (2). Reconverge inputs are
1894 // cuda-first (the "wrong" DFS order). The reorder must still emit the
1895 // heavy CUDA chunk's path before the light Vulkan chunk.
1896 let (g, out, cuda_nodes, vk_nodes, copy) = two_device_reconverge(8, 2);
1897 let runs = extract_runs(&g, out);
1898 let perm = device_alternating_order(&g, &runs);
1899 assert_valid_topo_run_order(&g, &runs, &perm);
1900
1901 let flat: Vec<NodeId> = perm
1902 .iter()
1903 .flat_map(|&ri| runs[ri].members.clone())
1904 .collect();
1905 let pos = |n: NodeId| flat.iter().position(|&x| x == n).expect("present");
1906 let cuda_root = *cuda_nodes.last().unwrap();
1907 let vk_root = *vk_nodes.last().unwrap();
1908
1909 // The HEAVY (CUDA) chunk's root is emitted BEFORE the LIGHT (Vulkan)
1910 // chunk's root — the heavy device is enqueued first.
1911 assert!(
1912 pos(cuda_root) < pos(vk_root),
1913 "the heavier CUDA chunk must be emitted before the lighter Vulkan \
1914 chunk (the auto-submit device runs first); flat={flat:?}",
1915 );
1916 // And BOTH chunks precede the host-blocking cross-device copy.
1917 assert!(pos(cuda_root) < pos(copy), "heavy chunk before the drain");
1918 assert!(pos(vk_root) < pos(copy), "light chunk before the drain");
1919
1920 // Symmetric check: make VULKAN the heavy chunk; now Vulkan's path
1921 // must come first. (Proves it's size-driven, not a CUDA hard-code.)
1922 let (g2, out2, cuda2, vk2, copy2) = two_device_reconverge(2, 8);
1923 let runs2 = extract_runs(&g2, out2);
1924 let perm2 = device_alternating_order(&g2, &runs2);
1925 assert_valid_topo_run_order(&g2, &runs2, &perm2);
1926 let flat2: Vec<NodeId> = perm2
1927 .iter()
1928 .flat_map(|&ri| runs2[ri].members.clone())
1929 .collect();
1930 let pos2 = |n: NodeId| flat2.iter().position(|&x| x == n).expect("present");
1931 assert!(
1932 pos2(*vk2.last().unwrap()) < pos2(*cuda2.last().unwrap()),
1933 "when Vulkan is the heavier chunk it is emitted first; flat2={flat2:?}",
1934 );
1935 assert!(pos2(*vk2.last().unwrap()) < pos2(copy2));
1936 assert!(pos2(*cuda2.last().unwrap()) < pos2(copy2));
1937 }
1938}