tenferro-einsum 0.3.0

Subscripts, contraction planning, concrete/traced/eager einsum APIs, extension runtime, and AD rule for tenferro.
#![cfg(all(feature = "autodiff", feature = "webgpu"))]

use num_complex::Complex32;
use tenferro_ad::{EagerRuntime, EagerTensor};
use tenferro_einsum::{EagerEinsumExt, TraceContextEinsumExt};
use tenferro_gpu::{
    webgpu::download_webgpu_tensor, webgpu::upload_webgpu_tensor, webgpu::webgpu_available,
};
use tenferro_gpu::{webgpu::WebGpuBackend, webgpu::WebGpuRuntime};
use tenferro_ops::dim_expr::DimExpr;
use tenferro_runtime::program::ProgramInputSpec;
use tenferro_runtime::{CompiledGraph, DType, GraphCompiler, Runtime, Tensor, TraceContext};

fn matmul2_col_major(lhs: &[Complex32], rhs: &[Complex32]) -> [Complex32; 4] {
    let a00 = lhs[0];
    let a10 = lhs[1];
    let a01 = lhs[2];
    let a11 = lhs[3];
    let b00 = rhs[0];
    let b10 = rhs[1];
    let b01 = rhs[2];
    let b11 = rhs[3];
    [
        a00 * b00 + a01 * b10,
        a10 * b00 + a11 * b10,
        a00 * b01 + a01 * b11,
        a10 * b01 + a11 * b11,
    ]
}

fn assert_complex_close(actual: &[Complex32], expected: &[Complex32]) {
    for (actual, expected) in actual.iter().zip(expected) {
        assert!(
            (actual.re - expected.re).abs() <= 1e-4,
            "real mismatch: actual={actual:?} expected={expected:?}"
        );
        assert!(
            (actual.im - expected.im).abs() <= 1e-4,
            "imag mismatch: actual={actual:?} expected={expected:?}"
        );
    }
}

fn batched_matmul_f32_reference() -> Vec<f32> {
    vec![58.0, 139.0, 64.0, 154.0, 5800.0, 13900.0, 6400.0, 15400.0]
}

fn assert_f32_close(actual: &[f32], expected: &[f32]) {
    for (actual, expected) in actual.iter().zip(expected) {
        assert!(
            (actual - expected).abs() <= 1e-4,
            "f32 mismatch: actual={actual} expected={expected}"
        );
    }
}

fn compile_einsum(dtype: DType, shapes: &[&[usize]], subscripts: &str) -> CompiledGraph {
    let mut trace = TraceContext::new();
    let inputs = shapes
        .iter()
        .map(|shape| {
            trace
                .input(ProgramInputSpec::new(dtype, DimExpr::from_concrete(shape)))
                .unwrap()
        })
        .collect::<Vec<_>>();
    let output = trace.einsum(&inputs, subscripts).unwrap();
    let graph = trace.finish(&[output]).unwrap();
    GraphCompiler::new().compile_traced_graph(&graph).unwrap()
}

fn webgpu_runtime_with_einsum(backend: &WebGpuBackend) -> Runtime {
    let mut builder = Runtime::builder();
    builder
        .register_engine(tenferro_gpu::webgpu::webgpu_runtime_engine_registration(backend).unwrap())
        .unwrap();
    builder
        .install_extension_module(
            tenferro_einsum::extension_module::<WebGpuBackend>(
                tenferro_gpu::webgpu::webgpu_runtime_engine_id().unwrap(),
            )
            .unwrap(),
        )
        .unwrap();
    builder.build().unwrap()
}

#[test]
fn eager_tensor_einsum_runs_rank2_f32_matmul_on_webgpu_when_adapter_available() {
    if !webgpu_available() {
        return;
    }

    let runtime = WebGpuRuntime::new_default().unwrap();
    let ctx =
        EagerRuntime::with_webgpu_backend(WebGpuBackend::from_runtime(runtime.clone())).unwrap();
    let lhs =
        Tensor::from_vec_col_major(vec![2, 3], vec![1.0_f32, 4.0, 2.0, 5.0, 3.0, 6.0]).unwrap();
    let rhs =
        Tensor::from_vec_col_major(vec![3, 2], vec![7.0_f32, 9.0, 11.0, 8.0, 10.0, 12.0]).unwrap();
    let lhs =
        EagerTensor::from_tensor_in(upload_webgpu_tensor(&runtime, &lhs).unwrap(), ctx.clone())
            .unwrap();
    let rhs =
        EagerTensor::from_tensor_in(upload_webgpu_tensor(&runtime, &rhs).unwrap(), ctx).unwrap();

    let out = [&lhs, &rhs].einsum("ij,jk->ik").unwrap();
    let host = download_webgpu_tensor(&runtime, out.to_tensor().unwrap()).unwrap();

    assert_eq!(host.shape(), &[2, 2]);
    let actual = host.as_slice::<f32>().unwrap();
    let expected = [58.0_f32, 139.0, 64.0, 154.0];
    for (actual, expected) in actual.iter().zip(expected) {
        assert!((actual - expected).abs() <= 1e-4);
    }
}

#[test]
fn eager_tensor_einsum_runs_batched_f32_matmul_on_webgpu_when_adapter_available() {
    if !webgpu_available() {
        return;
    }

    let runtime = WebGpuRuntime::new_default().unwrap();
    let ctx =
        EagerRuntime::with_webgpu_backend(WebGpuBackend::from_runtime(runtime.clone())).unwrap();
    let lhs = Tensor::from_vec_col_major(
        vec![2, 3, 2],
        vec![
            1.0_f32, 4.0, 2.0, 5.0, 3.0, 6.0, 10.0, 40.0, 20.0, 50.0, 30.0, 60.0,
        ],
    )
    .unwrap();
    let rhs = Tensor::from_vec_col_major(
        vec![3, 2, 2],
        vec![
            7.0_f32, 9.0, 11.0, 8.0, 10.0, 12.0, 70.0, 90.0, 110.0, 80.0, 100.0, 120.0,
        ],
    )
    .unwrap();
    let lhs =
        EagerTensor::from_tensor_in(upload_webgpu_tensor(&runtime, &lhs).unwrap(), ctx.clone())
            .unwrap();
    let rhs =
        EagerTensor::from_tensor_in(upload_webgpu_tensor(&runtime, &rhs).unwrap(), ctx).unwrap();

    let out = [&lhs, &rhs].einsum("ikb,kjb->ijb").unwrap();
    let host = download_webgpu_tensor(&runtime, out.to_tensor().unwrap()).unwrap();

    assert_eq!(host.shape(), &[2, 2, 2]);
    assert_f32_close(
        host.as_slice::<f32>().unwrap(),
        &batched_matmul_f32_reference(),
    );
}

#[test]
fn eager_tensor_einsum_runs_rank2_c32_matmul_on_webgpu_when_adapter_available() {
    if !webgpu_available() {
        return;
    }

    let runtime = WebGpuRuntime::new_default().unwrap();
    let ctx =
        EagerRuntime::with_webgpu_backend(WebGpuBackend::from_runtime(runtime.clone())).unwrap();
    let lhs_data = vec![
        Complex32::new(1.0, 0.5),
        Complex32::new(2.0, -1.0),
        Complex32::new(3.0, 0.25),
        Complex32::new(4.0, 1.0),
    ];
    let rhs_data = vec![
        Complex32::new(5.0, -0.5),
        Complex32::new(6.0, 0.25),
        Complex32::new(7.0, 1.0),
        Complex32::new(8.0, -0.75),
    ];
    let lhs = Tensor::from_vec_col_major(vec![2, 2], lhs_data.clone()).unwrap();
    let rhs = Tensor::from_vec_col_major(vec![2, 2], rhs_data.clone()).unwrap();
    let lhs =
        EagerTensor::from_tensor_in(upload_webgpu_tensor(&runtime, &lhs).unwrap(), ctx.clone())
            .unwrap();
    let rhs =
        EagerTensor::from_tensor_in(upload_webgpu_tensor(&runtime, &rhs).unwrap(), ctx).unwrap();

    let out = [&lhs, &rhs].einsum("ij,jk->ik").unwrap();
    let host = download_webgpu_tensor(&runtime, out.to_tensor().unwrap()).unwrap();

    assert_eq!(host.shape(), &[2, 2]);
    let actual = host.as_slice::<Complex32>().unwrap();
    let expected = matmul2_col_major(&lhs_data, &rhs_data);
    assert_complex_close(actual, &expected);
}

#[test]
fn traced_einsum_runs_rank2_f32_matmul_on_webgpu_when_adapter_available() {
    if !webgpu_available() {
        return;
    }

    let program = compile_einsum(DType::F32, &[&[2, 3], &[3, 2]], "ij,jk->ik");
    let gpu_runtime = WebGpuRuntime::new_default().unwrap();
    let lhs_host =
        Tensor::from_vec_col_major(vec![2, 3], vec![1.0_f32, 4.0, 2.0, 5.0, 3.0, 6.0]).unwrap();
    let rhs_host =
        Tensor::from_vec_col_major(vec![3, 2], vec![7.0_f32, 9.0, 11.0, 8.0, 10.0, 12.0]).unwrap();
    let lhs_gpu = upload_webgpu_tensor(&gpu_runtime, &lhs_host).unwrap();
    let rhs_gpu = upload_webgpu_tensor(&gpu_runtime, &rhs_host).unwrap();
    let backend = WebGpuBackend::from_runtime(gpu_runtime.clone());
    let exec_runtime = webgpu_runtime_with_einsum(&backend);

    let out = exec_runtime
        .run_compiled(&program, &[&lhs_gpu, &rhs_gpu])
        .unwrap()
        .remove(0);
    backend.synchronize().unwrap();
    let host = download_webgpu_tensor(backend.runtime(), &out).unwrap();

    assert_eq!(host.shape(), &[2, 2]);
    let actual = host.as_slice::<f32>().unwrap();
    let expected = [58.0_f32, 139.0, 64.0, 154.0];
    for (actual, expected) in actual.iter().zip(expected) {
        assert!((actual - expected).abs() <= 1e-4);
    }
}

#[test]
fn traced_einsum_runs_batched_f32_matmul_on_webgpu_when_adapter_available() {
    if !webgpu_available() {
        return;
    }

    let program = compile_einsum(DType::F32, &[&[2, 3, 2], &[3, 2, 2]], "ikb,kjb->ijb");
    let gpu_runtime = WebGpuRuntime::new_default().unwrap();
    let lhs_host = Tensor::from_vec_col_major(
        vec![2, 3, 2],
        vec![
            1.0_f32, 4.0, 2.0, 5.0, 3.0, 6.0, 10.0, 40.0, 20.0, 50.0, 30.0, 60.0,
        ],
    )
    .unwrap();
    let rhs_host = Tensor::from_vec_col_major(
        vec![3, 2, 2],
        vec![
            7.0_f32, 9.0, 11.0, 8.0, 10.0, 12.0, 70.0, 90.0, 110.0, 80.0, 100.0, 120.0,
        ],
    )
    .unwrap();
    let lhs_gpu = upload_webgpu_tensor(&gpu_runtime, &lhs_host).unwrap();
    let rhs_gpu = upload_webgpu_tensor(&gpu_runtime, &rhs_host).unwrap();
    let backend = WebGpuBackend::from_runtime(gpu_runtime.clone());
    let exec_runtime = webgpu_runtime_with_einsum(&backend);

    let out = exec_runtime
        .run_compiled(&program, &[&lhs_gpu, &rhs_gpu])
        .unwrap()
        .remove(0);
    backend.synchronize().unwrap();
    let host = download_webgpu_tensor(backend.runtime(), &out).unwrap();

    assert_eq!(host.shape(), &[2, 2, 2]);
    assert_f32_close(
        host.as_slice::<f32>().unwrap(),
        &batched_matmul_f32_reference(),
    );
}