Skip to main content

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}