Math Plus: tensors
Five packages. Two of them (tensor-wasm, tensor-webgpu) are marketed as “acceleration,” and the honest state of both is more interesting than the marketing — read How do I pick a backend? before architecting anything around them.
| Package | What it is |
|---|---|
@johnhenry/math-plus-tensor-core |
Typed n-D arrays: dtypes, strides/views, NumPy broadcasting, .npy I/O, seeded RNG. Pure JS, zero deps. Start here. |
@johnhenry/math-plus-tensor-autograd |
Reverse-mode tape (Variable), nn.*, optim.* (SGD/Adam/AdamW/RMSprop, StepLR), trainer, checkpoints |
@johnhenry/math-plus-tensor-compile |
Elementwise expression IR + fusion — trace once, execute fused. Opt-in. |
@johnhenry/math-plus-tensor-wasm |
Rust→WASM CPU kernels: SIMD, arena allocator, zero-alloc ...Into ops, opt-in Deno-native FFI |
@johnhenry/math-plus-tensor-webgpu |
WGSL GEMM, attention primitives, IR→WGSL fusion. Chromium-family browsers only. |
npm install @johnhenry/math-plus-tensor-coreHow 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. The
“WASM kernels swap in underneath Tensor” seam described in tensor-core’s
own header comment is intent, not implementation — the storage-model merge
is tracked separately and hasn’t happened.
| Backend | Environment | How you opt in | Honest status |
|---|---|---|---|
Pure JS (tensor-core) |
Node ≥22.12, 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. |
WASM (tensor-wasm) |
Anywhere with WebAssembly | Explicitly import Kernels, write against WasmTensor — a different type (f32 only, 1-D/2-D ops, manual free()) |
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. Binaries are CI artifacts, not yet published to npm. |
WebGPU (tensor-webgpu) |
Chromium-family browsers (no plain Node; no polyfill bundled) | Free functions: toWebGPU(tensor, device), runGemmWGSL, … |
GEMM_ELEMENT_THRESHOLD is Infinity: on the maintainer’s measured hardware (integrated GPU, naive untiled shader), WASM beat WebGPU at every size up to 768×768, by 5–10×. A test pins this so recalibration is deliberate. Measure on your GPU before believing either side. |
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.
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. - 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. .npyI/O is little-endian, C-order only —fortran_order: Truethrows, big-endian descrs throw, andf16/bf16have no.npyrepresentation at all. NumPy will happily write files this reader rejects.
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.- 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) and engages only for stride-1 operands onaddInto/mulInto. Results are bit-identical to scalar. - 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”- f32 only; contiguous only;
GPUBuffers are manually freed, including chain intermediates.runQKTis unscaled — apply1/sqrt(dim)yourself. runElementwiseWGSLdoesn’t broadcast; do it CPU-side first.- WGSL
powis NaN for negative bases where JS isn’t, and f32-epsilon comparisons can flipselectbranches — exactly the ops the GPU-vs-CPU fuzzer excludes on purpose.