Coming from ndarray / nalgebra / ndarray-linalg

This page is a translation guide for Rust users who arrive with ndarray, nalgebra, or ndarray-linalg priors. PyTorch/JAX users have their own mapping page; read Core Concepts for tenferro’s own mental model.

The short version is the five conventions every tenferro program must follow:

  • Column-major storage. Dense buffers are column-major: the leftmost dimension varies fastest. Row-major data passed to from_vec_col_major is silently reinterpreted as column-major — permuted/wrong values, never rejected.
  • No facade crate. cargo add tenferro fails by design; depend on the crates you need (tenferro-runtime, tenferro-cpu, and operation crates).
  • Explicit backend. Direct operations take an explicit backend argument; construct the backend/runtime once and reuse it — per-call construction discards the buffer pool.
  • Einsum dialect. Equations need the explicit arrow ("ij,jk->ik"); ... ellipsis is unsupported.
  • Result-returning operators. Traced operators return Result; propagate with ?.

Each is explained below with the concrete ndarray/nalgebra divergence.

The column-major asymmetry

Prior Storage order from_vec_col_major hazard
ndarray / NumPy Row-major (C order) Dangerous: a row-major flat buffer is silently reinterpreted as column-major (permuted/wrong values), never rejected
nalgebra Column-major (F order) Safe: as_slice() / .data map directly

This is the one prior that actively hurts. ndarray and NumPy store rows contiguously, so the natural flat buffer you already have is in the wrong physical order for tenferro — and construction does not detect the mistake. A [2, 3] tenferro tensor reads elements down each column first. Reorder the buffer explicitly before from_vec_col_major, or wrap it without copying (see Zero-copy interop). nalgebra uses the same Fortran/column-major order as tenferro, LAPACK, and Julia, so a Matrix’s as_slice() or .data maps directly into from_vec_col_major with its shape.

Crate selection: cargo add tenferro does not exist

There is deliberately no facade crate, so cargo add tenferro fails. Add the smallest set of crates for your API tier directly:

Program Minimum direct crates
Concrete Tensor / TypedTensor compute tenferro-runtime, tenferro-cpu
Eager forward / AD tenferro-ad, tenferro-cpu
Traced graph tenferro-runtime, tenferro-cpu (+ tenferro-ad for graph transforms)
Linear algebra / einsum / FFT add tenferro-linalg / tenferro-einsum / tenferro-fft
CUDA the value/op crates plus tenferro-gpu with the cuda feature

See the crate selection reference for full dependency blocks and feature rules.

The backend is an explicit value

In ndarray/nalgebra/ndarray-linalg the execution context is ambient: the BLAS that was linked at build time is whatever your ops run through. tenferro reifies it as a value — CpuBackend (or an EagerRuntime, or a Runtime for traced graphs) — because the backend owns device placement, provider selection, and buffer pools.

The companion idiom is construct once, reuse. A backend is owned state:

let mut backend = CpuBackend::new();
// ... many operations through the same `backend` ...

Constructing CpuBackend::new() per call discards the buffer pool and cache each time, and defeats reuse of compiled programs. The same rule applies across the tiers — see the performance idioms reference.

faer vs BLAS providers

tenferro’s CPU backend has two provider families controlled by additive features: the cpu-faer provider and the cpu-blas provider. ndarray-linalg users know feature-based BLAS selection; tenferro’s knobs live in the tenferro-cpu (and tenferro-runtime) features.

Provider Features When to use
faer (default) cpu-faer Portable, pure Rust, no system dependencies; the right default for most workloads
BLAS / LAPACK cpu-blas plus exactly one explicit provider feature Large GEMM-dominated workloads, or to reuse an already-tuned system BLAS

cpu-faer and cpu-blas are additive, and CpuBackend::new() selects the compiled default — BLAS when cpu-blas is compiled, otherwise faer — with CpuBackend::with_kind for explicit selection when both are compiled. Within the BLAS family the three explicit provider features (blas-openblas, blas-mkl, blas-accelerate) are mutually exclusive, and tenferro rejects a build that enables more than one.

Operation arity: .dot() to matmul

The receiver-and-arity shape changes across the three tenferro tiers, and the backends are pushed into method signatures. Compare with ndarray’s a.dot(&b):

Tier Tenferro Notes
Direct a.matmul(&b, &mut backend)? explicit mutable backend argument
Eager a.matmul(&b)? EagerRuntime owns the backend
Traced a.matmul(&b)? builds a graph; returns Result
use tenferro_cpu::CpuBackend;
use tenferro_runtime::{TypedTensor, TypedTensorOpsExt};

let mut backend = CpuBackend::new();
// The leftmost dimension varies fastest: this is a 2 x 3 column-major tensor.
let x = TypedTensor::<f64>::from_vec_col_major(
    vec![2, 3],
    vec![1.0, 4.0, 2.0, 5.0, 3.0, 6.0],
)?;
let weights = TypedTensor::<f64>::from_vec_col_major(
    vec![3, 2],
    vec![0.5, -1.0, 1.5, 1.0, 2.0, -0.5],
)?;
let projected = x.matmul(&weights, &mut backend)?;
assert_eq!(projected.shape(), &[2, 2]);
assert_eq!(projected.host_data()?, &[3.0, 6.0, 3.5, 11.0]);

In the direct tier the explicit backend moves from “linked somehow” to “passed per call”; in the eager and traced tiers it is owned by the runtime and the signature matches what ndarray users expect.

Tensor vs TypedTensor

ndarray’s Array<A, IxN> and nalgebra’s Matrix<T, ...> are generic over the element type — your priors map to TypedTensor<T>:

Your prior Tenferro
ndarray::Array2<f64> TypedTensor<f64> (rank-generic)
nalgebra::DMatrix<f64> TypedTensor<f64>
dtype chosen at runtime Tensor (dtype-erased, has no ndarray counterpart)

Tensor has no ndarray/nalgebra analogue: its element type is selected at runtime. Reach for it when you need runtime dtype dispatch or direct backend dispatch; for the ordinary fixed-element-type case the generic TypedTensor<T> is the direct match.

Zero-copy interop: keep your faer/ndarray buffers

Adopting tenferro kernels does not require migrating tensor ownership. You can wrap an existing column-major buffer — a faer::Mat, an ndarray view, or any slice plus strides — zero-copy with TypedTensorView::from_slice, and write through it with TypedTensorViewMut::from_slice:

your faer::Mat / ndarray view  ->  TypedTensorView::from_slice(shape, strides, offset, data)

The full runnable recipe (faer column padding, ndarray row-major wrap, and the mutable variant) is in the API cheatsheet “Borrowing external memory”. Because strides are arbitrary, row-major data wraps without transposition — but kernels are tuned for column-major contiguity, so materialize a copy when performance matters and the wrapped buffer is row-major.

The “do I have to own a new tensor type” objection is partly cost, and the measured answer is small: adding tenferro on top of an existing faer dependency costs about +10 unique crates and +28 s of one-time cold build (see tenferro-rs#1602). For a larger GEMM, the kernel-run portion is what you are adopting; the memory stays yours.

Next steps