A Polars GPU Engine for Apple Silicon, Now on M1 Macs Too: ArrowMetal 0.6.1 Runs Polars 2.1 and Ships Its First Outside Contribution
ArrowMetal's Polars GPU engine for Apple silicon now runs on M1 Macs and on polars 2.1.0: 52 of 107 queries at 50,000,000 rows, 1.31x to 10.31x faster, none behind. The M1 fix is the project's first outside contribution, issue #1 and pull request #2, with a 12-line reproducer; GitHub's runner now builds every GPU kernel.
On the evening of 2 November, issue #1 was opened on ArrowMetal. The reporter was on an M1 Max, and every sort of 64-bit keys failed to build its GPU pipeline. They did not stop at the report: they found the exact construct the M1's Metal compiler aborts on, wrote a twelve-line kernel that reproduces it, found the form that compiles, ran the suites, and sent the pull request thirty-six minutes later. It is merged this morning as ArrowMetal 0.6.1, the project's first outside contribution. The same release brings the Polars engine to polars 2.1.0 and, as a side effect nobody planned, turns GitHub's CI runner into a real GPU test gate.
Tests and the runner: ArrowMetal v0.6.1, 8 November 2026. Polars rows: Apple M4 Max, polars 2.1.0, 50,000,000 rows, best of 5, AC power, 6 November 2026.
ArrowMetal is my Apache-2.0 library that runs Apache Arrow compute on the Apple silicon GPU through Metal: Arrow buffers stay in unified memory, the GPU reads them in place, and results come back as Arrow. Its Polars GPU engine is the Mac counterpart of the NVIDIA-only one Polars ships: collect(engine=MetalEngine()), the same frames, no CUDA. It also has a DataFusion optimizer rule, DuckDB extensions and seven language bindings. Until this week every GPU number it published came from one machine, my M4 Max.
Issue #1: an M1 Max, a twelve-line reproducer, and the fix
The report, from GitHub user d-polikhranidi, is the kind a maintainer hopes for and rarely gets. On their M1 Max every sort of Int64, Double, timestamp, utf8 and binary keys failed with Metal pipeline creation failed: radix_histogram, and so did two group-by paths. They traced it to Apple's shader compiler aborting on one shape of code, a 64-bit value assembled with shifts from two threadgroup atomic_uint loads, showed which form compiles instead, fixed the three kernels that did it, ran the suites on the M1 Max (160 tests, 0 failures) and sorted a 11.9-million-row CSV to check the order on the CPU.
// 0.6.0 — the join the M1 Max's GPU compiler aborts on: two threadgroup atomics, shifted together
ulong v = (ulong)atomic_load_explicit(&lo, memory_order_relaxed)
| ((ulong)atomic_load_explicit(&hi, memory_order_relaxed) << 32);
// -> Metal pipeline creation failed: radix_histogram on Apple M1 Max:
// Compilation failed due to an interrupted connection: XPC_ERROR_CONNECTION_INTERRUPTED
// 0.6.1 (#2) — the same 64 bits, and it compiles on the M1 Max, the M4 Max and GitHub's runner
ulong v = as_type<ulong>(uint2(atomic_load_explicit(&lo, memory_order_relaxed),
atomic_load_explicit(&hi, memory_order_relaxed)));
I added the fourth kernel with the same shape, gs_b_priv, which happened to pass on their machine but had no business relying on it, and checked the two forms against each other on the M4 Max over 65,536 word pairs, edge words first, then random ones: identical bit for bit. The table has the whole of it.
| the M1 fix | issue #1, pull request #2, FINDINGS Round 25 |
|---|---|
| What failed | On an M1 Max (macOS 27.2), every sort of 64-bit keys (Int64, Double, timestamps, utf8, binary) and the two privatised group-by paths failed to create their GPU pipelines; 61 of 73 sort tests failed on 0.6.0. Sorts of 32-bit keys worked. |
| The shape | Apple's shader compiler on that machine aborts whenever two threadgroup atomic_uint loads are joined into one ulong with shifts. The same join from plain threadgroup integers compiles, and so does as_type<ulong>(uint2(lo, hi)) over the atomics. The M4 Max's compiler accepts all three. |
| The fix | Four kernels join with as_type: radix_histogram, gb_accumulate_priv, am_gb_priv (the three in #2) and gs_b_priv, same shape, which passed there but should not have to. The same 64 bits on a little-endian GPU; 12 lines added, 8 removed. |
Why had the suite never seen it? Because the kernels were written and tested where they ran, on an M4 Max whose compiler accepts all three forms. That is a reasonable way to build a GPU library alone, and exactly the gap a second machine closes. ArrowMetal is now verified at both ends of the Apple silicon line, by two people, and the upstream tracker records the compiler behaviour as an Apple Metal finding, worked around in 0.6.1, with the reproducer. One other M1 difference stays on the list: a float32 logb at one row, 231 ulp off, the same before and after the fix.
The side effect: GitHub's runner now builds every kernel
Since October, GitHub's macos-15 runner had refused to build the 64-bit-key radix_histogram in every form I tried; its virtual GPU reported Compilation failed and nothing else. So the pull-request conformance job, 2,000 cases sampled from the differential matrix, ran few of its GPU cases: on 6 November, with 0.6.0's kernels, 496 passed and 1,491 were skipped on pipeline failures. The runner and the M1 Max had been refusing the same construct all along.
With 0.6.1's kernels, the run of 8 November built every kernel its 2,000 cases asked for: 1,868 pass, 81 documented divergences, 0 unclassified, 51 not implemented for the type, 0 pipeline failures. One contributor's fix made the CI gate real; TESTING.md section 7 now describes a job that tests the GPU rather than one that mostly skips it.
No speed paid on M4 Max
The as_type join is the same 64 bits as the shift join on a little-endian GPU, so the compiler should emit the same code; the question was whether it does. I ran the matrix's sort, group-by and window families at 50,000,000 rows on the M4 Max in one session: the 0.6.0 wheel's kernels, then 0.6.1's twice, 63 rows each.
| kernel, 50,000,000 rows, ms | 0.6.0 (shift join) | 0.6.1, run 1 | 0.6.1, run 2 |
|---|---|---|---|
| argsort int64 | 38.91 | 41.50 | 38.54 |
| sort utf8 | 21.32 | 22.00 | 21.73 |
| lexsort, 2 int32 keys | 35.04 | 35.71 | 34.90 |
| sum by int32 key, 1,000 groups | 4.04 | 4.21 | 4.06 |
| sum by two int32 keys, ~1,024 groups | 5.76 | 5.73 | 5.76 |
| count by int32 key, 100,000 groups | 4.30 | 4.32 | 4.31 |
| mean by int32 key, 10,000,000 groups | 49.06 | 51.43 | 48.07 |
The median of 0.6.1's time over 0.6.0's is 1.003 in the first run and 0.991 in the second; the differences on any one row are the few percent two back-to-back runs of the same kernel always show, and every answer agreed with the CPU libraries in all three runs. The fix is free.
Polars 2.1.0 on the Apple silicon GPU
Polars 2.1.0 shipped on 25 October with a new plan IR major, (16, 0). MetalEngine fails closed on an IR major it has not been tested on, so on 2.1.0 it left every plan to Polars until the IR was checked. Checked, it is IR 15's node kinds plus two additions: a truncating division, which stays with Polars, and a predicate on the join node. Polars 2.1 lets a filter comparing columns from both sides, join(...).filter(pl.col("x") < pl.col("y")), ride inside the join as a fused predicate. An engine that read only the join keys would hand back every matching pair and silently drop the filter. The engine runs such a node as the join, then the filter; any other join kind with a predicate stays with Polars. Here it is from a fresh install of the 0.6.1 wheel, with the engine's own report:
$ pip install "arrowmetal[polars]" # 0.6.1 from PyPI; the extra now installs polars>=1.44,<2.2 -> 2.1.0
$ python -m arrowmetal.polars_engine check
polars 2.1.0: a tested release
IR version (16, 0): a tested major (tested: (14, 7), (15, 2), (16, 0))
crossover table: arrowmetal._engine_crossovers_pl2 (polars 2.0.0)
# 50,000,000 rows (k, x) joined to 1,000,000 rows (k, y), then a filter over BOTH sides —
# Polars 2.1 folds the filter INTO the join; the engine runs the join, then the filter
q = left.lazy().join(right.lazy(), on="k").filter(pl.col("x") < pl.col("y")).select(pl.len())
print(engine.explain(q))
INNER JOIN: FUSED PREDICATE: col("x") < col("y") <- Polars 2.1's own plan
MetalEngine report for explain (polars 2.1.0, IR (16, 0))
metal: SimpleProjection#7 [SimpleProjection > Join > DataFrameScan > DataFrameScan] over 51,000,000 rows
rule: 51,000,000 input rows is at or above the 1,942,068-row crossover for join:inner
polars: Select#4: rule: aggregate:count was not measured ahead of Polars up to 50,000,000 input rows
# best of 5: polars 2.1.0 collect() 70.18 ms; collect(engine=MetalEngine()) 27.82 ms; equal: True; 25,012,296 rows kept
Then the numbers. Polars 2.x uses the crossover table fitted on 2.0.0, and the default benchmark at 50,000,000 rows on the M4 Max says it holds on 2.1.0: the default takes the same 52 of 107 queries, each 1.31x to 10.31x faster than Polars 2.1.0's faster engine, none behind, every frame equal to Polars'.
| query, 50,000,000 rows | Polars 2.1.0, its faster engine | MetalEngine() default | ratio |
|---|---|---|---|
| group-by 2 keys, 1,000,000 groups, count | 170.5 ms (2,406 CPU-ms) | 16.5 ms (10 CPU-ms) | 10.31x |
| unique over (String, int32), keep first | 436.0 ms (6,206) | 43.4 ms (23) | 10.04x |
| group-by 2 keys, 1,000,000 groups, min + max | 219.8 ms (3,286) | 24.3 ms (12) | 9.05x |
| filter, then sort by (int32 asc, int64 desc) | 743.6 ms (10,997) | 97.3 ms (10) | 7.64x |
| group-by 2 keys, 100,000 groups, sum | 134.1 ms (1,967) | 20.7 ms (12) | 6.47x |
| group-by 1 key, 100,000 groups, count | 42.9 ms (620) | 9.7 ms (4) | 4.45x |
| group-by (String, int32), sum | 177.1 ms (2,624) | 51.3 ms (23) | 3.45x |
| sort 3 columns by an int64 key | 167.9 ms (2,333) | 53.6 ms (7) | 3.13x |
| left join, 1,000,000-row build side | 69.2 ms (1,035) | 25.6 ms (6) | 2.71x |
| inner join then sum | 9.8 ms (92) | 6.5 ms (5) | 1.49x |
| group-by 1 key, 200 groups, min + max | 23.5 ms (360) | 18.0 ms (11) | 1.31x |
Read the CPU column too. The first row is 2,406 CPU-milliseconds on sixteen cores against 10 on the GPU side, for the same answer in a tenth of the wall time; while the GPU does the group-by, the cores are free for the rest of the program. Four queries the table leaves to Polars are now 1.18x to 1.24x faster on the GPU on 2.1.0: headroom for the next fit. The engine conformance grid also ran on 2.0.0 and 2.1.0 for the first time, 15,376 cases, 0 unclassified on all three Polars versions (FINDINGS Round 24).
Also this week: 0.6.0
Two days earlier, 0.6.0 shipped something a reader prompted: under the Polars post on Medium, someone asked how the engine's group-count probe behaves on heavily skewed keys. I measured it. On keys concentrated on a few values Polars' streaming engine, with its hash table in cache, is the right tool, so the probe now reads the concentration off its sample and leaves such a group-by to the CPU. On polars 1.44.1 the default picks the faster engine in 68 of 72 skewed cases (was 59) and never runs the GPU where it is slower (was 13); uniform keys are unchanged (Round 23).
Install
- Python:
pip install "arrowmetal[polars]"installs 0.6.1 and a Polars from 1.44 to 2.1;python -m arrowmetal.polars_engine checkprints the Polars, the IR and the table in force. - Rust:
arrowmetal = "0.6.1"on crates.io; for DataFusion 55.1,datafusion-arrowmetal = "0.6.1". - macOS 14 or later on Apple silicon, verified this week on an M1 Max and an M4 Max;
python -m arrowmetal.benchmeasures yours in under 30 seconds, and a results CSV from any other chip is welcome on the tracker.
My thanks to d-polikhranidi, whose first issue on this project came with its own fix, its own tests and a reproducer Apple could act on. A library that runs on one person's machine is a prototype; one that runs on a machine its author has never touched is software. 0.6.1 is where ArrowMetal became the second thing.
FAQ: a Polars GPU engine on Apple silicon, M1 support and Polars 2.1
Is there a Polars GPU engine for Mac and Apple silicon?
Yes. Polars' own GPU engine (engine="gpu") is NVIDIA cuDF and runs on CUDA only. ArrowMetal's MetalEngine is a Polars engine for the Apple silicon GPU through Metal: pip install "arrowmetal[polars]", then collect(engine=am.MetalEngine()). It takes the plans its measured crossover table says the GPU wins and leaves the rest to Polars, so every frame equals Polars' own.
Does ArrowMetal run on M1 Macs now?
Yes. 0.6.1's 64-bit sorts and group-bys build their GPU pipelines on an M1 Max, where 0.6.0's did not, and the sort, group-by, top-k and window suites pass there, 160 of 160. That is one M1 Max, verified by the contributor, plus the M4 Max every published number comes from. macOS 14 or later on Apple silicon, as before.
What is a fused-predicate join and why does it matter?
Polars 2.1 lets a filter comparing columns from both sides of a join ride inside the join node. An engine that read only the keys would return every matching pair and drop the filter. ArrowMetal's engine runs the join, then the filter; the run in this post keeps 25,012,296 of 50,000,000 rows, equal to Polars.
Read next
- Polars 2.0 is faster. With ArrowMetal on the Apple silicon GPU, 52 of 107 queries go 1.35x to 7.94x faster still
- Apple's GPU has no 64-bit floats. I made it sort 50 million doubles anyway
- Apache Arrow and Apache DataFusion now list ArrowMetal
- ArrowMetal: Apache Arrow compute on the Apple silicon GPU through Metal
References & Citations
- ArrowMetal (2026). v0.6.1 release notes and the CHANGELOG (0.6.1, 8 November 2026; 0.6.0, 6 November 2026).
- d-polikhranidi (2026). Issue #1, “Sorts and group-bys fail on an M1 Max: the GPU compiler aborts on a ulong joined from two threadgroup atomics”, opened 2 November 2026; pull request #2, “Join threadgroup atomics with as_type”, merged 8 November 2026. The M1 Max test counts and the 11,914,043-row sort check are theirs.
- ArrowMetal (2026). docs/FINDINGS.md Round 25: the shape, the fix, the 65,536-pair bit comparison and the kernel A/B (Benchmarks/results/m1_join_ab_2026-11-08-0.6.0.csv, -0.6.1.csv, -0.6.1-second.csv; M4 Max, 50,000,000 rows). docs/TESTING.md section 7: the runner's 496 and 1,868. docs/UPSTREAM.md: the Apple Metal row.
- ArrowMetal (2026). docs/FINDINGS.md Round 24 and Benchmarks/results/polars_engine_bench_2026-11-06-polars21.csv: the 52 of 107 and every Polars row in the table (M4 Max, polars 2.1.0, 50,000,000 rows, best of 5, AC power, 6 November 2026). Round 23: the group-count probe on skewed keys (0.6.0).
- Polars (2026). Python Polars 2.1.0, released 25 October 2026; pola-rs/polars#30176, the join predicate.
- arrowmetal 0.6.1 on PyPI (polars extra
>=1.44,<2.2), arrowmetal and datafusion-arrowmetal 0.6.1 on crates.io, all verified 8 November 2026. - The Python card is a run for this post on 8 November 2026 (arrowmetal 0.6.1 wheel from PyPI in a fresh venv with the polars extra, polars 2.1.0, pyarrow 26.0.1, Python 3.13, Apple M4 Max, macOS 27, 8 November 2026); the report lines are the engine's actual output, trimmed for width. The Metal card shows the two forms as they appear in the kernels, reduced to the join itself.
Subscribe to new posts from theaivibe.org
Related Posts

Why Your Streaming Pipeline Lies About Latency: Event Time vs. Processing Time
Your streaming pipeline's latency metric is probably measuring when data arrived at the broker, not when the event actually happened. That distinction quietly breaks more anal

DuckDB GPU Acceleration After 2.0: Where It Wins, Where It Doesn't, and Why the Mac Is Different
DuckDB 2.0 turned DuckDB into a server, AWS is buying the company behind it, and the GPU question is back. DuckDB 2.0 also changed the query format my GPU extension reads, and gpudb 0.7.1 adapts to it: on an Apple M4 Max, 18 of 22 TPC-H SF10 queries run on the GPU under DuckDB 2.0, 3.7x faster end to end, with every answer identical to DuckDB's. Here is where a GPU helps DuckDB, how gpudb decides, and why the Mac is the interesting case.

Schema Evolution Is Still the Hardest Problem in Data Engineering
Adding a column is easy. Renaming one silently breaks three dashboards and a compliance report. Why schema evolution is data engineering's hardest problem.