📄 mod.rs
/home/palash/git/iron_learn/src/cuda_tensor/mod.rs
Language: rs • Lines: 700
//! GPU-backed tensor implementation and CUDA helpers. Only available with `--features=cuda`
//!
//! This module provides a `GpuTensor<T>` implementation of the crate `Tensor`
//! trait which stores data in CUDA device memory and exposes common
//! tensor operations (element-wise ops, matrix multiply, reduction, etc.)
//! implemented using CUDA kernels and cuBLAS where appropriate.
//!
//! Important notes for production use:
//! - The global `GPU_CONTEXT` must be initialized before creating or using
//!   any `GpuTensor` instances.
//! - Many helpers launch device kernels and thus rely on correct stream and
//!   kernel linkage; errors from kernel launches are surfaced as `Err(String)`
//!   in fallible APIs.
//! - Only tensors with up to 2 dimensions are currently supported for some
//!   operations (e.g. transpose) — callers should validate shape expectations.
use crate::cuda_tensor::custom_device_buffer::{
    get_device_buffer, get_device_buffer_from_slice, CustomDeviceBuffer,
};
use crate::numeric::FloatingPoint;
use crate::numeric::{Numeric, SignedNumeric};
use crate::tensor::math::TensorMath;
use crate::GPU_CONTEXT;
use core::ffi::c_void;
use cublas_sys::*;
use cust::memory::bytemuck::Zeroable;
use std::ops::{Add, Mul, Neg, Sub};

use crate::Tensor;
mod custom_device_buffer;
/// A lightweight device memory pool to reduce allocation overhead.
mod mem_pool;
/// Public re-export of the CUDA memory pool manager.
pub use mem_pool::CudaMemoryPool;
mod cublas_handle;
/// Public re-export for managing cuBLAS handles scoped to the GPU context.
pub use cublas_handle::CublasHandle;
use cust::launch;
use cust::memory::DeviceCopy;
use cust::prelude::DeviceBuffer;
use cust::prelude::Function;
use cust::stream::Stream;

/// Operation selector for element-wise device kernels.
///
/// This enum is passed to device kernels to select the requested element-wise
/// operation (scale, exp, trig functions, activation functions, etc.). The
/// numeric discriminants are matched in the CUDA kernels.
#[derive(Clone, Copy, Debug)]
enum OpType {
    Exp = 0,
    Sin = 2,
    Cos = 3,
    Tan = 4,
    Tanh = 5,
    Sigmoid = 6,
    Log = 7,
    Ln = 8,
    GreaterThanZeroMask = 9,
    ReLU = 10,
}

/// Selector for binary element-wise arithmetic kernels.
///
/// Used by the vector arithmetic kernel to select addition/subtraction/
/// multiplication/division between two tensors.
#[derive(Clone, Copy, Debug)]
enum ArithmaticType {
    Add = 1,
    Sub = 2,
    Mul = 3,
    Div = 4,
}

/// A tensor whose backing storage resides on a CUDA device.
///
/// `GpuTensor<T>` stores its shape as a `Vec<u32>` and owns a
/// `CustomDeviceBuffer<T>` that contains the device memory. The generic `T`
/// must implement `Numeric` and `DeviceCopy` so it can be moved between host
/// and device memory. Most public operations return `Result<Self, String>` to
/// surface errors coming from invalid shapes or CUDA/kernel failures.
#[derive(Debug)]
pub struct GpuTensor<T: Numeric + DeviceCopy> {
    shape: Vec<u32>,
    device_buffer: CustomDeviceBuffer<T>,
}

impl<T: Numeric + Zeroable + DeviceCopy> Tensor<T> for GpuTensor<T> {
    /// Create a new `GpuTensor` from host `data` and move it to device memory.
    ///
    /// Returns `Err(String)` when the provided data length does not match the
    /// product of `shape` or if other allocation errors occur.
    fn new(shape: Vec<u32>, data: Vec<T>) -> Result<Self, String> {
        Self::_new(shape, data)
    }

    /// Synchronously copy the tensor contents from device to host and return
    /// them as a `Vec<T>`.
    fn get_data(&self) -> Vec<T> {
        self._data()
    }

    /// Return the tensor shape as a slice of dimensions.
    fn get_shape(&self) -> &Vec<u32> {
        &self.shape
    }

    /// Element-wise addition of two tensors. Returns an error on shape
    /// mismatch.
    fn add(&self, rhs: &Self) -> Result<Self, String> {
        self._element_arithmatic(rhs, ArithmaticType::Add)
    }

    /// Element-wise subtraction. Returns an error on shape mismatch.
    fn sub(&self, rhs: &Self) -> Result<Self, String> {
        self._element_arithmatic(rhs, ArithmaticType::Sub)
    }

    /// Element-wise multiplication (Hadamard product). Returns an error on
    /// shape mismatch.
    fn matmul(&self, rhs: &Self) -> Result<Self, String> {
        self._mul(rhs)
    }

    /// Element-wise division. Returns an error on shape mismatch.
    fn div(&self, rhs: &Self) -> Result<Self, String> {
        self._element_arithmatic(rhs, ArithmaticType::Div)
    }

    /// Matrix multiplication. Validates dimensions and performs GPU-backed
    /// multiply (cuBLAS is used when available).
    fn mul(&self, rhs: &Self) -> Result<Self, String> {
        self._element_arithmatic(rhs, ArithmaticType::Mul)
    }

    /// Transpose the tensor (only supported up to 2D). Returns a new
    /// `GpuTensor` containing the transposed result.
    fn t(&self) -> Result<Self, String> {
        self._t()
    }

    /// Scale each element by `scalar`.
    fn scale(&self, scalar: T) -> Result<Self, String> {
        self._s(scalar)
    }

    /// Synchronize the current CUDA stream and block until all device work
    /// submitted to the global GPU context has completed.
    fn synchronize() {
        let _ = &(GPU_CONTEXT
            .get()
            .expect("No GPU Context Intialized")
            .stream
            .as_ref()
            .expect("Stream could not be found"))
        .synchronize();
    }

    /// Create a tensor of zeros with the given `shape`.
    fn zeroes(shape: &[u32]) -> Self {
        Self::_new_with_value(shape.to_vec(), T::zero()).unwrap()
    }

    /// Create a tensor filled with ones with the given `shape`.
    fn ones(shape: &[u32]) -> Self {
        Self::_new_with_value(shape.to_vec(), T::one()).unwrap()
    }

    /// Clip tensor values to the closed interval [`min`, `max`].
    fn clip(&self, min: T, max: T) -> Result<Self, String> {
        self._clip(min, max)
    }

    /// Sum all elements of the tensor and return a 1-element tensor
    /// containing the total.
    fn sum(&self) -> Result<Self, String> {
        let data = self._column_sum().unwrap().get_data();

        let total: T = data.iter().fold(T::zero(), |acc, &x| acc + x);

        Self::new(vec![1], vec![total])
    }
}

impl<T: Numeric + Zeroable + DeviceCopy> Add for GpuTensor<T> {
    type Output = Result<Self, String>;

    fn add(self, rhs: Self) -> Result<Self, String> {
        self._element_arithmatic(&rhs, ArithmaticType::Add)
    }
}

impl<T: Numeric + Zeroable + DeviceCopy> Sub for GpuTensor<T> {
    type Output = Result<Self, String>;

    fn sub(self, rhs: Self) -> Result<Self, String> {
        self._element_arithmatic(&rhs, ArithmaticType::Sub)
    }
}

impl<T: Numeric + Zeroable + DeviceCopy> Mul for GpuTensor<T> {
    type Output = Result<Self, String>;

    fn mul(self, rhs: Self) -> Result<Self, String> {
        self._mul(&rhs)
    }
}

impl<T: SignedNumeric + Zeroable + DeviceCopy> Neg for GpuTensor<T> {
    type Output = Result<Self, String>;
    fn neg(self) -> Result<Self, String> {
        self._s(-T::one())
    }
}

impl<T: Numeric + Zeroable + DeviceCopy> PartialEq for GpuTensor<T> {
    fn eq(&self, other: &Self) -> bool {
        println!("Partial eq called");
        self._eq(other)
    }
}

impl<T: Numeric + Zeroable + DeviceCopy> GpuTensor<T> {
    fn _get_function(fn_name: &str) -> Function<'_> {
        let t = GPU_CONTEXT
            .get()
            .expect("No GPU Context Set")
            .get_function(fn_name);
        t
    }

    fn _get_stream() -> &'static Stream {
        GPU_CONTEXT
            .get()
            .expect("No GPU Context Set")
            .stream
            .as_ref()
            .expect("Stream not found")
    }

    fn _get_cublas_handle() -> cublasHandle_t {
        GPU_CONTEXT
            .get()
            .expect("No GPU Context Set")
            .cublas_handle
            .handle
    }

    fn _eq(&self, other: &Self) -> bool {
        if self.shape != other.shape {
            return false;
        }

        let total_size = self.shape.iter().product::<u32>() as usize;

        if total_size == 0 {
            return true;
        }

        for i in 0..total_size {
            let diff = f32::abs(self.get_data()[i].f32() - other.get_data()[i].f32());

            if diff > f32::EPSILON * 10.0f32 {
                return false;
            }
        }
        true
    }

    fn element_op(&self, op_type: OpType) -> Result<Self, String> {
        let total_elements: u32 = self.shape.iter().product();
        let threads_per_block = 1024; // Use a typical 1D block size
        let grid_1d = total_elements.div_ceil(threads_per_block);

        let result = self._get_initialized_buffer(total_elements as usize);

        let stream = Self::_get_stream();

        let operation = Self::_get_function("element_op");

        unsafe {
            let _ = launch!(operation<<<grid_1d, threads_per_block, 0, stream>>>(
                self.device_buffer.as_device_ptr(),
                result.as_device_ptr(),
                total_elements as i32,
                op_type as i32,
                1.0_f32
            ));
        };

        Ok(Self::_with_device_buffer(self.shape.clone(), result))
    }

    fn _s(&self, scale: T) -> Result<Self, String> {
        let total_elements: u32 = self.shape.iter().product();
        let threads_per_block = 1024; // Use a typical 1D block size
        let grid_1d = total_elements.div_ceil(threads_per_block);

        let result = self._get_initialized_buffer(total_elements as usize);

        let stream = Self::_get_stream();

        let operation = Self::_get_function("element_op");

        unsafe {
            let _ = launch!(operation<<<grid_1d, threads_per_block, 0, stream>>>(
                self.device_buffer.as_device_ptr(),
                result.as_device_ptr(),
                total_elements as i32,
                1_i32,
                scale
            ));
        };

        Ok(Self::_with_device_buffer(self.shape.clone(), result))
    }

    fn _clip(&self, min: T, max: T) -> Result<Self, String> {
        let total_elements: u32 = self.shape.iter().product();
        let threads_per_block = 1024; // Use a typical 1D block size
        let grid_1d = total_elements.div_ceil(threads_per_block);

        let result = self._get_initialized_buffer(total_elements as usize);

        let stream = Self::_get_stream();

        let operation = Self::_get_function("clip");

        unsafe {
            let _ = launch!(operation<<<grid_1d, threads_per_block, 0, stream>>>(
                self.device_buffer.as_device_ptr(),
                result.as_device_ptr(),
                total_elements as i32,
                min,
                max
            ));
        };

        Ok(Self::_with_device_buffer(self.shape.clone(), result))
    }

    fn _t(&self) -> Result<Self, String> {
        if self.shape.len() > 2 {
            return Err("Only upto 2D tensors can be transposed.".to_string());
        }

        if self.shape.len() == 1 {
            unsafe {
                let new_device_buffer = CustomDeviceBuffer {
                    device_buffer: DeviceBuffer::from_raw_parts(
                        self.device_buffer.as_device_ptr(),
                        self.shape[0] as usize,
                    ),
                };

                return Ok(Self::_with_device_buffer(
                    self.shape.clone(),
                    new_device_buffer,
                ));
            }
        }

        let m = self.shape[0] as i32; // Rows of original
        let n = self.shape[1] as i32; // Cols of original

        let total_elements = (m * n) as usize;
        let result = self._get_initialized_buffer(total_elements);

        let alpha = 1.0f32;
        let beta = 0.0f32; // We don't have a second matrix, so beta is 0

        unsafe {
            // cublasSgeam: C = alpha * op(A) + beta * op(B)
            cublasSgeam(
                Self::_get_cublas_handle(),
                cublasOperation_t::CUBLAS_OP_T, // Transpose A
                cublasOperation_t::CUBLAS_OP_N, // B doesn't matter
                m,                              // Rows of C (which is Rows of A^T, so m)
                n,                              // Cols of C (which is Cols of A^T, so n)
                &alpha,
                self.device_buffer.as_device_ptr().as_raw() as *const f32,
                n, // Leading dimension of A (columns if row-major)
                &beta,
                std::ptr::null(), // B matrix is null
                m,                // Leading dimension of B
                result.as_device_ptr().as_raw() as *mut f32,
                m, // Leading dimension of C
            );
        }

        let new_shape = vec![self.shape[1], self.shape[0]];
        Ok(Self::_with_device_buffer(new_shape, result))
    }

    fn _data(&self) -> Vec<T> {
        let total_elements = self.shape.iter().product::<u32>() as usize;

        let mut data = vec![T::zero(); total_elements];

        unsafe {
            cust::sys::cuMemcpyDtoH_v2(
                data.as_mut_ptr() as *mut c_void,
                self.device_buffer.as_device_ptr().as_raw(),
                total_elements * size_of::<T>(),
            )
        };
        data
    }

    fn _shape(&self) -> Vec<u32> {
        self.shape.clone()
    }

    fn _element_arithmatic(&self, rhs: &Self, op: ArithmaticType) -> Result<Self, String> {
        if self.shape != rhs.shape {
            return Err(format!("ShapeMismatch:The dimensions of two matrices are not compatible for addition/subtraction- {:?} {:?}", self.shape, rhs.shape));
        }

        let add = Self::_get_function("vector_arithmatic");

        let total_elements = self.shape.iter().product::<u32>() as usize;

        let result = self._get_initialized_buffer(total_elements);

        let total_size_u32 = total_elements as u32;
        let threads_per_block = 1024;

        let grid_1d = total_size_u32.div_ceil(threads_per_block);

        let stream = Self::_get_stream();
        unsafe {
            let _ = launch!(add<<< grid_1d, threads_per_block, 0, stream >>>(
                self.device_buffer.as_device_ptr(),
                rhs.device_buffer.as_device_ptr(),
                result.as_device_ptr(),
                total_size_u32 as i32,
                op as u32
            ));
        }

        Ok(Self::_with_device_buffer(self.shape.clone(), result))
    }

    fn _column_sum(&self) -> Result<Self, String> {
        // Assuming self.shape is [rows, cols]
        let num_rows = self.shape[0];

        let num_cols = match self.shape.len() {
            2 => self.shape[1],
            _ => 1,
        };

        // 1. Get the kernel function
        let sum_kernel = Self::_get_function("column_reduce");

        // 2. Prepare result buffer: Only need space for 'num_cols' floats
        let result = self._get_initialized_buffer(num_cols as usize);

        // 3. Grid Setup: One thread per column
        // Your kernel uses: int col = blockIdx.x * blockDim.x + threadIdx.x;
        let threads_per_block = 256;
        let grid_size = num_cols.div_ceil(threads_per_block);

        let stream = Self::_get_stream();

        unsafe {
            // 4. Launch the kernel
            // Note: We pass num_rows and num_cols as i32 to match your C++ signature
            let _ = launch!(sum_kernel<<< grid_size, threads_per_block, 0, stream >>>(
                self.device_buffer.as_device_ptr(),
                result.as_device_ptr(),
                num_rows as i32,
                num_cols as i32
            ));
        }

        // 5. Return a new Self with the reduced shape (e.g., [1, cols] or just [cols])
        Ok(Self::_with_device_buffer(vec![1, num_cols], result))
    }

    fn check_shape(shape: &[u32]) -> Option<Result<GpuTensor<T>, String>> {
        if shape.is_empty() {
            return Some(Err(
                "ShapeError: GpuTensor must have at least one dimension.".to_string(),
            ));
        }

        if shape.len() > 2 {
            return Some(Err(
                "TemporaryShapeRestriction: Currently only accepting tensors upto 2 dimensions"
                    .to_string(),
            ));
        }
        None
    }

    fn calculate_length(shape: &Vec<u32>) -> u32 {
        let mut size = 1;

        for i in shape {
            size *= i;
        }
        size
    }

    fn _get_initialized_buffer(&self, size: usize) -> CustomDeviceBuffer<T> {
        let buffer = get_device_buffer(size);

        let threads_per_block = 1024;
        let grid_1d = (size as u32).div_ceil(threads_per_block);
        let stream = Self::_get_stream();
        let operation = Self::_get_function("fill_value");

        unsafe {
            let _ = launch!(operation<<<grid_1d, threads_per_block, 0, stream>>>(
                buffer.as_device_ptr(),
                size as i32,
                T::zero()
            ));
        }

        buffer
    }

    fn _new(shape: Vec<u32>, data: Vec<T>) -> Result<Self, String> {
        if let Some(value) = Self::check_shape(&shape) {
            return value;
        }

        let size = Self::calculate_length(&shape);

        if size != (data.len() as u32) {
            let err = format!("DataError: Data length ({}) does not match total num of elements provided by dimensions ({}))", data.len(), size);
            return Err(err);
        }

        Ok(Self::_with_device_buffer(
            shape.to_vec(),
            get_device_buffer_from_slice(&data),
        ))
    }

    fn _new_with_value(shape: Vec<u32>, value: T) -> Result<Self, String> {
        if let Some(value) = Self::check_shape(&shape) {
            return value;
        }

        let size = Self::calculate_length(&shape) as usize;

        let device_buffer = get_device_buffer::<T>(size);

        let threads_per_block = 1024; // Use a typical 1D block size
        let grid_1d = (size as u32).div_ceil(threads_per_block);

        let stream = Self::_get_stream();

        let operation = Self::_get_function("fill_value");

        unsafe {
            let _ = launch!(operation<<<grid_1d, threads_per_block, 0, stream>>>(
                device_buffer.as_device_ptr(),
                size as i32,
                value
            ));
        };

        Ok(Self::_with_device_buffer(shape.to_vec(), device_buffer))
    }

    fn _with_device_buffer(shape: Vec<u32>, device_buffer: CustomDeviceBuffer<T>) -> Self {
        Self {
            shape: shape.clone(),
            device_buffer,
        }
    }

    fn _gpu_mul(&self, rhs: &Self) -> Result<Self, String> {
        // Set up common block size
        let block_dim = 16;

        // Calculate grid size using ceiling division
        let grid_x = rhs.shape[1].div_ceil(block_dim);
        let grid_y = self.shape[0].div_ceil(block_dim);

        let total_elements = (self.shape[0] * rhs.shape[1]) as usize;

        let result = get_device_buffer(total_elements);

        let stream = Self::_get_stream();

        let mat_mul = Self::_get_function("matrix_mul");

        unsafe {
            let _ = launch!(mat_mul<<<(grid_x, grid_y, 1), (block_dim, block_dim, 1), 0, stream>>>(
                self.device_buffer.as_device_ptr(),
                rhs.device_buffer.as_device_ptr(),
                result.as_device_ptr(),
                self.shape[0] as i32,
                rhs.shape[1] as i32,
                self.shape[1] as i32
            ));
        }

        let result_shape = vec![self.shape[0], rhs.shape[1]];
        Ok(Self::_with_device_buffer(result_shape, result))
    }

    fn _gpu_mul_cublas(&self, rhs: &Self) -> Result<Self, String> {
        let m = self.shape[0] as i32;
        let k = self.shape[1] as i32;
        let n = rhs.shape[1] as i32;

        let total_elements = (m * n) as usize;
        let result = get_device_buffer(total_elements);

        let alpha = T::one();
        let beta = T::zero();

        unsafe {
            cublasSgemm_v2(
                Self::_get_cublas_handle(),
                cublasOperation_t::CUBLAS_OP_N,
                cublasOperation_t::CUBLAS_OP_N,
                n,
                m,
                k,
                &alpha.f32(),
                rhs.device_buffer.as_device_ptr().as_raw() as *const f32,
                n,
                self.device_buffer.as_device_ptr().as_raw() as *const f32,
                k,
                &beta.f32(),
                result.as_device_ptr().as_raw() as *mut f32,
                n,
            );
        }

        let result_shape = vec![self.shape[0], rhs.shape[1]];
        Ok(Self::_with_device_buffer(result_shape, result))
    }

    fn _mul(&self, rhs: &Self) -> Result<Self, String> {
        if self.shape[1] != rhs.shape[0] {
            let s = format!(
                "ShapeMismatch:The dimensions of two matrices are not compatible for multiplication- {:?} {:?}",
                self.shape, rhs.shape
            );
            return Err(s);
        }

        self._gpu_mul_cublas(rhs)
    }
}

impl<T> TensorMath<T> for GpuTensor<T>
where
    T: FloatingPoint + Zeroable + DeviceCopy,
{
    type MathOutputElem = T;
    type MathOutput = GpuTensor<T>;

    fn sigmoid(&self) -> Result<Self::MathOutput, String> {
        self.element_op(OpType::Sigmoid)
    }

    fn log(&self) -> Result<Self::MathOutput, String> {
        self.element_op(OpType::Log)
    }

    fn ln(&self) -> Result<Self::MathOutput, String> {
        self.element_op(OpType::Ln)
    }

    fn sin(&self) -> Result<Self::MathOutput, String> {
        self.element_op(OpType::Sin)
    }

    fn cos(&self) -> Result<Self::MathOutput, String> {
        self.element_op(OpType::Cos)
    }

    fn tan(&self) -> Result<Self::MathOutput, String> {
        self.element_op(OpType::Tan)
    }

    fn tanh(&self) -> Result<Self::MathOutput, String> {
        self.element_op(OpType::Tanh)
    }

    fn exp(&self) -> Result<Self::MathOutput, String> {
        self.element_op(OpType::Exp)
    }

    fn relu(&self) -> Result<Self::MathOutput, String> {
        self.element_op(OpType::ReLU)
    }

    fn greater_than_zero_mask(&self) -> Result<Self::MathOutput, String> {
        self.element_op(OpType::GreaterThanZeroMask)
    }
}