fuel_memory/lib.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! # fuel-storage
3//!
4//! Unified storage abstraction for fuel. Phase 7.5 foundation work item
5//! (see [docs/storage-unification.md](../../docs/storage-unification.md)).
6//!
7//! `Storage` is the single entry point that holds bytes, a dtype tag,
8//! and a backend memory region (closed enum over CPU/CUDA/Vulkan/Metal).
9//! Backends provide *kernels* that operate on these types — backend
10//! storage types live in their own crates and implement the
11//! `fuel_ir::backend::BackendStorage` trait.
12//!
13//! This crate now owns ONLY the closed-enum dispatch wrapper + the
14//! public `Storage` API. Dispatch infrastructure (KernelBindingTable,
15//! registration wrappers, CompiledNode, ExecutionPlan, the picker,
16//! FusedKernelRegistry, cost functions, the PipelinedExecutor) was
17//! extracted to [`fuel-dispatch`](../../fuel-dispatch/) 2026-05-31;
18//! see [docs/session-prompts/dispatch-move-to-fuel-core.md](
19//! ../../docs/session-prompts/dispatch-move-to-fuel-core.md) for the
20//! move's rationale.
21//!
22//! ## Where things live
23//!
24//! - `fuel_ir::backend::BackendStorage` — the abstract trait
25//! (just `len_bytes()` today; alloc/copy_from land in A4).
26//! - [`fuel_cpu_backend::CpuStorageBytes`] — CPU storage (Phase A3.0).
27//! Bytes-based, 64-byte aligned, `Arc`-clonable, CoW on mutation.
28//! - `fuel_metal_backend::MetalStorageBytes` — Metal storage (A3.1).
29//! - `fuel_cuda_backend::CudaStorageBytes` — CUDA storage (A3.2).
30//! - `fuel_vulkan_backend::VulkanStorageBytes` — Vulkan storage (A3.3).
31//! - **`fuel_dispatch`** — every binding-table, registration wrapper,
32//! picker, executor, and cost-fn that used to live here.
33
34/// Vulkan storage variant — re-exported from fuel-vulkan-backend when
35/// the vulkan feature is enabled.
36#[cfg(feature = "vulkan")]
37pub use fuel_vulkan_backend::VulkanStorageBytes as VulkanStorage;
38
39/// CUDA storage variant — re-exported from fuel-cuda-backend when
40/// the cuda feature is enabled.
41#[cfg(feature = "cuda")]
42pub use fuel_cuda_backend::CudaStorageBytes as CudaStorage;
43
44/// Metal storage variant — re-exported from fuel-metal-backend on
45/// Apple platforms when the metal feature is enabled. The metal
46/// feature has no effect on non-Apple platforms (the dep is
47/// target-gated).
48#[cfg(all(feature = "metal", any(target_os = "macos", target_os = "ios")))]
49pub use fuel_metal_backend::MetalStorageBytes as MetalStorage;
50
51use fuel_cpu_backend::CpuStorageBytes;
52use fuel_ir::storage::OutputView;
53use fuel_ir::{DType, Result, SType};
54use std::sync::Arc;
55
56#[cfg(feature = "dlpack")]
57pub mod dlpack_view;
58
59/// Closed enum over backend storage variants. The `Cpu` variant
60/// holds [`CpuStorageBytes`] from `fuel-cpu-backend`. GPU variants
61/// (feature-gated) hold the per-backend `*StorageBytes` types from
62/// each backend crate.
63#[derive(Debug)]
64pub enum BackendStorage {
65 Cpu(CpuStorageBytes),
66 #[cfg(feature = "cuda")]
67 Cuda(CudaStorage),
68 #[cfg(feature = "vulkan")]
69 Vulkan(VulkanStorage),
70 #[cfg(all(feature = "metal", any(target_os = "macos", target_os = "ios")))]
71 Metal(MetalStorage),
72}
73
74/// Top-level storage type: byte-erased payload + runtime dtype tag.
75/// Layout (shape + strides + start_offset) lives separately on the
76/// consuming `Tensor` — `Storage` owns only the bytes and which
77/// device/dtype they represent.
78///
79/// Optionally carries a `bundle` side-table describing how the inner
80/// byte buffer is partitioned into multiple logically independent
81/// outputs. Set by multi-output op authors via
82/// [`Storage::with_bundle`] / [`Storage::new_bundled`]; consumed by
83/// `Op::View` / `Op::ViewOwned` at realize time. See
84/// [`OutputView`](fuel_ir::storage::OutputView).
85#[derive(Debug)]
86pub struct Storage {
87 /// Backend variant + the bytes themselves.
88 pub inner: BackendStorage,
89 /// How to interpret the bytes. Storage's `len_bytes` is the byte
90 /// count; the element count is `len_bytes / dtype.size_in_bytes()`.
91 pub dtype: DType,
92 /// `None` for single-output storage (today's default). `Some(_)`
93 /// for multi-output bundles: a shared Arc'd slice of per-slot
94 /// [`OutputView`] entries, one per logical output. `Op::View`
95 /// nodes share this Arc so the bundle stays alive as long as any
96 /// view holds a reference.
97 pub bundle: Option<Arc<[OutputView]>>,
98 /// How the bytes are ENCODED (orthogonal to `dtype`, which is the
99 /// logical element type). Empty = plain dense `dtype`, byte-identical
100 /// to pre-SType behaviour. v1: PRIMARY storage only — bundle slots
101 /// keep `dtype` only (per-slot SType is a future addition). See
102 /// [`fuel_ir::SType`] and
103 /// `docs/specs/storage-encoding.md`.
104 pub stype: SType,
105}
106
107/// Feature-aware match over `BackendStorage` variants. Used wherever
108/// the dispatch shape `match s { Cpu(...) => ..., Cuda(...) => ... }`
109/// would otherwise need `#[cfg(feature = "...")]` arms inline.
110///
111/// ```
112/// # use fuel_memory::{BackendStorage, dispatch_storage};
113/// fn len_bytes(s: &BackendStorage) -> usize {
114/// dispatch_storage!(s, inner => inner.len_bytes())
115/// }
116/// ```
117#[macro_export]
118macro_rules! dispatch_storage {
119 ($s:expr, $name:ident => $body:expr) => {
120 match $s {
121 $crate::BackendStorage::Cpu($name) => $body,
122 #[cfg(feature = "cuda")]
123 $crate::BackendStorage::Cuda($name) => $body,
124 #[cfg(feature = "vulkan")]
125 $crate::BackendStorage::Vulkan($name) => $body,
126 #[cfg(all(feature = "metal", any(target_os = "macos", target_os = "ios")))]
127 $crate::BackendStorage::Metal($name) => $body,
128 }
129 };
130}
131
132impl BackendStorage {
133 /// Total addressable byte count, regardless of dtype.
134 pub fn len_bytes(&self) -> usize {
135 dispatch_storage!(self, inner => inner.len_bytes())
136 }
137}
138
139impl Storage {
140 /// Build a Storage from an already-allocated backend variant
141 /// plus its dtype tag. Single-output (no bundle metadata); use
142 /// [`Self::with_bundle`] / [`Self::new_bundled`] for the
143 /// multi-output case.
144 pub fn new(inner: BackendStorage, dtype: DType) -> Self {
145 Self {
146 inner,
147 dtype,
148 bundle: None,
149 stype: SType::default(),
150 }
151 }
152
153 /// Build a Storage from a backend variant + dtype tag + bundle
154 /// side-table in one shot. Validates that the bundle is
155 /// non-empty and that slot 0's dtype matches the storage's
156 /// primary dtype (the bundled-storage invariant: slot 0 IS the
157 /// primary).
158 pub fn new_bundled(
159 inner: BackendStorage,
160 dtype: DType,
161 bundle: Arc<[OutputView]>,
162 ) -> Result<Self> {
163 Self::validate_bundle(dtype, &bundle)?;
164 Ok(Self {
165 inner,
166 dtype,
167 bundle: Some(bundle),
168 stype: SType::default(),
169 })
170 }
171
172 /// Attach a bundle side-table to an existing single-output
173 /// Storage. Same validation as [`Self::new_bundled`]; panics in
174 /// debug mode if a bundle is already attached (re-bundling is a
175 /// contract bug).
176 pub fn with_bundle(mut self, bundle: Arc<[OutputView]>) -> Result<Self> {
177 debug_assert!(
178 self.bundle.is_none(),
179 "Storage::with_bundle: bundle already attached",
180 );
181 Self::validate_bundle(self.dtype, &bundle)?;
182 self.bundle = Some(bundle);
183 Ok(self)
184 }
185
186 fn validate_bundle(primary_dtype: DType, bundle: &Arc<[OutputView]>) -> Result<()> {
187 if bundle.is_empty() {
188 return Err(fuel_ir::Error::Msg(
189 "Storage::with_bundle: bundle slice must be non-empty".into(),
190 )
191 .bt());
192 }
193 let slot0 = &bundle[0];
194 if slot0.dtype != primary_dtype {
195 return Err(fuel_ir::Error::Msg(format!(
196 "Storage::with_bundle: slot 0 dtype {:?} must match \
197 Storage's primary dtype {:?}",
198 slot0.dtype, primary_dtype,
199 ))
200 .bt());
201 }
202 Ok(())
203 }
204
205 /// Whether this storage carries multi-output bundle metadata.
206 pub fn is_bundled(&self) -> bool {
207 self.bundle.is_some()
208 }
209
210 /// Number of logical output slots. `1` for single-output;
211 /// `bundle.len()` for bundled.
212 pub fn slot_count(&self) -> usize {
213 self.bundle.as_ref().map_or(1, |b| b.len())
214 }
215
216 /// Borrow the bundle slice, or `None` for single-output storage.
217 pub fn bundle(&self) -> Option<&[OutputView]> {
218 self.bundle.as_deref()
219 }
220
221 /// Per-slot view; `None` for out-of-range or single-output.
222 pub fn slot_view(&self, idx: usize) -> Option<&OutputView> {
223 self.bundle.as_deref().and_then(|b| b.get(idx))
224 }
225
226 /// The `DType` tag attached to these bytes.
227 pub fn dtype(&self) -> DType {
228 self.dtype
229 }
230
231 /// Total addressable byte count.
232 pub fn len_bytes(&self) -> usize {
233 self.inner.len_bytes()
234 }
235
236 /// Element count = `len_bytes / dtype.size_in_bytes()`.
237 pub fn elem_count(&self) -> usize {
238 let bps = self.dtype.size_in_bytes();
239 self.len_bytes().checked_div(bps).unwrap_or(0)
240 }
241
242 /// Attach an encoding scheme to this storage (consuming builder).
243 /// Does not touch the bytes or the logical dtype — only describes
244 /// HOW the bytes are encoded. See [`fuel_ir::SType`].
245 pub fn with_stype(mut self, stype: SType) -> Self {
246 self.stype = stype;
247 self
248 }
249
250 /// The encoding scheme. Empty = plain dense `dtype`.
251 pub fn stype(&self) -> &SType {
252 &self.stype
253 }
254}
255
256/// Allocate freshly on the CPU backend with the given dtype + element
257/// count. Bytes are zero-initialized and 64-byte aligned (suitable
258/// for AVX-512 SIMD).
259pub fn alloc_cpu_zeroed(dtype: DType, elem_count: usize) -> Result<Storage> {
260 let len_bytes = elem_count.saturating_mul(dtype.size_in_bytes());
261 Ok(Storage::new(
262 BackendStorage::Cpu(CpuStorageBytes::from_zero_bytes(len_bytes)),
263 dtype,
264 ))
265}
266
267/// Build a CPU `Storage` from a typed slice, copying the bytes. The
268/// result has the dtype matching `T` and is 64-byte aligned.
269pub fn from_slice_cpu<T: bytemuck::Pod + fuel_ir::WithDType>(data: &[T]) -> Storage {
270 Storage::new(
271 BackendStorage::Cpu(CpuStorageBytes::from_slice(data)),
272 T::DTYPE,
273 )
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 /// Smoke: building a Storage via the CPU backend and reading back
281 /// dtype + len_bytes + elem_count works.
282 #[test]
283 fn cpu_storage_basic_shape() {
284 let s = alloc_cpu_zeroed(DType::F32, 4).expect("alloc");
285 assert_eq!(s.dtype(), DType::F32);
286 assert_eq!(s.len_bytes(), 16);
287 assert_eq!(s.elem_count(), 4);
288 }
289
290 /// Smoke: dispatch_storage! macro picks the right variant arm.
291 #[test]
292 fn dispatch_macro_routes_to_variant() {
293 let bs = BackendStorage::Cpu(CpuStorageBytes::from_zero_bytes(8));
294 let n = dispatch_storage!(&bs, inner => inner.len_bytes());
295 assert_eq!(n, 8);
296 }
297
298 /// Smoke: BackendStorage::len_bytes goes through dispatch_storage!
299 /// and matches the underlying CpuStorageBytes len_bytes.
300 #[test]
301 fn backend_storage_len_bytes_dispatches() {
302 let bs = BackendStorage::Cpu(CpuStorageBytes::from_zero_bytes(32));
303 assert_eq!(bs.len_bytes(), 32);
304 }
305
306 /// Smoke: zero-element allocations still produce a valid Storage
307 /// with elem_count 0 and dtype intact.
308 #[test]
309 fn zero_element_allocation() {
310 let s = alloc_cpu_zeroed(DType::F64, 0).expect("alloc");
311 assert_eq!(s.dtype(), DType::F64);
312 assert_eq!(s.len_bytes(), 0);
313 assert_eq!(s.elem_count(), 0);
314 }
315
316 /// Smoke: from_slice_cpu preserves dtype + values via Pod cast.
317 #[test]
318 fn from_slice_cpu_round_trip() {
319 let data = vec![1.0_f32, 2.0, 3.0, 4.0];
320 let s = from_slice_cpu(&data);
321 assert_eq!(s.dtype(), DType::F32);
322 assert_eq!(s.elem_count(), 4);
323 assert_eq!(s.len_bytes(), 16);
324 }
325
326 /// Born-red (step 2): a plain Storage has an empty (plain) SType by default.
327 #[test]
328 fn plain_storage_has_empty_stype() {
329 let s = alloc_cpu_zeroed(DType::F32, 4).expect("alloc");
330 assert!(
331 s.stype().is_plain(),
332 "default Storage must carry a plain SType"
333 );
334 assert_eq!(s.stype().layers().len(), 0);
335 }
336
337 /// Born-red (step 2): attaching an SType is preserved and does not disturb
338 /// the logical dtype or the byte count.
339 #[test]
340 fn storage_with_stype_round_trips() {
341 use fuel_ir::{Encoding, GgmlDType, SType};
342 let s = alloc_cpu_zeroed(DType::F32, 8)
343 .expect("alloc")
344 .with_stype(SType::from_layer(Encoding::GgmlBlock {
345 ggml_dtype: GgmlDType::Q4_0,
346 }));
347 assert!(!s.stype().is_plain());
348 assert_eq!(s.dtype(), DType::F32); // dtype unchanged
349 assert_eq!(s.len_bytes(), 32); // bytes unchanged
350 }
351
352 /// A4: alloc symmetry — CpuStorageBytes::alloc and from_zero_bytes
353 /// produce the same shape.
354 #[test]
355 fn cpu_storage_alloc_alias() {
356 let a = fuel_cpu_backend::CpuStorageBytes::alloc(24);
357 let b = fuel_cpu_backend::CpuStorageBytes::from_zero_bytes(24);
358 assert_eq!(a.len_bytes(), b.len_bytes());
359 assert_eq!(a.bytes(), b.bytes());
360 }
361}