tenferro-gpu 0.3.0

CubeCL-backed CUDA and WebGPU provider backends for tenferro tensors.
#![cfg(all(feature = "webgpu", target_os = "macos"))]

use tenferro_gpu::{apple::AppleContext, apple::AppleTransferStats};
use tenferro_tensor::{HostAccessError, Tensor, TensorDot, TypedTensor};

fn apple_context() -> Option<AppleContext> {
    match AppleContext::new() {
        Ok(context) => Some(context),
        Err(error) => {
            eprintln!("skipping Apple context test: {error}");
            None
        }
    }
}

#[test]
fn independent_contexts_reject_foreign_managed_allocations() {
    let (Some(first), Some(second)) = (apple_context(), apple_context()) else {
        return;
    };
    assert_ne!(first.domain_id(), second.domain_id());

    let host = Tensor::from_vec_col_major(vec![2], vec![1.0_f32, 2.0]).unwrap();
    let managed = first.upload_tensor(&host).unwrap();
    let error = second.download_tensor(&managed).unwrap_err();
    assert!(matches!(
        error,
        tenferro_tensor::Error::HostAccess {
            source: HostAccessError::ForeignDomain { .. },
            ..
        }
    ));
}

#[test]
fn managed_upload_maps_without_post_creation_transfers_and_keeps_identity() {
    let Some(context) = apple_context() else {
        return;
    };
    let host = Tensor::from_vec_col_major(vec![2], vec![1.0_f32, 2.0]).unwrap();
    let mut managed = context.upload_tensor(&host).unwrap();
    assert_eq!(
        context.transfer_stats(),
        AppleTransferStats {
            uploaded_bytes: 8,
            downloaded_bytes: 0,
        }
    );

    let Tensor::F32(typed) = &mut managed else {
        panic!("expected f32 tensor")
    };
    assert_eq!(typed.allocation_domain(), Some(context.domain_id()));
    let allocation = typed.allocation_id().unwrap();
    assert_eq!(
        typed.with_host_read(|data| data.to_vec()).unwrap(),
        [1.0, 2.0]
    );
    typed
        .with_host_write(|data| data.copy_from_slice(&[3.0, 2.0]))
        .unwrap();
    assert_eq!(
        typed.with_host_read(|data| data.to_vec()).unwrap(),
        [3.0, 2.0]
    );
    assert_eq!(typed.allocation_id(), Some(allocation));
    assert_eq!(context.transfer_stats().downloaded_bytes, 0);

    let downloaded = context.download_tensor(&managed).unwrap();
    assert_eq!(downloaded.as_slice::<f32>().unwrap(), &[3.0, 2.0]);
    assert_eq!(context.transfer_stats().downloaded_bytes, 8);
}

#[test]
fn cpu_domain_allocator_produces_write_only_managed_outputs_without_transfers() {
    let Some(context) = apple_context() else {
        return;
    };
    let domain = context.cpu_backend().shared_allocation_domain().unwrap();
    let output = domain.allocate(tenferro_tensor::DType::F64, &[2]).unwrap();
    let Tensor::F64(mut output) = output else {
        panic!("expected f64 output")
    };
    assert_eq!(output.allocation_domain(), Some(context.domain_id()));
    output
        .with_host_write(|data| data.copy_from_slice(&[5.0, 8.0]))
        .unwrap();
    assert_eq!(
        output.with_host_read(|data| data.to_vec()).unwrap(),
        [5.0, 8.0]
    );
    assert_eq!(context.transfer_stats(), AppleTransferStats::default());
}

#[test]
fn metal_output_stays_in_the_context_domain_without_host_transfers() {
    let Some(context) = apple_context() else {
        return;
    };
    let lhs = Tensor::F32(TypedTensor::from_vec_col_major(vec![1, 1], vec![2.0_f32]).unwrap());
    let rhs = Tensor::F32(TypedTensor::from_vec_col_major(vec![1, 1], vec![3.0_f32]).unwrap());
    let lhs = context.upload_tensor(&lhs).unwrap();
    let rhs = context.upload_tensor(&rhs).unwrap();
    let Tensor::F32(lhs_typed) = &lhs else {
        panic!("expected f32 lhs")
    };
    let lhs_allocation = lhs_typed.allocation_id().unwrap();
    assert_eq!(
        lhs_typed.with_host_read(|data| data.to_vec()).unwrap(),
        [2.0]
    );
    let before = context.transfer_stats();
    let mut metal = context.metal_backend().clone();
    let output = metal
        .dot_general(
            &lhs,
            &rhs,
            &tenferro_tensor::DotGeneralConfig {
                lhs_contracting_dims: vec![1],
                rhs_contracting_dims: vec![0],
                lhs_batch_dims: vec![],
                rhs_batch_dims: vec![],
            },
        )
        .unwrap();
    metal.synchronize().unwrap();
    assert_eq!(
        lhs_typed.with_host_read(|data| data.to_vec()).unwrap(),
        [2.0]
    );
    assert_eq!(lhs_typed.allocation_id(), Some(lhs_allocation));

    let Tensor::F32(output) = output else {
        panic!("expected f32 output")
    };
    assert_eq!(output.allocation_domain(), Some(context.domain_id()));
    assert_eq!(context.transfer_stats(), before);
    assert_eq!(output.with_host_read(|data| data.to_vec()).unwrap(), [6.0]);
}