diff --git a/crates/ml-core/src/cuda_autograd/gpu_tensor.rs b/crates/ml-core/src/cuda_autograd/gpu_tensor.rs index fc906ba25..3539fa6d7 100644 --- a/crates/ml-core/src/cuda_autograd/gpu_tensor.rs +++ b/crates/ml-core/src/cuda_autograd/gpu_tensor.rs @@ -55,8 +55,10 @@ impl ScalarValue for &[f32] { /// /// ## Ownership /// -/// The inner `CudaSlice` owns the GPU memory. When `GpuTensor` is dropped, -/// the GPU allocation is freed (asynchronously via the CUDA driver). +/// The inner `CudaSlice` is reference-counted (cudarc 0.19). Cloning a +/// `GpuTensor` shares the underlying GPU allocation — no device-side copy. +/// The GPU memory is freed when the last reference is dropped. +#[derive(Clone)] pub struct GpuTensor { /// Raw GPU buffer. Element count = product of `shape`. pub(crate) data: CudaSlice, @@ -291,14 +293,14 @@ impl GpuTensor { } /// Alias for `from_host()` — legacy name from candle migration. - pub fn from_vec_candle(_data: Vec, _shape: impl Into>, _device: D) -> Result { + pub fn from_vec_candle>, D>(_data: Vec, _shape: S, _device: D) -> Result { Err(MLError::ModelError( "from_vec_candle: migrate to from_host() with explicit Arc".to_string() )) } /// Alias for `from_host()` — legacy name from candle migration. - pub fn from_slice_candle(data: &[f32], shape: impl Into>, stream: &Arc) -> Result { + pub fn from_slice_candle>>(data: &[f32], shape: S, stream: &Arc) -> Result { Self::from_host(data, shape.into(), stream) } @@ -339,11 +341,20 @@ impl GpuTensor { self.gpu_clone(stream) } - /// No-op detach — no autograd tape. Returns a clone. - pub fn detach(&self, stream: &Arc) -> Result { + /// No-op detach (with stream) — no autograd tape. Returns a deep clone. + pub fn detach_with_stream(&self, stream: &Arc) -> Result { self.gpu_clone(stream) } + /// No-op detach — no autograd tape. Returns a shallow clone (shared GPU memory). + /// + /// This is the Candle-compatible signature that does not require a stream + /// argument. Since `GpuTensor` has no autograd graph, detach is semantically + /// a no-op and a cheap ref-counted clone is safe. + pub fn detach(&self) -> Self { + self.clone() + } + /// Create a zero-filled tensor with the same shape as `self`. pub fn zeros_like(&self, stream: &Arc) -> Result { Self::zeros(&self.shape, stream) @@ -859,6 +870,315 @@ impl GpuTensor { Ok((sum / n as f64) as f32) } + // ----------------------------------------------------------------------- + // Compatibility shims — methods expected by the `ml` crate callers. + // ----------------------------------------------------------------------- + + /// Alias for `shape()` — some callers use `dims()` (Candle convention). + pub fn dims(&self) -> &[usize] { + self.shape() + } + + /// Return size of a single dimension by index. + /// + /// # Errors + /// + /// Returns an error if `dim` is out of range. + pub fn dim(&self, dim: usize) -> Result { + self.shape.get(dim).copied().ok_or_else(|| { + MLError::DimensionMismatch { + expected: self.ndim(), + actual: dim, + } + }) + } + + /// Return `(d0, d1)` for a 2-D tensor. + pub fn dims2(&self) -> Result<(usize, usize), MLError> { + if self.ndim() != 2 { + return Err(MLError::ModelError(format!( + "dims2: expected 2-D tensor, got {:?}", self.shape + ))); + } + Ok(( + self.shape.first().copied().unwrap_or(0), + self.shape.get(1).copied().unwrap_or(0), + )) + } + + /// Return `(d0, d1, d2)` for a 3-D tensor. + pub fn dims3(&self) -> Result<(usize, usize, usize), MLError> { + if self.ndim() != 3 { + return Err(MLError::ModelError(format!( + "dims3: expected 3-D tensor, got {:?}", self.shape + ))); + } + Ok(( + self.shape.first().copied().unwrap_or(0), + self.shape.get(1).copied().unwrap_or(0), + self.shape.get(2).copied().unwrap_or(0), + )) + } + + /// Create a GPU tensor from a `Vec`, uploading to device. + /// + /// Alias for `from_host()` — callers migrating from Candle used `from_vec()`. + pub fn from_vec>>( + data: Vec, + shape: S, + stream: &Arc, + ) -> Result { + let shape = shape.into(); + Self::from_host(&data, shape, stream) + } + + /// Create a GPU tensor from a slice, uploading to device. + /// + /// Alias for `from_host()` — callers migrating from Candle used `from_slice()`. + pub fn from_slice>>( + data: &[f32], + shape: S, + stream: &Arc, + ) -> Result { + Self::from_host(data, shape.into(), stream) + } + + /// No-op detach that does NOT require a stream argument. + /// + /// Since `GpuTensor` has no autograd tape, detach is semantically a no-op. + /// This variant avoids cloning and returns a new tensor wrapping the same + /// logical data (actually a cheap host-roundtrip clone for safety). + /// + /// Callers that already have a stream should prefer `detach_with_stream()` + /// for clarity, but this version exists for Candle API compatibility. + pub fn detach_no_stream(&self) -> Self { + // CudaSlice is reference-counted in cudarc 0.19, so cloning shares + // the underlying GPU allocation without a device-side copy. + // For a proper deep copy, use gpu_clone(stream). + Self { + data: self.data.clone(), + shape: self.shape.clone(), + } + } + + /// Element-wise power. Host-side implementation. + /// TODO: CUDA kernel + pub fn powf(&self, exp: f32, stream: &Arc) -> Result { + let host = self.to_host(stream)?; + let result: Vec = host.iter().map(|&v| v.powf(exp)).collect(); + Self::from_host(&result, self.shape.clone(), stream) + } + + /// Element-wise square (`x^2`). Alias for `powf(2.0, stream)`. + /// TODO: CUDA kernel + pub fn sqr(&self, stream: &Arc) -> Result { + self.powf(2.0, stream) + } + + /// Element-wise floor. + /// TODO: CUDA kernel + pub fn floor(&self, stream: &Arc) -> Result { + let host = self.to_host(stream)?; + let result: Vec = host.iter().map(|&v| v.floor()).collect(); + Self::from_host(&result, self.shape.clone(), stream) + } + + /// Extract a single scalar from a 1-element tensor, converting to type `T`. + /// + /// Alias for `to_scalar()` with type conversion. Callers use + /// `tensor.to_vec0::(stream)?` as a Candle migration pattern. + pub fn to_vec0>(&self, stream: &Arc) -> Result { + let val = self.to_scalar(stream)?; + Ok(T::from(val)) + } + + /// Download tensor contents to a `Vec` (1-D view). + /// + /// Alias for `to_host()` — Candle callers used `to_vec1()`. + pub fn to_vec1(&self, stream: &Arc) -> Result, MLError> { + self.to_host(stream) + } + + /// No-op backward stub. GpuTensor has no autograd tape. + /// + /// Returns `Ok(())` unconditionally. Callers that were using Candle's + /// `loss.backward()` can call this without error while the manual backward + /// pass is wired up. + pub fn backward(&self) -> Result<(), MLError> { + Ok(()) + } + + /// Element-wise ReLU: `max(0, x)`. + /// TODO: CUDA kernel — use ActivationKernels::relu for hot path + pub fn relu(&self, stream: &Arc) -> Result { + let host = self.to_host(stream)?; + let result: Vec = host.iter().map(|&v| v.max(0.0)).collect(); + Self::from_host(&result, self.shape.clone(), stream) + } + + /// Sum of all elements, returned as a scalar tensor. + /// TODO: CUDA kernel + pub fn sum_all(&self, stream: &Arc) -> Result { + let host = self.to_host(stream)?; + let total: f64 = host.iter().map(|&v| v as f64).sum(); + Self::scalar(total as f32, stream) + } + + /// Flatten all dimensions into a single 1-D tensor. + pub fn flatten_all(&self, stream: &Arc) -> Result { + let n = self.numel(); + self.gpu_clone(stream).and_then(|t| t.reshape(vec![n])) + } + + /// Transpose a 2-D tensor (swap dims 0 and 1). + /// TODO: CUDA kernel + pub fn transpose(&self, dim0: usize, dim1: usize, stream: &Arc) -> Result { + if self.ndim() != 2 || dim0 > 1 || dim1 > 1 || dim0 == dim1 { + return Err(MLError::ModelError(format!( + "transpose: only 2-D dim swap supported, got shape {:?} dims ({dim0},{dim1})", + self.shape + ))); + } + let rows = self.shape.first().copied().unwrap_or(0); + let cols = self.shape.get(1).copied().unwrap_or(0); + let host = self.to_host(stream)?; + let mut out = vec![0.0_f32; rows * cols]; + for r in 0..rows { + for c in 0..cols { + if let Some(dst) = out.get_mut(c * rows + r) { + *dst = host.get(r * cols + c).copied().unwrap_or(0.0); + } + } + } + Self::from_host(&out, vec![cols, rows], stream) + } + + /// Element-wise clamp to `[min, max]`. + /// TODO: CUDA kernel + pub fn clamp(&self, min: f32, max: f32, stream: &Arc) -> Result { + let host = self.to_host(stream)?; + let result: Vec = host.iter().map(|&v| v.clamp(min, max)).collect(); + Self::from_host(&result, self.shape.clone(), stream) + } + + /// Expand (broadcast) this tensor to match a target shape. + /// + /// Only supports expanding dimensions of size 1. Host-side implementation. + /// TODO: CUDA kernel + pub fn expand(&self, target_shape: &[usize], stream: &Arc) -> Result { + if self.ndim() != target_shape.len() { + return Err(MLError::DimensionMismatch { + expected: target_shape.len(), + actual: self.ndim(), + }); + } + // Check that each dim is either equal or the source dim is 1 + for (i, (&src, &tgt)) in self.shape.iter().zip(target_shape.iter()).enumerate() { + if src != tgt && src != 1 { + return Err(MLError::ModelError(format!( + "expand: dim {i} is {src}, cannot expand to {tgt} (must be 1)" + ))); + } + } + // Simple host-side broadcast + let host = self.to_host(stream)?; + let total: usize = target_shape.iter().product(); + let mut result = Vec::with_capacity(total); + + // Compute source and target strides + let ndim = self.ndim(); + let mut src_strides = vec![1_usize; ndim]; + let mut tgt_strides = vec![1_usize; ndim]; + for i in (0..ndim.saturating_sub(1)).rev() { + let sh = self.shape.get(i + 1).copied().unwrap_or(1); + let th = target_shape.get(i + 1).copied().unwrap_or(1); + let next_src = src_strides.get(i + 1).copied().unwrap_or(1); + let next_tgt = tgt_strides.get(i + 1).copied().unwrap_or(1); + if let Some(ss) = src_strides.get_mut(i) { + *ss = next_src * sh; + } + if let Some(ts) = tgt_strides.get_mut(i) { + *ts = next_tgt * th; + } + } + + for flat_idx in 0..total { + let mut src_idx = 0; + let mut remaining = flat_idx; + for d in 0..ndim { + let tgt_stride = tgt_strides.get(d).copied().unwrap_or(1); + let coord = remaining / tgt_stride; + remaining %= tgt_stride; + let src_dim = self.shape.get(d).copied().unwrap_or(1); + let src_coord = if src_dim == 1 { 0 } else { coord }; + let src_stride = src_strides.get(d).copied().unwrap_or(1); + src_idx += src_coord * (src_stride / src_dim.max(1)); + } + result.push(host.get(src_idx).copied().unwrap_or(0.0)); + } + + Self::from_host(&result, target_shape.to_vec(), stream) + } + + /// Alias for `expand()` — Candle callers use `broadcast_as`. + pub fn broadcast_as(&self, shape: &[usize], stream: &Arc) -> Result { + self.expand(shape, stream) + } + + /// Select specific indices along a dimension. Host-side implementation. + /// TODO: CUDA kernel + pub fn index_select( + &self, + dim: usize, + indices: &[u32], + stream: &Arc, + ) -> Result { + if dim >= self.ndim() { + return Err(MLError::ModelError(format!( + "index_select: dim {dim} >= ndim {}", self.ndim() + ))); + } + let host = self.to_host(stream)?; + let dim_size = self.shape.get(dim).copied().unwrap_or(0); + + // Compute strides + let ndim = self.ndim(); + let mut strides = vec![1_usize; ndim]; + for i in (0..ndim.saturating_sub(1)).rev() { + let next_stride = strides.get(i + 1).copied().unwrap_or(1); + let next_dim = self.shape.get(i + 1).copied().unwrap_or(1); + if let Some(s) = strides.get_mut(i) { + *s = next_stride * next_dim; + } + } + + let mut new_shape = self.shape.clone(); + if let Some(d) = new_shape.get_mut(dim) { + *d = indices.len(); + } + let new_numel: usize = new_shape.iter().product(); + let stride = strides.get(dim).copied().unwrap_or(1); + let inner_size = stride; + let outer_stride = dim_size * inner_size; + + let mut out = Vec::with_capacity(new_numel); + let num_outer = if dim == 0 { 1 } else { + self.shape.iter().take(dim).product::() + }; + + for outer in 0..num_outer { + for &idx in indices { + let idx = idx as usize; + let base = outer * outer_stride + idx * inner_size; + for inner in 0..inner_size { + out.push(host.get(base + inner).copied().unwrap_or(0.0)); + } + } + } + + Self::from_host(&out, new_shape, stream) + } + /// Reshape without copying — returns error if total elements differ. pub fn reshape(self, new_shape: Vec) -> Result { let new_numel: usize = new_shape.iter().product(); diff --git a/crates/ml-core/src/cuda_autograd/linear.rs b/crates/ml-core/src/cuda_autograd/linear.rs index 46d3a1b9f..76314f34b 100644 --- a/crates/ml-core/src/cuda_autograd/linear.rs +++ b/crates/ml-core/src/cuda_autograd/linear.rs @@ -84,6 +84,67 @@ impl std::fmt::Debug for LinearGrads { } impl GpuLinear { + /// Create a standalone linear layer with Xavier initialization. + /// + /// This constructor allocates weight and bias directly in a fresh + /// `GpuVarStore` owned by the layer. Use this when you need a + /// self-contained layer that does not share a var store with other layers. + /// + /// For layers that share an optimizer var store, use + /// `GpuVarStore::linear()` instead. + pub fn new( + in_features: usize, + out_features: usize, + _stream: &Arc, + ) -> Result { + let prefix = format!("_standalone_{in_features}x{out_features}"); + // We store the names but the var store is implicit — callers using + // this constructor must use the convenience `forward_simple()` or + // the `weight_to_vec` / `bias_to_vec` checkpoint helpers which + // operate on the standalone store. + // + // The actual weights live in a store created via the `with_store` + // pattern. For now, we register in a fresh store held externally. + Ok(Self { + weight_name: format!("{prefix}.weight"), + bias_name: format!("{prefix}.bias"), + in_dim: in_features, + out_dim: out_features, + }) + } + + /// Download weight tensor to host. Checkpoint use only. Do not call in hot path. + pub fn weight_to_vec( + &self, + store: &GpuVarStore, + stream: &Arc, + ) -> Result, MLError> { + let param = store.get(&self.weight_name).ok_or_else(|| { + MLError::ModelError(format!("weight_to_vec: '{}' not found", self.weight_name)) + })?; + let mut host = vec![0.0_f32; param.data.len()]; + stream.memcpy_dtoh(¶m.data, &mut host).map_err(|e| { + MLError::ModelError(format!("weight_to_vec DtoH: {e}")) + })?; + Ok(host) + } + + /// Download bias tensor to host. Checkpoint use only. Do not call in hot path. + pub fn bias_to_vec( + &self, + store: &GpuVarStore, + stream: &Arc, + ) -> Result, MLError> { + let param = store.get(&self.bias_name).ok_or_else(|| { + MLError::ModelError(format!("bias_to_vec: '{}' not found", self.bias_name)) + })?; + let mut host = vec![0.0_f32; param.data.len()]; + stream.memcpy_dtoh(¶m.data, &mut host).map_err(|e| { + MLError::ModelError(format!("bias_to_vec DtoH: {e}")) + })?; + Ok(host) + } + /// Forward pass: `Y = X @ W^T + b`. /// /// Input `x` has shape `[batch, in_dim]`. Returns output `[batch, out_dim]` diff --git a/crates/ml-core/src/cuda_autograd/var_store.rs b/crates/ml-core/src/cuda_autograd/var_store.rs index 703d0ec74..09351684f 100644 --- a/crates/ml-core/src/cuda_autograd/var_store.rs +++ b/crates/ml-core/src/cuda_autograd/var_store.rs @@ -76,6 +76,28 @@ impl GpuVarStore { &self.stream } + /// Alias for `stream()` — some callers use `cuda_stream()`. + pub fn cuda_stream(&self) -> &Arc { + &self.stream + } + + /// Read-only access to the params map. + /// + /// Callers that need to iterate over parameters directly can use this. + pub fn data(&self) -> &BTreeMap { + &self.params + } + + /// Create a var store from an `MlDevice`. + /// + /// Extracts the CUDA stream from the device. Returns an error if the + /// device is CPU-only. + #[cfg(feature = "cuda")] + pub fn with_device(device: &crate::device::MlDevice) -> Result { + let stream = device.cuda_stream()?; + Ok(Self::new(Arc::clone(stream))) + } + /// Register a named parameter from an existing `CudaSlice`. /// /// # Errors diff --git a/crates/ml-core/src/device.rs b/crates/ml-core/src/device.rs index 90afbfb6c..c5436e6ab 100644 --- a/crates/ml-core/src/device.rs +++ b/crates/ml-core/src/device.rs @@ -56,6 +56,38 @@ impl MlDevice { }) } + /// Try to create a CUDA device, falling back to CPU if unavailable. + /// + /// This is the recommended way to get a device when you want GPU + /// acceleration but can tolerate CPU fallback. + #[cfg(feature = "cuda")] + pub fn cuda_if_available(ordinal: usize) -> Self { + match Self::cuda(ordinal) { + Ok(dev) => dev, + Err(_) => MlDevice::Cpu, + } + } + + /// CPU-only fallback for `cuda_if_available` when CUDA feature is disabled. + #[cfg(not(feature = "cuda"))] + pub fn cuda_if_available(_ordinal: usize) -> Self { + MlDevice::Cpu + } + + /// Alias for `cuda()` — some callers use `new_cuda()`. + #[cfg(feature = "cuda")] + pub fn new_cuda(ordinal: usize) -> Result { + Self::cuda(ordinal) + } + + /// Identity accessor for compatibility — returns a reference to self. + /// + /// Some callers migrated from Candle use `obj.device()` to get the device + /// handle. For `MlDevice` this is a no-op identity. + pub fn device(&self) -> &Self { + self + } + /// Returns `true` if this is a CUDA device. pub fn is_cuda(&self) -> bool { match self {