@johnhenry/math-plus-tensor-mlx
v0.2.3
Published
EXPERIMENTAL: native Apple Silicon (MLX/Metal) arrays for Math Plus on Node, Bun and Deno, with explicit async transfers to/from tensor-core Tensor — built on @johnhenry/backend-mlx (issue #125, RFC 0001)
Maintainers
Readme
@johnhenry/math-plus-tensor-mlx
Experimental. This is the prototype for RFC 0001 (Accepted with changes, 2026-09-24). The API is 0.x and may still change. It is not re-exported from any other math-plus package.
Native Apple Silicon arrays for math-plus, running on MLX's Metal GPU (or
its CPU backend) from Node, Bun and Deno 2. You move data in from a
tensor-core Tensor and back out with explicit, async calls. Nothing is
copied implicitly.
This package contains no native code and no FFI of its own. Everything runs
through @johnhenry/backend-mlx,
a binding to Apple's mlx-c that uses koffi on Node, bun:ffi on Bun and
Deno.dlopen on Deno.
backend-mlx implements the
@johnhenry/tensor-backend
contract. The math-plus-style, method-chaining array API on top of it is
not implemented here: it is the one DeviceArray API every math-plus
device shares, from
@johnhenry/math-plus-tensor-cpu
(the same class runs on the CPU and on WebGPU). MlxDevice extends its
ArrayDevice, and MlxArray is a DeviceArray subclass that adds
nothing.
Install
npm install @johnhenry/math-plus-tensor-mlx @johnhenry/math-plus-tensor-core
bun add @johnhenry/math-plus-tensor-mlx @johnhenry/math-plus-tensor-core
deno add npm:@johnhenry/math-plus-tensor-mlx npm:@johnhenry/math-plus-tensor-coreThe package is configured for JSR (jsr.json), but it is not published on
jsr.io yet; under Deno, use the npm: specifiers above.
- Requires macOS on Apple Silicon (darwin/arm64). On darwin/arm64, npm
also installs the optional dependency
@johnhenry/backend-mlx-darwin-arm64(a 64 MB download, 207 MB unpacked: libmlxc, libmlx and mlx.metallib from MLX 0.32.2). - On any other platform the package still installs and imports.
createMlxDevice()throws, andmlxUnavailableReason()tells you why. - For other ways to supply
libmlxc.dylib($LAYA_MLXC_PATH, a local build or Homebrew), see backend-mlx's README.
Deno
Deno 2 loads mlx-c through Deno.dlopen (backend-mlx 0.3+). Tested on
Deno 2.9.7.
- Permissions. Run with
--allow-ffi --allow-read --allow-env(or-A). The last two let backend-mlx find the library. - The native library. It comes one of two ways:
- The platform package
npm:@johnhenry/backend-mlx-darwin-arm64. tensor-mlx imports backend-mlx asnpm:@johnhenry/backend-mlx, so Deno installs the platform package (backend-mlx's optional dependency for darwin/arm64) along with it, and backend-mlx finds it innode_modulesor in Deno's npm cache. This was checked with an emptyDENO_DIRand nonode_modules. If you importjsr:@johnhenry/backend-mlxdirectly, add it yourself:deno add npm:@johnhenry/backend-mlx-darwin-arm64. - Or point
LAYA_MLXC_PATHat alibmlxc.dylibfile, or at a directory containing one (for example a localbuild-mlxc.shbuild). This wins over the platform package. A set variable that points nowhere is an error, not a fallback.
- The platform package
mlxUnavailableReason()says why when neither is found, anddevice.info.runtimereports"deno".
Quick start
import { Tensor } from "@johnhenry/math-plus-tensor-core";
import { createMlxDevice } from "@johnhenry/math-plus-tensor-mlx";
const mlx = createMlxDevice(); // you create the device; there is no global default
const [x, w] = await Promise.all([ // explicit async uploads, one copy each
mlx.fromTensor(Tensor.from([1, 2, 3, 4]).reshape([2, 2])),
mlx.fromTensor(Tensor.from([0.5, -1, 2, 0]).reshape([2, 2])),
]);
const y = mlx.scope(() => x.matmul(w).add(1).softmax(-1)); // lazy MLX graph; intermediates are freed
const best = x.argmax(-1); // i32 indices, still on the device
const t = await y.toTensor(); // explicit async download: evaluates, then copies into a Tensor
y.dispose(); best.dispose(); x.dispose(); w.dispose();API
Device. createMlxDevice({ device?: "gpu" | "cpu", libPath?, finalizers? })
returns an MlxDevice. Everything except the introspection line below is
the shared ArrayDevice API.
- Transfers in:
await fromTensor(t)andawait fromHost(hostTensor). Both return a Promise (since 0.2). Validation errors (non-contiguous tensor, unsupported dtype) still throw synchronously. wrap(handle)adopts adevice.backendresult as anMlxArray, andsupports(dtype)reports the device dtypes (all five on MLX).- Graph control:
eval(...arrays)evaluates the given arrays; with no arguments it synchronizes the stream. - Lifetime:
scope(fn)frees every array created insidefnexcept the arrays it returns, directly or one level deep in an array or object. - Ops:
where(cond, a, b). - Introspection (MLX-specific):
liveArrays(),memory(),kind, andinfo(which library loaded,node,bunordeno, and the mlx-c ABI). Alsodestroy()andname("mlx"). backendexposes the raw@johnhenry/tensor-backendBackend, for code written against that contract and for its conformance suite.
mlxUnavailableReason() returns a string saying why MLX can't run here,
or null if it can.
MlxArray (the shared DeviceArray; x instanceof MlxArray holds
for every array an MlxDevice returns).
- Properties:
shape,dtype,ndim,size,device,disposed, andhandle(the backend-mlx tensor, fordevice.backend.*ops this API does not wrap). - Transfers out and lifetime:
toTensor()(async),toHost()(async, returns aHostTensor),eval()(returnsthis),dispose()(idempotent). - Arithmetic, with NumPy broadcasting:
add,sub,mul,div,maximum,minimumandpow. Each takes an array or a number. - Unary math:
neg,abs,exp,log,sqrt,rsqrt,tanh,sigmoid,erf,reluandgelu(exact erf). - Comparisons, returning bool, each taking an array or a number:
equal,notEqual,less,lessEqual,greaterandgreaterEqual. - Logic on bool arrays:
logicalAnd,logicalOrandlogicalNot. - Reductions:
sum,mean,max,min,argmaxandargmin, each taking(axis?, { keepDims? }). With no axis they reduce over every element (argmax/argminthen index the flattened array, like NumPy).argmax/argminreturn i32 and pick the first of equal values. - Scans and normalisation:
cumsum(axis?)(inclusive; with no axis, over the flattened array) andsoftmax(axis = -1). - Linear algebra and NN:
matmul(batched, with broadcast leading dims) andlayerNorm(weight?, bias?, eps = 1e-5)over the last axis. - Shape and dtype:
cast(dtype),reshape(shape),transpose(axes?).
Host helpers, which need no MLX: hostFromTensor(t) and
tensorFromHost(h) return zero-copy views across the tensor-core and
HostTensor boundary. Also exported: DEVICE_DTYPES and isDeviceDType.
Rules
- Transfers are explicit. Data enters only through
fromTensororfromHost, and leaves only throughtoTensorortoHost.- Passing a tensor-core
Tensor, or an array from anotherMlxDevice, to an op throws. Nothing is uploaded behind your back (PLAN.md non-goal 5). fromTensormakes exactly one copy, from the tensor's own storage into MLX unified memory. It reads asubarrayview of that storage, so there is no intermediate packing.- A non-contiguous tensor, such as a transposed view, is rejected. Call
.contiguous()yourself first. toTensormakes one copy out, and theTensorwraps that buffer.
- Passing a tensor-core
- dtypes. The device holds
f32,f16,bf16,i32andbool, under the same names as tensor-core.- There is no implicit promotion. Binary ops need matching dtypes.
Number operands take the array's dtype, so
f16.mul(0.5)stays f16, andi32.add(2.9)adds 2 (truncation, likenumpy.asarray(v, int32)). An i32 operand outside the i32 range throws. - Number operands are never uploaded: the constant is built on the device from the array itself (a few graph nodes), because uploads are async and ops are not.
exp,log,sqrt,rsqrt,pow,tanh,sigmoid,erf,gelu,mean,softmaxandlayerNormneed a float dtype.- Arithmetic,
neg,abs, reductions,argmax/argminandcumsumrefuse bool. Comparisons take any dtype (both sides the same) and return bool.logicalAnd/Or/Notneed bool inputs:cast("bool")first, there is no implicit truthiness. argmax/argminreturn i32.cumsumkeeps its input dtype.cast()is the only way to change dtype, even though MLX itself would promote.- f16 and bf16 use tensor-core's storage layout: raw IEEE bits in a
Uint16Array. On upload, f16 is re-viewed as aFloat16Arraywithout a copy. f64is refused (Metal has no float64):cast("f32")first. The other integer dtypes, includingi64, are also refused; cast them toi32yourself.
- There is no implicit promotion. Binary ops need matching dtypes.
Number operands take the array's dtype, so
- Lazy execution. Every op appends a node to MLX's graph and returns
immediately.
- Shape errors still throw at the call site. Only the arithmetic is
deferred, and it runs at
eval(),toTensor()ortoHost(). compile(MLX's fusing compiler) is not exposed. It stays opt-in (see the RFC).
- Shape errors still throw at the call site. Only the arithmetic is
deferred, and it runs at
- Lifetime. MLX memory is freed by
dispose()orscope(), not by the JS garbage collector. The FinalizationRegistry in backend-mlx is only a safety net.- A pending graph keeps its own inputs alive.
- Using a disposed array throws.
Tests
npm test -w @johnhenry/math-plus-tensor-mlx # node:test
npm run test:bun -w @johnhenry/math-plus-tensor-mlx
npm run test:deno -w @johnhenry/math-plus-tensor-mlx # Deno 2 (node:test via Deno's Node compat)test:deno runs the same files with deno test -A
--node-modules-dir=manual, so it uses the repo's node_modules (and the
platform package in it). Deno type-checks them too, against the built
dist/ of the workspace packages: the build rewrites the ./x.ts
specifiers tsc leaves in .d.ts files, and points each dist/*.js at its
declarations with @ts-self-types (scripts/rewrite-dts-extensions.mjs,
issue #157). Build first.
test/device-array.test.tsruns the shared DeviceArray suite (tensor-cpu'stest/device-array-suite.ts, the same suite that runs over the CPU and WebGPU devices) on Metal. It compares every op against a NumPy oracle, tensor-cpu'sscripts/device_array_oracle.py, which is resolved as$MATH_PLUS_ORACLE_PYTHON, elsepython3(see docs/TESTING.md), and checks the transfer, dtype, constant and lifetime rules.- Float ops run in f32 with tight tolerances, in f16 on f16-rounded inputs within 2e-2, and in bf16 on bf16-rounded inputs within 5e-2.
- Comparisons, logical ops,
argmax/argminand i32 arithmetic are compared exactly, including the result dtype (bool / i32). - Casts are compared bit-exact with NumPy's
astype.
test/conformance.test.tsruns@johnhenry/tensor-backend's shared conformance suite (the core op cases, the general-numerics cases and, since tensor-backend 0.3, the quantized-weight cases, in f32, f16 and bf16) againstdevice.backendon the GPU and CPU devices.test/bridge.test.tscovers theMlxArray/MlxDeviceAPI as this package has always exported it (transfers, lifetime,liveArrays,memory, the CPU MLX device). Its host-view half runs on every platform.
The suites skip, never fail, when MLX is unavailable (not darwin/arm64, or no libmlxc) or when numpy is missing. On an Apple Silicon machine with numpy, a real run must report 0 skipped (233 tests on Node, Bun and Deno).
GPU etiquette on shared machines: wrap GPU test runs in the ~/gpu.lock
convention, for example
until ( set -o noclobber; echo $$ > ~/gpu.lock ) 2>/dev/null; do sleep 3; done; trap 'rm -f ~/gpu.lock' EXIT; npm test -w @johnhenry/math-plus-tensor-mlx.
Limitations (what this does not do)
- darwin/arm64 only, on Node, Bun or Deno 2. There is no browser build (MLX needs FFI).
- The op set is the tensor-backend contract's. The general-numerics
ops go through tensor-backend's compose helpers, and backend-mlx has a
native mlx-c kernel for every one of them. Only elementwise
minimumis composed here, as-maximum(-a, -b), because the contract has nominimum. The package has no slicing, indexing,concat,sort, random numbers or linalg beyondmatmul, although the contract has some of these. New ops go into the contract upstream (RFC 0001 §12 Q7), not into a fork. - Logical ops take bool only, although the contract's are nonzero-is-true. Cast first.
- No autograd.
@johnhenry/math-plus-tensor-autogradworks on tensor-coreTensors only. Autograd on device arrays is an RFC open question. - Reductions take one axis or all axes, not a list of axes.
- No
compile. The contract'scompileexists ondevice.backend, but it is not wrapped forMlxArray. The same goes for the fused transformer ops (linear,rope,sdpa, …) and quantized weights: call them ondevice.backendwithx.handleanddevice.wrap()the result. - Every upload and download copies once. There is no zero-copy wrapping
of JS memory. Both directions are synchronous under the hood (MLX copies
at call time), so the Promises from
fromTensor()andtoTensor()are already settled when they are returned. They are async so that a transfer always looks like one (PLAN.md non-goal 5). Await a batch of uploads together withPromise.all. - f16/bf16 accumulation follows MLX.
softmaxasks for f32 accumulation (precise=true).sum,mean,cumsumandmatmuluse whatever MLX's own kernels do. This package checks f16 results only to 2e-2 and bf16 results to 5e-2, so it does not guarantee more accuracy than that. - NaN handling in comparisons,
argmax/argminandmin/maxis MLX's, and the tests do not cover NaN inputs. - Reproducibility. Results can differ from tensor-core's CPU results in the last ulps. They are compared within per-op tolerances and are never expected to be byte-identical (PLAN.md non-goal: no byte-exact cross-platform snapshots).
geluis the exact erf form, which matches PyTorch's default and MLX. It is not tensor-core's current tanh approximation (see #122).
Provenance
Built for #125 as the
prototype for #124's
RFC. The binding, its prebuilt runtime and the conformance fixtures come
from laya-js (Apache-2.0). See that
repo's docs/mlx-binding-decision.md for measurements (Apple M2: 0.55–0.8 µs
FFI overhead per op, and f16 GEMM at Python-MLX speed). Part of the
math-plus monorepo; family docs
are at https://opensource.johnhenry.me/math/.
