tensor: minimal Tensor crate over xtrain-cuda
New xtrain-tensor crate: DType (F32), shape/stride helpers, Arc-counted host/device Storage with CPU↔CUDA copy, and a contiguous Tensor with creation, host↔device transfer, and a scale() op driving the elementwise kernel. GPU integration tests (host↔device roundtrip + scale correctness) gated behind not(no_cuda); a thin build.rs emits the no_cuda cfg so the kernel call sites compile out locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
47
crates/xtrain-tensor/src/dtype.rs
Normal file
47
crates/xtrain-tensor/src/dtype.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
//! Tensor data types.
|
||||
//!
|
||||
//! T2 only needs `F32`, but the enum + `TensorDType` trait are structured so
|
||||
//! half-precision types (F16/BF16) can be added later (T7 mixed precision)
|
||||
//! without touching call sites.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DType {
|
||||
F32,
|
||||
}
|
||||
|
||||
impl DType {
|
||||
pub fn size_bytes(self) -> usize {
|
||||
match self {
|
||||
DType::F32 => 4,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
DType::F32 => "f32",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.name())
|
||||
}
|
||||
}
|
||||
|
||||
/// Rust types that can back a tensor. Gives `from_slice`/`as_slice` type safety.
|
||||
pub trait TensorDType: Copy + Send + Sync + 'static {
|
||||
const DTYPE: DType;
|
||||
fn to_f64(self) -> f64;
|
||||
fn from_f64(v: f64) -> Self;
|
||||
}
|
||||
|
||||
impl TensorDType for f32 {
|
||||
const DTYPE: DType = DType::F32;
|
||||
fn to_f64(self) -> f64 {
|
||||
self as f64
|
||||
}
|
||||
fn from_f64(v: f64) -> Self {
|
||||
v as f32
|
||||
}
|
||||
}
|
||||
15
crates/xtrain-tensor/src/lib.rs
Normal file
15
crates/xtrain-tensor/src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! Minimal tensor abstraction for xtrain (Phase T2).
|
||||
//!
|
||||
//! Provides a `DType`, shape/stride helpers, reference-counted host/device
|
||||
//! `Storage`, and a `Tensor` with creation, host↔device transfer, and one
|
||||
//! elementwise CUDA op (`scale`) wired end-to-end.
|
||||
|
||||
pub mod dtype;
|
||||
pub mod shape;
|
||||
pub mod storage;
|
||||
pub mod tensor;
|
||||
|
||||
pub use dtype::{DType, TensorDType};
|
||||
pub use shape::Dims;
|
||||
pub use storage::{Device, Storage};
|
||||
pub use tensor::Tensor;
|
||||
57
crates/xtrain-tensor/src/shape.rs
Normal file
57
crates/xtrain-tensor/src/shape.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
//! Shape / stride helpers. Strides are in **elements** (not bytes), row-major.
|
||||
|
||||
use smallvec::SmallVec;
|
||||
|
||||
/// Inline storage for the common ≤4D case; spills to the heap beyond that.
|
||||
pub type Dims = SmallVec<[usize; 4]>;
|
||||
|
||||
/// Row-major (C order) contiguous strides for a shape.
|
||||
/// Example: `[2, 3, 4]` => `[12, 4, 1]`.
|
||||
pub fn contiguous_strides(shape: &[usize]) -> Dims {
|
||||
let mut strides: Dims = SmallVec::with_capacity(shape.len());
|
||||
strides.resize(shape.len(), 0);
|
||||
if shape.is_empty() {
|
||||
return strides;
|
||||
}
|
||||
strides[shape.len() - 1] = 1;
|
||||
for i in (0..shape.len() - 1).rev() {
|
||||
strides[i] = strides[i + 1] * shape[i + 1];
|
||||
}
|
||||
strides
|
||||
}
|
||||
|
||||
/// True if `strides` describe a row-major contiguous layout for `shape`.
|
||||
/// A mismatched stride on a size-1 dimension is fine (it is never stepped).
|
||||
pub fn is_contiguous(shape: &[usize], strides: &[usize]) -> bool {
|
||||
let ndim = shape.len();
|
||||
let mut expected = 1usize;
|
||||
for d in (0..ndim).rev() {
|
||||
if shape[d] != 1 && strides[d] != expected {
|
||||
return false;
|
||||
}
|
||||
expected *= shape[d];
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Total element count.
|
||||
pub fn num_elements(shape: &[usize]) -> usize {
|
||||
shape.iter().product()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn contiguous_strides_basic() {
|
||||
assert_eq!(contiguous_strides(&[2, 3, 4]).as_slice(), &[12, 4, 1]);
|
||||
assert_eq!(contiguous_strides(&[5]).as_slice(), &[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_contiguous_detects_transpose() {
|
||||
assert!(is_contiguous(&[2, 3], &[3, 1]));
|
||||
assert!(!is_contiguous(&[3, 2], &[1, 3]));
|
||||
}
|
||||
}
|
||||
109
crates/xtrain-tensor/src/storage.rs
Normal file
109
crates/xtrain-tensor/src/storage.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
//! Tensor storage: host (CPU) bytes or a GPU buffer, reference-counted so
|
||||
//! views (clones with different shape/strides) can share the backing data.
|
||||
|
||||
use std::sync::Arc;
|
||||
use xtrain_cuda::{GpuBuffer, Result as CudaResult};
|
||||
|
||||
enum StorageInner {
|
||||
Cpu { data: Vec<u8> },
|
||||
Cuda { buffer: GpuBuffer, device: u32 },
|
||||
}
|
||||
|
||||
/// Reference-counted tensor storage. Cloning is cheap (bumps the `Arc`).
|
||||
#[derive(Clone)]
|
||||
pub struct Storage(Arc<StorageInner>);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Device {
|
||||
Cpu,
|
||||
Cuda(u32),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Device {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Device::Cpu => write!(f, "cpu"),
|
||||
Device::Cuda(i) => write!(f, "cuda:{i}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
pub fn cpu(data: Vec<u8>) -> Self {
|
||||
Self(Arc::new(StorageInner::Cpu { data }))
|
||||
}
|
||||
|
||||
pub fn cuda(buffer: GpuBuffer, device: u32) -> Self {
|
||||
Self(Arc::new(StorageInner::Cuda { buffer, device }))
|
||||
}
|
||||
|
||||
pub fn device(&self) -> Device {
|
||||
match self.0.as_ref() {
|
||||
StorageInner::Cpu { .. } => Device::Cpu,
|
||||
StorageInner::Cuda { device, .. } => Device::Cuda(*device),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len_bytes(&self) -> usize {
|
||||
match self.0.as_ref() {
|
||||
StorageInner::Cpu { data } => data.len(),
|
||||
StorageInner::Cuda { buffer, .. } => buffer.len(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only view of CPU bytes. Panics if the storage lives on the GPU.
|
||||
pub fn as_cpu_bytes(&self) -> &[u8] {
|
||||
match self.0.as_ref() {
|
||||
StorageInner::Cpu { data } => data,
|
||||
StorageInner::Cuda { .. } => panic!("cannot read GPU storage as CPU bytes"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow the GPU buffer. Panics if the storage lives on the CPU.
|
||||
pub fn gpu_buffer(&self) -> &GpuBuffer {
|
||||
match self.0.as_ref() {
|
||||
StorageInner::Cuda { buffer, .. } => buffer,
|
||||
StorageInner::Cpu { .. } => panic!("cannot read CPU storage as GPU buffer"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy to another device. Returns a clone of the `Arc` when already there.
|
||||
/// T2 supports CPU↔CUDA(0); device-to-device copy across GPUs is out of scope.
|
||||
pub fn to_device(&self, target: Device) -> CudaResult<Self> {
|
||||
let current = self.device();
|
||||
if current == target {
|
||||
return Ok(self.clone());
|
||||
}
|
||||
match (current, target) {
|
||||
(Device::Cpu, Device::Cuda(dev)) => {
|
||||
let host = self.as_cpu_bytes();
|
||||
let mut buf = GpuBuffer::alloc(host.len())?;
|
||||
buf.copy_from_host(host)?;
|
||||
Ok(Storage::cuda(buf, dev))
|
||||
}
|
||||
(Device::Cuda(_), Device::Cpu) => {
|
||||
let src = self.gpu_buffer();
|
||||
let mut host = vec![0u8; src.len()];
|
||||
src.copy_to_host(&mut host)?;
|
||||
Ok(Storage::cpu(host))
|
||||
}
|
||||
(Device::Cuda(_), Device::Cuda(_)) => {
|
||||
panic!("cross-GPU storage transfer is not supported in T2")
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeroed storage on the given device.
|
||||
pub fn zeros(len_bytes: usize, device: Device) -> CudaResult<Self> {
|
||||
match device {
|
||||
Device::Cpu => Ok(Storage::cpu(vec![0u8; len_bytes])),
|
||||
Device::Cuda(dev) => {
|
||||
// No device memset in T2: stage zeros from the host.
|
||||
let mut buf = GpuBuffer::alloc(len_bytes)?;
|
||||
buf.copy_from_host(&vec![0u8; len_bytes])?;
|
||||
Ok(Storage::cuda(buf, dev))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
194
crates/xtrain-tensor/src/tensor.rs
Normal file
194
crates/xtrain-tensor/src/tensor.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
//! The `Tensor` type: shape/strides/dtype over reference-counted [`Storage`],
|
||||
//! with host↔device transfer and one elementwise op (`scale`) wired end-to-end
|
||||
//! through a CUDA kernel.
|
||||
|
||||
use crate::dtype::{DType, TensorDType};
|
||||
use crate::shape::{self, Dims};
|
||||
use crate::storage::{Device, Storage};
|
||||
|
||||
/// Multi-dimensional array backed by CPU or GPU storage.
|
||||
///
|
||||
/// Strides are in elements (row-major). T2 tensors created here are always
|
||||
/// contiguous; the `strides`/`offset` fields exist so later phases can add
|
||||
/// zero-copy views without changing this type's shape.
|
||||
#[derive(Clone)]
|
||||
pub struct Tensor {
|
||||
storage: Storage,
|
||||
shape: Dims,
|
||||
strides: Dims,
|
||||
offset: usize,
|
||||
dtype: DType,
|
||||
}
|
||||
|
||||
impl Tensor {
|
||||
// --- Creation ---
|
||||
|
||||
/// Build a contiguous CPU tensor from a typed host slice.
|
||||
pub fn from_slice<T: TensorDType>(data: &[T], shape: &[usize]) -> Self {
|
||||
let numel = shape::num_elements(shape);
|
||||
assert_eq!(
|
||||
data.len(),
|
||||
numel,
|
||||
"data length {} != shape numel {numel}",
|
||||
data.len()
|
||||
);
|
||||
let bytes = unsafe {
|
||||
std::slice::from_raw_parts(data.as_ptr() as *const u8, numel * T::DTYPE.size_bytes())
|
||||
};
|
||||
Self {
|
||||
storage: Storage::cpu(bytes.to_vec()),
|
||||
shape: Dims::from_slice(shape),
|
||||
strides: shape::contiguous_strides(shape),
|
||||
offset: 0,
|
||||
dtype: T::DTYPE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-filled contiguous tensor on the given device.
|
||||
pub fn zeros(shape: &[usize], dtype: DType, device: Device) -> Self {
|
||||
let len_bytes = shape::num_elements(shape) * dtype.size_bytes();
|
||||
let storage = Storage::zeros(len_bytes, device).expect("zeros alloc failed");
|
||||
Self {
|
||||
storage,
|
||||
shape: Dims::from_slice(shape),
|
||||
strides: shape::contiguous_strides(shape),
|
||||
offset: 0,
|
||||
dtype,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Properties ---
|
||||
|
||||
pub fn shape(&self) -> &[usize] {
|
||||
&self.shape
|
||||
}
|
||||
pub fn strides(&self) -> &[usize] {
|
||||
&self.strides
|
||||
}
|
||||
pub fn dtype(&self) -> DType {
|
||||
self.dtype
|
||||
}
|
||||
pub fn ndim(&self) -> usize {
|
||||
self.shape.len()
|
||||
}
|
||||
pub fn numel(&self) -> usize {
|
||||
shape::num_elements(&self.shape)
|
||||
}
|
||||
pub fn offset(&self) -> usize {
|
||||
self.offset
|
||||
}
|
||||
pub fn device(&self) -> Device {
|
||||
self.storage.device()
|
||||
}
|
||||
pub fn is_contiguous(&self) -> bool {
|
||||
shape::is_contiguous(&self.shape, &self.strides)
|
||||
}
|
||||
pub fn storage(&self) -> &Storage {
|
||||
&self.storage
|
||||
}
|
||||
|
||||
// --- Device transfer ---
|
||||
|
||||
/// Move (copy) the tensor to `device`. Returns a cheap clone if already there.
|
||||
pub fn to_device(&self, device: Device) -> Self {
|
||||
if self.device() == device {
|
||||
return self.clone();
|
||||
}
|
||||
let storage = self
|
||||
.storage
|
||||
.to_device(device)
|
||||
.expect("device transfer failed");
|
||||
Self {
|
||||
storage,
|
||||
shape: self.shape.clone(),
|
||||
strides: self.strides.clone(),
|
||||
offset: self.offset,
|
||||
dtype: self.dtype,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Host data access (CPU only) ---
|
||||
|
||||
/// Typed read-only view of the data. Requires a contiguous CPU tensor.
|
||||
pub fn as_slice<T: TensorDType>(&self) -> &[T] {
|
||||
assert_eq!(T::DTYPE, self.dtype, "dtype mismatch");
|
||||
assert_eq!(self.device(), Device::Cpu, "as_slice requires CPU tensor");
|
||||
assert!(self.is_contiguous(), "as_slice requires contiguous tensor");
|
||||
let bytes = self.storage.as_cpu_bytes();
|
||||
let start = self.offset * self.dtype.size_bytes();
|
||||
unsafe { std::slice::from_raw_parts(bytes[start..].as_ptr() as *const T, self.numel()) }
|
||||
}
|
||||
|
||||
/// Raw element pointer at the tensor's offset (for kernel launches).
|
||||
pub fn data_ptr(&self) -> *const u8 {
|
||||
let byte_off = self.offset * self.dtype.size_bytes();
|
||||
match self.device() {
|
||||
Device::Cpu => unsafe { self.storage.as_cpu_bytes().as_ptr().add(byte_off) },
|
||||
Device::Cuda(_) => unsafe { self.storage.gpu_buffer().as_ptr().add(byte_off) },
|
||||
}
|
||||
}
|
||||
|
||||
// --- Elementwise op (the T2 end-to-end kernel) ---
|
||||
|
||||
/// Out-of-place elementwise scale: returns a new tensor `out[i] = self[i] * alpha`.
|
||||
///
|
||||
/// Runs the `scale_f32` CUDA kernel. Requires a contiguous F32 tensor on the
|
||||
/// GPU. Available only when CUDA was compiled in (`not(no_cuda)`).
|
||||
#[cfg(not(no_cuda))]
|
||||
pub fn scale(&self, alpha: f32) -> Self {
|
||||
assert_eq!(self.dtype, DType::F32, "scale only supports F32 in T2");
|
||||
assert!(self.is_contiguous(), "scale requires contiguous tensor");
|
||||
assert!(
|
||||
matches!(self.device(), Device::Cuda(_)),
|
||||
"scale requires a CUDA tensor"
|
||||
);
|
||||
|
||||
let out = Tensor::zeros(&self.shape, self.dtype, self.device());
|
||||
unsafe {
|
||||
xtrain_cuda::ffi::launch_scale_f32(
|
||||
self.data_ptr() as *const f32,
|
||||
out.data_ptr() as *mut f32,
|
||||
alpha,
|
||||
self.numel() as i32,
|
||||
std::ptr::null_mut(), // default stream
|
||||
);
|
||||
}
|
||||
xtrain_cuda::device::synchronize().expect("scale kernel sync failed");
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Tensor {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"Tensor(shape={:?}, dtype={}, device={}, contiguous={})",
|
||||
self.shape.as_slice(),
|
||||
self.dtype,
|
||||
self.device(),
|
||||
self.is_contiguous()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_slice_shape_and_data() {
|
||||
let t = Tensor::from_slice(&[1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0], &[2, 3]);
|
||||
assert_eq!(t.shape(), &[2, 3]);
|
||||
assert_eq!(t.strides(), &[3, 1]);
|
||||
assert_eq!(t.numel(), 6);
|
||||
assert_eq!(t.device(), Device::Cpu);
|
||||
assert!(t.is_contiguous());
|
||||
assert_eq!(t.as_slice::<f32>(), &[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zeros_cpu() {
|
||||
let t = Tensor::zeros(&[4], DType::F32, Device::Cpu);
|
||||
assert_eq!(t.as_slice::<f32>(), &[0.0, 0.0, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user