Skip to main content

forge/
tensor.rs

1use std::sync::Arc;
2
3use crate::backend::wgpu::{OFFSET_ALIGN_BYTES, WgpuContext};
4use crate::device::Device;
5use crate::dtype::DType;
6use crate::error::{ForgeError, Result};
7use crate::shape::Shape;
8
9#[derive(Clone, Debug)]
10pub enum CpuStorage {
11    F32(Arc<Vec<f32>>),
12    U32(Arc<Vec<u32>>),
13}
14
15#[derive(Clone)]
16pub struct WgpuStorage {
17    pub ctx: Arc<WgpuContext>,
18    pub buf: Arc<wgpu::Buffer>,
19    /// View offset into `buf`, in elements.
20    pub offset: usize,
21}
22
23impl std::fmt::Debug for WgpuStorage {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        write!(f, "WgpuStorage(offset={})", self.offset)
26    }
27}
28
29#[derive(Clone, Debug)]
30pub enum Storage {
31    Cpu(CpuStorage),
32    Wgpu(WgpuStorage),
33}
34
35/// A contiguous, row-major tensor on a specific device.
36#[derive(Clone, Debug)]
37pub struct Tensor {
38    pub(crate) storage: Storage,
39    pub(crate) shape: Shape,
40    pub(crate) dtype: DType,
41}
42
43impl Tensor {
44    pub fn from_f32(data: &[f32], shape: impl Into<Shape>, device: &Device) -> Result<Tensor> {
45        let shape = shape.into();
46        if shape.numel() != data.len() {
47            return Err(ForgeError::Shape(format!(
48                "data length {} does not match shape {shape}",
49                data.len()
50            )));
51        }
52        let storage = match device {
53            Device::Cpu => Storage::Cpu(CpuStorage::F32(Arc::new(data.to_vec()))),
54            Device::Wgpu(ctx) => Storage::Wgpu(WgpuStorage {
55                ctx: ctx.clone(),
56                buf: Arc::new(ctx.upload(bytemuck::cast_slice(data))),
57                offset: 0,
58            }),
59        };
60        Ok(Tensor {
61            storage,
62            shape,
63            dtype: DType::F32,
64        })
65    }
66
67    pub fn from_u32(data: &[u32], shape: impl Into<Shape>, device: &Device) -> Result<Tensor> {
68        let shape = shape.into();
69        if shape.numel() != data.len() {
70            return Err(ForgeError::Shape(format!(
71                "data length {} does not match shape {shape}",
72                data.len()
73            )));
74        }
75        let storage = match device {
76            Device::Cpu => Storage::Cpu(CpuStorage::U32(Arc::new(data.to_vec()))),
77            Device::Wgpu(ctx) => Storage::Wgpu(WgpuStorage {
78                ctx: ctx.clone(),
79                buf: Arc::new(ctx.upload(bytemuck::cast_slice(data))),
80                offset: 0,
81            }),
82        };
83        Ok(Tensor {
84            storage,
85            shape,
86            dtype: DType::U32,
87        })
88    }
89
90    pub fn zeros(shape: impl Into<Shape>, device: &Device) -> Result<Tensor> {
91        let shape = shape.into();
92        Tensor::from_f32(&vec![0.0f32; shape.numel()], shape, device)
93    }
94
95    pub fn shape(&self) -> &Shape {
96        &self.shape
97    }
98
99    pub fn dtype(&self) -> DType {
100        self.dtype
101    }
102
103    pub fn device(&self) -> Device {
104        match &self.storage {
105            Storage::Cpu(_) => Device::Cpu,
106            Storage::Wgpu(s) => Device::Wgpu(s.ctx.clone()),
107        }
108    }
109
110    pub(crate) fn storage(&self) -> &Storage {
111        &self.storage
112    }
113
114    /// Copy the tensor contents to a host `Vec<f32>`. Sync facade: on wasm32
115    /// only CPU tensors can be read synchronously (roadmap v4, pitfall 14) —
116    /// use [`Tensor::to_vec_f32_async`] for WebGPU tensors there.
117    pub fn to_vec_f32(&self) -> Result<Vec<f32>> {
118        if self.dtype != DType::F32 {
119            return Err(ForgeError::Shape("to_vec_f32 on non-f32 tensor".into()));
120        }
121        match &self.storage {
122            Storage::Cpu(CpuStorage::F32(v)) => Ok(v.as_ref().clone()),
123            Storage::Cpu(_) => unreachable!("dtype checked above"),
124            #[cfg(not(target_arch = "wasm32"))]
125            Storage::Wgpu(s) => {
126                let bytes = s
127                    .ctx
128                    .readback(&s.buf, s.offset * 4, self.shape.numel() * 4)?;
129                Ok(bytemuck::pod_collect_to_vec(&bytes))
130            }
131            #[cfg(target_arch = "wasm32")]
132            Storage::Wgpu(_) => Err(ForgeError::Wgpu(
133                "sync readback unavailable on wasm32; use to_vec_f32_async".into(),
134            )),
135        }
136    }
137
138    pub fn to_vec_u32(&self) -> Result<Vec<u32>> {
139        if self.dtype != DType::U32 {
140            return Err(ForgeError::Shape("to_vec_u32 on non-u32 tensor".into()));
141        }
142        match &self.storage {
143            Storage::Cpu(CpuStorage::U32(v)) => Ok(v.as_ref().clone()),
144            Storage::Cpu(_) => unreachable!("dtype checked above"),
145            #[cfg(not(target_arch = "wasm32"))]
146            Storage::Wgpu(s) => {
147                let bytes = s
148                    .ctx
149                    .readback(&s.buf, s.offset * 4, self.shape.numel() * 4)?;
150                Ok(bytemuck::pod_collect_to_vec(&bytes))
151            }
152            #[cfg(target_arch = "wasm32")]
153            Storage::Wgpu(_) => Err(ForgeError::Wgpu(
154                "sync readback unavailable on wasm32; use to_vec_u32_async".into(),
155            )),
156        }
157    }
158
159    /// Async readback — the primary form (identical result to `to_vec_f32`);
160    /// on wasm the browser event loop drives the buffer mapping.
161    pub async fn to_vec_f32_async(&self) -> Result<Vec<f32>> {
162        if self.dtype != DType::F32 {
163            return Err(ForgeError::Shape("to_vec_f32 on non-f32 tensor".into()));
164        }
165        match &self.storage {
166            Storage::Cpu(CpuStorage::F32(v)) => Ok(v.as_ref().clone()),
167            Storage::Cpu(_) => unreachable!("dtype checked above"),
168            Storage::Wgpu(s) => {
169                let bytes = s
170                    .ctx
171                    .readback_async(&s.buf, s.offset * 4, self.shape.numel() * 4)
172                    .await?;
173                Ok(bytemuck::pod_collect_to_vec(&bytes))
174            }
175        }
176    }
177
178    /// Read several f32 tensors back in one GPU round trip, in the order
179    /// given.
180    ///
181    /// [`Tensor::to_vec_f32_async`] is a submit and a fence wait per call, so
182    /// a step that wants several small tensors — the attention probe reads
183    /// `n_layer + 1` per generated token — pays for the round trips, not the
184    /// bytes. Every tensor must be on the same device.
185    pub async fn to_vec_f32_batch(tensors: &[Tensor]) -> Result<Vec<Vec<f32>>> {
186        let mixed = || ForgeError::Shape("to_vec_f32_batch needs one device".into());
187        let mut ctx: Option<&Arc<WgpuContext>> = None;
188        for t in tensors {
189            if t.dtype != DType::F32 {
190                return Err(ForgeError::Shape("to_vec_f32 on non-f32 tensor".into()));
191            }
192            if let Storage::Wgpu(s) = &t.storage {
193                match ctx {
194                    None => ctx = Some(&s.ctx),
195                    Some(c) if Arc::ptr_eq(c, &s.ctx) => {}
196                    Some(_) => return Err(mixed()),
197                }
198            }
199        }
200        let Some(ctx) = ctx else {
201            // All host-side: nothing to stage, and no round trip to save.
202            return tensors.iter().map(Tensor::to_vec_f32).collect();
203        };
204        let mut regions = Vec::with_capacity(tensors.len());
205        for t in tensors {
206            match &t.storage {
207                Storage::Wgpu(s) => {
208                    regions.push((s.buf.as_ref(), s.offset * 4, t.shape.numel() * 4))
209                }
210                // A host tensor has nothing to stage, so a mixed batch would
211                // misalign the results with their inputs.
212                Storage::Cpu(_) => return Err(mixed()),
213            }
214        }
215        Ok(ctx
216            .readback_many_async(&regions)
217            .await?
218            .iter()
219            .map(|b| bytemuck::pod_collect_to_vec(b))
220            .collect())
221    }
222
223    pub async fn to_vec_u32_async(&self) -> Result<Vec<u32>> {
224        if self.dtype != DType::U32 {
225            return Err(ForgeError::Shape("to_vec_u32 on non-u32 tensor".into()));
226        }
227        match &self.storage {
228            Storage::Cpu(CpuStorage::U32(v)) => Ok(v.as_ref().clone()),
229            Storage::Cpu(_) => unreachable!("dtype checked above"),
230            Storage::Wgpu(s) => {
231                let bytes = s
232                    .ctx
233                    .readback_async(&s.buf, s.offset * 4, self.shape.numel() * 4)
234                    .await?;
235                Ok(bytemuck::pod_collect_to_vec(&bytes))
236            }
237        }
238    }
239
240    /// Reinterpret the shape; element count must be unchanged. Zero-copy.
241    pub fn reshape(&self, shape: impl Into<Shape>) -> Result<Tensor> {
242        let shape = shape.into();
243        if shape.numel() != self.shape.numel() {
244            return Err(ForgeError::Shape(format!(
245                "reshape {} -> {shape} changes element count",
246                self.shape
247            )));
248        }
249        Ok(Tensor {
250            storage: self.storage.clone(),
251            shape,
252            dtype: self.dtype,
253        })
254    }
255
256    /// View of `count` rows of a rank-2 tensor starting at `start`.
257    /// Zero-copy on WebGPU (subject to 256-byte offset alignment), copied on CPU.
258    pub fn narrow_rows(&self, start: usize, count: usize) -> Result<Tensor> {
259        if self.shape.rank() != 2 {
260            return Err(ForgeError::Shape(
261                "narrow_rows needs a rank-2 tensor".into(),
262            ));
263        }
264        let (rows, cols) = (self.shape.dim(0), self.shape.dim(1));
265        if start + count > rows {
266            return Err(ForgeError::Shape(format!(
267                "narrow_rows {start}+{count} out of bounds for {rows} rows"
268            )));
269        }
270        let shape = Shape::new(&[count, cols]);
271        match &self.storage {
272            Storage::Cpu(CpuStorage::F32(v)) => {
273                let slice = v[start * cols..(start + count) * cols].to_vec();
274                Ok(Tensor {
275                    storage: Storage::Cpu(CpuStorage::F32(Arc::new(slice))),
276                    shape,
277                    dtype: self.dtype,
278                })
279            }
280            Storage::Cpu(CpuStorage::U32(v)) => {
281                let slice = v[start * cols..(start + count) * cols].to_vec();
282                Ok(Tensor {
283                    storage: Storage::Cpu(CpuStorage::U32(Arc::new(slice))),
284                    shape,
285                    dtype: self.dtype,
286                })
287            }
288            Storage::Wgpu(s) => {
289                let offset = s.offset + start * cols;
290                if !(offset * self.dtype.size_bytes()).is_multiple_of(OFFSET_ALIGN_BYTES) {
291                    return Err(ForgeError::Wgpu(format!(
292                        "narrow_rows offset {offset} elems violates {OFFSET_ALIGN_BYTES}-byte alignment"
293                    )));
294                }
295                Ok(Tensor {
296                    storage: Storage::Wgpu(WgpuStorage {
297                        ctx: s.ctx.clone(),
298                        buf: s.buf.clone(),
299                        offset,
300                    }),
301                    shape,
302                    dtype: self.dtype,
303                })
304            }
305        }
306    }
307}