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}