Skip to main content

fuel_dispatch/
compiled.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Compiled-node representation. Phase 7.5 B4 (interface only).
3//!
4//! A [`CompiledNode`] is the output of dispatch resolution: a Node
5//! plus its resolved kernel function pointer plus its `OpParams`.
6//! The executor consumes these and runs them.
7//!
8//! ## Design intent: pipelined compile + execute (deferred)
9//!
10//! The original B-plan called for pipelined compilation: a compiler
11//! thread that walks the graph in topological order, resolves
12//! kernels per node, and pushes [`CompiledNode`] entries onto a
13//! `crossbeam::channel`. An executor thread consumes the channel,
14//! looks up inputs from the graph's storage map (blocking when
15//! inputs aren't yet realized), calls the kernel, and stores the
16//! output back. Time-to-first-token wins because the compiler runs
17//! ahead of the executor.
18//!
19//! Today's "compile" step is just a `KernelBindingTable` lookup —
20//! roughly nanoseconds per node. There's no meaningful work to
21//! overlap with execution. Pipelining adds `std::thread` +
22//! `crossbeam::channel` + lifecycle handling for zero current win.
23//!
24//! When compile grows beyond trivial — residency-aware planning,
25//! transfer-cost minimization, kernel auto-tuning, dynamic-shape
26//! specialization — pipelining lands as a follow-up. The
27//! interfaces in this module ([`CompiledNode`] +
28//! [`compile_node`] + [`execute_compiled`]) are designed so the
29//! threaded variant slots in without changing call sites.
30
31use std::sync::{Arc, RwLock};
32
33#[cfg_attr(not(feature = "cuda"), allow(unused_imports))]
34use fuel_ir::Error;
35use fuel_ir::dispatch::OpKind;
36use fuel_ir::probe::BackendId;
37use fuel_ir::{DType, Layout, Result};
38
39use crate::kernel::{KernelBindingTable, KernelCaps, KernelDTypes, KernelRef, OpParams};
40use fuel_graph::{Graph, NodeId};
41use fuel_memory::Storage;
42
43/// A graph node plus its resolved kernel function pointer and
44/// op-specific parameters. Produced by [`compile_node`]; consumed
45/// by [`execute_compiled`].
46#[derive(Debug, Clone)]
47pub struct CompiledNode {
48    /// The op family this node implements.
49    pub op: OpKind,
50    /// Per-operand dtypes used as the binding-table lookup key.
51    /// Inputs in order, then outputs. For uniform-precision ops the
52    /// list is `[T, T, ..., T]`; for mixed-precision ops it carries
53    /// the distinguishing combo. The output dtype is always the last
54    /// entry.
55    pub dtypes: KernelDTypes,
56    /// Which backend's kernel was selected.
57    pub backend: BackendId,
58    /// Resolved kernel function pointer. Looked up once at compile
59    /// time; the executor calls this directly.
60    pub kernel: KernelRef,
61    /// Capability flags registered alongside the kernel. The executor
62    /// consults `caps.strided_input` to decide whether to skip
63    /// auto-Contiguize for non-contiguous inputs.
64    pub caps: KernelCaps,
65    /// Op-specific parameters. Most ops use `OpParams::None`;
66    /// reductions / conv / slice carry their config here.
67    pub op_params: OpParams,
68}
69
70impl CompiledNode {
71    /// The output dtype — last entry in `dtypes`. Convenience accessor
72    /// for callers that only need the output type (e.g. shape inference,
73    /// allocation).
74    pub fn output_dtype(&self) -> DType {
75        *self
76            .dtypes
77            .last()
78            .expect("CompiledNode::dtypes must have at least one entry (the output)")
79    }
80}
81
82/// Opt-in per-`(OpKind, BackendId)` host-time accounting for kernel launches
83/// ([`execute_compiled_with_wait_hint`]). Off by default (one relaxed atomic load
84/// per launch). What it measures: wall time of the kernel call on the HOST. A
85/// kernel that only enqueues work (Vulkan batches into a command buffer, CUDA
86/// launches on a stream) is charged its enqueue cost, NOT its device time; a
87/// kernel that blocks (CPU math, a host-to-device upload, a forced flush) is
88/// charged all of it. Device time lands wherever the realize finally waits, so
89/// compare the per-op sum with the caller's own wall clock around a step.
90pub mod op_profile {
91    use std::collections::HashMap;
92    use std::sync::Mutex;
93    use std::sync::atomic::{AtomicBool, Ordering};
94    use std::time::Duration;
95
96    use fuel_ir::dispatch::OpKind;
97    use fuel_ir::probe::BackendId;
98
99    static ENABLED: AtomicBool = AtomicBool::new(false);
100    static PHASES: Mutex<Option<HashMap<&'static str, (u64, Duration)>>> = Mutex::new(None);
101
102    /// Times a named phase of a realize from `start` until it is dropped, when
103    /// accounting is on (a no-op otherwise: one relaxed atomic load). Phases are
104    /// hierarchical by NAME only: an outer phase's time includes the inner ones
105    /// nested inside it, so rows of [`take_phases`] are not additive.
106    pub struct PhaseTimer(Option<(&'static str, std::time::Instant)>);
107
108    impl PhaseTimer {
109        pub fn start(name: &'static str) -> Self {
110            Self(enabled().then(|| (name, std::time::Instant::now())))
111        }
112    }
113
114    impl Drop for PhaseTimer {
115        fn drop(&mut self) {
116            if let Some((name, t0)) = self.0.take() {
117                let mut g = PHASES.lock().unwrap_or_else(|p| p.into_inner());
118                let e = g
119                    .get_or_insert_with(HashMap::new)
120                    .entry(name)
121                    .or_insert((0, Duration::ZERO));
122                e.0 += 1;
123                e.1 += t0.elapsed();
124            }
125        }
126    }
127
128    /// Drain the phase totals: `(phase, entries, total time)`, longest first.
129    pub fn take_phases() -> Vec<(&'static str, u64, Duration)> {
130        let mut g = PHASES.lock().unwrap_or_else(|p| p.into_inner());
131        let mut v: Vec<_> = g
132            .take()
133            .unwrap_or_default()
134            .into_iter()
135            .map(|(k, (n, d))| (k, n, d))
136            .collect();
137        v.sort_by_key(|row| std::cmp::Reverse(row.2));
138        v
139    }
140
141    static TRACE: AtomicBool = AtomicBool::new(false);
142    static TRACE_ROWS: Mutex<Vec<LaunchRow>> = Mutex::new(Vec::new());
143
144    /// One kernel launch, in launch order (see [`set_trace`]).
145    #[derive(Clone, Debug)]
146    pub struct LaunchRow {
147        /// `OpKind` rendered with `Debug`.
148        pub op: String,
149        /// The kernel's backend, rendered with `Debug`.
150        pub backend: String,
151        /// Where the first input lives (`Cpu`, `Vulkan`, `Cuda`, `Metal`, `?`).
152        pub src: &'static str,
153        /// Where the first output lives.
154        pub dst: &'static str,
155        /// Byte length of the first input.
156        pub bytes: usize,
157        /// Host wall time of the launch.
158        pub dt: Duration,
159    }
160
161    fn kind(s: &std::sync::Arc<std::sync::RwLock<fuel_memory::Storage>>) -> (&'static str, usize) {
162        let Ok(g) = s.try_read() else {
163            return ("?", 0);
164        };
165        let k = match &g.inner {
166            fuel_memory::BackendStorage::Cpu(_) => "Cpu",
167            #[cfg(feature = "cuda")]
168            fuel_memory::BackendStorage::Cuda(_) => "Cuda",
169            #[cfg(feature = "vulkan")]
170            fuel_memory::BackendStorage::Vulkan(_) => "Vulkan",
171            #[allow(unreachable_patterns)]
172            _ => "Other",
173        };
174        (k, g.len_bytes())
175    }
176    /// `(op, backend)` rendered with `Debug` -> (launches, total host time).
177    type Totals = HashMap<(String, String), (u64, Duration)>;
178
179    static TOTALS: Mutex<Option<Totals>> = Mutex::new(None);
180
181    /// Turn accounting on or off. Does not clear what was recorded.
182    pub fn set_enabled(on: bool) {
183        ENABLED.store(on, Ordering::Relaxed);
184    }
185
186    /// Also keep every launch as a [`LaunchRow`], in order (implies the
187    /// accounting path). For a one-step anatomy, not for long runs: one row per
188    /// launch. Does not clear what was recorded.
189    pub fn set_trace(on: bool) {
190        TRACE.store(on, Ordering::Relaxed);
191    }
192
193    /// Drain the launch trace.
194    pub fn take_trace() -> Vec<LaunchRow> {
195        std::mem::take(&mut *TRACE_ROWS.lock().unwrap_or_else(|p| p.into_inner()))
196    }
197
198    /// Whether accounting (or tracing) is on.
199    pub fn enabled() -> bool {
200        ENABLED.load(Ordering::Relaxed) || TRACE.load(Ordering::Relaxed)
201    }
202
203    pub(super) fn record_launch(
204        compiled: &super::CompiledNode,
205        inputs: &[std::sync::Arc<std::sync::RwLock<fuel_memory::Storage>>],
206        outputs: &[std::sync::Arc<std::sync::RwLock<fuel_memory::Storage>>],
207        dt: Duration,
208    ) {
209        record(compiled.op, compiled.backend, dt);
210        if TRACE.load(Ordering::Relaxed) {
211            let (src, bytes) = inputs.first().map_or(("?", 0), kind);
212            let dst = outputs.first().map_or("?", |o| kind(o).0);
213            TRACE_ROWS
214                .lock()
215                .unwrap_or_else(|p| p.into_inner())
216                .push(LaunchRow {
217                    op: format!("{:?}", compiled.op),
218                    backend: format!("{:?}", compiled.backend),
219                    src,
220                    dst,
221                    bytes,
222                    dt,
223                });
224        }
225    }
226
227    pub(super) fn record(op: OpKind, backend: BackendId, dt: Duration) {
228        let mut g = TOTALS.lock().unwrap_or_else(|p| p.into_inner());
229        let e = g
230            .get_or_insert_with(HashMap::new)
231            .entry((format!("{op:?}"), format!("{backend:?}")))
232            .or_insert((0, Duration::ZERO));
233        e.0 += 1;
234        e.1 += dt;
235    }
236
237    /// Drain the totals: `(op, backend, launches, total host time)`, longest first.
238    pub fn take() -> Vec<(String, String, u64, Duration)> {
239        let mut g = TOTALS.lock().unwrap_or_else(|p| p.into_inner());
240        let mut v: Vec<_> = g
241            .take()
242            .unwrap_or_default()
243            .into_iter()
244            .map(|((op, be), (n, d))| (op, be, n, d))
245            .collect();
246        v.sort_by_key(|row| std::cmp::Reverse(row.3));
247        v
248    }
249}
250
251/// Resolve a node's kernel from the binding table and return a
252/// [`CompiledNode`] ready for execution. Production-correct: surfaces
253/// `NoBackendForOp` if the table doesn't have a binding for the
254/// requested triple.
255///
256/// Today this is one HashMap lookup. When dispatch grows more
257/// sophisticated (residency-aware, cost-aware, auto-tuning), the
258/// added work happens here. The interface stays the same.
259pub fn compile_node(
260    op: OpKind,
261    dtypes: &[DType],
262    backend: BackendId,
263    op_params: OpParams,
264    bindings: &KernelBindingTable,
265) -> Result<CompiledNode> {
266    let (kernel, caps) = bindings.lookup_with_caps(op, dtypes, backend)?;
267    Ok(CompiledNode {
268        op,
269        dtypes: KernelDTypes::from_slice(dtypes),
270        backend,
271        kernel,
272        caps,
273        op_params,
274    })
275}
276
277/// The `(kernel_source, kernel_revision_hash)` identity of the kernel the
278/// executor dispatches for `node`, derived from the SAME graph stamp +
279/// registry the executor resolves through: `op_to_op_kind` +
280/// `build_lookup_dtypes` + `graph.target_backend` → the first-registered
281/// binding at `(op, dtypes, backend)` (the entry [`compile_node`]'s
282/// `lookup_with_caps` picks). `None` when the node has no dispatch mapping
283/// (view/structural op) or no registered binding.
284///
285/// Both fields come from the ONE `find()` — the same
286/// [`BindingEntry`](crate::kernel::BindingEntry) — so
287/// the tag and the revision describe the same kernel by construction; a
288/// second lookup keyed on the tag could not disambiguate variant siblings
289/// that share it, which is precisely the collapse the revision exists to
290/// prevent. This is the post-realize attribution the bridge reports for the
291/// Judge. Step D moved it off the plan's `AlternativeSet::winner()`: the
292/// production realize path dispatches via the binding-table lookup (no plan),
293/// so the first-registered binding IS the matching attribution.
294pub fn dispatched_kernel_ident(
295    graph: &Graph,
296    node: NodeId,
297    bindings: &KernelBindingTable,
298) -> Option<(&'static str, u64)> {
299    let n = graph.node(node);
300    let op = crate::pipelined::op_to_op_kind(&n.op)?;
301    let dtypes = crate::pipelined::build_lookup_dtypes(graph, n);
302    let backend = graph.target_backend(node)?;
303    // Mirror `lookup_with_caps`'s dense-safe pick: skip baked-broadcast
304    // siblings so the attribution matches the kernel the executor actually
305    // dispatches (path 1a).
306    bindings
307        .lookup_alternatives(op, &dtypes, backend)
308        .iter()
309        .find(|e| !e.caps.requires_broadcast)
310        .map(|e| (e.kernel_source, e.kernel_revision_hash))
311}
312
313/// Run a compiled node against the given inputs/outputs. The output
314/// `Storage`s must be pre-allocated (the executor's responsibility,
315/// using the node's shape + dtype).
316///
317/// `layouts` parallels `inputs.append(outputs)` and carries the
318/// stride/shape/offset metadata that stride-aware kernels consume.
319/// Today's contiguous-only kernels ignore it; the executor's
320/// auto-Contiguize pass guarantees every input layout is
321/// `Layout::contiguous(shape)` before this is called.
322///
323/// Production-correct: surfaces kernel errors as `Result`; never
324/// panics on dispatch mismatch (the wrapper functions handle that).
325pub fn execute_compiled(
326    compiled: &CompiledNode,
327    inputs: &[Arc<RwLock<Storage>>],
328    outputs: &mut [Arc<RwLock<Storage>>],
329    layouts: &[Layout],
330) -> Result<CompletionHandle> {
331    // Conservative default: `true` reproduces the pre-"event only where
332    // waited" behavior byte-for-byte (every CUDA node gets an Event). Callers
333    // that know a node will never be individually waited mid-walk (the
334    // `pipelined` executor's wait-set pre-scan) should call
335    // [`execute_compiled_with_wait_hint`] instead.
336    execute_compiled_with_wait_hint(compiled, inputs, outputs, layouts, true)
337}
338
339/// Step E A4b open question #4 — "event only where waited". Identical to
340/// [`execute_compiled`] except the caller supplies `will_be_waited`: whether
341/// ANY later step in this realize will individually wait this node's
342/// completion handle before the realize-end drain (today: the cross-device
343/// `Copy`/`Move` producer wait, `wait_producer_handle` in `pipelined.rs`).
344///
345/// `will_be_waited == false` lets `produce_pending` skip the CUDA
346/// `Event`/in-flight-counter machinery entirely and return
347/// [`CompletionHandle::Ready`] immediately — the kernel is still launched on
348/// the device stream exactly as before; only the completion-observation
349/// bookkeeping is elided. Same-device consumers stay correct because CUDA has
350/// one stream per device: stream order alone guarantees a later same-device
351/// kernel doesn't start before this one's writes land, with no host-side wait
352/// required. Cutting the event is safe PROVIDED the caller's wait-set is
353/// conservative (a producer that turns out to be waited must always pass
354/// `true`) — `pipelined.rs`'s pre-scan is exactly that: built from the SAME
355/// Copy/Move nodes the mid-walk wait consults.
356///
357/// `will_be_waited == true` is byte-identical to the pre-elision path.
358pub fn execute_compiled_with_wait_hint(
359    compiled: &CompiledNode,
360    inputs: &[Arc<RwLock<Storage>>],
361    outputs: &mut [Arc<RwLock<Storage>>],
362    layouts: &[Layout],
363    will_be_waited: bool,
364) -> Result<CompletionHandle> {
365    let profile_start = op_profile::enabled().then(std::time::Instant::now);
366    let launched = (compiled.kernel)(inputs, outputs, layouts, &compiled.op_params);
367    if let Some(t0) = profile_start {
368        op_profile::record_launch(compiled, inputs, outputs, t0.elapsed());
369    }
370    launched?;
371    // Step E A4b-1: the kernel has *enqueued* its work (CUDA: launched on the
372    // device stream and returned without a per-op sync, per A3; CPU/Vulkan: see
373    // below). Produce a completion handle FROM THE OUTPUT STORAGE — the kernel
374    // reaches the device via the `Storage`, not an executor-held backend, so the
375    // output's `BackendStorage` is where we find the device/stream to record a
376    // signal on. The executor stores this handle and defers the wait to a
377    // dependency / realize-end boundary instead of `wait`ing inline here.
378    produce_pending(compiled.backend, outputs, will_be_waited)
379}
380
381/// Step E A4b-1: build the [`CompletionHandle`] for a just-launched kernel by
382/// inspecting `outputs[0]`'s backend storage.
383///
384/// - **CUDA, `will_be_waited`** → [`CompletionHandle::Pending`] wrapping an
385///   [`Event`] recorded on the output's device stream (signals when this
386///   kernel + all prior stream work completes; one stream per device makes a
387///   per-node event a sufficient stream marker — A4b-1 §1.2).
388/// - **CUDA, NOT `will_be_waited`** (A4b open question #4 — "event only
389///   where waited") → [`CompletionHandle::Ready`], with NO `cuEventCreate` /
390///   `cuEventRecord` and NO in-flight-counter increment. The kernel already
391///   launched (line above); only the completion-observation bookkeeping is
392///   skipped, because nothing will ever individually wait THIS node's
393///   handle — the realize-end drain now does one blanket
394///   `cuStreamSynchronize` per active CUDA device instead
395///   (`pipelined::sync_active_cuda_devices`), which flushes elided work too
396///   (single stream per device ⇒ stream order already makes it complete by
397///   then).
398/// - **CPU** → [`CompletionHandle::Ready`] (the kernel computed in-process; the
399///   work is already done).
400/// - **Vulkan** → [`CompletionHandle::Ready`]. The per-op compute path keeps
401///   RECORDING into the deferred batch and returns `Ready` during the walk (no
402///   per-op handle, no eager submit — eager submission at sub-DAG boundaries is
403///   A4b-4). A4b-2's async split lives at the executor's realize-end drain
404///   (`drain_vulkan_pending`): it `submit_pending`s the open batch (the per-batch
405///   fence) then waits it through a `VulkanCompletion` handle — byte-identical to
406///   A2's atomic submit+wait, just split. The A2 lazy-batch model still carries
407///   intra-walk Vulkan correctness (same-queue order + the eviction `force_flush`).
408///
409/// Multi-output kernels share one backing buffer (one `BackendStorage`), so
410/// `outputs[0]` is the device handle for every slot — a single event covers them.
411fn produce_pending(
412    _backend: BackendId,
413    outputs: &[Arc<RwLock<Storage>>],
414    will_be_waited: bool,
415) -> Result<CompletionHandle> {
416    // `_backend` (= `compiled.backend`) records the *intent*; we read the
417    // device truth off the output storage's variant instead, which is robust
418    // even where the backend stamp and the realized storage could ever diverge.
419    #[cfg(not(feature = "cuda"))]
420    let _ = (outputs, will_be_waited);
421    #[cfg(feature = "cuda")]
422    {
423        if will_be_waited {
424            if let Some(out0) = outputs.first() {
425                let guard = out0.read().map_err(|_| {
426                    Error::Msg("output storage lock poisoned recording completion".into()).bt()
427                })?;
428                if let fuel_memory::BackendStorage::Cuda(cuda_bytes) = &guard.inner {
429                    // Step E B1: this is the single CUDA async-submit site — the kernel
430                    // was just enqueued on the device stream. The completion handle's
431                    // lifetime IS the in-flight window, so `CudaCompletion::new`
432                    // increments the per-device in-flight counter here and the handle's
433                    // `Drop` decrements it (covering wait, drain, AND the eviction-drop
434                    // path uniformly — see the type docs). The device location keys the
435                    // counter.
436                    let loc = cuda_bytes.device().location();
437                    let ev = cuda_bytes.record_completion_event()?;
438                    return Ok(CompletionHandle::Pending(Box::new(CudaCompletion::new(
439                        ev, loc,
440                    ))));
441                }
442            }
443        }
444    }
445    Ok(CompletionHandle::Ready)
446}
447
448/// Step E A4b-1: a CUDA [`Completion`] over a recorded [`Event`]. `wait` is a
449/// blocking host wait (`cuEventSynchronize`) on the event — which fires after
450/// the producing kernel and every prior op on the (single per-device) stream.
451///
452/// Step E B1: carries the producing op's [`DeviceLocation`] and owns one slot
453/// of the process-wide in-flight counter. [`CudaCompletion::new`] (called at the
454/// async-submit site, `produce_pending`) increments; [`Drop`] decrements. Putting
455/// the decrement in `Drop` — NOT in `wait` — is what makes the counter BALANCE:
456/// every path that ends this handle's life decrements exactly once and only once:
457///   - `wait(self: Box<Self>)` synchronizes the event (a `&self` read) then the
458///     `Box` drops → one dec;
459///   - the executor's `drain_handles` / `wait_producer_handle` consume the handle
460///     via `wait` → the same one dec;
461///   - the eviction path `handles.remove(&destroyed)` DROPS the handle WITHOUT
462///     waiting → still one dec (via `Drop`);
463///   - an error (`?`) unwinding the realize loop drops the still-owned handles →
464///     one dec each.
465/// `wait` deliberately does not touch the counter, so no path double-counts.
466#[cfg(feature = "cuda")]
467struct CudaCompletion {
468    ev: fuel_cuda_backend::Event,
469    loc: fuel_ir::DeviceLocation,
470}
471
472#[cfg(feature = "cuda")]
473impl CudaCompletion {
474    /// Build the handle for a just-enqueued CUDA op and register it as one
475    /// in-flight op on `loc`. The matching decrement is in [`Drop`].
476    fn new(ev: fuel_cuda_backend::Event, loc: fuel_ir::DeviceLocation) -> Self {
477        crate::dispatch::inflight_inc(loc);
478        Self { ev, loc }
479    }
480}
481
482#[cfg(feature = "cuda")]
483impl Drop for CudaCompletion {
484    fn drop(&mut self) {
485        // The single in-flight decrement — fires on wait-consume, drain, OR
486        // eviction-drop, so the counter returns to baseline once every handle
487        // is gone. Relaxed + saturating (a hint, never a correctness gate).
488        crate::dispatch::inflight_dec(self.loc);
489    }
490}
491
492#[cfg(feature = "cuda")]
493impl Completion for CudaCompletion {
494    fn wait(self: Box<Self>) -> Result<()> {
495        use fuel_cuda_backend::WrapErr;
496        // cuEventSynchronize: blocks the host until the event completes; surfaces
497        // a sticky async kernel fault on this stream as the mapped `Err`. The
498        // in-flight decrement is NOT here — it rides the `Box`'s `Drop` (which
499        // runs as this method returns), so the eviction-drop path also decrements.
500        self.ev.synchronize().w()
501    }
502}
503
504/// A node's completion signal, returned by [`execute_compiled`]. Step E
505/// Phase A1 foundation: today every dispatch is synchronous, so this is always
506/// [`CompletionHandle::Ready`] (the work finished before the call returned) and
507/// callers `wait` it immediately — byte-identical to the prior `Result<()>`.
508/// Phases A2/A3 add an async GPU path that returns [`CompletionHandle::Pending`]
509/// carrying a device fence/event the executor defers the wait on.
510///
511/// `#[must_use]`: dropping a `Pending` handle without `wait`ing would leak
512/// un-awaited async work (harmless for today's `Ready`, a correctness bug once
513/// A2/A3 land) — callers must `wait` it or thread it to the executor.
514#[must_use = "a CompletionHandle must be waited on (or threaded to the executor) — dropping a Pending handle leaks async work"]
515pub enum CompletionHandle {
516    /// The work finished synchronously before [`execute_compiled`] returned —
517    /// [`wait`](CompletionHandle::wait) is a no-op.
518    Ready,
519    /// The work was enqueued asynchronously; [`wait`](CompletionHandle::wait)
520    /// blocks until the carried device signal fires. (No producers until A2/A3.)
521    Pending(Box<dyn Completion>),
522}
523
524impl CompletionHandle {
525    /// Block until this node's work has finished. No-op for [`Ready`].
526    ///
527    /// [`Ready`]: CompletionHandle::Ready
528    pub fn wait(self) -> Result<()> {
529        match self {
530            CompletionHandle::Ready => Ok(()),
531            CompletionHandle::Pending(c) => c.wait(),
532        }
533    }
534}
535
536/// An async-dispatch completion signal — a backend's wrapper over a CUDA event
537/// / Vulkan fence so the executor can defer the wait past the submit point
538/// (Step E Phase A2/A3). No implementors today; all dispatch is synchronous.
539pub trait Completion: Send {
540    /// Block until the enqueued work this handle represents has finished.
541    fn wait(self: Box<Self>) -> Result<()>;
542}
543
544#[cfg(test)]
545mod op_profile_tests {
546    use super::op_profile;
547    use fuel_ir::dispatch::OpKind;
548    use fuel_ir::probe::BackendId;
549    use std::time::Duration;
550
551    /// The only test that touches the process-global totals.
552    #[test]
553    fn totals_sum_per_key_sort_longest_first_and_drain() {
554        let _ = op_profile::take();
555        op_profile::record(
556            OpKind::AddElementwise,
557            BackendId::Cpu,
558            Duration::from_millis(2),
559        );
560        op_profile::record(
561            OpKind::AddElementwise,
562            BackendId::Cpu,
563            Duration::from_millis(3),
564        );
565        op_profile::record(OpKind::MatMul, BackendId::Cpu, Duration::from_millis(1));
566        op_profile::record(OpKind::MatMul, BackendId::Vulkan, Duration::from_millis(9));
567        let rows = op_profile::take();
568        let got: Vec<(&str, &str, u64, u128)> = rows
569            .iter()
570            .map(|(o, b, n, d)| (o.as_str(), b.as_str(), *n, d.as_millis()))
571            .collect();
572        assert_eq!(
573            got,
574            vec![
575                ("MatMul", "Vulkan", 1, 9),
576                ("AddElementwise", "Cpu", 2, 5),
577                ("MatMul", "Cpu", 1, 1),
578            ]
579        );
580        assert!(op_profile::take().is_empty(), "take() drains");
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use crate::dispatch::register_cpu_kernels;
588
589    /// E2E with the compile/execute split: register CPU kernels,
590    /// compile a node, execute it. Same shape as B5's e2e test but
591    /// goes through the CompiledNode interface.
592    #[test]
593    fn compile_then_execute_add_f32() {
594        let mut bindings = KernelBindingTable::new();
595        register_cpu_kernels(&mut bindings);
596
597        let compiled = compile_node(
598            OpKind::AddElementwise,
599            &[DType::F32, DType::F32, DType::F32],
600            BackendId::Cpu,
601            OpParams::None,
602            &bindings,
603        )
604        .expect("compile");
605
606        assert_eq!(compiled.op, OpKind::AddElementwise);
607        assert_eq!(compiled.output_dtype(), DType::F32);
608        assert_eq!(compiled.backend, BackendId::Cpu);
609
610        let lhs = fuel_memory::from_slice_cpu(&[5.0_f32, 6.0, 7.0]);
611        let rhs = fuel_memory::from_slice_cpu(&[1.0_f32, 2.0, 3.0]);
612        let out = fuel_memory::alloc_cpu_zeroed(DType::F32, 3).unwrap();
613        let inputs = vec![Arc::new(RwLock::new(lhs)), Arc::new(RwLock::new(rhs))];
614        let mut outputs = vec![Arc::new(RwLock::new(out))];
615
616        let layout_3 = Layout::contiguous(fuel_ir::Shape::from(vec![3]));
617        let layouts = vec![layout_3.clone(), layout_3.clone(), layout_3];
618        execute_compiled(&compiled, &inputs, &mut outputs, &layouts)
619            .expect("execute")
620            .wait()
621            .expect("wait");
622
623        let out_guard = outputs[0].read().unwrap();
624        if let fuel_memory::BackendStorage::Cpu(c) = &out_guard.inner {
625            let typed: &[f32] = c.as_slice().unwrap();
626            assert_eq!(typed, &[6.0, 8.0, 10.0]);
627        }
628    }
629
630    /// Step E A4b-1 behavior-preservation: a CPU kernel's `execute_compiled`
631    /// returns `CompletionHandle::Ready` — `produce_pending` must NOT turn the
632    /// (synchronous, in-process) CPU path into a `Pending` handle. The CUDA arm
633    /// (Pending) is covered by the live `cuda_async_realize_live` gate.
634    #[test]
635    fn execute_compiled_cpu_is_ready_not_pending() {
636        let mut bindings = KernelBindingTable::new();
637        register_cpu_kernels(&mut bindings);
638        let compiled = compile_node(
639            OpKind::AddElementwise,
640            &[DType::F32, DType::F32, DType::F32],
641            BackendId::Cpu,
642            OpParams::None,
643            &bindings,
644        )
645        .expect("compile");
646
647        let lhs = fuel_memory::from_slice_cpu(&[1.0_f32, 2.0]);
648        let rhs = fuel_memory::from_slice_cpu(&[3.0_f32, 4.0]);
649        let out = fuel_memory::alloc_cpu_zeroed(DType::F32, 2).unwrap();
650        let inputs = vec![Arc::new(RwLock::new(lhs)), Arc::new(RwLock::new(rhs))];
651        let mut outputs = vec![Arc::new(RwLock::new(out))];
652        let l = Layout::contiguous(fuel_ir::Shape::from(vec![2]));
653        let layouts = vec![l.clone(), l.clone(), l];
654
655        let handle = execute_compiled(&compiled, &inputs, &mut outputs, &layouts).expect("execute");
656        assert!(
657            matches!(handle, CompletionHandle::Ready),
658            "CPU execute_compiled must return Ready (A4b-1 behavior-preserving)",
659        );
660        handle.wait().expect("wait");
661    }
662
663    /// A4b open question #4 — "event only where waited": the wait-hint entry
664    /// point computes and returns Ready on CPU under BOTH hints (the hint only
665    /// gates the CUDA event machinery), and the elided (`false`) hint must not
666    /// change the computed bytes.
667    #[test]
668    fn execute_compiled_with_wait_hint_cpu_ready_and_correct_under_both_hints() {
669        let mut bindings = KernelBindingTable::new();
670        register_cpu_kernels(&mut bindings);
671        let compiled = compile_node(
672            OpKind::AddElementwise,
673            &[DType::F32, DType::F32, DType::F32],
674            BackendId::Cpu,
675            OpParams::None,
676            &bindings,
677        )
678        .expect("compile");
679
680        for hint in [true, false] {
681            let lhs = fuel_memory::from_slice_cpu(&[1.0_f32, 2.0]);
682            let rhs = fuel_memory::from_slice_cpu(&[3.0_f32, 4.0]);
683            let out = fuel_memory::alloc_cpu_zeroed(DType::F32, 2).unwrap();
684            let inputs = vec![Arc::new(RwLock::new(lhs)), Arc::new(RwLock::new(rhs))];
685            let mut outputs = vec![Arc::new(RwLock::new(out))];
686            let l = Layout::contiguous(fuel_ir::Shape::from(vec![2]));
687            let layouts = vec![l.clone(), l.clone(), l];
688
689            let handle =
690                execute_compiled_with_wait_hint(&compiled, &inputs, &mut outputs, &layouts, hint)
691                    .expect("execute");
692            assert!(
693                matches!(handle, CompletionHandle::Ready),
694                "CPU path returns Ready regardless of the wait hint ({hint})",
695            );
696            handle.wait().expect("wait");
697
698            let out_guard = outputs[0].read().unwrap();
699            if let fuel_memory::BackendStorage::Cpu(c) = &out_guard.inner {
700                let typed: &[f32] = c.as_slice().unwrap();
701                assert_eq!(typed, &[4.0, 6.0], "bytes identical under hint={hint}");
702            }
703        }
704    }
705
706    /// Step E A1: the completion-handle contract — `Ready.wait()` is a no-op
707    /// success; `Pending(c).wait()` delegates to the boxed `Completion`.
708    #[test]
709    fn completion_handle_ready_is_noop_pending_delegates() {
710        use std::sync::Arc as StdArc;
711        use std::sync::atomic::{AtomicBool, Ordering};
712
713        CompletionHandle::Ready.wait().expect("Ready.wait() is Ok");
714
715        struct Flag(StdArc<AtomicBool>);
716        impl Completion for Flag {
717            fn wait(self: Box<Self>) -> Result<()> {
718                self.0.store(true, Ordering::SeqCst);
719                Ok(())
720            }
721        }
722        let waited = StdArc::new(AtomicBool::new(false));
723        CompletionHandle::Pending(Box::new(Flag(waited.clone())))
724            .wait()
725            .expect("Pending.wait() is Ok");
726        assert!(
727            waited.load(Ordering::SeqCst),
728            "Pending.wait() must run the boxed Completion",
729        );
730    }
731
732    /// compile_node surfaces NoBackendForOp on missing binding.
733    #[test]
734    fn compile_node_errors_on_missing_binding() {
735        let bindings = KernelBindingTable::new(); // empty
736        let result = compile_node(
737            OpKind::AddElementwise,
738            &[DType::F32, DType::F32, DType::F32],
739            BackendId::Cpu,
740            OpParams::None,
741            &bindings,
742        );
743        assert!(result.is_err());
744    }
745}