Backend conformance#
Every BackendOps method must match the NumPy reference within its per-op
tolerance on every backend; the matrix below records what tests/backend/
checks.
How it is checked#
Harness.
tests/_conformance.py(re-exported bytests/backend/_conformance.py) holdsTOLERANCE_TABLEandassert_matches_reference. Tolerance is keyed by(op, dtype kind); the default isrtol=1e-6, atol=1e-8, looser foreigh,svd, complexsqrt, and float32logsumexp/cholesky/solve.Backends. The
backend_opsfixture (tests/backend/conftest.py) runs each test on NumPy and on each of JAX, Torch, CuPy that is importable.dtypes.
float32,float64,complex64,complex128. A dtype the backend does not honor is skipped (backend_supports_dtype): JAX 64-bit withoutjax_enable_x64, Torch according to its default dtype.Not checked. Performance, device placement, sharding, autograd.
Differences. A result that passes but differs from NumPy in a way users notice (dtype promotion, default precision) is listed in Backend deviations.
Adding a backend#
A backend is a BackendOps subclass registered under a family name. Everything
the Array API standard covers delegates to the class attribute xp; you write
only what it does not.
Subclass
sc.BackendOpsand set:xp— an Array API namespace (array_api_compat.<library>);_family— the registry key, any lowercase string;_allow_sparse— whether the backend has sparse arrays.
Implement the abstract methods:
dense_arrayandsparse_array(the array types),sanitize_dtype(Nonemeans the default dtype),logsumexp,ix_,assparse,sparse_matmulandallclose_sparse(raiseCapabilityErrorwithout sparse support), andfori_loop/while_loop/scan/cond(see Control flow below: an eager library mixes inspacecore.backend.EagerControlFlowMixin).Implement
_copyand_scatter_add_inplaceifindex_set/index_addshould work; override any inherited method whosexpbehaviour differs from NumPy’s (TorchOpsoverrideswhere,concatenate, …).Register by decorating the class with
@sc.register_ops(or callingsc.register_ops(cls)). A family can be registered once per process (ContextConflictErrorotherwise).Context("<family>")andContext(cls())then both work, andconvertmoves spaces, operators and functionals onto it.Test it: add the family to
_all_familiesandbackend_opsintests/backend/conftest.pyso the conformance suite runs against the NumPy reference, and add its column to the matrix below.
A complete dense-only backend over NumPy:
import numpy as np
import scipy.special
import array_api_compat.numpy
import spacecore as sc
from spacecore.backend import EagerControlFlowMixin
@sc.register_ops # registers the class and returns it
class DenseNumpyOps(EagerControlFlowMixin, sc.BackendOps):
"""NumPy arrays, no sparse support: the smallest complete backend."""
xp = array_api_compat.numpy # every Array API method delegates here
_family = "npdense" # registry key; Context("npdense") finds it
_allow_sparse = False
# --- array types and dtypes ------------------------------------------
@property
def dense_array(self):
return np.ndarray
@property
def sparse_array(self):
return ()
def sanitize_dtype(self, dtype):
return np.float64 if dtype is None else np.dtype(dtype)
# --- what the Array API standard does not cover ------------------------
def logsumexp(self, a, axis=None, b=None, keepdims=False, return_sign=False):
return scipy.special.logsumexp(a, axis=axis, b=b, keepdims=keepdims,
return_sign=return_sign)
def ix_(self, *args):
return np.ix_(*args)
def _copy(self, x): # used by index_set / index_add
return x.copy()
def _scatter_add_inplace(self, y, index, values):
np.add.at(y, index, values)
# --- sparse: refuse, since _allow_sparse is False ----------------------
def assparse(self, x, *, format="csr", dtype=None):
raise sc.CapabilityError("npdense has no sparse arrays")
def sparse_matmul(self, a, b):
raise sc.CapabilityError("npdense has no sparse arrays")
def allclose_sparse(self, a, b, rtol=1e-5, atol=1e-8):
raise sc.CapabilityError("npdense has no sparse arrays")
ctx = sc.Context("npdense", dtype=np.float64)
X = sc.DenseCoordinateSpace((3,), ctx)
A = sc.DenseLinOp(ctx.asarray(np.diag([1.0, 2.0, 4.0])), X, X, ctx)
b = ctx.asarray([1.0, 1.0, 1.0])
x = sc.cg(A, b).x
assert ctx.ops.family == "npdense"
np.testing.assert_allclose(x, [1.0, 0.5, 0.25])
A_np = A.convert(sc.Context(sc.NumpyOps(), dtype=np.float64)) # move to another backend
np.testing.assert_allclose(A_np.apply(b), A.apply(b))
Control flow#
Library loops go through ops, so one solver runs eagerly and compiles under
``jax.jit``. cg, power_iteration, lanczos_smallest and
expm_multiply never write a Python while; they call ops.while_loop,
ops.fori_loop, ops.cond and ops.scan. Each backend decides what those
mean.
Method ( |
Eager meaning |
|---|---|
|
|
|
|
|
|
|
loop over the leading axis of |
The two kinds of backend need different implementations:
Tracing (
JaxOps): a Pythonwhilecannot test a traced value, a Pythonifcannot branch on one, and a Pythonforunrolls into the compiled graph.JaxOpsmaps the four methods tojax.lax.while_loop/fori_loop/cond/scanand does not use the mixin.Eager (
NumpyOps,TorchOps,CuPyOpsthroughNumpyOps): the values are concrete, so plain Python loops are correct.EagerControlFlowMixinis that one implementation, including the tree stackingscanneeds; list it beforeBackendOpsin the bases so its methods win.
A new eager backend therefore mixes it in and writes no control flow. A new
tracing backend implements the four with its own staged primitives, keeping the
carry structure fixed across iterations and both cond branches returning the
same structure, as jax.lax requires. Code written against ops follows the
same rules:
def newton_sqrt(ops, a, tol=1e-12):
"""sqrt(a) by Newton's method, written once against ops."""
def keep_going(carry):
x, step = carry
return step > tol
def update(carry):
x, _ = carry
x_new = 0.5 * (x + a / x)
return x_new, ops.abs(x_new - x)
x, _ = ops.while_loop(keep_going, update, (a, ops.asarray(1.0)))
return x
np_ops = sc.NumpyOps()
print(newton_sqrt(np_ops, np_ops.asarray(2.0))) # eager Python loop
import jax
jax_ops = sc.JaxOps()
print(jax.jit(lambda a: newton_sqrt(jax_ops, a, tol=1e-6))(jax_ops.asarray(2.0))) # lax.while_loop
Matrix#
N J T C = NumPy, JAX, Torch, CuPy. y = checked against the reference;
s = checked, skipped when the library or platform lacks the operation
(e.g. CuPy without a GPU).
Metadata and predicates#
Method |
Reference |
N J T C |
|---|---|---|
|
constant |
y y y y |
|
array type |
y y y s |
|
direct |
y y y y |
|
|
y y y y |
|
dtype kind |
y y y y |
Construction#
Method |
Reference |
N J T C |
|---|---|---|
|
|
y y y y |
|
|
y y y y |
|
|
y y y y |
Shape and layout#
Method |
Reference |
N J T C |
|---|---|---|
|
direct |
y y y y |
|
direct |
y y y y |
|
direct |
y y y y |
|
direct |
y y y y |
Elementwise and reductions#
Method |
Reference |
N J T C |
|---|---|---|
|
direct |
y y y y |
|
direct |
y y y y |
|
direct |
y y y y |
|
direct |
y y y y |
|
direct |
y y y y |
|
direct |
y y y y |
|
|
y y y y |
Linear algebra#
Method |
Reference |
N J T C |
|---|---|---|
|
|
y y y y |
|
|
y y y y |
|
identity |
y y y s |
|
direct |
y y y y |
Indexing, control flow, mapping#
Method |
Reference |
N J T C |
|---|---|---|
|
|
y y y y |
|
|
y y y y |
|
sequential Python |
y y y y |
|
stacked Python loop |
y y y y |
Sparse#
Method |
Reference |
N J T C |
|---|---|---|
|
SciPy sparse |
y y y s |
Conversion and constants#
Method |
Reference |
N J T C |
|---|---|---|
|
round-trip equality |
y y y y |
|
|
y y y y |
|
|
y y y y |
trace sums the diagonal over the trailing two axes, so a
(batch, n, n) stack returns one trace per matrix.
is_tracing and install_pytree_protocol have no NumPy reference and are
not in the matrix.
See Context, dtype and conversion for the dtype and conversion contract.