Choosing A Backend
Choose a backend in two steps. First choose the execution backend that owns the device and its synchronization boundary. If that backend is CPU, choose a dense CPU provider separately. The important choice is control: a provider that owns its own workers can deliver peak library performance, but tenferro cannot make the same affinity, nesting, and thread-budget guarantees around it.
Execution Backend
| Backend | Device | Hard requirements | Cargo feature | Threading and synchronization | Good fit |
|---|---|---|---|---|---|
| CPU | Host CPUs | At least one CPU provider; cpu-faer is the default |
tenferro-cpu cpu-faer or cpu-blas |
CpuBackend admits an operation to its selected domain. faer and native kernels use that domain; CPU calls return synchronously. |
Controlled CPU and NUMA execution, dense linalg, and host pipelines |
| CUDA | NVIDIA GPU | CUDA runtime plus the NVIDIA library stack required by the operation, including cuBLAS/cuSOLVER/cuTENSOR where applicable; missing libraries are typed errors | tenferro-gpu cuda |
The CUDA runtime owns the stream and device work. Eager launches are asynchronous until an explicit synchronize, download, or host inspection. | NVIDIA production workloads and CUDA library-backed dense operations |
| WebGPU | A WebGPU adapter/device and queue | A working wgpu/WebGPU implementation; coverage is experimental and operation-specific | tenferro-gpu webgpu |
wgpu owns the device queue. Submissions are asynchronous; queue synchronization or download establishes a host-visible boundary. | Portable GPU experiments and Apple shared CPU/Metal paths |
| XLA/PJRT | A PJRT addressable device | A PJRT plugin shared library; the pjrt loader returns a typed error when it is absent or invalid |
tenferro-xla pjrt |
The PJRT plugin owns device execution and intra-op scheduling. tenferro controls lowering and invocation, not the plugin’s worker pool. | Static traced programs and deployments already using XLA plugins |
CUDA does not use a native CubeCL kernel as a silent fallback when a required NVIDIA library is unavailable. The operation returns a typed library/provider error instead. XLA likewise rejects an unavailable plugin or unsupported lowering instead of changing execution backends behind the caller’s back. See Devices and GPU and XLA and PJRT for setup and operation coverage.
The hardware-gated two-CUDA-device identity and execution evidence can be run with:
cargo test -p tenferro-gpu --features cuda --lib \
cuda_registration_preserves_two_caller_selected_engine_ids_and_devices -- \
--ignored --nocapture
The test uses discovered device IDs, registers both caller-selected engines in one runtime, and reports an explicit SKIP with the detected count only when successful discovery finds fewer than two visible devices. A discovery error fails the test with the original typed error.
CPU Provider Choice
CpuBackend::new() uses the default provider compiled into the application. The faer provider is the default because it gives tenferro the most control: tenferro can inject Par::Seq or Par::rayon(n) from the selected backend context, and a managed CPU domain can own the corresponding Rayon executor and affinity contract.
External BLAS providers own their worker pools. Their environment variables are generally process-wide, so a provider call can oversubscribe an outer tenferro fan-out unless its provider thread count is reduced, often to one. Choose an external provider when its peak dense-kernel performance is worth giving up tenferro’s strict worker-placement and per-operation control.
| Provider family | Required library | Cargo feature | Thread ownership and scope | Guidance |
|---|---|---|---|---|
| faer | None beyond the Rust dependencies | cpu-faer |
tenferro-managed CpuDomainExecutor and Rayon pool; budget is per selected CPU domain |
Default choice when placement, reproducibility, and predictable nesting matter |
| OpenBLAS | OpenBLAS and its CBLAS/LAPACK entry points | cpu-blas, blas-openblas |
OpenBLAS-owned pool; settings such as OPENBLAS_NUM_THREADS are provider/process-wide |
Use for peak BLAS/LAPACK throughput; use CpuPlacement::Auto and avoid outer oversubscription |
| Intel MKL | MKL and its BLAS/LAPACK entry points | cpu-blas, blas-mkl |
MKL-owned pool; MKL_NUM_THREADS and related OpenMP settings are provider/process-wide |
Use when the deployment already standardizes on MKL; tenferro cannot verify worker affinity |
| Apple Accelerate | Apple Accelerate BLAS/LAPACK | cpu-blas, blas-accelerate |
Accelerate-owned pool; VECLIB_MAXIMUM_THREADS is provider/process-wide |
Use for Apple-native deployments; explicit NUMA placement is not a tenferro guarantee |
| BLIS | BLIS | Planned in #1334 | Provider-owned; the final scope and controls are not part of the current API | Do not rely on a BLIS feature until the planned provider contract lands |
| TBLIS external provider | TBLIS source or a separately supplied library | External example in #1493 | The provider bundle owns the TBLIS call policy; the example clamps its call to one thread and restores the setting | A dot_general provider example, not a complete dense backend; other operations delegate to the default provider |
The CPU provider kind is selected per backend when both base provider kinds are compiled:
use tenferro_cpu::{CpuBackend, CpuBackendKind};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let faer = CpuBackend::with_threads_and_kind(4, CpuBackendKind::Faer)?;
let blas = CpuBackend::with_threads_and_kind(4, CpuBackendKind::Blas)?;
assert_eq!(faer.kind(), CpuBackendKind::Faer);
assert_eq!(blas.kind(), CpuBackendKind::Blas);
Ok(())
}This requires both cpu-faer and cpu-blas in the resolved Cargo feature set. It does not make OpenBLAS and MKL independently selectable at runtime: the concrete external BLAS implementation is selected by the process’s build and linked library configuration. Its worker environment remains a provider-owned process-wide concern.
Nested Parallelism
The two common nesting models are intentionally different:
Panel A: faer (tenferro-managed)
caller thread
-> CpuOperationEntry::enter (permit + owned pool entry, budget N fixed)
-> with_native_parallelism (ExecutionPolicy::Rayon{max_threads: N})
-> strided-kernel fanout (<= N partitions on SAME owned pool;
nested strided ops inside partition sequential by fanout guard)
-> faer ops (Par::rayon(N) same budget/pool)
Panel B: external BLAS (provider-owned)
caller thread
-> CpuOperationEntry::enter (outer permit, tenferro budget N)
-> outer tenferro fanout (N partitions)
-> provider call -> provider-owned pool
possible workers: N partitions * OPENBLAS_NUM_THREADS
remedy: set the provider thread count to 1 when outer fanout is active
The faer model keeps outer and inner work inside one admitted domain and prevents a child operation from recursively fanning out on the same pool. The external model cannot provide that invariant because the library owns the inner pool. Do not infer a strict tenferro thread budget from a provider environment variable.
Backend Capability Catalog
The catalog uses the same vocabulary as runtime engine registration. A future EngineRegistration can expose these properties as capabilities without changing the user-facing decision structure.
| Backend/family | Control tier | Outer/inner parallelism and nesting | Worker, stream, or queue owner and scope | Managed or ExternalManaged | Synchronization | Storage and event domain | Unsupported behavior and dtype/op coverage |
|---|---|---|---|---|---|---|---|
| CPU/faer and native kernels | Tenferro-managed | Both: engine-owned outer fan-out and context-selected inner kernels; nested child fan-out is sequential | CpuDomainExecutor and Rayon pool owned per CPU domain/backend coordinator |
Managed for the default pinned faer path; ExternalManaged only when the caller supplies a domain executor |
Direct CPU operations complete before return | Host storage classes; the current CPU runtime uses an immediate event domain | Typed unsupported errors; see CPU execution and linear algebra |
| CPU/BLAS or LAPACK | Provider-owned | Tenferro may own outer work, while BLAS/LAPACK owns inner work; nesting is not fully controllable | Linked provider pool and environment; usually process-wide | ExternalManaged/provider-default exclusive; no tenferro affinity proof |
Provider call returns before the synchronous CPU API returns; worker synchronization is provider-owned | Host storage classes; CPU event domain | Unsupported placement and provider operations are errors, not faer fallback; see CPU execution |
| CUDA | Backend and NVIDIA-library managed | Device kernels use provider-selected grid/stream parallelism; host-side nesting is limited to explicit backend submissions | CUDA runtime stream, handles, and backend plan caches owned by the CUDA runtime/backend instance | GPU runtime managed; CPU ExternalManaged labels do not describe CUDA worker ownership |
Launches may be asynchronous; synchronize, download, or host inspection is the boundary | CUDA device storage and CUDA stream/event domain | Unsupported CUDA operation or dtype, and missing NVIDIA library, return typed errors; see GPU coverage |
| WebGPU | Queue/provider managed | Device parallelism is inside the submitted shader; independent work may share the queue, with no tenferro CPU-style nested budget | wgpu device and queue owned by the WebGPU runtime/backend | GPU runtime managed; no CPU NUMA contract | Queue submission is asynchronous; explicit runtime synchronization or download makes results host-visible | WebGPU storage and queue/event domain | Unsupported operation or dtype returns an error; no implicit CPU fallback; see GPU coverage |
| XLA/PJRT | Plugin-managed | XLA decides intra-op decomposition; tenferro does not add a second hidden inner pool | PJRT plugin owns its client, device, streams, and worker scope | Plugin-managed; not a tenferro CPU Managed or ExternalManaged domain |
PJRT execution and transfers define the host-visible boundary | PJRT device buffers and plugin event domain | Unsupported lowering, dtype, shape, or missing plugin is rejected; see XLA subset |
The dtype and operation links above are the sources of truth for coverage. This page describes ownership and failure behavior, not a second operation catalog.
Ownership Contracts
Managed and ExternalManaged are resource-domain contracts, not performance labels:
Managedmeans tenferro constructs and owns the executor, can enforce the declared worker budget and supported affinity guarantee, and owns shutdown for that executor. The faer path is the primary CPU example.ExternalManagedmeans the caller or provider owns the executor and its shutdown. tenferro retains the owner for dependent work and arbitrates the declared domain, but it does not repin external workers or claim live OS affinity verification. External BLAS uses this conservative contract.
GPU runtimes and PJRT plugins have their own stream, queue, and client lifetimes. They should be described by explicit engine capabilities rather than being forced into a CPU NUMA label.
Placement
An ordinary tensor is single-device. Placement selects one registered engine and storage class for an operation; it does not create a distributed tensor. The current multi-device work schedules independent single-device tensors and inserts explicit transfers. Cross-device transfer providers, event domains, and asynchronous submission are tracked in execution substrate issue #1471. Distributed tensors and collectives are outside this contract.
When one process needs two CPU engine registrations, use distinct engine IDs. The canonical helper keeps tenferro-cpu.default.v1 for the usual single backend case; the caller-selected helper avoids an ID collision:
use tenferro_cpu::{runtime_engine_registration_with_id, CpuBackend, CpuBackendKind};
use tenferro_runtime::{EngineId, Runtime};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let primary = CpuBackend::with_threads_and_kind(2, CpuBackendKind::Faer)?;
let secondary = CpuBackend::with_threads_and_kind(2, CpuBackendKind::Blas)?;
let mut builder = Runtime::builder();
builder.register_engine(runtime_engine_registration_with_id(
&primary,
EngineId::new("example.cpu.faer.v1")?,
)?)?;
builder.register_engine(runtime_engine_registration_with_id(
&secondary,
EngineId::new("example.cpu.blas.v1")?,
)?)?;
let runtime = builder.build()?;
assert_eq!(runtime.snapshot()?.engine_count(), 2);
Ok(())
}Compile this example with both cpu-faer and cpu-blas. If the second backend uses OpenBLAS, MKL, or Accelerate, its library and thread settings are still process-wide provider settings. Separate processes are required when the application needs mutually incompatible external BLAS installations or independent provider environment pools.
Policy Selection
Use CpuBackend::with_threads, CpuBackend::with_kind, and explicit placement when tenferro should own the policy. Use a provider bundle or an external domain only when the provider’s own controls are part of the application’s deployment contract. In either case, an unsupported request must produce a typed error; a backend must not silently switch to another provider or device.
See Parallelism and Caching for thread-budget mechanics, provider environment variables, cache lifetime, and oversubscription diagnostics.