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}