Apache Arrow compute on Apple silicon GPUs, from TypeScript and JavaScript. A thin N-API addon over
the one C ABI (include/arrowmetal.h), speaking the Arrow C Data Interface in both directions, with
an apache-arrow (Arrow JS) Vector on either end.
macOS on Apple silicon only. The browser is out of scope: there is no Metal there, and this
package loads a .dylib through dlopen.
Full documentation: ../docs/TYPESCRIPT.md.
Not publishable as it stands. binding.gyp reaches ../include for the repository's
arrow_abi.h, which does not exist inside an installed package, so a tarball would not build. The
package is marked private and files / os / cpu have been removed rather than left as a
promise the tarball cannot keep; see the note below.
cd node
npm install # builds the addon (node-gyp) as part of the install
npm run build:ts # compiles src/*.ts to dist/, with .d.ts
npm test
libArrowMetalC.dylib is found at runtime in this order:
$ARROWMETAL_LIB, a full path to the dylib<package>/../.build/release/libArrowMetalC.dylib
Neither present is an error naming both paths.
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
swift build -c release --product ArrowMetalC # from the repository root
import { MetalArray, groupBy } from 'arrowmetal';
import { vectorFromArray, Int64, Utf8 } from 'apache-arrow';
const amount = MetalArray.fromArrow(vectorFromArray([100n, 250n, 300n, 50n], new Int64()));
const region = MetalArray.fromArrow(vectorFromArray(['e', 'w', 'e', 'w'], new Utf8()));
amount.sum(); // 700n
amount.filter(amount.gt(60n)).toArrow(); // Vector<Int64> [100n, 250n, 300n]
const gb = groupBy(region);
[...gb.keys(0).toArrow()]; // ['e', 'w']
[...gb.sum(amount).toArrow()]; // [400n, 300n]Copy-free out, always. Copy-free in when the producer's buffers are page aligned; one copy otherwise. This binding adds no copy of its own in either direction:
- In. The addon builds the
ArrowArray/ArrowSchemastructs over the V8 backing store and keeps a JS reference to every producer buffer in theArrowArray's ownprivate_data. Those references are dropped when ArrowMetal calls the release callback and at no other time — not when the handle is released. ArrowMetal then either wraps those pages (MetalArray#wrappedProducerBuffers === true) or copies once. Nothing is staged through an intermediate buffer here. - Out. Each
ArrowArraybuffer ArrowMetal returns becomes an externalArrayBuffersharing a refcount; the array's release callback runs when the last of them is collected. - Import validates every buffer against the size the Arrow layout requires for
offset + lengthrows, and throws with the byte counts rather than reading past the end of a short view.
| Object | Pinned until |
|---|---|
The producer's V8 TypedArray (import) |
ArrowMetal calls the ArrowArray release callback — when the imported array and everything derived from it is gone |
A MetalArray handle |
garbage collected, or release() |
| ArrowMetal's own result buffers (export) | the last external ArrayBuffer of that result is collected |
col.slice(...), groupBy(col) and PlanSource.create(name, { col }) all retain the imported
array inside ArrowMetal, and on a page-aligned import that array is the V8 pages. So release()
frees the handle and nothing else: the derived object keeps reading the right bytes.
test/lifetime.test.js proves it by dropping every JS reference to the source array, forcing two
collections and trampling the freed pages.
node bench/bench.mjs, node v24.9.0, macOS on an Apple M4 Max, page size 16,384 bytes. "wrapped"
is wrappedProducerBuffers: ArrowMetal did not hand the buffers back during am_import.
| Elements | BigInt64Array aligned |
wrapped | Float64Array aligned |
wrapped |
|---|---|---|---|---|
| 1,000 | true | true | false | false |
| 100,000 | true | true | true | true |
| 1,000,000 | true | true | true | true |
| 10,000,000 | true | true | true | true |
At 1M and 10M elements V8 gives the backing store its own mmaped pages, so it is page aligned and
the import is free. Small typed arrays are packed together and land wherever they land: the 1,000
element Float64Array above was not aligned and was copied. A view whose byteOffset is not a
multiple of the page size is never aligned; one that is a multiple, into an aligned buffer, still
lands on a page boundary and is still wrapped. Two tests pin the consequence rather than the
address: a wrapped import sees a later write through the typed array; a copied one does not.
node bench/spread.mjs on 2026-09-07. Apple M4 Max, macOS, node v24.9.0, apache-arrow 21.2.0,
ArrowMetal 0.1.0 release build. 10,000,000 Int64 rows, no nulls, values i % 1000.
Method: five fresh processes; each builds the dataset once, warms each row up 3 times, times 5
calls with process.hrtime.bigint() and keeps its best. The tables report the min, max and
median of those five per-process bests. Every row's answer is checked against the plain-loop
answer inside each process before it reports. A single process's best-of-5 is not stable enough
here to order two methods within ~20% of each other, so the benchmark prints any pair of rows whose
ranges overlap.
| Method | Min (ms) | Max (ms) | Median (ms) |
|---|---|---|---|
| ArrowMetal, column already on the device | 0.27 | 0.36 | 0.30 |
ArrowMetal, end to end from a JS BigInt64Array |
1.88 | 2.06 | 2.02 |
| plain typed-array loop | 16.38 | 19.81 | 16.47 |
Arrow JS, Vector.get(i) |
173.52 | 181.75 | 177.97 |
Arrow JS, vector.toArray() then loop |
16.35 | 16.66 | 16.43 |
| Method | Min (ms) | Max (ms) | Median (ms) |
|---|---|---|---|
| ArrowMetal, column already on the device | 2.12 | 2.32 | 2.16 |
ArrowMetal, end to end from a JS BigInt64Array |
3.57 | 4.53 | 3.92 |
| plain typed-array loop | 8.08 | 9.52 | 8.44 |
Arrow JS, Vector.get(i) into a new Vector |
170.57 | 183.74 | 179.41 |
Reading the tables:
- The two
filterrows do not do the same amount of work. ArrowMetal's end-to-end row finishes by wrapping the result as a typed-array view — the 5,000,000 output rows are written once by the GPU and never touched on the host — while the plain loop writes 5,000,000BigInts as it goes. - The only overlapping pair is
plain typed-array loopandArrow JS, vector.toArray() then loopin thesumtable; they overlap becausetoArray()on an unslicedInt64vector hands back the sameBigInt64Array, so they are the same code. Vector.get(i)is 170–184 ms because Arrow JS allocates aBigIntper row. It is what a naive Arrow JS user writes, not a fair kernel.- The 0.27–0.36 ms resident
sumis 80 MB in about 0.3 ms, roughly 265 GB/s, in range for an M4 Max. Not a cached answer: a test mutates the wrapped buffer and the sum changes. - On these two operations at this size ArrowMetal is ahead in every row. The measured pair is
the end-to-end
sumat 2.02 ms median against the plain loop's 16.47, and the end-to-endfilterat 3.92 against 8.44. The small-array crossover is not measured here. - A single process's best-of-5 is not a stable measurement here, which is why the table reports the spread over five processes.
Numbers are from one machine on one day. node bench/spread.mjs re-runs it.
cd node
ARROWMETAL_LIB=/path/to/libArrowMetalC.dylib npm test # 62 tests, node:test
Oracles are Apache Arrow JS and plain JS over the same rows. Sizes include 0, small, and 1,000,001 (a length that crosses a threadgroup boundary), with nulls, all-null columns, empty columns and sliced input.
npm publish is blocked ("private": true) and the files, os and cpu fields have been
removed. The reason is binding.gyp, which adds ../include so the addon can include the
repository's arrow_abi.h. That path is outside the package, so npm pack produces a tarball that
installs and then fails to compile. Publishing needs either a vendored copy of arrow_abi.h inside
node/ or a prebuilt binary; neither is done here, and pretending otherwise with a files list
would only make the failure later and stranger. main and types are kept, since they are what
make a local file: or npm link install resolve to dist/.