fuel_dispatch/driver.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! The `optimize_graph` lock-step pass driver — Phase B PR-B3 of the
3//! "plan IS the graph" rebuild
4//! ([`../../docs/session-prompts/plan-is-graph-rebuild.md`](
5//! ../../docs/session-prompts/plan-is-graph-rebuild.md) capability 5;
6//! [`../../docs/architecture/04-optimization.md`](
7//! ../../docs/architecture/04-optimization.md) §"The two-stage
8//! transformation", §"Relationship to PR 3").
9//!
10//! ## What this is
11//!
12//! `optimize_graph` was a hardcoded sequence — `execution_plan` order →
13//! [`compile_plan`](crate::plan::compile_plan) (per-node placement +
14//! cost ranking + the PR-B2 per-ending-device Pareto frontier) →
15//! `seed_placement_fork_branches` (the PR-A4 deliberate-fork
16//! pathfinder). PR-B3 restructures that sequence into a **lock-step
17//! driver** over three registered pass *kinds*, mirroring the shape of
18//! `fuel_graph::opt::RuleRegistry` (a `Vec<Box<dyn …>>` of passes +
19//! builder + a driver method that walks them) so future passes (fusion,
20//! algebraic, dtype-lowering) plug in without rewriting the loop:
21//!
22//! - **[`Pathfinder`]s** — *ADD* candidate paths / branches to the
23//! graph. The PR-A4 [`PlacementForkPathfinder`] (deliberate-fork seed,
24//! the one pathfinder that records an `Op::Branch` per genuine
25//! multi-placement fork) is the first registered pathfinder.
26//! - **Rankers** — *MEASURE* each path on the `CostVector` (PR-B1/B2
27//! `rank_by_cost`). Rankers are not a driver-visible trait in B3:
28//! ranking is applied **per kernel-bearing node inside
29//! [`crate::plan::compile_plan`]** as the per-node `AlternativeSet` is built, and
30//! the ranked result is carried into the driver via
31//! [`OptimizationContext::plan`]. The driver re-runs no ranker; the
32//! measure step is the plan it is handed. (B3 is a structural refactor
33//! — when a later PR adds a pathfinder that emits *new* candidates the
34//! plan never priced, that is the point to lift ranking into a
35//! driver-visible `Ranker` trait.)
36//! - **[`Optimizer`]s** — *MERGE / DISCARD* paths. The PR-B2
37//! per-ending-device Pareto frontier + crowding cap is applied
38//! per-node inside [`crate::plan::compile_plan`]; the registered
39//! [`FrontierConvergenceOptimizer`] is the in-graph counterpart that
40//! runs **after each pathfinder** to (a) collapse duplicate
41//! (forward-identical) arms a pathfinder may have proposed
42//! [duplicate-path convergence], and (b) assert the optimizer
43//! invariants the constitution requires — never strand the last
44//! `(device, backend)` path, never mutate a node in an active cycle /
45//! currently-executing region.
46//!
47//! ## Lock-step / prune-as-you-go (the constitution's bound)
48//!
49//! The driver runs the passes interleaved, **not** explode-then-extract:
50//! for each registered pathfinder, it ADDs its candidate paths, then
51//! immediately runs every registered optimizer to prune the region the
52//! pathfinder just touched, before moving to the next pathfinder. This
53//! is the [`PassRegistry::run_lockstep`] contract — the working set
54//! never explodes-then-extracts, so it stays bounded by construction
55//! (per [`../../docs/architecture/04-optimization.md`](
56//! ../../docs/architecture/04-optimization.md) §"Bounding the
57//! frontier": *"run the merge/discard optimizers after each pathfinder
58//! step, not once at the end"*).
59//!
60//! ## Behavior preservation (B3 is a structural refactor)
61//!
62//! The optimized result — the arm-0 winner, the retained per-device
63//! frontier (applied inside `compile_plan`, unchanged), and the emitted
64//! `Op::Branch` nodes — is **identical** to the pre-B3 hardcoded
65//! sequence on every graph. The driver is the same three operations in
66//! the same order; the only change is that they are reached through the
67//! registry rather than inlined. The load-bearing
68//! `driver_matches_legacy_sequence` test in
69//! [`crate::optimize`] proves it on a representative multi-placement
70//! graph.
71
72use std::collections::{HashMap, HashSet};
73
74use fuel_graph::{Graph, Node, NodeId, Op};
75use fuel_ir::Result;
76use fuel_ir::probe::BackendId;
77
78use crate::kernel::KernelBindingTable;
79use crate::plan::ExecutionPlan;
80
81/// Read-only context threaded to every [`Pathfinder::propose`] and
82/// [`Optimizer::prune`] call during a [`PassRegistry::run_lockstep`]
83/// drive.
84///
85/// It carries the **measure** stage's output — the ranked, per-device
86/// frontier-pruned per-node `AlternativeSet`s [`compile_plan`] produced
87/// ([`Self::plan`]) plus the dispatch [`Self::order`] — and the
88/// **incremental-reopt guard** ([`Self::cycle_guard`]): the set of nodes
89/// that lie on an active cycle or in a currently-executing region and so
90/// must never be mutated by an optimizer. Batch `optimize_graph` has no
91/// executing region, so the guard is empty there; it exists to document
92/// and enforce the incremental-reopt contract for the Phase-C runtime.
93///
94/// [`compile_plan`]: crate::plan::compile_plan
95pub struct OptimizationContext<'a> {
96 /// The dispatch order [`compile_plan`](crate::plan::compile_plan)
97 /// was driven over — data-flow topo refined by destructive-op
98 /// ordering edges.
99 pub order: &'a [NodeId],
100 /// The per-node ranked + frontier-pruned `AlternativeSet`s (the
101 /// *measure* + per-node *prune* output). Pathfinders read the
102 /// winner + runner-up placements off this; optimizers read it to
103 /// honor the never-strand-last invariant.
104 pub plan: &'a ExecutionPlan,
105 /// Nodes on an active cycle / currently-executing region — an
106 /// optimizer must not mutate any node in this set. Empty for batch
107 /// `optimize_graph`.
108 pub cycle_guard: &'a HashSet<NodeId>,
109 /// The kernel binding table `optimize_graph` was handed by its caller —
110 /// the SAME table `compile_plan` resolved placements against. Pathfinders
111 /// that need a capability-gate answer (e.g. "is a kernel bound for this
112 /// runtime-fused op on this backend?") read THIS reference rather than
113 /// re-acquiring `crate::dispatch::global_bindings()`, so a background
114 /// adopt's write can never nest against a pathfinder's read on the same
115 /// lock (Spec B Task 1 — see
116 /// `crate::runtime_fused_kernels::fused_kernel_available_in`).
117 pub bindings: &'a KernelBindingTable,
118}
119
120impl<'a> OptimizationContext<'a> {
121 /// The threaded binding table (see [`Self::bindings`]).
122 pub fn bindings(&self) -> &KernelBindingTable {
123 self.bindings
124 }
125}
126
127/// A **pathfinder** *ADDs* candidate paths / branches to the graph. The
128/// PR-A4 deliberate-fork seed ([`PlacementForkPathfinder`]) is the first
129/// registered pathfinder; later pathfinders (fusion, algebraic,
130/// dtype-lowering under tolerance, layout fixups) implement the same
131/// trait and register the same way.
132///
133/// `propose` may append nodes and record `Op::Branch` decision points
134/// (via the `fuel-graph` A1 builders). It must never *remove* a path —
135/// that is an [`Optimizer`]'s job — and it returns `Result` so a
136/// build-time failure surfaces (never panic, per the working agreement).
137pub trait Pathfinder: Send + Sync {
138 /// Stable, human-readable name. Shows up in debug traces.
139 fn name(&self) -> &'static str;
140
141 /// Add candidate paths to `graph`. `ctx` carries the ranked plan +
142 /// dispatch order the *measure* stage produced.
143 fn propose(&self, graph: &mut Graph, ctx: &OptimizationContext<'_>) -> Result<()>;
144}
145
146/// An **optimizer** *MERGEs / DISCARDs* paths. The PR-B2 per-device
147/// Pareto frontier is applied per-node inside
148/// [`compile_plan`](crate::plan::compile_plan); the registered
149/// [`FrontierConvergenceOptimizer`] is its in-graph counterpart that
150/// runs after each pathfinder to collapse duplicate (forward-identical)
151/// arms and enforce the optimizer invariants.
152///
153/// `prune` runs **after** each [`Pathfinder::propose`] (lock-step), so
154/// the working set never explodes-then-extracts. It must honor the
155/// optimizer invariants: never strand the last `(device, backend)` path,
156/// and never mutate a node in `ctx.cycle_guard`.
157pub trait Optimizer: Send + Sync {
158 /// Stable, human-readable name. Shows up in debug traces.
159 fn name(&self) -> &'static str;
160
161 /// Merge/discard candidate paths in `graph`. Runs after each
162 /// pathfinder over the region that pathfinder just touched.
163 fn prune(&self, graph: &mut Graph, ctx: &OptimizationContext<'_>) -> Result<()>;
164}
165
166/// A registry of [`Pathfinder`]s and [`Optimizer`]s, driven lock-step by
167/// [`Self::run_lockstep`]. Mirrors `fuel_graph::opt::RuleRegistry`: a
168/// `Vec<Box<dyn …>>` of passes built up with `with_*` builders and run
169/// through one driver method. Keeping the same shape means the future
170/// passes the constitution names (fusion, algebraic, dtype-lowering)
171/// register exactly as the two shipped passes do.
172#[derive(Default)]
173pub struct PassRegistry {
174 pathfinders: Vec<Box<dyn Pathfinder>>,
175 optimizers: Vec<Box<dyn Optimizer>>,
176}
177
178impl PassRegistry {
179 /// Empty registry. Use [`Self::with_pathfinder`] /
180 /// [`Self::with_optimizer`] to add passes, or [`Self::default_passes`]
181 /// for the shipped configuration.
182 pub fn new() -> Self {
183 Self {
184 pathfinders: Vec::new(),
185 optimizers: Vec::new(),
186 }
187 }
188
189 /// Append a pathfinder. Returns self for builder-style chaining.
190 pub fn with_pathfinder(mut self, p: Box<dyn Pathfinder>) -> Self {
191 self.pathfinders.push(p);
192 self
193 }
194
195 /// Append an optimizer. Returns self for builder-style chaining.
196 pub fn with_optimizer(mut self, o: Box<dyn Optimizer>) -> Self {
197 self.optimizers.push(o);
198 self
199 }
200
201 /// The shipped Phase-B configuration: the PR-A4
202 /// [`PlacementForkPathfinder`] (the one pathfinder that records an
203 /// `Op::Branch` per genuine multi-placement fork) and the
204 /// [`FrontierConvergenceOptimizer`] (duplicate-path convergence +
205 /// the per-device-frontier invariant guards). This is exactly the
206 /// pre-B3 hardcoded sequence re-expressed as registered passes.
207 pub fn default_passes() -> Self {
208 Self::new()
209 .with_pathfinder(Box::new(PlacementForkPathfinder))
210 .with_optimizer(Box::new(FrontierConvergenceOptimizer))
211 }
212
213 /// [`Self::default_passes`] **plus runtime fusion**: the
214 /// [`crate::runtime_fused_pathfinder::RuntimeFusedArmPathfinder`] runs
215 /// FIRST (before placement — placement must see the fused arms it emits;
216 /// the fork seed can't double-fork them because `ctx.plan`/`ctx.order`
217 /// predate the drive and don't contain the appended arm nodes).
218 ///
219 /// A separate constructor rather than a change to `default_passes` so the
220 /// bare registry never scans the process-global runtime-fused sidecar —
221 /// non-adopting tests stay hermetic *by construction*, not by reset
222 /// discipline (dd-shapes coordination, 2026-07-08). Transitional, like the
223 /// sidecar itself: both collapse when runtime entries fold into the one
224 /// binding registry (10-decisions-log, runtime-fused-sidecar entry).
225 pub fn default_passes_with_runtime_fusion() -> Self {
226 Self::new()
227 .with_pathfinder(Box::new(
228 crate::runtime_fused_pathfinder::RuntimeFusedArmPathfinder,
229 ))
230 .with_pathfinder(Box::new(PlacementForkPathfinder))
231 .with_optimizer(Box::new(FrontierConvergenceOptimizer))
232 }
233
234 /// Number of registered pathfinders.
235 pub fn pathfinder_count(&self) -> usize {
236 self.pathfinders.len()
237 }
238
239 /// Number of registered optimizers.
240 pub fn optimizer_count(&self) -> usize {
241 self.optimizers.len()
242 }
243
244 /// Drive the passes **lock-step / prune-as-you-go**: for each
245 /// registered pathfinder, ADD its candidate paths, then immediately
246 /// run every registered optimizer to MERGE/DISCARD over the region
247 /// that pathfinder just touched, before moving to the next
248 /// pathfinder.
249 ///
250 /// This is the constitution's bound: the merge/discard optimizers
251 /// run **after each pathfinder step, not once at the end**, so the
252 /// working set never explodes-then-extracts. Ordering is enforced by
253 /// construction — an optimizer can only ever see the paths the
254 /// pathfinders *before it in this drive* added.
255 ///
256 /// Returns `Result`; the first failing pass aborts the drive
257 /// (build-time validation, never a panic).
258 pub fn run_lockstep(&self, graph: &mut Graph, ctx: &OptimizationContext<'_>) -> Result<()> {
259 for pf in &self.pathfinders {
260 // (1) ADD.
261 pf.propose(graph, ctx)?;
262 // (2) PRUNE — every optimizer, over the region just touched.
263 // Lock-step: never deferred to a final batch pass.
264 for opt in &self.optimizers {
265 opt.prune(graph, ctx)?;
266 }
267 }
268 Ok(())
269 }
270}
271
272/// The PR-A4 deliberate-fork pathfinder, re-expressed as a registered
273/// [`Pathfinder`]. Scans the ranked plan for nodes the placement DP /
274/// ranker admitted with **≥2 distinct `(backend, device)` placements** —
275/// a genuine placement choice — and records each as ONE `Op::Branch` via
276/// the `fuel-graph` A1 builders (arm-0 = the DP winner, arm-1 = the
277/// runner-up clone, orphaned so the live data flow is untouched and the
278/// realize result is identical).
279///
280/// This is **the same logic** the pre-B3 `seed_placement_fork_branches`
281/// free function ran — moved verbatim behind the trait. The
282/// deliberate-fork gate (real ≥2-placement choice, a producer to
283/// diverge from, exactly one consumer to reconverge at) is unchanged, so
284/// the fewness gate still holds and a CPU-only build emits zero branches.
285///
286/// **No `DEFAULT_MAX_N` / fixed top-N anywhere** — the bound is the
287/// per-device frontier the plan already carries; this pass reads the
288/// winner + the first distinct-placement runner-up off that set.
289pub struct PlacementForkPathfinder;
290
291impl Pathfinder for PlacementForkPathfinder {
292 fn name(&self) -> &'static str {
293 "PlacementForkSeed"
294 }
295
296 fn propose(&self, graph: &mut Graph, ctx: &OptimizationContext<'_>) -> Result<()> {
297 // Consumer count over the realize-reachable set: a fork must
298 // have exactly one consumer (its reconverge); ≥2 is plain
299 // fan-out, not a decision point.
300 let mut consumer_count: HashMap<NodeId, usize> = HashMap::new();
301 let mut sole_consumer: HashMap<NodeId, NodeId> = HashMap::new();
302 for &id in ctx.order {
303 for &input in &graph.node(id).inputs {
304 *consumer_count.entry(input).or_insert(0) += 1;
305 sole_consumer.insert(input, id);
306 }
307 }
308
309 // Collect the fork specs first (immutable borrow of the plan),
310 // then mutate the graph — `open_branch`/`finalize_branches` need
311 // `&mut`.
312 struct ForkSpec {
313 fork: NodeId,
314 diverge: NodeId,
315 reconverge: NodeId,
316 runner_up_backend: BackendId,
317 }
318 let mut specs: Vec<ForkSpec> = Vec::new();
319
320 for &id in ctx.order {
321 let Some(set) = ctx.plan.alternatives(id) else {
322 continue;
323 };
324 // (1) A genuine ≥2-placement choice: two or more distinct
325 // (backend, device) pairs admitted by the DP/ranker.
326 let winner = match set.winner() {
327 Some(w) => (w.backend, w.device),
328 None => continue,
329 };
330 let runner_up = set
331 .alternatives()
332 .iter()
333 .map(|c| (c.backend, c.device))
334 .find(|&p| p != winner);
335 let Some((ru_backend, _ru_device)) = runner_up else {
336 continue;
337 };
338
339 // (2) A producer to serve as the shared diverge point.
340 let Some(&diverge) = graph.node(id).inputs.first() else {
341 continue;
342 };
343
344 // (3) Exactly one consumer ⇒ deliberate fork (becomes the
345 // reconverge); ≥2 ⇒ ordinary fan-out, skip.
346 if consumer_count.get(&id).copied().unwrap_or(0) != 1 {
347 continue;
348 }
349 let reconverge = sole_consumer[&id];
350
351 specs.push(ForkSpec {
352 fork: id,
353 diverge,
354 reconverge,
355 runner_up_backend: ru_backend,
356 });
357 }
358
359 for spec in specs {
360 // arm-1: a runner-up-placement clone of the fork's op reading
361 // the same inputs. Orphaned (read only by the Branch) so the
362 // live data flow is untouched and arm-0 = the original winner.
363 let (op, inputs, shape, dtype) = {
364 let n = graph.node(spec.fork);
365 (n.op.clone(), n.inputs.clone(), n.shape.clone(), n.dtype)
366 };
367 let arm1 = graph.push(Node {
368 op,
369 inputs,
370 shape,
371 dtype,
372 });
373 graph.set_target_backend(arm1, spec.runner_up_backend);
374
375 // Record the fork as a 2-arm Branch: arm-0 = the DP winner
376 // (`fork`), arm-1 = the runner-up clone. A1 validates
377 // descendant reconverge, internal disjointness, uniform
378 // dtype, and arm-0 runnability; it never panics — a rejection
379 // surfaces as a typed `Error::InvalidBranch`.
380 //
381 // A rejection is **non-fatal**: the branch is only a
382 // *recording* of an alternative placement, so a candidate
383 // fork whose surrounding graph shape happens to violate an A1
384 // invariant is simply NOT recorded — realize proceeds on the
385 // unchanged single route. The orphaned `arm1` clone left
386 // behind is unreachable from any realize root, so it never
387 // dispatches and never affects the result.
388 let mut builder = graph.open_branch(spec.diverge);
389 builder.add_arm(spec.fork); // arm-0 = winner
390 builder.add_arm(arm1); // arm-1 = runner-up
391 let _ = builder.finalize_branches(graph, spec.reconverge);
392 }
393
394 Ok(())
395 }
396}
397
398/// The PR-B2 per-device-frontier optimizer, re-expressed as a registered
399/// [`Optimizer`]. It runs **after** each pathfinder (lock-step) and is
400/// the in-graph MERGE/DISCARD counterpart of
401/// [`AlternativeSet::retain_per_device_frontier`](
402/// crate::ranker::AlternativeSet::retain_per_device_frontier), which is
403/// applied per kernel-bearing node *inside*
404/// [`compile_plan`](crate::plan::compile_plan).
405///
406/// Its two responsibilities:
407///
408/// 1. **Duplicate-path convergence (the trivial MERGE optimizer).**
409/// Detect any two arms of a just-emitted `Op::Branch` that are
410/// *forward-identical* — same op, same inputs, same target backend.
411/// Two pathfinders (or one pathfinder firing twice) could in
412/// principle propose structurally-identical routes; the constitution
413/// requires *"convergence merges only forward-identical paths"*. The
414/// PR-A4 seed proposes one runner-up per fork, distinct from arm-0 by
415/// construction (different `target_backend`), so today no duplicate
416/// arm ever arises and this detection is a no-op — it is the trivial
417/// second optimizer the spec calls for, in place (with a debug
418/// assertion that it stays a no-op) so the contract is documented and
419/// enforced before a future multi-runner-up pathfinder can leak a
420/// duplicate arm into a branch.
421///
422/// 2. **Invariant guard (the never-strand / no-active-cycle contract).**
423/// Assert that every `Op::Branch` keeps ≥1 viable `(device, backend)`
424/// route — never strand the last path (upheld by the per-node frontier
425/// inside `compile_plan`, re-asserted here over the in-graph form) —
426/// and that no branch the optimizer would touch is in `ctx.cycle_guard`
427/// (the incremental-reopt contract — batch optimize has an empty
428/// guard, but the guard documents and enforces the rule for the
429/// Phase-C runtime).
430pub struct FrontierConvergenceOptimizer;
431
432impl FrontierConvergenceOptimizer {
433 /// Forward-identical key of an arm: `(op, inputs, target_backend)`.
434 /// Two arms with equal keys are the same route and would be merged
435 /// by duplicate-path convergence.
436 fn arm_key(graph: &Graph, id: NodeId) -> (String, Vec<NodeId>, Option<BackendId>) {
437 let n = graph.node(id);
438 (
439 format!("{:?}", n.op),
440 n.inputs.clone(),
441 graph.target_backend(id),
442 )
443 }
444
445 /// Count of *distinct* (forward-identical-deduplicated) arms among
446 /// `arms`. arm-0 always counts; a later arm equal to an earlier one
447 /// does not. Equal to `arms.len()` exactly when every arm is a
448 /// distinct route.
449 fn distinct_arm_count(graph: &Graph, arms: &[NodeId]) -> usize {
450 let mut seen: Vec<(String, Vec<NodeId>, Option<BackendId>)> = Vec::new();
451 for &arm in arms {
452 let k = Self::arm_key(graph, arm);
453 if !seen.contains(&k) {
454 seen.push(k);
455 }
456 }
457 seen.len()
458 }
459}
460
461impl Optimizer for FrontierConvergenceOptimizer {
462 fn name(&self) -> &'static str {
463 "FrontierConvergence"
464 }
465
466 fn prune(&self, graph: &mut Graph, ctx: &OptimizationContext<'_>) -> Result<()> {
467 // Scan the in-graph branches. The per-device Pareto frontier
468 // proper is applied per kernel-bearing node *inside* compile_plan
469 // (PR-B2); here we run its in-graph counterpart — duplicate-path
470 // convergence + the never-strand / no-active-cycle invariant
471 // guards.
472 let n = graph.len();
473 let mut branches: Vec<(NodeId, Vec<NodeId>)> = Vec::new();
474 for i in 0..n {
475 let id = NodeId(i);
476 if matches!(graph.node(id).op, Op::Branch { .. }) {
477 branches.push((id, graph.node(id).inputs.clone()));
478 }
479 }
480
481 for (branch_id, arms) in branches {
482 // Invariant guard (incremental-reopt contract): never touch a
483 // branch whose decision point or any arm is on an active
484 // cycle / currently-executing region. Batch optimize has an
485 // empty guard, so this never trips here; it enforces the
486 // contract for the Phase-C runtime incremental re-opt.
487 if ctx.cycle_guard.contains(&branch_id)
488 || arms.iter().any(|a| ctx.cycle_guard.contains(a))
489 {
490 continue;
491 }
492
493 // (1) Duplicate-path convergence: count distinct routes.
494 let distinct = Self::distinct_arm_count(graph, &arms);
495
496 // (2) Invariant: never strand the last path — a branch always
497 // keeps ≥1 viable `(device, backend)` route.
498 debug_assert!(
499 distinct >= 1,
500 "FrontierConvergence: branch {branch_id:?} would strand to zero routes",
501 );
502
503 // The shipped PR-A4 pathfinder emits arm-1 on a distinct
504 // backend from arm-0, so every arm is a distinct route and no
505 // merge is needed. Assert that — if a future pathfinder leaks
506 // a forward-identical arm, this fires and signals that an
507 // in-place arm-rewrite (a `fuel-graph` setter) must land with
508 // it. We deliberately do NOT silently rewrite arms here in B3
509 // (it would need a new `fuel-graph` mutation API and is a
510 // behavior change for a case that cannot arise from the
511 // shipped passes) — B3 is a structural refactor only.
512 debug_assert_eq!(
513 distinct,
514 arms.len(),
515 "FrontierConvergence: branch {branch_id:?} has forward-identical arms \
516 ({} distinct of {}); a multi-route pathfinder leaked a duplicate — \
517 land an in-graph arm-merge with it",
518 distinct,
519 arms.len(),
520 );
521 }
522
523 Ok(())
524 }
525}