Skip to main content

fuel_backend_contract/
backend.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Backend storage capability traits — the object-safe contract surface
3//! every backend implements.
4//!
5//! After step 8 of the backend-agnostic refactor (2026-04-30), the static
6//! `BackendStorage`/`BackendDevice` generic traits were deleted: every backend
7//! now implements [`DynBackendStorage`](crate::dyn_backend::DynBackendStorage)
8//! and [`DynBackendDevice`](crate::dyn_backend::DynBackendDevice) directly.
9//!
10//! What remains here is [`HostStorage`], the orthogonal capability trait
11//! marking storage types whose data lives in host-addressable RAM, plus
12//! the Phase 7.5 storage-unification trait additions ([`BackendStorage`],
13//! [`BackendCapabilityProvider`], [`BackendRuntime`]). Their *data* types —
14//! `SubstrateClass`, `TransferPath`, `BackendCapabilities`, `FitStatus` —
15//! live in [`fuel_ir::backend`].
16use fuel_ir::backend::{BackendCapabilities, FitStatus};
17use fuel_ir::{HostBuffer, HostBufferRef, Result};
18
19/// Capability trait for storage types whose data lives in
20/// host-addressable RAM — i.e., the storage can be viewed as a
21/// typed slice without a device-to-host copy.
22///
23/// This trait is orthogonal to `DynBackendStorage`: a storage type can
24/// implement either, both, or neither.
25///
26/// Implementors:
27///
28/// - `CpuStorage` (owned `Vec<T>` via [`HostBuffer`])
29/// - `MmappedHostStorage` — memory-mapped weights via `mmap2`
30/// - `PinnedHostStorage` — page-locked memory for GPU DMA
31/// - `SharedMemHostStorage` — inter-process shared memory
32/// - `RemoteHostStorage` — network-accessible buffers for multi-host (future)
33pub trait HostStorage {
34    /// Borrow the underlying data as a [`HostBufferRef`] (zero-copy).
35    fn as_host_buffer_ref(&self) -> Result<HostBufferRef<'_>>;
36
37    /// Extract the underlying data as an owned [`HostBuffer`].
38    ///
39    /// Default impl materializes via `as_host_buffer_ref().to_owned()`,
40    /// which is a full copy. Owned-buffer implementors should override to
41    /// hand out the existing buffer without copying.
42    fn into_host_buffer(self) -> Result<HostBuffer>
43    where
44        Self: Sized,
45    {
46        Ok(self.as_host_buffer_ref()?.to_owned())
47    }
48}
49
50/// Phase 7.5 storage unification — see [docs/storage-unification.md].
51///
52/// Minimum contract every per-backend storage type implements. The
53/// trait defines only the universally-required surface today
54/// (`len_bytes`); allocation, copy-from-other-backend, and the
55/// capability advertisement land in subsequent phases as the rest of
56/// the design fills in.
57///
58/// Bounds:
59///
60/// - `Send + Sync` so storage handles can cross thread boundaries
61///   (`Arc<RwLock<Storage>>` lives in graph slots accessed from
62///   compiler + executor threads).
63/// - `Debug` for diagnostic error messages and tracing.
64///
65/// Implementors:
66///
67/// - `fuel_cpu_backend::CpuStorageBytes` (Phase A3.0)
68/// - `fuel_metal_backend::MetalStorageBytes` (Phase A3.1)
69/// - `fuel_cuda_backend::CudaStorageBytes` (Phase A3.2)
70/// - `fuel_vulkan_backend::VulkanStorageBytes` (Phase A3.3)
71pub trait BackendStorage: Send + Sync + std::fmt::Debug {
72    /// Total addressable byte count, regardless of dtype.
73    ///
74    /// The dtype tag lives on the `Storage` wrapper (in fuel-storage),
75    /// not on the variant — `len_bytes` is dtype-agnostic.
76    fn len_bytes(&self) -> usize;
77}
78
79/// Backends implement this to advertise their capabilities at
80/// registration time. Typical impl is on the backend's device type
81/// (e.g., `CpuDevice`, `CudaDevice`); each device instance reports
82/// what it can do.
83///
84/// The advertised [`BackendCapabilities`] *data* lives in
85/// [`fuel_ir::backend`].
86pub trait BackendCapabilityProvider {
87    /// Snapshot of the backend's capabilities. Capabilities are
88    /// static at backend instantiation — no runtime mutation, no
89    /// versioning. Adding a new dtype or op to a backend requires
90    /// recompiling Fuel.
91    fn capabilities(&self) -> BackendCapabilities;
92}
93
94/// Runtime state every backend reports. Phase 5.1/5.2 substrate of
95/// the picker arc — replaces backend-specific inherent methods (e.g.
96/// `VulkanBackend::vram_budget`, future `BaracudaDevice::vram_free`)
97/// with a uniform contract surface. See architecture v0.3
98/// (`docs/architecture/05-backend-contract.md`) §Trait surface for
99/// the full tiering.
100///
101/// # Honesty contract
102///
103/// `Option<u64>` returns let backends say "I genuinely can't measure
104/// this" without forcing fabrications. Selectors MUST treat `None`
105/// as "no signal — fall back to static cost," NEVER as "zero
106/// available memory."
107///
108/// # Caching
109///
110/// Implementations are expected to be cheap to call (sub-millisecond
111/// for cache hits, well under a microsecond on a hot path).
112/// Implementations that wrap non-trivial queries (parsing
113/// `/proc/meminfo`, OS syscalls) SHOULD internally cache results for
114/// ~100ms to amortize the cost of selector polling.
115pub trait BackendRuntime {
116    /// Bytes currently available for new allocations on this
117    /// backend's device. `None` when the backend genuinely cannot
118    /// measure (no OS query exposed, no vendor API supports it).
119    ///
120    /// "Available" semantics are device-relative:
121    ///
122    /// - CPU backends report system-wide free memory (OS query).
123    ///   Note this is shared with the whole OS — other processes
124    ///   can inflate / deflate the value unpredictably. The signal
125    ///   is noisier than per-process VRAM tracking; selectors
126    ///   should weight it accordingly.
127    /// - GPU backends report device-local free memory (driver query).
128    ///   Driver estimates include this process, other processes,
129    ///   and driver internals.
130    /// - Reference / synthetic backends MAY return `Some(u64::MAX)`
131    ///   to advertise "unbounded" capacity (never reports pressure).
132    fn available_bytes(&self) -> Option<u64>;
133
134    /// Total memory on this backend's device. Static after first
135    /// call; implementations cache unconditionally. `None` for
136    /// backends with unbounded notional capacity (e.g. Reference
137    /// returns `Some(u64::MAX)`, or `None` if the backend prefers
138    /// to advertise "unknowable").
139    fn total_bytes(&self) -> Option<u64>;
140
141    /// Predictive fit-check: would an allocation of `size` bytes
142    /// likely succeed given current state? Returns [`FitStatus::Tight`]
143    /// when projected post-allocation usage crosses the pressure
144    /// threshold (default 0.85 of total).
145    ///
146    /// Default implementation derives the answer from
147    /// [`Self::available_bytes`] + [`Self::total_bytes`]. Backends
148    /// with native predictive APIs (Vulkan `VK_EXT_memory_budget`)
149    /// override for accuracy — driver-level predictive checks can
150    /// detect fragmentation that a simple bytes-available subtraction
151    /// would miss.
152    fn would_fit(&self, size: u64) -> FitStatus {
153        match (self.available_bytes(), self.total_bytes()) {
154            (Some(a), _) if size > a => FitStatus::WontFit,
155            (Some(a), Some(t)) if t > 0 => {
156                let post_used = t.saturating_sub(a.saturating_sub(size));
157                if (post_used as f64) / (t as f64) > 0.85 {
158                    FitStatus::Tight
159                } else {
160                    FitStatus::Comfortable
161                }
162            }
163            _ => FitStatus::Unknown,
164        }
165    }
166
167    /// Tier-2 upcast seam: a runtime handle that also exposes a
168    /// deferred-execution queue model returns `Some(self)` so a
169    /// selector holding only a `&dyn BackendRuntime` (the type the
170    /// route picker's `BackendRuntimeLookup` hands out) can reach
171    /// the [`BackendStreams`] live-load surface without naming the
172    /// concrete handle type.
173    ///
174    /// Defaults to `None` — the honesty contract for Tier 2: a backend
175    /// with no queue concept (CPU, Reference) is **not** a
176    /// `BackendStreams` and says so, exactly as `available_bytes`
177    /// returns `None` for "I can't measure this." Returning `Some` is
178    /// a per-handle opt-in (see `DeviceRuntimeHandle` in
179    /// `fuel-tensor::pipelined_bridge`), never forced on the base trait.
180    ///
181    /// (Step E Phase C / B1: the seam the load-aware selector — C2 —
182    /// reads through. B1 wires the signal; nothing consults it yet.)
183    fn as_backend_streams(&self) -> Option<&dyn BackendStreams> {
184        None
185    }
186}
187
188/// Tier-2 conditional contract — backends with a deferred-execution
189/// (queue / stream / command-buffer) model expose live in-flight load
190/// here. See architecture v0.4
191/// (`docs/architecture/05-backend-contract.md` §Tier 2 → `BackendStreams`).
192///
193/// # Why a separate trait (the honesty contract)
194///
195/// The base [`BackendRuntime`] stays VRAM-only: a backend that
196/// dispatches synchronously (CPU, Reference) has no queue depth to
197/// report, and forcing a `pending_work_count` onto every backend would
198/// fabricate a "0 in flight" that reads as a real signal. `BackendStreams`
199/// is the Tier-2 extension only stream-like backends (CUDA streams,
200/// Vulkan queues / command buffers) implement; selectors reach it via
201/// [`BackendRuntime::as_backend_streams`] and treat its absence as
202/// "no load signal — fall back to static cost," never as "idle."
203///
204/// # Where the count comes from (Step E Phase C / B1)
205///
206/// The in-flight count a selector needs is the **executor's own
207/// submitted-but-not-drained async-op count**, not a driver query
208/// (`cuStreamQuery` is a busy/idle bool, not a depth). Fuel tracks it
209/// in a process-wide per-[`fuel_ir::DeviceLocation`] atomic counter
210/// (`fuel-dispatch::dispatch::inflight_count`) incremented when the
211/// executor submits an async op and decremented when the completion
212/// handle retires. A `BackendStreams` impl reads that counter for its
213/// own device — see `DeviceRuntimeHandle` in `fuel-tensor::pipelined_bridge`.
214pub trait BackendStreams: BackendRuntime {
215    /// Number of slots (streams / queues / command buffers) currently
216    /// busy with submitted-but-not-yet-finished work, for this handle's
217    /// device. `None` when the backend dispatches synchronously and has
218    /// no queue concept (selectors then have no load signal — fall back
219    /// to static cost, never treat `None` as "idle").
220    fn pending_work_count(&self) -> Option<u32>;
221
222    /// Maximum concurrent in-flight work this backend advertises. Used
223    /// by the runtime route picker for load tiering (`count /
224    /// slot_capacity`) and dispatch lookahead sizing.
225    fn slot_capacity(&self) -> u32;
226
227    /// Block until all submitted work on this backend's slots has
228    /// completed. Used at realize boundaries and by training-loop
229    /// barriers (gradient accumulation, optimizer step).
230    fn flush(&self) -> Result<()>;
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use fuel_ir::backend::{BackendCapabilities, SubstrateClass};
237    use fuel_ir::dispatch::OpKind;
238    use fuel_ir::{BackendId, DType, DeviceLocation};
239    use std::collections::HashSet;
240
241    /// Smoke: the BackendCapabilityProvider trait is object-safe and
242    /// implementable. (The `BackendCapabilities` *data* round-trip lives
243    /// with the data type in `fuel-ir`.)
244    #[test]
245    fn capability_provider_is_implementable() {
246        struct DummyDevice;
247        impl BackendCapabilityProvider for DummyDevice {
248            fn capabilities(&self) -> BackendCapabilities {
249                BackendCapabilities {
250                    backend_id: BackendId::Cpu,
251                    device_location: DeviceLocation::Cpu,
252                    op_dtype_support: HashSet::new(),
253                    required_alignment: 1,
254                    access_granularity_bits: 8,
255                    transfer_paths: Vec::new(),
256                    storage_substrate: SubstrateClass::HostBytes,
257                    compute_throughput_flops_per_ns: 1.0,
258                    mem_bandwidth_bytes_per_ns: 4.0,
259                }
260            }
261        }
262        let d = DummyDevice;
263        let caps = d.capabilities();
264        assert_eq!(caps.backend_id, BackendId::Cpu);
265        let _ = (OpKind::MatMul, DType::F32); // vocab reachable from the contract crate
266    }
267
268    // ===== BackendRuntime default `would_fit` impl =====
269
270    /// Test harness: an in-memory backend with configurable available
271    /// / total bytes for testing the default `would_fit` derivation.
272    struct MockRuntime {
273        available: Option<u64>,
274        total: Option<u64>,
275    }
276    impl BackendRuntime for MockRuntime {
277        fn available_bytes(&self) -> Option<u64> {
278            self.available
279        }
280        fn total_bytes(&self) -> Option<u64> {
281            self.total
282        }
283    }
284
285    /// Allocation strictly larger than available bytes → `WontFit`.
286    #[test]
287    fn would_fit_wont_fit_when_size_exceeds_available() {
288        let r = MockRuntime {
289            available: Some(1_000),
290            total: Some(10_000),
291        };
292        assert_eq!(r.would_fit(1_001), FitStatus::WontFit);
293    }
294
295    /// Small allocation that leaves usage well under the threshold
296    /// → `Comfortable`.
297    #[test]
298    fn would_fit_comfortable_when_post_alloc_under_threshold() {
299        // total=100, available=80 → currently using 20.
300        // size=10 → post-used=30, post-used/total=0.3 < 0.85.
301        let r = MockRuntime {
302            available: Some(80),
303            total: Some(100),
304        };
305        assert_eq!(r.would_fit(10), FitStatus::Comfortable);
306    }
307
308    /// Allocation that fits but pushes usage above the 0.85
309    /// threshold → `Tight`.
310    #[test]
311    fn would_fit_tight_when_post_alloc_above_threshold() {
312        // total=100, available=20 → currently using 80.
313        // size=10 → post-used=90, post-used/total=0.9 > 0.85.
314        let r = MockRuntime {
315            available: Some(20),
316            total: Some(100),
317        };
318        assert_eq!(r.would_fit(10), FitStatus::Tight);
319    }
320
321    /// `None` for either field → `Unknown`. Honest "I don't know,"
322    /// not a false `Comfortable` or `WontFit`.
323    #[test]
324    fn would_fit_unknown_on_none_signals() {
325        let r1 = MockRuntime {
326            available: None,
327            total: Some(100),
328        };
329        let r2 = MockRuntime {
330            available: Some(50),
331            total: None,
332        };
333        let r3 = MockRuntime {
334            available: None,
335            total: None,
336        };
337        assert_eq!(r1.would_fit(10), FitStatus::Unknown);
338        assert_eq!(r2.would_fit(10), FitStatus::Unknown);
339        assert_eq!(r3.would_fit(10), FitStatus::Unknown);
340    }
341
342    /// `total == 0` produces `Unknown` rather than a divide-by-zero
343    /// or a spurious `Tight`. Defensive.
344    #[test]
345    fn would_fit_unknown_on_zero_total() {
346        let r = MockRuntime {
347            available: Some(0),
348            total: Some(0),
349        };
350        assert_eq!(r.would_fit(0), FitStatus::Unknown);
351    }
352
353    /// Zero-byte allocation is always Comfortable on a non-saturated
354    /// backend.
355    #[test]
356    fn would_fit_zero_byte_alloc() {
357        let r = MockRuntime {
358            available: Some(50),
359            total: Some(100),
360        };
361        assert_eq!(r.would_fit(0), FitStatus::Comfortable);
362    }
363
364    // ===== Tier-2 BackendStreams + the as_backend_streams seam =====
365
366    /// A base `BackendRuntime` with no queue model is NOT a
367    /// `BackendStreams`: the default `as_backend_streams` returns `None`
368    /// (the honesty contract — a selector must read "no load signal,"
369    /// never a fabricated "idle"). `MockRuntime` does not implement
370    /// `BackendStreams`, so it keeps the default.
371    #[test]
372    fn base_runtime_is_not_streams_by_default() {
373        let r = MockRuntime {
374            available: Some(50),
375            total: Some(100),
376        };
377        assert!(
378            r.as_backend_streams().is_none(),
379            "a backend with no queue concept must report None via as_backend_streams",
380        );
381    }
382
383    /// A handle that DOES implement `BackendStreams` and opts in via
384    /// `as_backend_streams` is reachable through a bare
385    /// `&dyn BackendRuntime` — the exact upcast the load-aware selector
386    /// (C2) performs over the route picker's `BackendRuntimeLookup`
387    /// handle. `pending_work_count` is the carried live-load value.
388    #[test]
389    fn streams_handle_reachable_through_runtime_upcast() {
390        struct StreamingHandle {
391            inflight: u32,
392        }
393        impl BackendRuntime for StreamingHandle {
394            fn available_bytes(&self) -> Option<u64> {
395                None
396            }
397            fn total_bytes(&self) -> Option<u64> {
398                None
399            }
400            fn as_backend_streams(&self) -> Option<&dyn BackendStreams> {
401                Some(self)
402            }
403        }
404        impl BackendStreams for StreamingHandle {
405            fn pending_work_count(&self) -> Option<u32> {
406                Some(self.inflight)
407            }
408            fn slot_capacity(&self) -> u32 {
409                4
410            }
411            fn flush(&self) -> Result<()> {
412                Ok(())
413            }
414        }
415
416        let handle = StreamingHandle { inflight: 3 };
417        let as_runtime: &dyn BackendRuntime = &handle;
418        let streams = as_runtime
419            .as_backend_streams()
420            .expect("streaming handle exposes BackendStreams");
421        assert_eq!(streams.pending_work_count(), Some(3));
422        assert_eq!(streams.slot_capacity(), 4);
423        streams.flush().expect("flush is Ok");
424    }
425}