math-plus: tensors
Nine packages. Four of them (tensor-wasm, tensor-webgpu, tensor-mlx, tensor-cpu) are ways to go faster or to run model code, and none of them swaps in underneath Tensor. Read How do I pick a backend? before architecting anything around them.
| Package | Version | What it is |
|---|---|---|
@johnhenry/math-plus-tensor-core |
0.2.1 | Typed n-D arrays: dtypes (including real f16/bf16 storage), strides/views, NumPy broadcasting, .npy I/O, seeded RNG. Pure JS; its one dependency is special. Start here. |
@johnhenry/math-plus-tensor-autograd |
0.1.3 | Reverse-mode tape (Variable), nn.*, optim.* (SGD/Adam/AdamW/RMSprop, StepLR), trainer, checkpoints |
@johnhenry/math-plus-tensor-compile |
0.1.3 | Elementwise expression IR + fusion — trace once, execute fused. Opt-in. The shared lowering target for WGSL. |
@johnhenry/math-plus-tensor-wasm |
0.1.2 | Rust→WASM CPU kernels: SIMD, a blocked SIMD GEMM, arena allocator, zero-alloc ...Into ops, opt-in Deno-native FFI |
@johnhenry/math-plus-tensor-webgpu |
0.3.0 | WebGPU device facade over @johnhenry/backend-webgpu: GEMM, fused attention, the tensor-backend ops, plus IR→WGSL fusion. Browsers, Deno, and Node/Bun through Dawn. |
@johnhenry/math-plus-tensor-mlx |
0.2.1 | Experimental. MLX (Metal) device facade over @johnhenry/backend-mlx: chainable device arrays with explicit async transfers. darwin/arm64 on Node, Bun and Deno 2. |
@johnhenry/math-plus-tensor-cpu |
0.2.1 | The CPU reference Backend for the @johnhenry/tensor-backend contract: f32 compute on tensor-core’s kernels, every optional op native. Node, Bun, Deno, browsers. |
@johnhenry/math-plus-safetensors |
0.1.3 | safetensors reader/writer: validated headers, typed views (F16 as Float16Array), lazy reads from files, Blobs and HTTP Range requests. Zero deps. |
@johnhenry/math-plus-special |
0.1.1 | The one canonical double-precision erf/erfc/GELU (SciPy-verified). Zero deps; used by tensor-core (which re-exports it), tensor-cpu and frame-arrow. |
npm install @johnhenry/math-plus-tensor-corenpx jsr add @johnhenry/math-plus-tensor-core# or, in Denodeno add jsr:@johnhenry/math-plus-tensor-core<!-- In <head>, before any <script type="module"> or modulepreload. --><script type="importmap"> { "imports": { "@johnhenry/math-plus-special": "https://cdn.jsdelivr.net/npm/@johnhenry/[email protected]/dist/index.js", "@johnhenry/math-plus-tensor-core": "https://cdn.jsdelivr.net/npm/@johnhenry/[email protected]/dist/index.js" } }</script><script type="module"> import * as mathPlusTensorCore from "@johnhenry/math-plus-tensor-core";</script>Every package here needs Node ≥ 24 (lowered from 26) and is also tested under Bun 1.2; runtime specifics are in the table above.
How do I pick a backend? (Read this first)
Section titled “How do I pick a backend? (Read this first)”There is no setBackend() API. There is no automatic dispatch. tensor-core’s Tensor is the host type: the eager, strided, full-dtype reference that NumPy checks. Accelerators are separate devices that you create yourself, and data crosses between the host and a device only through explicit, async transfers (await device.fromTensor(t), await device.toTensor(x)).
| Backend | Environment | How you opt in | Honest status |
|---|---|---|---|
Pure JS (tensor-core) |
Node ≥ 24, Bun, Deno, browsers | It’s the default and only Tensor |
The reference path. Everything else in the family (fft, signal, image, autograd, frames’ toTensor()) runs on it. Contiguous inputs take flat, bit-identical fast paths; matmul is a 4×4 register-blocked GEMM. Single-threaded, no SIMD. |
WASM (tensor-wasm) |
Anywhere with WebAssembly | Explicitly import Kernels, write against WasmTensor — a different type (f32 only, 1-D/2-D ops, manual free()) |
matmulInto runs a cache-blocked SIMD128 GEMM: ~37 GFLOP/s at 1024³ on an M2 (21.6× the old naive loop). Elementwise ops are 1.78× faster than JS at N=1e6 over resident buffers — and 2.27× slower if you copy in/out per call, which is why the API forces residency on you. |
Native FFI (tensor-wasm/native) |
Deno with --allow-ffi + a platform binary |
NativeKernels.load() ?? await Kernels.load() — load() returns undefined, never throws |
1.2–5.3× over WASM depending on op; an opt-in accelerate build routes GEMM to Apple’s cblas_sgemm. Binaries are CI artifacts, not published to npm. |
WebGPU (tensor-webgpu) |
Browsers (navigator.gpu), Deno (built-in WebGPU), Node ≥ 24 and Bun ≥ 1.2 (Dawn) |
const gpu = await createWebGpuDevice(), then gpu.backend.* ops and gpu.fuse / gpu.compile |
The same runtime laya-js runs its models on: subgroup-matrix, skinny and tiled GEMM kernels, fused flash attention. For a one-shot GEMM on host arrays, chooseGemmBackend(m, n, k) picks WebGPU only when m·n ≥ 256² and m·n·k ≥ 2²⁴, otherwise tensor-wasm (see below). |
MLX (tensor-mlx) |
macOS on Apple Silicon: Node, Bun, Deno 2 | const mlx = createMlxDevice(), then await mlx.fromTensor(t) and chainable MlxArray ops |
Experimental prototype for RFC 0001. Lazy MLX graph on the Metal GPU; no browser build (MLX needs FFI). |
CPU Backend (tensor-cpu) |
Anywhere | createCpuBackend(): a Backend for code written against @johnhenry/tensor-backend |
Not an accelerator: the reference that model code (such as laya-js’s ModernBERT encoder) runs on without a GPU. At least as fast as laya-js’s old backend-cpu, which now re-exports it. |
The corollary: if you’re not sure you need acceleration, you don’t — stay on pure-JS tensor-core and you keep every sibling package compatible.
The device contract (RFC 0001)
Section titled “The device contract (RFC 0001)”RFC 0001 was accepted with changes on 2026-09-24. math-plus does not grow its own device abstraction; it depends on @johnhenry/tensor-backend, the op contract laya-js’s backends implement, and on its shared conformance suite. The decisions:
- Host vs device. tensor-core’s
Tensorstays the host type and does not become a backend. Each accelerator gets a device package over the contract (tensor-mlx,tensor-webgpu). - Uploads are async (the one change from the recommendation), so a transfer never looks synchronous in either direction. Devices may be lazy inside, as long as errors surface at the call site.
- math-plus owns the CPU reference (
tensor-cpu, on tensor-core’s kernels); laya-js’s@johnhenry/backend-cpuis now a re-export of it. - One WebGPU runtime.
@johnhenry/backend-webgpuis the single WebGPU implementation;tensor-webgpubecame a facade over it in 0.2, and the old API was removed in 0.3. - Deno support came from a
Deno.dlopenloader in@johnhenry/backend-mlx, sotensor-mlxnow runs its suites under Deno 2 and is configured for JSR (not yet published there). - Contract growth goes upstream: the 22 “general numerics” ops (comparisons,
sqrt,erf,argmax,cumsum, …) went into tensor-backend itself, not into a fork.
Autograd on device arrays is still future work: tensor-autograd works on tensor-core Tensors only.
tensor-core traps
Section titled “tensor-core traps”- Default dtype is
f32everywhere (from,zeros,arange, …);random.randintdefaults toi32. Pass{ dtype: "f64" }for numeric work — most of the family’s own tests do. - No implicit dtype promotion. Mixing dtypes in any binary op, matmul,
or comparison throws —
cast()first.divoni64throws too. The one NumPy-matching exception:mean/variance/stdof integer dtypes returnf64. gelu()is exact erf-GELU by default (since 0.1.0), like PyTorch’snn.GELU(). Passgelu({ approximate: "tanh" })for the old numbers; they differ by up to ~4.7e-4.erf/erfcare the canonical ones frommath-plus-special(~1e-15 relative), re-exported unchanged.- f16/bf16 are storage dtypes (raw bits in a
Uint16Array, zero-copy with safetensors, ONNX Runtime and WebGPU). Since 0.1.0 values convert correctly:from/full/random.*encode,at/toArraydecode, andcast()is bit-for-bit NumPy’sastype(float16). But arithmetic, comparison, reduction, sort and matmul throw on half dtypes — computing in f32 would be implicit promotion. Opt in explicitly withwithCompute("f32", [a, b], (a, b) => a.matmul(b).relu()), which casts in, runs, and rounds back once when the region exits. - Views vs copies is a contract, not an optimization.
reshape/permute/transpose/slice/select/broadcastTo/unfoldare views (a.data === b.datadetects them);take/gather/mask/cast/contiguouscopy.cast()always copies, even same-dtype, and integer casts truncate toward zero. - Fast paths are invisible except in time. Contiguous inputs take flat typed-array kernels; strided views, BigInt dtypes and uncommon broadcast patterns take the general path. Both produce bit-identical results.
.npyI/O is little-endian, C-order only —fortran_order: Truethrows, big-endian descrs throw. f16 round-trips as<f2; bf16 is written as'<V2'(theml_dtypesconvention) and read back only withTensor.fromNpy(bytes, { voidAs: "bf16" })./kernelsis for device packages. The@johnhenry/math-plus-tensor-core/kernelssubpath exposes the flat typed-array kernels under the fast paths (gemmNT,softmaxAxis,linearNT,attention,layerNormRows, …).tensor-cpuis built on them; ordinary code should useTensor.
autograd traps
Section titled “autograd traps”- Gradients accumulate across
backward()calls;zeroGrad()resets.gradtonull. Only leaves get.grad; only scalar outputs may callbackward()without an explicitgradOutput. nn.Linearinitializes at f64 — combined with no-implicit-promotion, f32 inputs throw. This bites hardest viamath-plus-data’s collate, which defaults to f32.trainer.fit(dataLoader)ignoresconfig.epochs— one pass, since an arbitraryAsyncIterableisn’t guaranteed re-iterable. Put epochs in the pipeline (dataset.epochs(n)).- Checkpoints are a custom
"MPCK"container, not.npz;loadStateDictis strict in both directions (missing and unexpected keys throw). binaryCrossEntropyis logits-based (BCEWithLogits reformulation) so saturated logits give finite losses and gradients rather than NaN.
compile traps
Section titled “compile traps”- v1 is elementwise/broadcast only — no reductions, no matmul — and float-only with one shared dtype across inputs.
forward()skips gradient bookkeeping entirely (~15× faster than the grad-carrying evaluator when you only want values);asVariableOp()plugs the fused op into the autograd tape with matching gradients.- IR op
"gelu"is exact GELU; the tanh form is its own op,"gelu_tanh". Code that switches exhaustively overUnaryOpneeds both cases. - Step functions (
floor/round/sign/…) and comparisons have zero gradient — correct, and a classic “why isn’t my parameter moving” trap.
wasm traps
Section titled “wasm traps”- Trap poisoning: the first Rust panic (WASM trap) permanently poisons
the whole
Kernelsinstance — every later call throws, reads refuse possibly-corrupt memory,free()becomes a no-op. Recovery is a freshKernels.load(). IEEE division-by-zero does not poison (±Infinity/NaN, same as JS). - SIMD is a second
.wasmmodule (any v128 instruction fails validation wholesale on non-SIMD runtimes). It engages for stride-1 operands onaddInto/mulInto, and for any strides inmatmulInto’s GEMM. Results are bit-identical to scalar. - The GEMM is single-threaded, f32 only and has no FMA (WASM SIMD128
has none, and relaxed-SIMD is deliberately unused because it fails
validation on Safari).
matmulIntorejects a wrongly-shapedoutwith aRangeError, andoutmust not aliasa/b. - In a git clone the
.wasmartifacts are gitignored —npm run build:wasm(Rust + lld) orKernels.load()throwsENOENT. The published npm package ships them prebuilt. - The SIMD benchmark is deliberately not in
npm test— see the family page for why.
webgpu traps
Section titled “webgpu traps”- The pre-0.2 API is gone (removed in 0.3.0):
toWebGPU,GPUTensor,runGemm*,runAttention,runElementwiseWGSL, the profiling functions and the./dawnsubpath. Useawait gpu.fromTensor(t)/await gpu.toTensor(x),gpu.backend.matmul/linear/sdpa, andgpu.fuse/gpu.compile. The package README has the full replacement table. - No chainable array wrapper yet (unlike
tensor-mlx’sMlxArray): ops are called ongpu.backendwith backend tensors. - Fusion is f32-only and forward-only; inputs broadcast with NumPy’s rules, up to rank 8, and each expression is one dispatch.
- One runtime per
GPUDevice. Dispatches are batched and submitted later, so two runtimes on one device could reorder each other’s work;createWebGpuDevice({ device })shares an existing backend and refuses a second one. - Subgroup-matrix GEMM needs Dawn’s experimental feature: in practice Apple
GPUs, under Dawn (set automatically) or Chrome’s
--enable-unsafe-webgpu. Elsewhere the portable tiled kernel runs. sdpatakes a bool mask on[B, H, L, D]tensors; fully masked query rows are undefined.
The GEMM threshold
Section titled “The GEMM threshold”chooseGemmBackend(m, n, k?) returns "webgpu" when the output has at
least GEMM_ELEMENT_THRESHOLD = 256² elements and the product does at
least GEMM_WORK_THRESHOLD = 2²⁴ multiply-adds, and "wasm" otherwise. It
prices a host-array call end to end (upload, matmul or linear,
readback) against tensor-wasm’s SIMD GEMM.
For 0.3.0 it was re-measured on 43 shapes in Dawn, headless Chrome and a
real, visible Chromium without subgroup matrices, on an Apple M2. Dawn wins
from 160³, but both browsers lose at 192³, so the previous rule (192² and
2²²) was tightened: the new one sends no measured shape to a slower
WebGPU in any of the three, at the cost of leaving some WebGPU wins on
WASM. It is one machine’s number, and it ignores residency — operands
already on the GPU make WebGPU cheaper at every size. Re-run the package’s
scripts/measure-gemm-threshold.ts on your own hardware before trusting it.
mlx traps
Section titled “mlx traps”- Experimental, 0.x: the API may still change, and nothing else in math-plus re-exports it.
- Uploads are async (since 0.2):
await mlx.fromTensor(t); batch them withPromise.all. An upload can’t be chained directly. - Device dtypes only: f32, f16, bf16, i32 and bool.
f64is refused (Metal has no float64) and so are the other integer dtypes;cast()first. There is no implicit promotion, and non-contiguous tensors are rejected. - Lazy execution: ops build an MLX graph and return immediately. Shape
errors still throw at the call site; the arithmetic runs at
eval(),toTensor()ortoHost(). Memory is freed bydispose()/scope(), not by the garbage collector. - Deno needs
--allow-ffi --allow-read --allow-env; the MLX libraries come from the@johnhenry/backend-mlx-darwin-arm64platform package orLAYA_MLXC_PATH. - Results can differ from tensor-core’s CPU results in the last ulps; f16 is only checked to 2e-2 and bf16 to 5e-2.
cpu, safetensors and special
Section titled “cpu, safetensors and special”tensor-cpucomputes in f32 (f16/bf16 host data is widened on upload) and implements every contract op exceptcompile. It also hosts theTensor↔HostTensorbridge (hostFromTensor,tensorFromHost) thattensor-mlxandtensor-webgpushare.safetensorsvalidates headers like the reference implementation and writes files byte-identical to Python’ssafetensors.serialize. Open big checkpoints lazily withopenSafetensors()over a file path, aBlob/Fileor an HTTP URL (Range requests, with a full-download fallback); only the tensors you ask for are read. tensor-core interop is an optional peer (@johnhenry/math-plus-safetensors/tensor). laya-js reads every checkpoint through it.specialexists so the canonicalerfhas exactly one implementation. frame-arrow’sfn.erf()switched to it from an Abramowitz & Stegun copy, so its results moved in the 7th decimal place.