feat(rtx-tensor): genericize GenericTensor over B::FloatElem (f64-capable)

Phase 2 of the rustytorch f32→f64 plan. All 6 `impl<B: Backend<FloatElem = f32>>`
blocks on GenericTensor relaxed to `impl<B: Backend>`, with concrete f32 →
B::FloatElem (full/from_slice/to_vec/add_scalar/mul_scalar/pow/clamp/leaky_relu/
elu/layer_norm/rms_norm). The genericization was fully clean — every Backend trait
scalar param was already Self::FloatElem, so no methods had to stay f32-gated.

GenericTensor now works with CpuBackendF64 as well as CpuBackend. Backward-compat
holds via B::FloatElem = f32 for CpuBackend: to_vec() still returns Vec<f32>,
from_slice still takes &[f32].

Validated: 706 rtx-tensor tests pass (704 f32 + 2 new f64); the f64 test proves
1+2^-30 survives through from_slice/matmul/to_vec (f32 rounds to 1.0). rtx-nn
builds; **QPUDIDP's qpu-didp-surrogate (external, ~125 f32 sites) still compiles**.
clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Claude Code
2026-06-26 22:28:26 -07:00
co-authored by Claude Opus 4.8
parent 2360d7bffc
commit a25f24494c
+61 -23
View File
@@ -124,11 +124,11 @@ impl<B: Backend, const D: usize> GenericTensor<B, D> {
} }
} }
// ==================== f32-Specific Creation Operations ==================== // ==================== Scalar / Slice Creation Operations ====================
impl<B: Backend<FloatElem = f32>, const D: usize> GenericTensor<B, D> { impl<B: Backend, const D: usize> GenericTensor<B, D> {
/// Create a tensor filled with a scalar value. /// Create a tensor filled with a scalar value.
pub fn full(shape: [usize; D], fill_value: f32, device: &B::Device) -> Self { pub fn full(shape: [usize; D], fill_value: B::FloatElem, device: &B::Device) -> Self {
Self { Self {
primitive: B::full(shape, fill_value, device), primitive: B::full(shape, fill_value, device),
device: device.clone(), device: device.clone(),
@@ -137,7 +137,7 @@ impl<B: Backend<FloatElem = f32>, const D: usize> GenericTensor<B, D> {
} }
/// Create a tensor from a slice of data. /// Create a tensor from a slice of data.
pub fn from_slice(data: &[f32], shape: [usize; D], device: &B::Device) -> Self { pub fn from_slice(data: &[B::FloatElem], shape: [usize; D], device: &B::Device) -> Self {
Self { Self {
primitive: B::from_data(data, shape, device), primitive: B::from_data(data, shape, device),
device: device.clone(), device: device.clone(),
@@ -148,11 +148,12 @@ impl<B: Backend<FloatElem = f32>, const D: usize> GenericTensor<B, D> {
// ==================== Data Access ==================== // ==================== Data Access ====================
impl<B: Backend<FloatElem = f32>, const D: usize> GenericTensor<B, D> { impl<B: Backend, const D: usize> GenericTensor<B, D> {
/// Convert tensor data to a Vec<f32>. /// Convert tensor data to a `Vec<B::FloatElem>` (`Vec<f32>` for `CpuBackend`,
/// `Vec<f64>` for `CpuBackendF64`).
/// ///
/// This may involve copying data from GPU to CPU memory. /// This may involve copying data from GPU to CPU memory.
pub fn to_vec(&self) -> Vec<f32> { pub fn to_vec(&self) -> Vec<B::FloatElem> {
B::to_data(&self.primitive) B::to_data(&self.primitive)
} }
} }
@@ -206,17 +207,17 @@ impl<B: Backend, const D: usize> GenericTensor<B, D> {
} }
} }
// ==================== f32-Specific Arithmetic Operations ==================== // ==================== Scalar Arithmetic Operations ====================
impl<B: Backend<FloatElem = f32>, const D: usize> GenericTensor<B, D> { impl<B: Backend, const D: usize> GenericTensor<B, D> {
/// Add a scalar to all elements. /// Add a scalar to all elements.
pub fn add_scalar(&self, scalar: f32) -> Self { pub fn add_scalar(&self, scalar: B::FloatElem) -> Self {
let scalar_tensor = Self::full(self.shape(), scalar, &self.device); let scalar_tensor = Self::full(self.shape(), scalar, &self.device);
self.add(&scalar_tensor) self.add(&scalar_tensor)
} }
/// Multiply all elements by a scalar. /// Multiply all elements by a scalar.
pub fn mul_scalar(&self, scalar: f32) -> Self { pub fn mul_scalar(&self, scalar: B::FloatElem) -> Self {
let scalar_tensor = Self::full(self.shape(), scalar, &self.device); let scalar_tensor = Self::full(self.shape(), scalar, &self.device);
self.mul(&scalar_tensor) self.mul(&scalar_tensor)
} }
@@ -280,11 +281,11 @@ impl<B: Backend, const D: usize> GenericTensor<B, D> {
} }
} }
// ==================== f32-Specific Unary Math Operations ==================== // ==================== Parameterized Unary Math Operations ====================
impl<B: Backend<FloatElem = f32>, const D: usize> GenericTensor<B, D> { impl<B: Backend, const D: usize> GenericTensor<B, D> {
/// Element-wise power. /// Element-wise power.
pub fn pow(&self, exp: f32) -> Self { pub fn pow(&self, exp: B::FloatElem) -> Self {
Self { Self {
primitive: B::pow(self.primitive.clone(), exp), primitive: B::pow(self.primitive.clone(), exp),
device: self.device.clone(), device: self.device.clone(),
@@ -293,7 +294,7 @@ impl<B: Backend<FloatElem = f32>, const D: usize> GenericTensor<B, D> {
} }
/// Clamp values to a range. /// Clamp values to a range.
pub fn clamp(&self, min: f32, max: f32) -> Self { pub fn clamp(&self, min: B::FloatElem, max: B::FloatElem) -> Self {
Self { Self {
primitive: B::clamp(self.primitive.clone(), min, max), primitive: B::clamp(self.primitive.clone(), min, max),
device: self.device.clone(), device: self.device.clone(),
@@ -351,11 +352,11 @@ impl<B: Backend, const D: usize> GenericTensor<B, D> {
} }
} }
// ==================== f32-Specific Activation Functions ==================== // ==================== Parameterized Activation Functions ====================
impl<B: Backend<FloatElem = f32>, const D: usize> GenericTensor<B, D> { impl<B: Backend, const D: usize> GenericTensor<B, D> {
/// Leaky ReLU activation: max(negative_slope * x, x). /// Leaky ReLU activation: max(negative_slope * x, x).
pub fn leaky_relu(&self, negative_slope: f32) -> Self { pub fn leaky_relu(&self, negative_slope: B::FloatElem) -> Self {
Self { Self {
primitive: B::leaky_relu(self.primitive.clone(), negative_slope), primitive: B::leaky_relu(self.primitive.clone(), negative_slope),
device: self.device.clone(), device: self.device.clone(),
@@ -364,7 +365,7 @@ impl<B: Backend<FloatElem = f32>, const D: usize> GenericTensor<B, D> {
} }
/// ELU activation: x if x > 0, else alpha * (exp(x) - 1). /// ELU activation: x if x > 0, else alpha * (exp(x) - 1).
pub fn elu(&self, alpha: f32) -> Self { pub fn elu(&self, alpha: B::FloatElem) -> Self {
Self { Self {
primitive: B::elu(self.primitive.clone(), alpha), primitive: B::elu(self.primitive.clone(), alpha),
device: self.device.clone(), device: self.device.clone(),
@@ -498,15 +499,15 @@ impl<B: Backend, const D: usize> GenericTensor<B, D> {
} }
} }
// ==================== f32-Specific LLM Operations ==================== // ==================== Normalization (parameterized) Operations ====================
impl<B: Backend<FloatElem = f32>, const D: usize> GenericTensor<B, D> { impl<B: Backend, const D: usize> GenericTensor<B, D> {
/// Layer normalization. /// Layer normalization.
pub fn layer_norm( pub fn layer_norm(
&self, &self,
weight: &GenericTensor<B, 1>, weight: &GenericTensor<B, 1>,
bias: Option<&GenericTensor<B, 1>>, bias: Option<&GenericTensor<B, 1>>,
eps: f32, eps: B::FloatElem,
) -> Self { ) -> Self {
Self { Self {
primitive: B::layer_norm( primitive: B::layer_norm(
@@ -521,7 +522,7 @@ impl<B: Backend<FloatElem = f32>, const D: usize> GenericTensor<B, D> {
} }
/// RMS normalization. /// RMS normalization.
pub fn rms_norm(&self, weight: &GenericTensor<B, 1>, eps: f32) -> Self { pub fn rms_norm(&self, weight: &GenericTensor<B, 1>, eps: B::FloatElem) -> Self {
Self { Self {
primitive: B::rms_norm(self.primitive.clone(), &weight.primitive, eps), primitive: B::rms_norm(self.primitive.clone(), &weight.primitive, eps),
device: self.device.clone(), device: self.device.clone(),
@@ -678,3 +679,40 @@ impl<B: Backend, const D: usize> Debug for GenericTensor<B, D> {
.finish() .finish()
} }
} }
#[cfg(test)]
mod f64_tests {
use super::*;
use rtx_backend_cpu::{CpuBackend, CpuBackendF64, CpuDevice};
/// `GenericTensor<CpuBackendF64, D>` runs in real f64: the API (from_slice,
/// to_vec, add_scalar, matmul) is now generic over `B::FloatElem`, and f64
/// preserves a value f32 would round away.
#[test]
fn generic_tensor_f64_exceeds_f32_precision() {
let dev = CpuDevice::new();
// 1 + 2^-30 is indistinguishable from 1.0 in f32, distinct in f64.
let v = 1.0_f64 + 2.0_f64.powi(-30);
let t = GenericTensor::<CpuBackendF64, 2>::from_slice(&[v, 2.0, 3.0, 4.0], [2, 2], &dev);
let out: Vec<f64> = t.to_vec();
assert_eq!(out[0], v, "f64 backend must preserve 1 + 2^-30");
assert_ne!(out[0], 1.0, "value must be distinct from 1.0 (would fail in f32)");
// add_scalar + matmul also run in f64.
let s = t.add_scalar(1.0_f64);
assert_eq!(s.to_vec()[0], v + 1.0);
let m = t.matmul(&t);
assert_eq!(m.to_vec().len(), 4);
}
/// The f32 API surface is unchanged: `GenericTensor<CpuBackend, D>` still
/// takes `&[f32]` and returns `Vec<f32>` (backward compatibility).
#[test]
fn generic_tensor_f32_backward_compat() {
let dev = CpuDevice::new();
let t = GenericTensor::<CpuBackend, 2>::from_slice(&[1.0f32, 2.0, 3.0, 4.0], [2, 2], &dev);
let out: Vec<f32> = t.to_vec();
assert_eq!(out, vec![1.0f32, 2.0, 3.0, 4.0]);
}
}