Skip to main content

fuel_tensor/
factories.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Backend factory registry — Step 12 of the backend-agnostic refactor.
3//!
4//! Every backend that fuel-core can drive at runtime declares a single
5//! [`BackendFactory`] value in this module. The registry ([`registry`])
6//! returns the cfg-gated subset that's actually compiled in, and
7//! consumers — currently the [`fuel_hardware::probe`] enumerator and the
8//! [`crate::judge`] profiler — walk that registry instead of naming
9//! `fuel_cuda_backend::CudaDevice`/`fuel_vulkan_backend::VulkanBackend`/...
10//! by hand.
11//!
12//! # Realization path (executor-unification Session 2, 2026-06-11)
13//!
14//! [`Realizer`] realizes a [`crate::lazy::Tensor`] through the
15//! pipelined bridge ([`crate::pipelined_bridge::realize_one_as_with_initial`])
16//! on a pinned [`crate::Device`] — the SAME dispatch path production
17//! `realize_f32` uses. Pre-Session-2 this module constructed a typed
18//! legacy executor per backend; that was the last architectural
19//! reason to retain the 33-method legacy backend trait (re-audit
20//! gap 11), and it measured the legacy evaluator rather than the
21//! binding-table kernels the picker actually dispatches.
22//!
23//! The realizer keeps a persistent [`StorageCache`] across calls so
24//! `Op::Const` uploads amortize over the Judge's warmup + timed
25//! iterations — parity with the retired legacy executor's
26//! `const_pool` (without it, every GPU timing iteration would pay
27//! H2D for its inputs and the profile would measure PCIe, not the
28//! kernel).
29//!
30//! # Adding a new backend
31//!
32//! 1. Add a unit struct `MyBackendFactory` here, behind a cfg(feature).
33//! 2. Implement `BackendFactory` — `try_make_realizer` constructs the
34//!    backend's `crate::Device` handle and wraps it in `BridgeRealizer`.
35//!    (Device enumeration is `fuel-hardware`'s `HardwareEnumerator`, not
36//!    this factory, as of B0.2.)
37//! 3. Add a cfg-gated entry in [`registry`].
38//!
39//! No edits to judge.rs or probe.rs.
40
41use crate::lazy::Tensor;
42use fuel_dispatch::pipelined::StorageCache;
43use fuel_ir::probe::BackendId;
44use fuel_ir::{DType, Error, Result};
45
46/// Object-safe realize seam used by judge.rs. Realizes the tensor's
47/// graph on the realizer's pinned device through the pipelined
48/// bridge and returns the result's host bytes as `Vec<f32>`.
49///
50/// Result-returning (no-panics policy): a backend that can't realize
51/// the graph (missing kernel, device error) surfaces a typed `Err`;
52/// the Judge logs and skips the cell.
53pub trait Realizer {
54    fn realize_f32(&mut self, tensor: &Tensor) -> Result<Vec<f32>>;
55
56    /// Realize `tensor` and return the output numerically converted to
57    /// `Vec<f32>`, dispatching the byte-readback on the tensor's ROOT
58    /// dtype. `realize_f32` byte-reinterprets the output storage as
59    /// f32 (4 bytes/elem); that is only correct for an F32 root — an
60    /// F16/BF16 root produces a 2-byte-per-elem storage that must be
61    /// read back at its native width and then *numerically* converted
62    /// to f32 (not byte-reinterpreted) so the Judge's cross-backend
63    /// consensus can compare it against F32/other-dtype peers.
64    ///
65    /// The dtype axis (Judge Layer-2 coverage, 2026-07-04): the Judge
66    /// times the native-dtype kernel (the f16/bf16 realize) and keeps
67    /// the f32-converted output only for the correctness verdict.
68    ///
69    /// Defaulted to [`Self::realize_f32`] so test stubs (which only
70    /// exercise F32) stay one-method; the production
71    /// `BridgeRealizer` overrides it with the per-dtype readback.
72    fn realize_capture_f32(&mut self, tensor: &Tensor) -> Result<Vec<f32>> {
73        self.realize_f32(tensor)
74    }
75
76    /// `kernel_source` of the alternative the picker dispatched for
77    /// the most recent [`Self::realize_f32`]'s root node (Session 3
78    /// rider — the Judge tags the realizer-measured `CellRun` with
79    /// this so multi-sibling cells record the TRUE dispatched
80    /// sibling).
81    ///
82    /// `None` means "no report": either no realize has run yet, or
83    /// the plan carried no `AlternativeSet` for the root (the
84    /// executor then dispatched the first-registered binding — the
85    /// caller's fallback attribution should match that convention).
86    /// Defaulted so test stubs without a picker stay one-method.
87    fn last_kernel_source(&self) -> Option<&'static str> {
88        None
89    }
90
91    /// The `kernel_revision_hash` of the sibling the picker dispatched for
92    /// the realize root — taken from the SAME picker result as
93    /// [`Realizer::last_kernel_source`], never a re-lookup keyed on the tag
94    /// (a shared tag cannot disambiguate variant siblings, which is exactly
95    /// the collapse the revision exists to prevent). `None` on the same
96    /// conditions as `last_kernel_source`. Defaulted so test stubs without a
97    /// picker stay one-method.
98    fn last_kernel_revision(&self) -> Option<u64> {
99        None
100    }
101}
102
103/// Bridge-backed realizer pinned to one [`crate::Device`].
104///
105/// `cache` persists device-resident `Op::Const` storages across
106/// calls: the first realize uploads every reachable Const to the
107/// pinned device (via [`crate::pipelined_bridge::build_const_cache`]);
108/// subsequent calls find the NodeIds already present and skip the
109/// upload, so timed iterations measure dispatch + kernel + result
110/// download — not input H2D.
111struct BridgeRealizer {
112    device: crate::Device,
113    cache: StorageCache,
114    /// Picker attribution from the most recent realize — see
115    /// [`Realizer::last_kernel_source`].
116    last_kernel_source: Option<&'static str>,
117    /// Revision half of the same attribution — see
118    /// [`Realizer::last_kernel_revision`]. Set from the same picker result
119    /// as `last_kernel_source`, so the two never describe different kernels.
120    last_kernel_revision: Option<u64>,
121}
122
123impl BridgeRealizer {
124    fn new(device: crate::Device) -> Self {
125        Self {
126            device,
127            cache: StorageCache::new(),
128            last_kernel_source: None,
129            last_kernel_revision: None,
130        }
131    }
132
133    /// Realize `tensor` on the pinned device, reading the output
134    /// storage back as `Vec<T>` (a byte reinterpret — `T` must EQUAL the
135    /// root node's dtype, now ENFORCED by the realize funnel guard: a
136    /// mismatch returns [`fuel_ir::Error::UnexpectedDType`], GAP-327).
137    /// Shared body for the per-dtype
138    /// realize seams: [`Realizer::realize_f32`] is `T = f32`;
139    /// [`Realizer::realize_capture_f32`] picks `T` from the root
140    /// dtype (F16 → `half::f16`, BF16 → `half::bf16`) and converts.
141    ///
142    /// Sets `last_kernel_source` from the picker's dispatched sibling
143    /// for the realize root.
144    fn realize_as<T: bytemuck::Pod + fuel_ir::WithDType>(
145        &mut self,
146        tensor: &Tensor,
147    ) -> Result<Vec<T>> {
148        let graph = tensor.graph_tensor().graph().clone();
149        let target = tensor.graph_tensor().id();
150
151        // Top up the persistent cache with any reachable Const not
152        // yet uploaded. No-op after the first call for a given
153        // tensor (build_const_cache skips ids already present).
154        let order = {
155            let g = graph
156                .read()
157                .map_err(|_| Error::Msg("graph lock poisoned".into()).bt())?;
158            fuel_graph::topo_order_multi(&g, &[target])
159        };
160        self.cache = crate::pipelined_bridge::build_const_cache(
161            &graph,
162            &order,
163            &self.device,
164            std::mem::take(&mut self.cache),
165        )?;
166
167        // Planner Stage 2 (2026-06-11): the Judge measures a
168        // SPECIFIC backend at a SPECIFIC device, so hard-pin every
169        // reachable node with an explicit placement. The priced
170        // off-device admission relax only applies to soft
171        // (realize-call) pins — without this stamp the planner
172        // could legitimately move a profiled op to a "cheaper"
173        // sibling device and the cell would record a mislabeled
174        // latency. Idempotent across the warmup + timed re-realizes
175        // of one cell (same graph, same device).
176        {
177            let mut g = graph
178                .write()
179                .map_err(|_| Error::Msg("graph lock poisoned".into()).bt())?;
180            let loc = self.device.location();
181            for &id in &order {
182                g.set_placement(id, loc);
183            }
184        }
185
186        let (bytes, root_ident) =
187            crate::pipelined_bridge::realize_one_as_with_initial_reporting::<T>(
188                &graph,
189                target,
190                &self.device,
191                self.cache.clone(),
192                &fuel_ir::SymEnv::default(),
193            )?;
194        // Split the single (source, revision) attribution — one picker
195        // result, so the tag and the revision cannot disagree about which
196        // kernel ran.
197        self.last_kernel_source = root_ident.map(|(s, _)| s);
198        self.last_kernel_revision = root_ident.map(|(_, r)| r);
199        Ok(bytes)
200    }
201}
202
203impl Realizer for BridgeRealizer {
204    fn realize_f32(&mut self, tensor: &Tensor) -> Result<Vec<f32>> {
205        self.realize_as::<f32>(tensor)
206    }
207
208    fn realize_capture_f32(&mut self, tensor: &Tensor) -> Result<Vec<f32>> {
209        // Read the output back at the root's native width, then
210        // NUMERICALLY convert to f32 (half::f16/bf16 → f32) so the
211        // Judge's cross-backend consensus compares like-for-like. A
212        // byte-reinterpret (`realize_as::<f32>` on an f16 root) would
213        // splice adjacent 2-byte elements into bogus 4-byte floats.
214        match tensor.dtype() {
215            DType::F32 => self.realize_as::<f32>(tensor),
216            DType::F16 => Ok(self
217                .realize_as::<half::f16>(tensor)?
218                .into_iter()
219                .map(|x| x.to_f32())
220                .collect()),
221            DType::BF16 => Ok(self
222                .realize_as::<half::bf16>(tensor)?
223                .into_iter()
224                .map(|x| x.to_f32())
225                .collect()),
226            other => Err(Error::Msg(format!(
227                "judge: realize_capture_f32 has no readback path for root dtype \
228                 {other:?} — only F32/F16/BF16 are wired into the Judge dtype axis",
229            ))
230            .bt()),
231        }
232    }
233
234    fn last_kernel_source(&self) -> Option<&'static str> {
235        self.last_kernel_source
236    }
237
238    fn last_kernel_revision(&self) -> Option<u64> {
239        self.last_kernel_revision
240    }
241}
242
243/// One concrete backend the runtime can drive. Implementors are
244/// zero-sized factory tags; the actual per-call state (device
245/// handle, persistent const cache) lives in the [`Realizer`]
246/// returned by [`BackendFactory::try_make_realizer`].
247pub trait BackendFactory: Send + Sync {
248    /// Stable identifier — the same one the probe uses.
249    fn id(&self) -> BackendId;
250
251    /// Construct a fresh realizer pinned to one device. Errors are
252    /// propagated as-is — judge.rs prints and skips that device.
253    fn try_make_realizer(&self, device_index: u32) -> Result<Box<dyn Realizer>>;
254}
255
256/// All backend factories compiled into this build, in the same order
257/// the probe used to list them. Plain CPU is always present; the
258/// rest gate on cargo features.
259pub fn registry() -> Vec<&'static dyn BackendFactory> {
260    #[allow(unused_mut)]
261    let mut v: Vec<&'static dyn BackendFactory> = vec![&CpuFactory];
262    #[cfg(feature = "cuda")]
263    v.push(&CudaFactory);
264    #[cfg(feature = "vulkan")]
265    v.push(&VulkanFactory);
266    v
267}
268
269/// Look up a factory by its stable [`BackendId`]. `None` if the backend
270/// isn't compiled into this build.
271pub fn factory_for(id: BackendId) -> Option<&'static dyn BackendFactory> {
272    registry().into_iter().find(|f| f.id() == id)
273}
274
275// ---------------------------------------------------------------------
276// CPU
277// ---------------------------------------------------------------------
278
279pub struct CpuFactory;
280
281impl BackendFactory for CpuFactory {
282    fn id(&self) -> BackendId {
283        BackendId::Cpu
284    }
285    fn try_make_realizer(&self, _device_index: u32) -> Result<Box<dyn Realizer>> {
286        Ok(Box::new(BridgeRealizer::new(crate::Device::cpu())))
287    }
288}
289
290// AOCL + MKL factories retired 2026-06-08 (backend-extensions
291// Phase 2). They were vestigial after AOCL/MKL kernels became
292// kernel-source extensions of `BackendId::Cpu` via the binding
293// table — the factories created realizers identical to
294// `CpuFactory`'s. The picker now selects among CPU-substrate
295// alternatives by `kernel_source`; no per-vendor factory needed.
296
297// ---------------------------------------------------------------------
298// CUDA
299// ---------------------------------------------------------------------
300
301#[cfg(feature = "cuda")]
302pub struct CudaFactory;
303
304#[cfg(feature = "cuda")]
305impl BackendFactory for CudaFactory {
306    fn id(&self) -> BackendId {
307        BackendId::Cuda
308    }
309    fn try_make_realizer(&self, device_index: u32) -> Result<Box<dyn Realizer>> {
310        let dev = fuel_cuda_backend::CudaDevice::new(device_index as usize).map_err(|e| {
311            fuel_ir::Error::Msg(format!("CudaDevice::new({device_index}) failed: {e}"))
312        })?;
313        Ok(Box::new(BridgeRealizer::new(dev.into())))
314    }
315}
316
317// ---------------------------------------------------------------------
318// Vulkan
319// ---------------------------------------------------------------------
320
321#[cfg(feature = "vulkan")]
322pub struct VulkanFactory;
323
324#[cfg(feature = "vulkan")]
325impl BackendFactory for VulkanFactory {
326    fn id(&self) -> BackendId {
327        BackendId::Vulkan
328    }
329    fn try_make_realizer(&self, device_index: u32) -> Result<Box<dyn Realizer>> {
330        let backend = fuel_vulkan_backend::VulkanBackend::with_selection(
331            fuel_vulkan_backend::DeviceSelection::Index(device_index as usize),
332        )
333        .map_err(|e| fuel_ir::Error::Msg(format!("VulkanBackend init failed: {e}")))?;
334        Ok(Box::new(BridgeRealizer::new(backend.into())))
335    }
336}