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}