@johnhenry/backend-mlx
Native Apple Silicon backend for
@johnhenry/tensor-backend. It calls Apple's
mlx-c, the C API over
MLX, through FFI. There is no C or C++
of our own. Bun uses the built-in bun:ffi, Deno 2 the built-in
Deno.dlopen, and Node 24+ the prebuilt koffi addon. MLX runs on the Metal GPU, which is the
default, or on the CPU.
import { createMlxBackend } from "@johnhenry/backend-mlx";
const mlx = createMlxBackend(); // { device: "gpu" } by default
const x = await mlx.fromHost({ dtype: "f16", shape: [2, 3], data: new Float16Array([1, 2, 3, 4, 5, 6]) });
const y = mlx.scope(() => mlx.softmax(mlx.scale(x, 2), -1)); // lazy graph; intermediates freed
console.log(await mlx.read(y)); // evaluates on the GPU, then copies to the host
mlx.dispose(y);
mlx.dispose(x);
Install
npm install @johnhenry/backend-mlx
bun add @johnhenry/backend-mlx
deno add jsr:@johnhenry/backend-mlx npm:@johnhenry/backend-mlx-darwin-arm64
- macOS on Apple Silicon (darwin/arm64) with Metal. Tested on macOS 27
(Apple M2), Node 24.9 and Bun 1.2.17. On any other platform the
package still installs,
createMlxBackendthrows, and its tests skip. - Native libraries come with the install. npm adds the optional
dependency
@johnhenry/backend-mlx-darwin-arm64on darwin/arm64 only (64 MB download, 207 MB unpacked:libmlxc.dylib, Apple'slibmlx.dylib,libjaccl.dylibandmlx.metallibfrom MLX 0.32.2, the same MLX that Python laya-mlx uses).npm install --omit=optionalskips it; then use one of the other sources below. - Node loads the libraries through
koffi, also an optional dependency (a prebuilt N-API addon; no compiler, no install script). Bun uses the built-inbun:ffi. - Deno 2 uses the built-in
Deno.dlopenand needs--allow-ffi(plus--allow-readand--allow-envto locate the library). From JSR, add the platform package yourself (deno add npm:@johnhenry/backend-mlx-darwin-arm64): Deno finds it innode_modulesor, without one, in its npm cache. Tested on Deno 2.9.7: the conformance suite (f32/f16/bf16 including the numerics cases, GPU and CPU devices) and@johnhenry/layaparity on the three published checkpoints (bit-identical to Python, as on Node/Bun).
libmlxc.dylib is resolved at runtime, first match wins:
createMlxBackend({ libPath }).$LAYA_MLXC_PATH: the dylib, or a directory containing it ($LAYA_MLXC_LIBis a legacy alias). If it is set and the path does not exist,createMlxBackendthrows instead of falling back.- The platform package
@johnhenry/backend-mlx-darwin-arm64(lib/). Deno looks innode_modules(next to the module, then the working directory) and then in its npm cache. - A local build in this package,
prebuilds/darwin-arm64/(skipped when the module was loaded over https, e.g. from JSR):npm run build:mlxc -w @johnhenry/backend-mlxcompiles mlx-c (commitd4afaec, "Support MLX v0.32.2") against themlx0.32.2 Python wheel in about 10 s. It needscmake, a macOS SDK that links (the script probes; override withSDKROOT) andpip install mlx==0.32.2(orMLX_PY_DIR=…/site-packages/mlx).scripts/build-mlxc.shships in the tarball. @nielspeter/mlx-ts-darwin-arm64(npm; MLX 0.32.1 with the older mlx-c ABI).- Homebrew:
brew install mlx-c.
libCandidates() lists every path that was tried, and backend.info
reports which library loaded and which ABI it has. At load time the backend
detects the two mlx-c ABIs that differ in the signatures it uses (sdpa
force_fused, compile-cache API, cumsum → cumsum_axis with an optional dtype).
API
createMlxBackend(opts?): MlxBackenddevice: "gpu" | "cpu". The default is"gpu".libPath: an explicit path tolibmlxc.dylib.finalizers. Defaulttrue. AFinalizationRegistryfrees handles you leaked after garbage collection. It is a safety net only; usedisposeandscopeto free handles deterministically.compiledGelu. Defaulttrue. On the GPU,geluis anmlx_compiled shapeless kernel, likemlx.nn.gelu.
- The full
Backendinterface is implemented, including the optionalflush,destroy,geglu,meanPool,compileand every general-numerics op, each one mlx-c call (mlx_equal,mlx_less, …,mlx_logical_and,mlx_sqrt,mlx_rsqrt,mlx_power,mlx_negative,mlx_abs,mlx_tanh,mlx_sigmoid,mlx_erf,mlx_argmax_axis,mlx_argmin_axis,mlx_mean_axis,mlx_min_axis,mlx_cumsum_axis). MLX's uint32 arg-reduction results are cast to i32, integer inputs of float-valued ops (and ofpow) are cast to f32 first, andcumsumof bool runs on i32, as the contract specifies. Extras:readSync(t),memory()(MLX active and peak bytes),liveTensors()andinfo. - Quantized weights (tensor-backend 0.3's optional trio, native):
fromHostQuantizedkeeps a laya-js q8/q4 matrix packed in MLX's own affine layout (uint32 words + scales + biases in the compute dtype),quantizedLinearismlx_quantized_matmul(transposed), andquantizedEmbeddinggathers packed rows and runsmlx_dequantize. The laya bytes read as little-endian u32 words already are MLX's packing, so q4 (affine) uploads as-is; symmetric q8 becomes affine by flipping each byte's sign bit (q + 128) with bias = −128·scale — a repack, never a dequantization, and bit-exact: the device weights dequantize to exactly fl32(q·scale + bias). Groups of 32, 64 or 128 without a partial last group; anything else resolves to null and runs through the default composition. Use it throughuploadQuantized/quantizedLinear/quantizedEmbeddingfrom@johnhenry/tensor-backend. libCandidates(),resolveLib(),mlxPlatformSupported()andPLATFORM_PACKAGE(the platform package's npm name).
Behaviour
- Lazy graph. Every op makes one FFI call that appends an MLX graph node
and returns immediately. Shapes and dtypes are queried from MLX lazily
and cached.
flush(...ts)evaluates (mlx_eval), andflush()with no arguments synchronizes the stream.readevaluates, makes the array row-contiguous and copies it into a new typed array. For the dtype mapping, seeHostDatain tensor-backend. - dtypes.
supports()is true for f32, f16, bf16, i32 and bool. Ops run in the input dtype, with MLX's type promotion.softmaxusesprecise=true(f32 accumulation).scaleandreluuse a scalar of the input's dtype, so f16 stays f16, as with MLX's weakly typed Python scalars. - Fused kernels.
layerNormusesmlx.fast.layer_norm.ropeusesmlx.fast.ropewithtraditional=false,dims = Dh,scale = 1andoffset = 0.sdpausesmlx.fast.scaled_dot_product_attentionwith an array bool mask.linearusesaddmm(b, x, wᵀ)orx @ wᵀ. wᵀ is a strided view, solinearmatchesmlx.nn.Linear.geluuseserf, likemlx.nn.gelu.embeddingusestake, andgatherRowsusestake_along_axis.
- compile. The backend wraps your function in an
mlx_closurewhose C function is a trampoline back into JS, then appliesmlx_compile.- Tracing happens synchronously inside the first call for each input signature.
- Outputs can be a tensor, an array of tensors or an object of tensors.
- An exception thrown during tracing propagates to the caller.
- On
device: "cpu",compilereturnsfnunchanged, because MLX's CPU compile path JIT-builds C++ with the host toolchain. - At process exit, the MLX compile cache is cleared, as Python MLX does. Without this, static teardown segfaults.
- Memory.
disposefrees themlx_arrayhandle and is idempotent. MLX reference-counts buffers, so a pending graph keeps its inputs alive. Using a disposed tensor throws.scopefrees everything created inside it except the tensors it returns (directly, or one level deep in an array or object). It frees everything iffnthrows. The tests check thatliveTensors()and MLX active memory stay flat across 50 iterations. - Errors. mlx-c's default error handler calls
exit(). The backend replaces it, so failures become JS exceptions such asbackend-mlx add: Shapes (2) and (3) cannot be broadcast., and the backend stays usable. - Copies.
fromHostmakes exactly one copy, from the JS buffer into an MLX unified-memory buffer (mlx_array_new_data), when it is called; the returned Promise is already settled, so awaiting a batch of uploads costs one microtask. For fp16 safetensors, that means one disk read into aBuffer, a zero-copyFloat16Arrayview and one copy into MLX.readmakes one copy out (memcpyinto the result array).- Zero-copy wrapping of JS memory (
mlx_array_new_data_managed) is not used. It needs GC-pinned, page-aligned memory and a native destructor callback, and uploading the whole 421M English checkpoint takes only 120–300 ms.
Performance
Preliminary (Apple M2, macOS 27; the development machine, which the
binding-decision doc originally mislabelled as an M3 Max), on a machine shared with other
jobs, so expect about ±30% run-to-run noise. A separate benchmark document
will supersede these numbers. The scripts and their Python
twins are in bench/. The analysis is in
docs/mlx-binding-decision.md.
| Node (koffi) | Bun (bun:ffi) | Python MLX 0.32.2 | |
|---|---|---|---|
| op dispatch (graph node + dispose) | 0.80 µs | 0.55 µs | 0.40 µs |
| linear f16 [16,128,1024]·[1024,1024]ᵀ | 1.50 ms | 1.51 ms | 1.48–1.54 ms |
| 8 stacked ModernBERT-large layers f16, B=1 L=32 | 5.5 ms | 7.3 ms | 9.2 ms |
| English checkpoint, 1 short question (33 tok), f16, P50 | 22–42 ms | 33–42 ms | 32–33 ms |
The rows were measured under the same GPU lock, interleaved.
Deno 2.9.7 (Deno.dlopen) dispatches at Node's speed: 0.67 µs per op against
0.67 µs on Node and 0.50 µs on Bun in one interleaved run of
bench/ops.bench.ts, and the same linear f16 time (1.52 ms).
The Laya English model (421M, @johnhenry/laya) runs on this backend and
is compared with the laya-mlx fp32 fixture over 63 questions:
- f32: 63/63 argmax agreement, maximum |Δlogit| 1.3e-5.
- f16: 63/63 argmax agreement, maximum |Δlogit| 4.0e-2.
Graph build for the whole model takes about 1.5 ms of JS/FFI time. The rest is GPU time in the same libmlx that Python uses.
Limitations
- macOS/arm64 only.
- The prebuilt bundle exists for darwin/arm64 only (there is no MLX for
other platforms). It pins MLX 0.32.2; a different MLX needs a local build
or
$LAYA_MLXC_PATH. - mlx-c is 0.x and its C signatures change. About 60 symbols are bound by hand and two ABI variants are detected; an unknown Homebrew mlx-c could misalign arguments. Prefer the platform package.
- There is no zero-copy weight upload (one copy per tensor).
- Quantized weights: MLX's
quantized_matmulcomputes each group as scale·Σx·q + bias·Σx, so symmetric weights (run as affine with bias = −128·scale) lose a little to cancellation: ≈1e-6 of Σ|x·w|, or up to ≈1e-4 of a small output. Group sizes other than 32/64/128 and partial groups fall back to the composition (no memory saving). compileis the identity ondevice: "cpu", so the CPU device also has no fused GELU.- Compiled functions must be pure in their tensor arguments. Captured tensors become constants, as in Python MLX.
- One MLX stream per backend, which is the device's default stream.
readevaluates synchronously on the calling thread, so its promise is already settled when it is returned.
Family
Part of laya-js, Laya typed decisions in JavaScript on MLX, WebGPU and CPU — see its package map and results.
- Implements
@johnhenry/tensor-backend; the native libraries come from@johnhenry/backend-mlx-darwin-arm64. @johnhenry/layaselects it first underbackend: "auto"on Apple Silicon (optional peer dependency).- Parallel to
@johnhenry/backend-webgpu(portable GPU) and@johnhenry/backend-cpu(reference).
License
MIT. MLX and mlx-c are MIT, Apple Inc.; their notices ship in the
platform package (NOTICE, lib/licenses/).