New in 0.5.0Polars 2.0.0 with its own crossover table, integer means in one pass, DuckDB 1.5.6 →
Apache Arrow compute, on the Apple silicon GPU

The GPU does the scan. Your CPU does your app.

Arrow columns already sit in memory the GPU can read. ArrowMetal runs the reductions, filters, joins, sorts and group-bys there, and hands the result back as an Arrow array. Copy-free out, always; copy-free in when the producer's buffers are page aligned, one copy otherwise; and the CPU stays free for the rest of your program. The rows at or above 3x are led by group-bys and sorts, with reductions, window functions and string scans behind them, over columns that stay on the GPU between calls; under a million rows the dispatch is the operation.

Apache Arrow is the columnar memory format Polars, pyarrow, DuckDB and pandas already share. Unified memory means the Apple GPU reads the same bytes the CPU does, so there is no bus to cross.

Get it See the numbers Source on GitHub
0.5.0: pip install arrowmetal, arrowmetal = "0.5.0" and datafusion-arrowmetal = "0.5.0" on crates.io, the Swift package from the repositorylisted on Apache Arrow's Powered By page and Apache DataFusion's integrations listApache-2.0Apple siliconPython · Swift · C ABI
one unified memory · the same columns, read in place
ArrowMetal · the scan on the GPU
ArrowMetal

DuckDB 1.5.5 on this row, measured 2026-09-12 with the same data and protocol: 9.0 ms on its native table, so 1.8x. It is the closest CPU engine here; from 100,000 groups upward and on every sort it is 3x or more behind. The full comparison.

Engines

Inside DataFusion, Polars and DuckDB, with each engine's own answers.

ArrowMetal runs inside three query engines. Each one plans the query as it always does; ArrowMetal takes the parts of the plan measured faster on the Apple GPU, hands back what the engine would have returned, and leaves every other node to the engine. Every figure below comes from a run recorded in the repository, linked from its card.

DataFusion

A physical optimizer rule for DataFusion 55.1

Registered on a SessionContext, the rule replaces DataFusion's full sorts and the hash aggregates of measured shapes with MetalExec, which runs them on the GPU and hands DataFusion the RecordBatches it expects. The SQL does not change.

6.9x to 28.8xfull ORDER BY sorts against DataFusion alone, 250,000 to 50,000,000 rows; at the largest, 96 to 126 CPU-ms against DataFusion's 6,104 to 8,311
2.31x to 4.27xthe ten group-by series the default takes, warm; first run against first run after 500 ms of idle, 1.24x to 2.17x, and after 5 s, 1.11x to 1.68x
13,632query pairs, each run with and without the rule and the answers compared: 0 mismatches

The default takes full sorts from 250,000 rows, over DataFusion's own Parquet reader too (10.8x to 16.5x with a Float64 key, 3.6x to 5.5x over a file with a string column), and ten group-by series over integer keys of a MemTable: count, DISTINCT and min/max over an integer column, each from 50,000,000 rows, each at the numbers of groups where it was measured ahead, estimated from a sample when the query runs. Top-k, filters and every other aggregate stay DataFusion's; rule.report() gives each decision and its reason.

use std::sync::Arc;
use datafusion::{datasource::MemTable, prelude::*};
use datafusion_arrowmetal::{
    session_context, ArrowMetalConfig, ArrowMetalRule};

let rule = ArrowMetalRule::new(ArrowMetalConfig::default());
let ctx = session_context(SessionConfig::new(), rule.clone());
let t = MemTable::try_new(batch.schema(), vec![vec![batch]])?;
ctx.register_table("t", Arc::new(t))?;
ctx.sql("SELECT * FROM t ORDER BY amount DESC")
    .await?.show().await?;      // as written
println!("{}", rule.report()); // TAKEN / LEFT, why
Polars

A MetalEngine for lf.collect(engine=…)

Polars optimises the lazy plan as it always does. MetalEngine replaces the subtrees it translates with ArrowMetal plans that run on the GPU and return Polars DataFrames; Polars' in-memory engine runs everything else, on Polars 2.0.0 too, where a plain collect() runs the streaming engine.

1.52x to 11.54xagainst the faster Polars 1.44.1 engine, in-memory or streaming, on every one of the 75 subtrees the default took across 220 benchmarked case-size pairs
1.35x to 7.94xagainst the faster Polars 2.0.0 engine on every one of the 52 of 107 benchmarked cases at 50M rows the default took, none behind
15,376generated cases against Polars 1.44.1: 15,096 identical, 33 within the float-summation bound, 0 different

The default takes the shapes measured ahead, from one table per Polars major. On Polars 1.x: full sorts from 1,000,000 rows, inner, left and anti joins, unique, String sorts and unique from 5,000,000 rows, a sort over a Parquet scan, and group-bys judged by their estimated number of groups. On Polars 2.x: numeric sorts from 10,000,000 rows and unique from 2,943,034; String sorts and the group-by sums and means at 200 to 10,000 groups, but for two buckets, stay with Polars. Top-k, filters, whole-frame aggregates and semi joins stay with Polars; engine.last_report names each node and the reason.

import polars as pl, arrowmetal as am

engine = am.MetalEngine()  # the measured defaults
out = (orders.lazy()
         .join(customers.lazy(), on="customer_id")
         .sort("amount", descending=True)
         .collect(engine=engine))  # Polars' answer
print(engine.last_report)  # Metal or Polars, per node
DuckDB

An optimizer extension; the SQL is unchanged

the rewrite extension, built once

Loaded into a connection, the rewrite extension puts ARROWMETAL_AGGREGATE where DuckDB's HASH_GROUP_BY or UNGROUPED_AGGREGATE would have been. The scan, the pushed-down filters and everything above the aggregate stay DuckDB's, planned and run as before.

1.08x to 5.32xagainst DuckDB's own operators, on every query the default (auto) rewrote in the benchmark
33,376generated queries with the rewrite off and forced: 21,844 rewritten and identical, 11,532 left to DuckDB, 0 different

The default takes sum, avg, min, max and count over integer, DATE and TIMESTAMP columns, grouped by one column or none, over a table scan or read_parquet: a fused group-by of an estimated 10,000 groups or more from 10,000,000 rows, the other measured classes from 50,000,000. A single sum, few groups with at most two aggregates, and VARCHAR keys stay DuckDB's; arrowmetal_rewrites() logs each decision.

LOAD
  'duckdb-extension/build/arrowmetal_rewrite.duckdb_extension';

SELECT region, sum(amount), count(*), max(amount)
FROM sales GROUP BY region;          -- as written

SELECT * FROM arrowmetal_rewrites(); -- each decision
SET arrowmetal_rewrite = 'off';      -- plain DuckDB
What's new

New in 0.5.0.

Released on 2026-10-08: pip install arrowmetal, or arrowmetal = "0.5.0" and datafusion-arrowmetal = "0.5.0" on crates.io.

Also new in 0.4.0released 2026-10-02
DataFusion

An optimizer rule for DataFusion

datafusion-arrowmetal replaces full ORDER BY sorts and measured GROUP BY shapes with GPU plans that hand back DataFusion's batches: sorts 6.9x to 28.8x against DataFusion alone.

DataFusion →
Polars

Polars' sort order, one key each

Every Polars sort key is one ArrowMetal key carrying Polars' null placement and NaN order. A nullable Float64 sort under the default, 50M rows, 134.03 → 93.54 ms.

The defaults →
Group-by

Float64 group sums, correctly rounded

A grouped Float64 sum is the double nearest the exact sum, equal to math.fsum whatever the row order: 1M groups of 50M rows, 43.50 → 17.69 ms.

How it works →
Sorts

Null placement and float order, per key

Nulls first or last, and three float orders: Arrow C++'s, IEEE 754 totalOrder and nan_largest, each inside the radix sort's key map.

Sort key options →
Import

Chunked columns in one import

A pyarrow ChunkedArray, a multi-chunk Polars Series or a stream of arrow-rs batches is written straight into Metal buffers, with no concatenated copy.

The import →
Correctness

Row numbers past 231, arrays near 232

Index arrays are UInt32, so row 231 comes back as itself; loop steps, grids and byte positions near 232 elements stop at the end instead of wrapping.

Findings →
Languages

Go, Node and R: sort options, chunked import

Per-key null placement and float order on argsort, sort, top-k and lexsort, and one call to import a chunked column.

The Go doc →
Coverage

Every Arrow function, counted

A generated table gives each Arrow compute function its differential cases, passes and documented divergences, 41,688 cases in all.

Coverage →
Also fixed in 0.4.0: the streaming group-by's count of non-numeric columns, empty typed arrays in Node, and a buffer-pool eviction order that grew with every reuse. Findings →The full changelog
Who it is for
  • You analyse data on a Mac with Polars, pyarrow, DuckDB or pandas, the scans and group-bys are the part you would hand to the GPU, and the columns can stay there between calls.
  • You build a tool or a service on Apple silicon and want the CPU back for the application while the GPU does the columns.
  • You maintain an engine and want an Apple-silicon GPU backend that speaks Arrow at every edge, without writing Metal.
Get it

Install, load, query.

Built and tested on an M4 Max; the wheel is checked in a clean virtual environment, with pyarrow as its only dependency. The Swift package resolves from the v0.5.0 tag. The ⧉ mark copies a block or a command.

Python

pyarrow in, one kernel, pyarrow out

import pyarrow as pa, arrowmetal as am

cols = {k: am.array(t[k]) for k in t.column_names}
region, amount = am.col("region"), am.col("amount")
q = am.filter((region == 2) & (amount > 100)).sum(amount)
am.query(cols, q)     # one Metal kernel, one round trip
cols["amount"].top_k(100).to_arrow()   # back to pyarrow
Full example →
DataFusion

the optimizer rule, from crates.io

Cargo.toml

The crate is on crates.io:

# Cargo.toml
[dependencies]
datafusion = { version = "=55.1.0", default-features = false, features = ["sql"] }
datafusion-arrowmetal = "0.5.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
Full example →
Polars

the engine on a lazy plan

import polars as pl, arrowmetal as am

df = pl.read_parquet("orders.parquet")
engine = am.MetalEngine()      # Polars' own answers
top = (df.lazy().sort("amount", descending=True)
         .head(100).collect(engine=engine))
print(engine.last_report)     # what ran on Metal, and why
Full example →
DuckDB

unchanged SQL, aggregates on the GPU

the rewrite extension, built once
import arrowmetal as am

con = am.duckdb_connect()   # DuckDB + rewrite extension
con.sql("CREATE TABLE sales AS "
        "SELECT * FROM 'sales.parquet'")
con.sql("""SELECT region, sum(amount), count(*)
           FROM sales GROUP BY region""").show()
# as written; eligible aggregates run on the GPU
Full example →
pandas

an .am accessor on Series and DataFrame

import pandas as pd, arrowmetal as am
import arrowmetal.pandas_bridge       # adds .am

df = pd.read_parquet("trades.parquet",
                     dtype_backend="pyarrow")
df["price"].am.sum()
df["symbol"].am.contains("BRK")
df.am.groupby("symbol").sum("size")
Full example →
Swift

the library itself

Package.swift
// Package.swift
.package(url: "https://github.com/singhpratech/ArrowMetal",
         from: "0.5.0")

import ArrowMetal
let ctx    = try MetalContext()
let amount = try MetalArray<Int64>(values, context: ctx)
let mask   = try amount.compare(.gt, 100)
let total  = try amount.filter(where: mask).sum()
Full example →
Requirements

What it needs

macOS on Apple silicon. Python 3.10 or later. Builds with the Xcode Command Line Tools; no Metal toolchain needed because kernels compile at runtime. The wheel carries the library; pyarrow is its only dependency. C ABI for every other language: include/arrowmetal.h.

Why this exists

Unified memory changes the arithmetic of GPU analytics.

On a discrete GPU the data has to cross a bus before any kernel runs, so only long jobs pay off. On Apple silicon the Arrow buffer is already where the GPU reads, so a 3 millisecond scan is a 3 millisecond scan.

zero copy

Arrow buffers are Metal buffers

Every array is a shared-storage MTLBuffer with Arrow's exact layout. Import from Polars, pyarrow or any C Data Interface producer without copying when the producer's buffers are page aligned, with one copy otherwise, and the import result says which; export back copy-free. ArrowMetal implements ARROW_DEVICE_METAL from the Arrow C Device Interface.

gpu first

The full Arrow compute surface

Reductions, arithmetic, comparisons, strings, temporal, decimals, nested types, sorts, joins, window functions, grouped aggregates. Generated Metal kernels per element type, including IEEE-754 binary64 built in software because Apple GPUs have none in hardware: arithmetic and sqrt correctly rounded; exp/ln/log within 1-2 ulp; trig within 5 (asserted 6), where an ulp is one unit in the last place.

one kernel

Whole queries fuse

A filter, an expression and an aggregate compile to one Metal kernel at runtime, so each input byte is read once at memory bandwidth. On top of it: a lazy engine with an optimizer, six join kinds and an as-of join, a Parquet reader that decodes pages on the GPU, and bridges for Polars, DuckDB and pandas in 0.1.0.

The landscape

GPU dataframes existed for NVIDIA. We could not find Arrow compute on Metal.

Apache Arrow defines ARROW_DEVICE_METAL in its C Device Data Interface, which the project still marks experimental, and its nanoarrow library implements it as memory: page-aligned Metal buffers, allocation and copies, tested on Apple silicon runners, with no compute on them. ArrowMetal is the compute. Here is what we found, and what each one does on an Apple GPU, as of September 2026. If something is missing from this table, tell us and it will be corrected.

GPU dataframe and Arrow projects and where each one fits with ArrowMetal
ProjectWhat it isApple silicon GPUArrow compute on the GPUWhere it fits with ArrowMetal
ArrowMetalArrow compute engine on Metal: kernels, a fused expression compiler, a lazy query engine, a GPU Parquet reader, out-of-core streaming, and bridges for Polars, DuckDB and pandasyes312 Arrow functions audited: 284 GPU, 17 host, 7 partial—
cuDF (RAPIDS)GPU dataframe library, pandas-compatible APIno, NVIDIA CUDA onlyyes, on NVIDIAthe mental model; ArrowMetal is an Apple-silicon counterpart without CUDA
Polars GPU enginePolars lazy plans executed by cuDFno, NVIDIA Volta+ on Linux or WSL2yes, on NVIDIAPolars stays a CPU engine on Macs; ArrowMetal hands it Arrow arrays zero-copy
pyarrow, Polars, pandas, DuckDBCPU analytics enginesCPU onlynothe baselines in every table on this site; all accept Arrow from ArrowMetal without a copy
gpudb (DuckDB community extension)GPU aggregates and joins for DuckDB on CUDA and Metal, by the same authoryesno, SQL vectors, not Arrow buffersthe sister project; ArrowMetal is the Arrow-native engine, and its own DuckDB extension is the Arrow-side counterpart
nanoarrow device extensionArrow C Device Interface helpersMetal buffers: allocation, alignment and copies; experimentalnoArrow's own device plumbing; ArrowMetal implements the same interface with kernels behind it and can hand buffers to it
arrow-swiftArrow format library for Swiftno GPUnoformat only; ArrowMetal is independent of it
MLX, mlx-dataApple's array framework for ML and its data loaderyes, for tensorsno Arrow, no dataframe operatorsdifferent problem; ArrowMetal does not depend on MLX
ADBCArrow-native database connectivity API and driversn/ano, it moves data, it does not computea way to get Arrow batches into ArrowMetal from any database

Claims about other projects come from their public documentation at the time of writing; corrections welcome as an issue on the repository.

Upstream tracker

Upstream tracker: what the matrix and the bindings found in other projects, reported with a reproduction, and where each report stands.

Nineteen findings across six projects: eighteen reported to the projects, as our own issues or as evidence added to an existing one, six of them fixed upstream; one pyarrow crash withheld until it reproduces. Each one has a test in the repository that fails on the day upstream fixes it, so the workaround can be removed. Nothing here is a complaint about another project: every entry is a divergence the matrix has to carry.

Apache Arrow (pyarrow)

8 findings · 7 reported
  • pc.utf8_normalize decomposes whatever form says (its NFC output is NFD) fixed upstream: apache/arrow#51225
  • pc.pairwise_diff on a sliced array ignores ArrowArray.offset fixed upstream: apache/arrow#50524
  • pc.fill_null_forward, pc.fill_null_backward, pc.replace_with_mask on a sliced boolean array ignore the offset fixed upstream: apache/arrow#51223
  • pc.winsorize on a sliced array nulls the wrong rows and places values where the input has nulls fixed upstream: apache/arrow#51224
  • pc.binary_slice with its own default stop raises ArrowInvalid: Negative buffer resize filed: apache/arrow#51222
  • pc.year_month_day and pc.iso_calendar crashed the process in the first matrix run withheld
  • A filtered Parquet read drops NaN rows when the filter is true for NaN (!=, negated comparisons, is_nan when min == max, is_null(nan_is_null=True), isin([nan])) and the file has row-group statistics… filed: apache/arrow#51491
  • DST rules stop being applied after the last tabulated transition (2037), so a summer instant in 2050 gets the winter offset, on a fat TZif file tracked on an existing report: apache/arrow#36110

Apple Metal

3 findings · 3 reported
  • the Feature Set Tables say the Apple9 family has "the full set of 64-bit atomic operations", but the shading-language headers admit device ulong only for atomic min and max… filed: Feedback Assistant FB24858110
  • makeComputePipelineState fails sporadically on the "Apple Paravirtual device" GitHub-hosted runners expose filed: Feedback Assistant FB24858160
  • a persistent GPU worker polling shared memory cannot be made to work: CPU/GPU coherence and the driver's submit-and-notify floor filed: Feedback Assistant FB24858235

Apache Arrow JS

1 finding · 1 reported
  • no C Data Interface export or import in the JavaScript library, so the Node binding builds the ArrowArray/ArrowSchema structs itself from a Vector's buffers… tracked on an existing report: apache/arrow-js#88

arrow R

2 findings · 2 reported
  • the C Data Interface entry points (Array$export_to_c, Array$import_from_c) exist but the only allocators for the structs they take (allocate_arrow_array(), allocate_arrow_schema()) are… tracked on an existing report: apache/arrow#39793
  • Array$create() on an R double or integer vector borrows R's memory, whose data begins 48 bytes into a page-aligned allocation, so the values buffer sits at offset 48 mod 64… closed by the project: apache/arrow#51227

Apache Arrow Go

4 findings · 4 reported
  • the compute package registers 85 scalar and vector functions and no aggregate at all (no sum, mean, min_max, count, no hash_*), and its package doc does not say so… filed: apache/arrow-go#1296
  • no allocator promises an alignment: NewGoAllocator buffers inherit Go's 8 KiB spans, so a buffer starts at offset 0 or 8192 within a 16 KiB page and page-aligned import is a coin flip whose odds… fixed upstream: apache/arrow-go#1297
  • compute.NewDatumWithoutOwning returns the same Datum type as an owning datum, so an over-release compiles and frees buffers under a live array with no error… filed: apache/arrow-go#1298
  • and_kleene and or_kleene return false where the answer is null when ExecCtx.ChunkSize is below the input length (same code on main 281776d)… fixed upstream: apache/arrow-go#1305

Swift

1 finding · 1 reported
  • in a generic throws function compiled at -O in its own module, the closure passed to withUnsafeBytes uses the error register x21 as scratch around an Objective-C message send and exits by… reported by others: swiftlang/swift#90477

The full tracker, with the evidence behind each row and where every report stands, is the Upstream tracker page in Docs and docs/UPSTREAM.md in the repository.

From the matrix CSV

Against each library's fastest idiom, on every row that has one.

One representative row per operation from the full matrix, wall-clock milliseconds on an Apple M4 Max with 16 CPU cores, the best of up to five calls after a warm-up (a slow row is repeated fewer times, never fewer than twice; the count is in the CSV). Every CPU library is measured twice on the same data — its plain eager idiom, and the most parallel idiom it has for the same answer: Polars through pl.LazyFrame on the in-memory or streaming engine, pyarrow through its Acero query engine as a plan over 16 record batches (pandas' threaded paths, numexpr and numba, were not installed for this run; numpy's ufuncs are single-threaded). Each library column below shows the faster of its two, tagged with which; the small number after each library's wall time is the cores that call used, and the Compare tab shows every idiom side by side. Speedup is the fastest CPU idiom of any library divided by ArrowMetal's time; rows are sorted by it. Regenerated from the CSV on every build; the full table is the Compare tab.

One representative row per operation against each library's fastest idiom
FamilyOperationRowsArrowMetal msPolars mspyarrow mspandas / numpy msSpeedup
reductionsquantile(float64, 0.5)50M4.61178 lazy 6.8c369 1.0c366 1.0c38.7x
sortpartition_nth_indices (n/2)50M12.16—298 1.0c349 numpy 1.0c24.5x
compare+selecttake (n/2 random indices)50M5.90172 1.0c143 1.0c171 1.0c24.2x
sortlexsort (2 int32 keys)50M37.59903 lazy 11.0c7,774 1.0c29,655 numpy 1.0c24.0x
sortargsort utf810M13.73228 lazy 13.8c1,601 1.0c1,601 1.0c16.6x
sortrank (min)50M68.03700 5.6c5,553 1.0c21,473 1.0c10.3x
chainscompare + filter + take50M4.5445.72 1.0c124 1.0c202 numpy 1.0c10.1x
sorttop_k (k=100, int64)50M2.6765.08 lazy 1.0c24.00 1.0c328 numpy 1.0c9.0x
sortargsort int6450M39.15302 11.8c5,197 1.0c4,545 numpy 1.0c7.7x
windowcumulative_sum (float64)50M13.04132 1.0c99.50 1.0c120 1.0c7.6x
group-bysum by int32 key (10000000 groups)50M46.12322 lazy 14.3c1,303 acero 2.8c1,294 1.0c7.0x
group-bymean by int32 key (100000 groups)50M7.3999.19 lazy 14.7c47.02 acero 11.5c430 1.0c6.4x
group-bysum by utf8 key (100000 distinct)50M23.80178 12.9c145 12.3c783 1.0c6.1x
temporalassume_timezone (America/New_York)50M15.85205 lazy 15.4c92.53 acero 14.4c3,119 1.0c5.8x
reductionsmean(int64, 10% nulls)50M1.1513.25 1.0c6.11 acero 13.3c64.18 1.0c5.3x
stringsmatch_like("cust_1%") [_ wildcard]10M1.698.56 lazy 14.6c18.31 acero 12.1c203 1.0c5.1x
group-bymin by int32 key (10000000 groups)50M72.04329 14.3c1,314 acero 2.8c1,423 1.0c4.6x
sortsort float6450M32.12133 9.7c6,044 1.0c1,759 numpy 1.0c4.1x
group-bysum by two int32 keys (~1024 groups)50M5.91164 13.5c23.88 13.5c448 1.0c4.0x
stringschar_length10M1.104.22 lazy 14.8c5.45 acero 11.6c52.47 1.0c3.8x
group-bysum by int32 key (1000 groups)50M4.8981.93 lazy 14.5c18.45 acero 13.6c248 1.0c3.8x
group-bycount_distinct by key (1000 groups)50M41.78157 lazy 14.0c1,859 acero 2.3c4,773 1.0c3.8x
reductionsmode(int64)50M33.61132 lazy 14.6c104 1.0c342 1.0c3.1x
joinleft outer hash join (50000000 x 1000000 on int64)50M19.08285 5.0c57.95 14.8c1,690 1.0c3.0x
joininner hash join (50000000 x 1000000 on int64)50M21.80106 10.0c63.87 15.0c543 1.0c2.9x
stringsupper10M5.3225.14 lazy 14.2c15.29 acero 12.9c169 1.0c2.9x
element-wiseexp (float32)50M1.935.29 lazy 13.4c6.42 acero 12.6c61.94 numpy 1.0c2.7x
stringssplit_pattern("_")10M8.4725.36 lazy 12.9c20.74 acero 13.0c223 1.0c2.4x
reductionstdigest(float64, q=0.5)50M33.18—79.96 acero 15.3c—2.4x
temporalstrftime (%Y-%m-%d)50M151361 lazy 15.5c839 acero 15.4c4,629 1.0c2.4x
reductionssum(int64, 10% nulls)50M1.112.30 lazy 11.1c5.09 acero 13.0c51.54 1.0c2.1x
decimaldecimal add (128-bit)50M5.4221.56 lazy 14.1c11.04 acero 12.8c74.38 1.0c2.0x
element-wisemultiply scalar (int64)50M2.104.13 lazy 12.6c4.26 acero 11.5c54.87 1.0c2.0x
element-wiseadd_checked (int64 + int64)50M4.50—8.63 acero 12.1c—1.9x
nestedlist_value_length10M0.360.73 lazy 10.2c0.66 acero 9.6c0.79 1.0c1.8x
element-wisecoalesce (2 int64 columns)50M3.325.89 lazy 14.0c8.48 acero 12.5c147 1.0c1.8x
reductionsmin(int64, 10% nulls)50M1.182.01 lazy 10.1c5.05 acero 13.3c47.68 1.0c1.7x
temporaladd_duration (+1h)50M2.093.26 lazy 12.7c4.37 acero 12.0c183 1.0c1.6x
element-wiseround (float64)50M3.184.72 lazy 9.6c11.49 acero 12.3c110 1.0c1.5x
element-wiseif_else (bool ? int64 : int64)50M3.755.31 lazy 13.6c12.67 acero 12.5c14.61 numpy 1.0c1.4x
element-wiseadd array (int64 + int64)50M3.124.31 lazy 12.8c5.64 acero 12.0c148 1.0c1.4x
compare+selectfilter int64 (30% kept)50M2.232.85 lazy 12.6c22.61 acero 13.2c157 1.0c1.3x
group-byvariance by key (1000 groups)50M30.3683.36 14.4c29.17 12.7c239 1.0c0.96x
compare+selectdrop_null (int64, 10% nulls)50M4.203.81 lazy 13.4c11.14 acero 12.7c167 1.0c0.91x
element-wisesqrt (float64)50M3.923.42 lazy 12.7c3.74 acero 11.6c12.39 numpy 1.0c0.87x
sortunique (int32, 1000 distinct)50M15.72102 4.6c13.04 acero 13.6c63.76 1.0c0.83x
chainsfilter two columns + sum50M3.532.92 lazy 13.9c17.73 acero 13.5c112 numpy 1.0c0.83x
compare+selectcompare scalar (int64 > 0)50M2.411.91 lazy 11.8c3.53 acero 11.5c6.16 1.0c0.79x
temporalyear50M26.8232.65 lazy 15.2c17.91 acero 13.7c1,096 1.0c0.67x
temporalfloor_temporal (day)50M8.743.35 lazy 11.9c9.71 acero 12.5c44.82 1.0c0.38x
stringsparse (utf8 -> int64)10M19.593.15 lazy 14.6c5.46 acero 11.2c439 1.0c0.16x
element-wisesin (float64)50M15023.28 lazy 15.1c25.57 acero 13.7c301 numpy 1.0c0.16x
temporaldays_between50M46.315.75 lazy 13.8c6.66 acero 12.7c451 1.0c0.12x
element-wiseln (float64)50M81.318.46 lazy 14.2c9.85 acero 13.3c86.95 numpy 1.0c0.10x
stringsmatch_substring_regex (real regex)10M16714.57 lazy 14.3c29.32 acero 13.7c331 1.0c0.09x
windowshift (lag 1, int64)50M3.580.03 lazy 2.2c—0.15 1.0c0.01x

A dash means the library has no equivalent operation or was not measured for that row; where pandas has no idiom, the numpy one is shown and marked, and a small lazy or acero tag means that library's parallel idiom was the faster of its two. Polars' lazy engine used a median of 11.5 of the 16 cores across this run and pyarrow's Acero 11.1, against 1.0 for the eager idioms on most families; the per-row figure is in every cell. Every row is on the Compare tab with the processor time each call consumed. Bold speedups are at or above the project's 3x bar.

Open opportunities. Pick one.

Every comparison on this site is against the fastest idiom any CPU library has for the row — Polars' lazy engine (Polars 1.44.1) and pyarrow's Acero included, which used a median of 11.5 and 11.1 of the 16 cores across this run. None of them has a GPU path on a Mac. DuckDB is not in the matrix; measured separately on its sort, group-by, filter and sum rows on 2026-09-12, it is the fastest CPU engine on low-cardinality group-by and multi-key sort, and the group-by card at the top carries its number. 145 comparison rows clear 3x against that baseline, 102 are ahead but under it, and 77 are behind, all of them in the tables with the idiom and the cores that set the bar. Of the 77, 28 are single passes at unified-memory bandwidth and 16 are the fixed cost of one GPU dispatch below a million rows; the other 33 are in the items below. Each item names its measured cause, and each is open to a contributor:

  • Faster correct doubles. ln, sin and days_between are 0.10x to 0.16x at fifty million rows of a vectorised libm on 14 to 15 cores. Apple GPUs have no binary64 hardware, so these run emulated, arithmetic and sqrt correctly rounded; exp/ln/log within 1-2 ulp; trig within 5 (asserted 6), where the CPU has one instruction.
  • A GPU regular-expression engine. LIKE and literal patterns are GPU kernels at 1.6x to 7.0x at ten million rows; a real regex runs on the host through ICU, the Unicode regex library, at 0.09x to 0.14x.
  • Single passes at ten million rows. Element-wise arithmetic, compare and filter sit at 0.57x to 0.98x there: both sides move the same bytes through the same memory at close to the same rate, and 9 to 13 cores get there too.
  • Views. The matrix's shift writes a new column, 3.58 ms at 50M rows, which is this machine's bandwidth; Polars answers with a two-chunk view in 0.03 ms and moves nothing. shift(view=True) gives the same answer in 0.04 ms at 10M rows.
  • Temporal field extraction at fifty million rows. year, month and day are 0.66x to 0.67x of pyarrow's Acero: a calendar conversion is a few dozen integer operations an element on both sides.
  • Grouped moments at a few groups. Variance and stddev at a thousand groups are 0.96x to 1.24x, because Metal has no 64-bit atomic add and the moments accumulate in software binary64.
  • unique and value_counts. 0.55x to 0.84x on a thousand-distinct column against a threaded hash aggregation.
  • Calls under a million rows. A GPU command buffer costs 110 to 240 microseconds for a sum or a filter of a thousand to a million rows, and 400 to 1,100 for a group-by sum over 1000 keys; 60 to 70 microseconds of that is the empty round trip, and a CPU core finishes first. Batching and the fused compiler amortise it across a plan.
  • String conversions with variable-length output. parse (utf8 → int64) and to_strings are 0.16x to 0.57x: the output length of each row depends on its value, so the kernel measures, prefix-sums and writes in a second pass, against one streaming pass on the CPU.
  • Whole-query chains against a streaming engine. filter two columns + sum and group-by after filter are 0.43x to 0.83x of Polars' lazy engine, which fuses the predicate and the aggregate and runs it on 11 to 14 cores. A plan handed down from Polars or DuckDB is longer than the harness's one question, and every operator added to it amortises against the same dispatch.

Closed in the 2026-09-07 run, and now above the bar on the same baseline: the float64 sort's last gather (sort float64 3.8x to 4.1x) and the small-group aggregates (sum, mean and min by int32 key at a thousand groups, 3.3x to 3.8x).

The Join forces tab shows what already runs beside the engines we measure and what each path costs today.

Coverage by exact Arrow name

312 compute functions, audited one by one.

Every function name in the Arrow 25 compute list plus the grouped aggregates, each executed through call_function against pyarrow and recorded as GPU, host, partial or missing. The counts come from the registry the tests run; the status is what runs, not what was intended.

284
run as Metal kernels
17
run on the host, by design: ICU regex, t-digest merge, a handful of Unicode tables
7
partial: a missing option, a missing input type, or a stated difference in the answer, each one named
4
missing: names new in Arrow 26.0.0, recorded and not yet implemented; the other 308 names are callable
Differential41,688 cases against pyarrow.compute across 46 column types, every public method. 1,643 of them differ, across 22 documented divergences; none unclassified. Every difference is documented as an open finding, a deliberate divergence, or a pyarrow bug, each with a reproduction, and the fixed ones stay as regression tests.
Tests and review1,047 Swift test functions in release plus 7,278 collected Python test cases, on a physical Apple silicon Mac, then a review pass whose every finding carries a regression test. Every GPU kernel has a CPU reference; the hostile-input suites pin threadgroup boundaries, nulls and slices.
InteropC Data, C Device and C Stream import and export (the C Device Stream is not implemented); Arrow IPC read and write, including dictionary batches.
TypesAll primitives, boolean, utf8 and binary, temporal, decimal32 to 256, list, struct, map, union, dictionary, run-end encoded, float16, intervals, extension types.
How it works

Kernels at the bottom, an engine on top, one package.

in 0.1.0
KernelsMetal Shading Language generated per element type at runtime and cached. Reductions without atomics, radix sorts, privatised hash group-by, stream compaction.
in 0.1.0
Fused compilerAn expression tree with filters and aggregates lowers to a single kernel: nulls compiled to bit logic, shared subexpressions computed once, 4-wide loads.
in 0.1.0
Query engineLazy plans with predicate pushdown, projection pruning and fusion planning. Six join kinds and an as-of join over multi-column and string keys. explain().
in 0.1.0
Parquet on the GPUPages decoded by Metal: PLAIN, RLE, dictionary, delta, byte-stream-split. Snappy and LZ4 decompressed on the GPU.
in 0.1.0
StreamingData flows from disk through the GPU with reads and compute overlapped, and the group-by and top-k state resident on the GPU, measured to 30.21 GB on a 64 GB machine; RAM tracks the answer, not the scan. Hands batches to pyarrow or Polars through the C Stream interface.
Languages

Python and Swift natively; Rust, Go, TypeScript and R over one C header.

One library, one C header. The Python and Swift packages call the same kernels; every other language reaches them through include/arrowmetal.h with no Swift runtime in sight, which is how the Rust plugin and the C++ extension were built. Each binding is tested against its own ecosystem's kernels on the same data and carries measured timings in its doc, the rows where the ecosystem's own kernel is ahead included. Anything marked not started is an invitation.

Language bindings, how each reaches the kernels, and its status in 0.5.0
LanguageHowWhat it getsStatus
Pythonpip install arrowmetal (macOS 14 or later, Apple silicon), or from source; ctypes over the C ABI, no compiled extensionthe full API: arrays, fused queries, the lazy engine, Parquet, streaming, the Polars, DuckDB and pandas bridgesin 0.5.0: 7,278 collected test cases
SwiftSwift package, import ArrowMetalthe native API the Python package is built on: MetalArray, MetalRecordBatch, LazyFrame, ParquetFile, StreamQueryin 0.5.0: 1,047 test functions
Cinclude/arrowmetal.h, one dylibimport and export through the Arrow C Data and C Device interfaces, every kernel by name, a JSON plan runner for whole queriescompiled as C in the test suite
Rustthe arrowmetal crate on crates.io over the C ABI (rust/), arrow-rs arrays in and out through arrow::ffi; the Polars expression plugin uses the same -sys layerimport and export, reductions, compare, filter, take, slice, sort, group-by, the JSON plan runner, batching; the rest of the ABI is listed as not wrapped in the docin 0.5.0: 66 tests, 64 against arrow-rs plus 2 over the raw ABI and 5 doc-tests, three measured rows against arrow-rs in the Rust doc
Ran R package (r/arrowmetal) with a C shim over the ABI, columns crossing through the arrow R package's C Data Interfaceimport and export, reductions, compare, filter, take, slice, sort, group-by with the standard aggregates, the plan runner; int64 through bit64in 0.5.0: 88 test blocks in the sources, 920 expectations, against arrow and base R, timings in the R doc
TypeScript / JavaScripta Node addon over the C ABI with N-API (node/), exchanging columns with Apache Arrow JS through C Data Interface structs the addon builds itselfimport and export for the primitive, boolean and string types, reductions, compare, arithmetic, cast, filter, take, slice, sorts, group-by, the plan runner; the browser is out of scopein 0.5.0: 84 tests against Arrow JS and plain JS, timings in the TypeScript doc
C++the C ABI, as the DuckDB extension doesplans run from SQL, results back as Arrowthrough the extension today
Goa cgo module (go/arrowmetal) that loads the dylib and forwards the entry points it uses, typed from the header; Arrow Go arrays through its cdata package, buffers pinned for the life of the handleimport and export, reductions, compare, filter, take, sort, argsort, lexsort, group-by, the plan runnerin 0.5.0: 69 tests, 66 test functions and 3 runnable Examples, 584 passing results with subtests, green under the runtime's cgo checker, timings in the Go doc
Java, C#, Juliathe C ABI, through each language's FFIthe same header; nothing language-specific is requiredno binding in this repository

Each binding's doc carries its own measured timing table with the method stated, the rows where the ecosystem's own kernel is ahead included.

License

  • Apache License 2.0
  • Swift, Metal Shading Language and Python; no copyleft linked
  • © 2026 the ArrowMetal authors

Built for Apple silicon. Every number on this page comes from a run in this repository; the one estimate is marked as one.

Apache Arrow and the Arrow logo are trademarks of The Apache Software Foundation. Apple and Metal are trademarks of Apple Inc. ArrowMetal is not affiliated with or endorsed by the ASF or by Apple.

About the mark. Five arrowheads in a wake, cut like machined metal. Read from back to front they follow the order in which the pancha mahabhuta, the five elements of Sanatana Dharma, are said to manifest: akasha, space, is an outline only; vayu, air, the first dark solid; agni, fire, warmer; jala, water, the first to show its facets; prithvi, earth, the polished head in front, and metal is what the earth gives up. The point at the origin of the wake is the bindu. The arrow is the direction data flows; the facets are the light on metal; the five heads leaving together are columns finishing in one dispatch. It is a reading the mark supports, offered plainly, not a claim that it was designed from scripture.

Generated from Benchmarks/results/full_matrix_2026-09-07-parallel.csv

Every measured operation, every library, on the M4 Max.

173 operations at one to three sizes: 339 comparison rows, 15 of them with no CPU equivalent, built from 1,860 library measurements (the CSV holds 1,883 timed records: the nested family at 10M rows was measured twice and the later pass is shown). Apple M4 Max, 16 CPU cores, 64 GB unified memory. The best of up to five calls after a warm-up; a slow row is repeated fewer times, never fewer than twice, and the count is in the CSV. Each CPU library is shown in its plain eager idiom and, where the matrix has measured it, in the most parallel idiom it has for the same answer (Polars lazy; pyarrow through its Acero query engine over 16 record batches; pandas' threaded paths, numexpr and numba, were not installed for this run, and numpy stands in where pandas has no equivalent). Polars and pyarrow run 16-thread pools, unpinned. The small number after each library's wall time is the cores that call used, CPU-ms divided by wall ms. "Fastest CPU" is the best of every idiom for that row, named. Speedup is that wall time divided by ArrowMetal's; rows are sorted by it, strongest first.

145
at or above 3x the fastest CPU library (4 of them host short-circuits, any and all, noted on their rows)
102
faster, but under 3x
77
to improve: behind the fastest CPU idiom; the home page groups them by cause
15
not measurable yet (not reachable from Python, or no CPU equivalent)
Rows per family by verdict
Family≥3x1–3xto improven/arows
chains204612
compare+select3411018
decimal253010
element-wise52817050
group-by65181084
join42006
latency009918
nested22004
reductions16173036
sort2124027
strings81713038
temporal7710024
window1002012

How to read a row: bold in a library column marks the fastest CPU idiom for that row; hover or tap a cell for its CPU-ms and the exact idiom. The small line under each ArrowMetal time is its own processor time; the small line under an operation name is the CSV's note for that row. A dash in a lazy or Acero column means the matrix has not measured that idiom for the row yet, or the library has no parallel form of it. Rows marked to improve are behind the fastest CPU idiom; the home page lists the open opportunities behind them.

Every measured operation against every library
OperationRowsSpeedupVerdictArrowMetal ms Polars msPolars lazy mspyarrow mspyarrow Acero mspandas / numpy ms
quantile(float64, 0.5)50M38.7x≥3x4.6111.626 CPU-ms179.3 6.7c178.5 6.8c368.9 1.0c—366.5 1.0c
count by int32 key (10000000 groups)50M25.7x≥3x16.72.015 CPU-ms429.1 9.5c427.2 9.3c1,067 3.0c1,065 3.0c772.7 1.0c
partition_nth_indices (n/2)50M24.5x≥3x12.23.838 CPU-ms——297.8 1.0c—349.4 numpy 1.0c
take (n/2 random indices)50M24.2x≥3x5.9040.786 CPU-ms171.8 1.0c172.0 1.0c142.6 1.0c—171.4 1.0c
lexsort (2 int32 keys)50M24.0x≥3x37.62.416 CPU-ms920.1 10.9c902.9 11.0c7,774 1.0c—29,655 numpy 1.0c
dictionary_encode (utf8)10M20.1x≥3x5.6494.025 CPU-ms113.8 1.0c113.4 1.0c131.3 1.0c—137.3 1.0c
take (n/2 random indices)10M18.0x≥3x1.2780.638 CPU-ms28.9 1.0c28.6 1.0c23.0 1.0c—28.3 1.0c
count by int32 key (10000000 groups)10M17.3x≥3x5.0721.664 CPU-ms87.8 8.8c90.6 8.8c266.8 2.1c270.7 2.2c229.2 1.0c
argsort utf810M16.6x≥3x13.73.327 CPU-ms232.7 13.7c227.5 13.8c1,601 1.0c—1,601 1.0c
lexsort (2 int32 keys)10M15.3x≥3x7.2912.299 CPU-ms111.4 10.9c114.6 10.6c1,123 1.0c—3,371 numpy 1.0c
sum by two int32 keys (~10004569 groups)50M12.1x≥3x47.93.208 CPU-ms580.1 13.7c619.4 12.6c1,363 2.8c1,363 2.8c1,610 1.0c
list by key (1000 groups)50M10.9x≥3x28.43.945 CPU-ms319.0 3.2c308.4 3.3c400.6 1.6c394.1 1.5c8,179 1.0c
argsort float6450M10.5x≥3x39.90.825 CPU-ms420.1 13.2c422.7 13.1c5,885 1.0c—5,472 numpy 1.0c
dense_rank50M10.3x≥3x67.83.070 CPU-ms699.2 5.6c716.1 5.5c5,552 1.0c—21,375 1.0c
rank (min)50M10.3x≥3x68.03.078 CPU-ms700.1 5.6c708.8 5.6c5,553 1.0c—21,473 1.0c
compare + filter + take50M10.1x≥3x4.5451.132 CPU-ms45.7 1.0c46.5 1.0c124.4 1.0c—202.0 numpy 1.0c
rolling_sum (window 64)50M10.0x≥3x23.32.088 CPU-ms430.9 1.0c427.7 1.0c——234.1 1.0c
list by key (1000 groups)10M9.8x≥3x5.9142.878 CPU-ms58.6 3.5c58.1 3.5c73.5 1.5c74.5 1.6c1,621 1.0c
list by key (100000 groups)50M9.7x≥3x35.44.417 CPU-ms343.9 3.7c344.7 3.6c487.6 1.7c489.7 1.8c9,555 1.0c
argsort float6410M9.7x≥3x8.1070.851 CPU-ms78.6 12.8c80.3 12.6c984.1 1.0c—841.9 numpy 1.0c
quantile(float64, 0.5)10M9.7x≥3x3.6261.773 CPU-ms35.0 6.6c35.0 6.6c61.9 1.0c—62.3 1.0c
strptime (%Y-%m-%d %H:%M:%S)50M9.2x≥3x15.52.054 CPU-ms2,167 1.0c157.0 15.6c1,728 1.0c142.6 14.8c4,887 1.0c
top_k (k=100, int64)50M9.0x≥3x2.6741.380 CPU-ms65.5 1.0c65.1 1.0c24.0 1.0c—328.4 numpy 1.0c
strptime (%Y-%m-%d %H:%M:%S)10M8.9x≥3x3.3700.775 CPU-ms418.4 1.0c32.5 14.8c344.8 1.0c30.0 13.9c967.5 1.0c
rolling_sum (window 64)10M8.9x≥3x5.3481.734 CPU-ms86.7 1.0c85.7 1.0c——47.3 1.0c
partition_nth_indices (n/2)10M8.6x≥3x5.8233.631 CPU-ms——49.9 1.0c—54.6 numpy 1.0c
dense_rank10M8.5x≥3x12.01.801 CPU-ms110.7 6.3c102.7 6.8c883.7 1.0c—2,223 1.0c
rolling_mean (window 64)50M8.4x≥3x25.32.084 CPU-ms416.9 1.0c415.0 1.0c——213.3 1.0c
sum by int32 key (100000 groups)10M8.4x≥3x1.9511.082 CPU-ms22.7 12.1c24.8 11.6c16.4 6.0c21.6 6.4c67.5 1.0c
list by key (100000 groups)10M8.4x≥3x8.8273.447 CPU-ms74.5 3.4c74.1 3.4c88.8 1.7c92.4 2.0c2,533 1.0c
sum by two int32 keys (~10004569 groups)10M8.4x≥3x12.43.705 CPU-ms103.9 12.5c104.4 13.0c337.3 2.0c352.6 2.1c442.8 1.0c
rank (min)10M8.2x≥3x12.41.896 CPU-ms101.9 6.8c111.6 6.3c889.8 1.0c—2,260 1.0c
cumulative_prod (float64)50M8.1x≥3x12.40.409 CPU-ms134.4 1.0c135.7 1.0c100.0 1.0c—123.4 1.0c
first by key (1000 groups)50M8.0x≥3x6.8233.401 CPU-ms54.2 14.3c55.8 14.0c210.8 1.0c—196.6 1.0c
count by int32 key (100000 groups)50M7.8x≥3x4.9241.258 CPU-ms120.7 8.8c121.9 8.8c39.7 11.0c38.3 11.0c170.9 1.0c
argsort int6450M7.7x≥3x39.10.855 CPU-ms302.0 11.8c304.5 11.7c5,197 1.0c—4,545 numpy 1.0c
sum by two int32 keys (~100489 groups)10M7.7x≥3x2.4891.888 CPU-ms38.0 11.8c38.2 11.9c19.1 6.1c25.6 6.3c107.7 1.0c
cumulative_sum (float64)50M7.6x≥3x13.00.405 CPU-ms132.1 1.0c135.5 1.0c99.5 1.0c—120.2 1.0c
compare + filter + take10M7.6x≥3x0.9790.994 CPU-ms7.550 1.0c7.392 1.0c23.9 1.0c—40.3 numpy 1.0c
cumulative_prod (float64)10M7.5x≥3x2.6970.401 CPU-ms26.4 1.0c26.4 1.0c20.2 1.0c—25.2 1.0c
sum by two int32 keys (~100489 groups)50M7.4x≥3x8.2702.212 CPU-ms213.8 12.2c213.0 12.7c66.5 10.6c61.2 10.7c573.3 1.0c
argsort int6410M7.4x≥3x7.8110.826 CPU-ms57.6 11.4c57.8 11.3c841.2 1.0c—699.0 numpy 1.0c
is_in (int64, 100-value set)re-measured in place after the full run flagged it (same build, quieter machine)50M7.4x≥3x2.1681.507 CPU-ms269.7 1.0c16.0 15.0c383.7 1.0c33.2 14.6c406.6 1.0c
sum by int32 key (10000000 groups)10M7.4x≥3x9.4571.643 CPU-ms72.0 12.8c69.5 13.6c312.4 2.0c319.2 2.1c321.0 1.0c
rolling_mean (window 64)10M7.3x≥3x5.8721.761 CPU-ms85.5 1.0c85.6 1.0c——43.0 1.0c
count_distinct by key (10000000 groups)10M7.2x≥3x11.42.727 CPU-ms82.8 13.8c83.9 13.9c645.6 2.0c668.2 2.1c1,033 1.0c
cumulative_sum (float64)10M7.0x≥3x2.8330.427 CPU-ms26.9 1.0c26.0 1.0c19.9 1.0c—24.5 1.0c
contains("north")10M7.0x≥3x1.6400.394 CPU-ms141.8 1.0c11.5 15.0c118.8 1.0c13.8 11.1c119.3 1.0c
top_k (k=10000, int64)50M7.0x≥3x4.9601.887 CPU-ms66.2 1.0c65.5 1.0c34.7 1.0c—327.6 numpy 1.0c
sum by int32 key (10000000 groups)50M7.0x≥3x46.12.157 CPU-ms327.0 14.1c321.7 14.3c1,309 2.9c1,303 2.8c1,294 1.0c
mean by int32 key (10000000 groups)10M7.0x≥3x11.61.727 CPU-ms80.9 13.6c82.2 13.9c304.7 2.1c315.2 2.2c326.3 1.0c
max by key, float64 (100000 groups)10M7.0x≥3x2.5571.390 CPU-ms22.7 12.1c23.1 11.9c17.8 5.8c24.5 5.8c64.9 1.0c
pairwise_diff (float64)50M6.9x≥3x3.5730.889 CPU-ms25.5 1.0c25.6 1.0c102.5 1.0c—24.7 1.0c
min by key, float64 (100000 groups)10M6.8x≥3x2.5781.381 CPU-ms22.6 12.3c22.3 12.4c17.6 5.8c24.3 6.0c64.6 1.0c
mean by int32 key (10000000 groups)50M6.8x≥3x49.62.233 CPU-ms343.4 14.1c336.7 14.0c1,294 2.8c1,285 2.8c1,350 1.0c
count_distinct by key (10000000 groups)50M6.7x≥3x60.73.087 CPU-ms407.5 14.8c410.6 14.7c3,210 2.5c3,195 2.5c5,238 1.0c
max by key, float64 (100000 groups)50M6.7x≥3x9.6741.661 CPU-ms120.8 14.7c95.6 14.5c73.5 10.6c64.9 10.2c310.3 1.0c
min by key, float64 (100000 groups)50M6.6x≥3x9.7001.680 CPU-ms123.0 14.9c95.4 14.6c84.8 10.6c64.3 10.5c311.0 1.0c
count by int32 key (100000 groups)10M6.6x≥3x1.9581.132 CPU-ms28.2 7.2c27.4 7.6c13.0 6.3c17.1 6.4c32.2 1.0c
mean by int32 key (100000 groups)10M6.6x≥3x2.4781.167 CPU-ms23.5 12.0c23.3 12.2c16.2 6.0c20.8 6.6c85.6 1.0c
min by int32 key (100000 groups)10M6.5x≥3x2.7871.433 CPU-ms23.6 11.9c23.3 12.0c18.1 5.8c24.5 5.9c68.6 1.0c
max by int32 key (100000 groups)10M6.5x≥3x2.7781.432 CPU-ms23.5 11.6c24.5 11.5c17.9 5.8c25.0 5.8c68.7 1.0c
first by key (10000000 groups)10M6.4x≥3x14.62.630 CPU-ms94.0 8.3c98.2 8.2c307.9 1.0c—300.1 1.0c
assume_timezone (America/New_York)10M6.4x≥3x3.3630.708 CPU-ms540.3 1.0c41.0 15.4c215.3 1.0c21.7 12.1c669.1 1.0c
last(int64)50M6.4x≥3x0.0020.003 CPU-ms24.5 1.0c0.010 1.0c0.150 1.0c—35.2 1.0c
mean by int32 key (100000 groups)50M6.4x≥3x7.3891.207 CPU-ms101.2 14.8c99.2 14.7c47.3 11.4c47.0 11.5c429.6 1.0c
first(int64)10M6.3x≥3x0.0020.002 CPU-ms4.782 1.0c0.009 1.1c0.033 1.0c—4.883 1.0c
sum by int32 key (100000 groups)50M6.3x≥3x7.4111.253 CPU-ms102.4 14.2c99.4 14.7c48.1 11.3c46.4 11.7c334.8 1.0c
fill_null_forward (int64)50M6.2x≥3x9.6271.742 CPU-ms137.3 1.0c138.0 1.0c59.2 1.0c—59.7 1.0c
sum by utf8 key (100000 distinct)50M6.1x≥3x23.83.816 CPU-ms178.0 12.9c184.1 13.5c144.6 12.3c144.6 12.0c782.8 1.0c
first by key (100000 groups)10M6.1x≥3x2.9421.967 CPU-ms17.9 11.2c18.0 11.2c54.4 1.0c—49.3 1.0c
last(int64)10M5.9x≥3x0.0020.003 CPU-ms4.823 1.0c0.009 1.0c0.037 1.0c—6.940 1.0c
assume_timezone (America/New_York)50M5.8x≥3x15.81.831 CPU-ms2,697 1.0c204.8 15.4c1,069 1.0c92.5 14.4c3,119 1.0c
first by key (100000 groups)50M5.8x≥3x11.33.384 CPU-ms65.6 14.6c69.2 13.6c260.6 1.0c—241.6 1.0c
sort utf810M5.8x≥3x23.07.129 CPU-ms133.1 10.0c134.7 9.5c1,865 1.0c2,486 1.0c1,867 1.0c
list by key (10000000 groups)50M5.7x≥3x139.35.772 CPU-ms797.3 5.5c794.4 5.4c1,487 2.4c1,433 2.5c85,183 1.0c
first(int64)50M5.6x≥3x0.0020.002 CPU-ms24.4 1.0c0.010 1.1c0.149 1.0c—23.4 1.0c
sum by utf8 key (100000 distinct)10M5.4x≥3x6.6053.701 CPU-ms35.8 11.4c35.8 12.1c39.9 6.1c47.8 7.6c157.9 1.0c
indices_nonzero (bool)50M5.4x≥3x3.1680.826 CPU-ms129.6 1.0c130.3 1.0c117.6 1.0c—17.0 numpy 1.0c
max by key, float64 (10000000 groups)10M5.3x≥3x14.41.729 CPU-ms77.1 13.4c80.9 13.3c300.7 2.1c311.5 2.0c321.5 1.0c
mean(int64, 10% nulls)50M5.3x≥3x1.1450.428 CPU-ms13.2 1.0c13.3 1.0c64.6 1.0c6.115 13.3c64.2 1.0c
max by int32 key (100000 groups)50M5.3x≥3x10.61.529 CPU-ms115.8 12.6c106.1 13.8c59.6 10.2c56.3 10.9c334.9 1.0c
min by key, float64 (10000000 groups)10M5.2x≥3x14.41.701 CPU-ms75.4 14.0c79.3 13.6c298.8 2.1c312.6 2.1c318.4 1.0c
mean(int64, 10% nulls)10M5.2x≥3x0.2860.321 CPU-ms2.385 1.0c2.426 1.0c11.6 1.0c1.497 10.9c11.5 1.0c
max by key, float64 (10000000 groups)50M5.2x≥3x63.01.935 CPU-ms327.4 14.2c328.8 14.2c1,241 3.1c1,228 3.1c1,343 1.0c
first by key (10000000 groups)50M5.1x≥3x65.63.870 CPU-ms336.1 11.6c344.8 11.5c1,146 1.0c—1,047 1.0c
min by key, float64 (10000000 groups)50M5.1x≥3x63.01.930 CPU-ms321.5 14.3c325.2 14.5c1,259 3.0c1,234 3.1c1,326 1.0c
decimal sum50M5.1x≥3x1.8140.443 CPU-ms92.1 1.0c11.4 9.5c95.0 1.0c9.211 12.8c93.9 1.0c
match_like("cust_1%") [_ wildcard]_ is LIKE's single-character wildcard, so this is not a pure prefix and takes the ICU host path10M5.1x≥3x1.6950.422 CPU-ms100.0 1.0c8.559 14.6c183.6 1.0c18.3 12.1c203.1 1.0c
max by int32 key (10000000 groups)10M5.0x≥3x15.01.817 CPU-ms75.5 13.8c79.6 13.4c309.1 2.0c321.4 2.2c324.8 1.0c
min by key, float64 (1000 groups)50M5.0x≥3x6.1111.720 CPU-ms139.0 14.5c88.9 13.2c41.8 12.0c30.5 12.7c224.8 1.0c
min by int32 key (100000 groups)50M4.9x≥3x10.61.505 CPU-ms100.4 14.5c103.4 14.3c52.7 11.1c52.1 11.2c335.0 1.0c
min by int32 key (10000000 groups)10M4.9x≥3x14.91.684 CPU-ms72.6 14.4c78.2 13.7c310.7 2.0c320.8 2.1c324.5 1.0c
pairwise_diff (float64)10M4.8x≥3x0.9370.582 CPU-ms4.516 1.0c4.666 1.0c21.0 1.0c—4.976 1.0c
replace_substring("cust" -> "cx")10M4.8x≥3x3.0491.259 CPU-ms300.4 1.0c23.4 15.1c140.9 1.0c14.7 11.7c141.4 1.0c
top_k (k=10000, int64)10M4.8x≥3x2.4741.856 CPU-ms11.8 1.0c11.9 1.0c12.0 1.0c—45.6 numpy 1.0c
sum by utf8 key (10000000 distinct)50M4.8x≥3x126.16.389 CPU-ms602.8 12.3c685.0 11.5c2,944 2.1c2,928 2.1c4,288 1.0c
fill_null_forward (int64)10M4.7x≥3x2.3411.361 CPU-ms27.2 1.0c27.6 1.0c11.5 1.0c—11.1 1.0c
max by key, float64 (1000 groups)50M4.7x≥3x6.0861.724 CPU-ms142.2 14.3c89.6 12.9c41.3 12.1c28.7 12.5c234.4 1.0c
concat (a + '-' + b)10M4.7x≥3x4.1571.496 CPU-ms312.3 1.0c20.2 14.3c201.2 1.0c19.5 12.7c363.6 1.0c
index_in (int64, 100-value set)50M4.7x≥3x5.9283.510 CPU-ms——332.4 1.0c27.7 14.2c163.0 numpy 1.0c
min by int32 key (10000000 groups)50M4.6x≥3x72.01.950 CPU-ms328.9 14.3c347.1 13.5c1,356 2.8c1,314 2.8c1,423 1.0c
max by int32 key (10000000 groups)50M4.5x≥3x72.12.009 CPU-ms334.0 14.2c326.5 14.2c1,346 2.8c1,346 2.8c1,402 1.0c
dictionary_encode (int32)50M4.4x≥3x19.17.095 CPU-ms——342.0 1.0c—84.2 1.0c
count_distinct by key (100000 groups)10M4.3x≥3x7.2772.330 CPU-ms31.6 13.0c31.0 13.3c363.8 2.1c375.6 2.4c869.5 1.0c
pad_left (width 20)10M4.2x≥3x3.1471.307 CPU-ms173.7 1.0c13.2 14.2c181.8 1.0c17.2 12.5c182.3 1.0c
sort float6450M4.1x≥3x32.11.253 CPU-ms132.9 9.7c133.1 9.7c6,044 1.0c7,498 1.0c1,759 numpy 1.0c
count by int32 key (1000 groups)50M4.1x≥3x4.2291.232 CPU-ms89.0 9.1c89.6 9.1c17.5 13.5c17.4 13.5c138.5 1.0c
is_dst (America/New_York)10M4.1x≥3x3.0830.418 CPU-ms388.0 1.0c29.5 15.1c110.1 1.0c12.6 10.7c—
variance by key (10000000 groups)50M4.1x≥3x79.43.063 CPU-ms323.0 14.4c322.9 14.7c2,444 4.8c2,438 4.8c1,368 1.0c
sum by utf8 key (10000000 distinct)10M4.1x≥3x27.55.817 CPU-ms111.9 11.9c112.7 11.8c572.0 1.8c581.4 2.0c801.9 1.0c
match_substring_regex (literal)10M4.0x≥3x2.8580.414 CPU-ms144.8 1.0c11.6 14.9c256.6 1.0c24.6 12.1c248.4 1.0c
sum by two int32 keys (~1024 groups)50M4.0x≥3x5.9132.115 CPU-ms164.2 13.5c177.1 12.7c23.9 13.5c24.0 13.5c448.4 1.0c
first by key (1000 groups)10M4.0x≥3x3.8212.240 CPU-ms15.2 10.6c15.1 10.8c42.4 1.0c—39.4 1.0c
char_length10M3.8x≥3x1.0960.387 CPU-ms52.6 1.0c4.217 14.8c52.3 1.0c5.447 11.6c52.5 1.0c
sort float6410M3.8x≥3x6.8541.168 CPU-ms26.2 9.6c26.3 9.5c1,006 1.0c1,159 1.0c303.1 numpy 1.0c
mean by int32 key (1000 groups)50M3.8x≥3x4.9141.240 CPU-ms81.5 14.5c82.1 14.4c18.7 13.6c18.6 13.5c264.6 1.0c
sum by int32 key (1000 groups)50M3.8x≥3x4.8881.227 CPU-ms82.4 14.5c81.9 14.5c18.5 13.5c18.5 13.6c247.9 1.0c
count_distinct by key (1000 groups)50M3.8x≥3x41.82.640 CPU-ms307.9 13.0c156.8 14.0c1,886 2.2c1,859 2.3c4,773 1.0c
match_substring_regex (literal)1M3.7x≥3x0.5130.354 CPU-ms14.5 1.0c1.921 9.3c25.2 1.0c2.726 11.3c25.4 1.0c
count_distinct by key (1000 groups)10M3.7x≥3x7.1272.332 CPU-ms26.6 13.1c26.7 13.1c327.0 2.0c323.1 2.1c854.6 1.0c
hash join indices (10000000 x 200000 on int64)the join kernel alone (am_join): the matching index pairs, no payload gather10M3.7x≥3x2.8360.788 CPU-ms22.0 9.5c22.6 9.2c10.4 14.1c10.4 14.3c102.9 1.0c
local_timestamp (America/New_York)10M3.6x≥3x3.2780.686 CPU-ms493.8 1.0c37.2 15.1c100.9 1.0c12.0 10.7c224.5 1.0c
product(int64)50M3.6x≥3x1.3630.428 CPU-ms48.5 1.0c48.4 1.0c50.6 1.0c4.960 13.8c50.8 1.0c
variance by key (10000000 groups)10M3.6x≥3x20.42.834 CPU-ms73.4 13.9c78.8 13.5c460.1 2.4c462.4 2.4c347.6 1.0c
shift_left (int64 << 2)50M3.6x≥3x1.8940.393 CPU-ms——53.6 1.0c6.812 11.8c6.729 numpy 1.0c
stddev by key (10000000 groups)50M3.5x≥3x93.215.7 CPU-ms328.9 14.1c326.8 14.6c2,443 4.8c2,453 4.8c1,370 1.0c
is_dst (America/New_York)50M3.5x≥3x14.20.443 CPU-ms1,938 1.0c144.1 15.4c544.6 1.0c49.4 13.7c—
hash join indices (50000000 x 1000000 on int64)the join kernel alone (am_join): the matching index pairs, no payload gather50M3.5x≥3x18.30.805 CPU-ms107.1 9.7c110.4 9.6c63.3 15.1c64.7 15.0c589.3 0.9c
count_distinct by key (100000 groups)50M3.4x≥3x42.32.672 CPU-ms161.8 14.6c142.5 15.3c2,028 2.4c2,029 2.4c4,674 1.0c
tdigest(float64, q=0.5)10M3.3x≥3x7.0501.703 CPU-ms——195.2 1.0c23.6 9.9c—
struct_field10M3.3x≥3x0.0010.003 CPU-ms0.013 1.1c0.210 3.9c0.003 1.3c0.590 10.7c0.014 1.1c
min by int32 key (1000 groups)50M3.3x≥3x5.9151.545 CPU-ms82.5 14.2c80.6 14.8c19.7 13.8c19.6 13.9c270.6 1.0c
max by int32 key (1000 groups)50M3.3x≥3x5.9111.593 CPU-ms83.4 14.4c83.2 14.4c19.8 13.6c19.5 13.7c273.5 1.0c
list_flatten10M3.1x≥3x0.0010.002 CPU-ms19.8 1.0c19.3 1.0c0.002 0.9c—15.6 1.0c
decimal sum10M3.1x≥3x0.6050.374 CPU-ms18.4 1.0c2.340 10.0c19.8 1.0c1.880 12.7c19.7 1.0c
list by key (10000000 groups)10M3.1x≥3x60.04.482 CPU-ms186.0 4.6c190.6 4.7c306.3 1.9c317.8 1.9c49,627 1.0c
product(int64)10M3.1x≥3x0.4300.370 CPU-ms9.388 1.0c8.954 1.0c9.878 1.0c1.327 10.6c9.750 1.0c
mode(int64)50M3.1x≥3x33.68.560 CPU-ms142.8 13.4c132.2 14.6c103.7 1.0c—342.3 1.0c
left outer hash join (10000000 x 200000 on int64)am_join join_type 1: index pairs with a null right index for an unmatched left row10M3.1x≥3x3.0451.026 CPU-ms57.9 5.2c57.7 5.2c9.390 14.0c9.517 14.0c252.8 1.0c
left outer hash join (50000000 x 1000000 on int64)am_join join_type 1: index pairs with a null right index for an unmatched left row50M3.0x≥3x19.11.331 CPU-ms285.0 5.0c286.3 4.9c58.0 14.8c58.1 14.8c1,690 1.0c
all(bool, 10% nulls)host short-circuit: any/all stop at the first set bit; no GPU dispatch, so this is a CPU scan and the ratio is data-dependent10M19.2x≥3x0.0020.002 CPU-ms0.105 1.0c0.114 1.0c0.029 1.0c0.361 9.7c0.033 1.0c
all(bool, 10% nulls)host short-circuit: any/all stop at the first set bit; no GPU dispatch, so this is a CPU scan and the ratio is data-dependent50M106.4x≥3x0.0010.003 CPU-ms0.773 1.0c0.591 1.0c0.149 1.0c1.436 14.1c0.154 1.0c
any(bool, 10% nulls)host short-circuit: any/all stop at the first set bit; no GPU dispatch, so this is a CPU scan and the ratio is data-dependent10M23.8x≥3x0.0010.003 CPU-ms0.098 1.0c0.108 1.0c0.029 1.0c0.360 9.7c0.033 1.0c
any(bool, 10% nulls)host short-circuit: any/all stop at the first set bit; no GPU dispatch, so this is a CPU scan and the ratio is data-dependent50M106.4x≥3x0.0010.002 CPU-ms0.544 1.0c0.555 1.0c0.149 1.0c1.414 13.9c0.154 1.0c
inner hash join (50000000 x 1000000 on int64)am.join index pairs, then one take per output column50M2.9x1–3x21.81.567 CPU-ms105.8 10.0c106.6 10.0c63.9 15.0c64.7 14.9c542.9 1.0c
count by int32 key (1000 groups)10M2.9x1–3x1.5161.026 CPU-ms21.8 7.5c21.2 7.8c4.562 9.2c4.425 10.9c27.5 1.0c
lower10M2.9x1–3x5.3501.344 CPU-ms373.1 1.0c29.3 14.5c172.2 1.0c15.6 12.7c172.0 1.0c
upperre-measured in place after the full run flagged it (same build, quieter machine)10M2.9x1–3x5.3231.378 CPU-ms295.6 1.0c25.1 14.2c168.2 1.0c15.3 12.9c168.6 1.0c
min by key, float64 (1000 groups)10M2.8x1–3x1.7441.407 CPU-ms19.8 12.0c20.2 12.0c5.055 9.2c4.863 11.0c49.3 1.0c
exp (float32)50M2.7x1–3x1.9270.450 CPU-ms63.3 1.0c5.294 13.4c55.9 1.0c6.422 12.6c61.9 numpy 1.0c
contains("north")1M2.7x1–3x0.5090.349 CPU-ms14.4 1.0c1.923 9.2c12.3 1.0c1.394 11.0c11.6 1.0c
local_timestamp (America/New_York)50M2.7x1–3x15.51.839 CPU-ms2,461 1.0c183.6 15.5c508.1 1.0c42.5 15.3c1,122 1.0c
char_length1M2.7x1–3x0.2290.273 CPU-ms5.089 1.0c0.802 8.3c5.202 1.0c0.621 10.2c5.277 1.0c
decimal multiply (by scalar)50M2.7x1–3x3.5880.405 CPU-ms458.6 1.0c35.5 15.1c74.1 1.0c9.638 12.4c75.5 1.0c
inner hash join (10000000 x 200000 on int64)am.join index pairs, then one take per output column10M2.7x1–3x3.8831.536 CPU-ms22.1 9.5c22.5 9.4c10.4 14.1c10.6 14.1c96.2 1.0c
sum by int32 key (1000 groups)10M2.7x1–3x1.7341.034 CPU-ms20.3 11.4c20.2 12.0c4.803 9.2c4.613 11.0c45.5 1.0c
top_k (k=100, int64)10M2.6x1–3x1.8341.328 CPU-ms11.7 1.0c11.9 1.0c4.845 1.0c—45.2 numpy 1.0c
stddev by key (100000 groups)50M2.5x1–3x38.12.896 CPU-ms123.6 14.6c95.8 14.6c235.0 11.7c167.1 11.6c424.5 1.0c
variance by key (100000 groups)50M2.5x1–3x37.72.470 CPU-ms98.1 14.8c94.9 14.7c239.9 11.8c161.6 11.5c421.5 1.0c
sum by two int32 keys (~1024 groups)10M2.5x1–3x2.1901.769 CPU-ms34.0 11.3c35.1 11.3c5.652 9.2c5.497 11.0c84.1 1.0c
slice_codeunits [5:10]10M2.5x1–3x2.7901.213 CPU-ms86.7 1.0c6.964 14.4c70.5 1.0c7.441 11.6c70.4 1.0c
split_pattern("_")10M2.4x1–3x8.4672.563 CPU-ms263.3 1.0c25.4 12.9c224.2 1.0c20.7 13.0c223.1 1.0c
strftime (%Y-%m-%d)10M2.4x1–3x30.81.390 CPU-ms941.1 1.0c75.0 14.9c2,158 1.0c174.8 15.0c935.6 1.0c
variance by key (100000 groups)10M2.4x1–3x9.4662.186 CPU-ms23.0 12.2c23.5 12.2c39.4 6.6c46.5 7.8c84.3 1.0c
tdigest(float64, q=0.5)50M2.4x1–3x33.22.025 CPU-ms——1,061 1.0c80.0 15.3c—
strftime (%Y-%m-%d)50M2.4x1–3x151.12.095 CPU-ms4,709 1.0c361.5 15.5c10,645 1.0c839.0 15.4c4,629 1.0c
dictionary_encode (int32)10M2.4x1–3x7.0825.724 CPU-ms——71.2 1.0c—16.9 1.0c
stddev by key (100000 groups)10M2.4x1–3x9.6562.390 CPU-ms23.7 12.1c23.0 12.4c39.1 6.7c46.8 7.6c84.8 1.0c
sum by utf8 key (1000 distinct)50M2.2x1–3x18.23.460 CPU-ms144.0 13.4c142.4 13.7c40.5 13.9c40.1 14.1c554.5 1.0c
is_alpha10M2.2x1–3x2.0860.542 CPU-ms85.6 1.0c7.267 14.1c46.2 1.0c4.580 11.8c47.7 1.0c
kurtosis(float64)10M2.2x1–3x2.5160.784 CPU-ms10.7 1.0c24.5 1.0c48.2 1.0c5.502 11.5c17.6 1.0c
variance(float64, ddof=1)50M2.2x1–3x5.4460.788 CPU-ms52.6 1.0c52.0 1.0c127.9 1.0c11.9 13.3c127.9 1.0c
stddev(float64, ddof=1)50M2.2x1–3x5.4910.826 CPU-ms52.7 1.0c52.2 1.0c128.0 1.0c11.9 13.2c128.1 1.0c
indices_nonzero (bool)10M2.1x1–3x1.5900.784 CPU-ms24.0 1.0c24.0 1.0c22.6 1.0c—3.402 numpy 1.0c
trim (whitespace)10M2.1x1–3x5.0661.319 CPU-ms144.2 1.0c10.8 14.8c139.0 1.0c13.6 12.0c139.0 1.0c
kurtosis(float64)50M2.1x1–3x10.90.846 CPU-ms58.7 1.0c132.5 1.0c258.7 1.0c22.9 13.7c92.4 1.0c
mean by int32 key (1000 groups)10M2.1x1–3x2.2071.171 CPU-ms20.3 11.4c20.4 11.5c4.808 9.2c4.633 11.0c49.7 1.0c
sum(int64, 10% nulls)50M2.1x1–3x1.1110.405 CPU-ms15.9 1.0c2.304 11.1c52.1 1.0c5.091 13.0c51.5 1.0c
stddev by key (10000000 groups)10M2.1x1–3x37.018.8 CPU-ms76.6 13.5c79.8 13.8c460.6 2.4c463.7 2.3c351.8 1.0c
decimal add (128-bit)50M2.0x1–3x5.4180.407 CPU-ms275.7 1.0c21.6 14.1c74.1 1.0c11.0 12.8c74.4 1.0c
fill_null (int64)50M2.0x1–3x2.1070.421 CPU-ms24.3 1.0c4.165 12.9c41.8 1.0c6.439 11.9c42.0 1.0c
count_distinct(int64)50M2.0x1–3x9.6072.322 CPU-ms251.1 2.4c251.9 2.4c200.4 1.0c18.9 13.1c204.5 1.0c
multiply scalar (int64)50M2.0x1–3x2.1020.400 CPU-ms24.8 1.0c4.132 12.6c13.0 1.0c4.264 11.5c54.9 1.0c
mode(int64)10M2.0x1–3x10.0007.463 CPU-ms28.7 13.4c32.2 11.7c19.5 1.0c—66.8 1.0c
add_checked (int64 + int64)50M1.9x1–3x4.4970.642 CPU-ms——75.7 1.0c8.631 12.1c—
sum(int64, 10% nulls)10M1.9x1–3x0.3030.337 CPU-ms3.035 1.0c0.575 10.5c9.342 1.0c1.294 10.7c9.155 1.0c
max(int64, 10% nulls)50M1.9x1–3x1.1720.497 CPU-ms13.7 1.0c2.218 10.1c50.5 1.0c5.083 13.4c50.6 1.0c
week10M1.9x1–3x5.0280.707 CPU-ms111.6 1.0c9.496 14.4c126.1 1.0c13.9 10.7c284.6 1.0c
week50M1.9x1–3x24.21.841 CPU-ms557.3 1.0c45.0 15.3c587.4 1.0c56.0 13.4c1,391 1.0c
list_value_length10M1.8x1–3x0.3610.372 CPU-ms5.468 1.0c0.733 10.2c0.730 1.0c0.659 9.6c0.788 1.0c
multiply scalar (int64)re-measured in place after the full run flagged it (same build, quieter machine)10M1.8x1–3x0.4820.356 CPU-ms4.589 1.0c0.875 11.8c2.616 1.0c1.049 9.4c10.3 1.0c
match_like("cust%") [pure prefix]a pure prefix pattern, which Regex.likePredicate maps onto the GPU starts_with kernel10M1.8x1–3x1.1490.422 CPU-ms23.0 1.0c2.065 12.8c18.9 1.0c2.115 11.1c185.8 1.0c
coalesce (2 int64 columns)50M1.8x1–3x3.3150.413 CPU-ms55.3 1.0c5.887 14.0c74.5 1.0c8.480 12.5c146.5 1.0c
decimal add (128-bit)10M1.8x1–3x1.4360.394 CPU-ms55.3 1.0c4.356 13.7c15.0 1.0c2.538 10.1c15.0 1.0c
add_checked (int64 + int64)10M1.7x1–3x1.1670.504 CPU-ms——15.2 1.0c2.037 10.1c—
concat (a + '-' + b)1M1.7x1–3x1.2461.157 CPU-ms30.4 1.0c2.611 11.0c20.1 1.0c2.138 10.9c37.1 1.0c
min(int64, 10% nulls)re-measured in place after the full run flagged it (same build, quieter machine)50M1.7x1–3x1.1830.554 CPU-ms13.4 1.0c2.015 10.1c47.2 1.0c5.054 13.3c47.7 1.0c
count_distinct(int64)10M1.7x1–3x2.8722.054 CPU-ms48.9 2.6c49.1 2.5c37.1 1.0c4.830 10.4c38.7 1.0c
match_like("cust_1%") [_ wildcard]_ is LIKE's single-character wildcard, so this is not a pure prefix and takes the ICU host path1M1.7x1–3x0.9460.419 CPU-ms10.1 1.0c1.582 8.4c18.3 1.0c2.024 11.1c20.5 1.0c
index_in (int64, 100-value set)10M1.7x1–3x4.0312.629 CPU-ms——62.5 1.0c6.696 11.7c32.9 numpy 1.0c
sum by utf8 key (1000 distinct)10M1.7x1–3x5.6613.371 CPU-ms30.9 10.7c32.7 12.2c9.396 9.3c9.480 10.7c104.5 1.0c
is_in (int64, 100-value set)re-measured in place after the full run flagged it (same build, quieter machine)10M1.7x1–3x2.2081.620 CPU-ms43.7 1.0c3.646 12.3c74.3 1.0c8.145 11.9c78.5 1.0c
skew(float64)50M1.6x1–3x10.80.796 CPU-ms50.2 1.0c127.7 1.0c193.1 1.0c17.6 13.2c95.9 1.0c
skew(float64)10M1.6x1–3x2.4820.771 CPU-ms9.476 1.0c24.5 1.0c36.0 1.0c4.039 11.7c18.4 1.0c
multiply array (int64 * int64)10M1.6x1–3x0.7130.419 CPU-ms5.214 1.0c1.137 11.3c3.102 1.0c1.211 10.3c28.3 1.0c
add array (int64 + int64)10M1.6x1–3x0.6840.406 CPU-ms5.133 1.0c1.085 11.4c2.904 1.0c1.228 10.8c28.3 1.0c
is_nan (float64)50M1.6x1–3x1.2100.379 CPU-ms4.872 1.0c1.916 13.0c9.664 1.0c3.497 11.0c4.938 numpy 1.0c
starts_with("cust_1")10M1.6x1–3x1.8120.406 CPU-ms35.3 1.0c2.841 14.6c31.0 1.0c3.392 11.1c31.0 1.0c
add_duration (+1h)50M1.6x1–3x2.0890.383 CPU-ms25.3 1.0c3.260 12.7c13.2 1.0c4.367 12.0c182.7 1.0c
decimal compare (> scalar)50M1.6x1–3x2.1970.405 CPU-ms14.9 1.0c3.411 14.0c41.9 1.0c5.506 12.8c42.1 1.0c
add scalar (int64)50M1.5x1–3x1.9470.414 CPU-ms23.3 1.0c2.997 12.1c13.3 1.0c4.444 11.3c51.9 1.0c
round (float64)50M1.5x1–3x3.1790.406 CPU-ms51.1 1.0c4.725 9.6c112.1 1.0c11.5 12.3c110.1 1.0c
is_in (utf8, 100-value set)10M1.5x1–3x3.8152.000 CPU-ms96.0 1.0c5.663 13.6c104.8 1.0c10.6 11.7c109.4 1.0c
add_duration (+1h)10M1.5x1–3x0.5410.373 CPU-ms4.710 1.0c0.800 9.8c2.663 1.0c1.005 9.8c37.6 1.0c
pad_left (width 20)1M1.5x1–3x1.2981.070 CPU-ms16.9 1.0c2.051 9.6c18.3 1.0c1.895 11.6c18.4 1.0c
bit_wise_and (int64)50M1.5x1–3x2.9660.626 CPU-ms28.0 1.0c4.321 13.3c86.6 1.0c9.372 12.5c10.1 numpy 1.0c
multiply array (int64 * int64)50M1.5x1–3x2.9640.619 CPU-ms27.8 1.0c4.318 13.8c15.1 1.0c5.909 11.4c147.5 1.0c
variance(float64, ddof=1)10M1.4x1–3x1.9310.797 CPU-ms9.603 1.0c9.816 1.0c23.7 1.0c2.797 11.4c23.8 1.0c
negate (int64)50M1.4x1–3x2.1440.403 CPU-ms23.4 1.0c3.095 13.0c13.3 1.0c3.975 10.8c52.5 1.0c
hash64 (int64)50M1.4x1–3x3.2000.418 CPU-ms55.4 1.0c4.537 13.2c——79.8 1.0c
if_else (bool ? int64 : int64)50M1.4x1–3x3.7450.958 CPU-ms27.2 1.0c5.307 13.6c122.7 1.0c12.7 12.5c14.6 numpy 1.0c
add scalar (int64)10M1.4x1–3x0.5470.373 CPU-ms4.352 1.0c0.775 10.1c2.703 1.0c1.013 10.5c10.0 1.0c
stddev(float64, ddof=1)10M1.4x1–3x1.9230.778 CPU-ms9.767 1.0c9.531 1.0c23.4 1.0c2.704 11.8c23.9 1.0c
max by int32 key (1000 groups)10M1.4x1–3x3.5251.460 CPU-ms19.9 11.9c20.7 11.7c5.087 9.2c4.942 10.9c53.1 1.0c
add array (int64 + int64)50M1.4x1–3x3.1230.614 CPU-ms27.0 1.0c4.313 12.8c15.2 1.0c5.643 12.0c147.5 1.0c
min_max(int64)50M1.3x1–3x3.7651.598 CPU-ms27.4 1.0c14.1 2.7c50.6 1.0c5.074 13.3c101.4 1.0c
decimal multiply (by scalar)10M1.3x1–3x1.6040.399 CPU-ms91.0 1.0c7.410 14.4c14.9 1.0c2.161 10.4c15.2 1.0c
min by int32 key (1000 groups)10M1.3x1–3x3.7081.642 CPU-ms20.3 11.9c20.5 11.4c5.060 9.2c4.989 10.9c52.4 1.0c
compare scalar (int64 > 0)10M1.3x1–3x0.4710.381 CPU-ms0.875 1.0c0.610 8.0c1.200 1.0c0.843 9.7c1.283 1.0c
max by key, float64 (1000 groups)10M1.3x1–3x3.7651.626 CPU-ms19.6 11.9c20.1 11.7c4.998 9.2c4.834 11.0c51.0 1.0c
bit_wise_and (int64)10M1.3x1–3x0.8560.432 CPU-ms5.053 1.0c1.098 11.2c16.5 1.0c2.137 10.3c1.856 numpy 1.0c
filter int64 (30% kept)re-measured in place after the full run flagged it (same build, quieter machine)50M1.3x1–3x2.2290.673 CPU-ms17.5 1.0c2.845 12.6c122.7 1.0c22.6 13.2c157.0 1.0c
starts_with("cust_1")1M1.3x1–3x0.3410.352 CPU-ms3.553 1.0c0.591 7.7c3.096 1.0c0.429 9.4c3.122 1.0c
stddev by key (1000 groups)50M1.2x1–3x30.62.110 CPU-ms120.6 14.6c82.7 14.5c60.8 10.5c37.7 12.3c242.8 1.0c
shift_left (int64 << 2)10M1.2x1–3x1.0010.442 CPU-ms——9.986 1.0c1.553 10.4c1.212 numpy 1.0c
replace_substring("cust" -> "cx")1M1.2x1–3x1.3331.079 CPU-ms29.9 1.0c3.294 10.6c14.3 1.0c1.578 11.2c14.2 1.0c
variance by key (1000 groups)10M1.2x1–3x5.3121.863 CPU-ms19.7 12.0c19.6 11.9c6.331 9.2c6.119 11.0c52.0 1.0c
list_element (index 1)10M1.2x1–3x2.3861.367 CPU-ms27.8 1.0c2.745 13.5c70.6 1.0c7.446 11.8c70.6 1.0c
stddev by key (1000 groups)10M1.1x1–3x5.3671.875 CPU-ms19.6 12.2c19.6 12.0c6.346 9.3c6.170 10.9c48.6 1.0c
divide (float64 / float64)50M1.1x1–3x3.8310.611 CPU-ms27.8 1.0c4.383 13.2c88.6 1.0c9.159 12.5c247.0 1.0c
match_like("cust%") [pure prefix]a pure prefix pattern, which Regex.likePredicate maps onto the GPU starts_with kernel1M1.1x1–3x0.2670.313 CPU-ms2.249 1.0c0.450 6.7c1.890 1.0c0.303 8.7c18.6 1.0c
fill_null (int64)10M1.1x1–3x0.9410.381 CPU-ms4.700 1.0c1.048 9.9c8.067 1.0c1.481 10.1c8.176 1.0c
hash64 (int64)10M1.1x1–3x0.9550.370 CPU-ms11.3 1.0c1.056 12.0c——15.9 1.0c
compare array (int64 > int64)50M1.1x1–3x2.9120.641 CPU-ms6.833 1.0c3.167 13.8c8.588 1.0c4.168 12.0c80.0 1.0c
is_nan (float64)10M1.1x1–3x0.4610.362 CPU-ms0.896 1.0c0.491 9.6c1.758 1.0c0.791 9.3c0.850 numpy 1.0c
abs (float64)50M1.1x1–3x3.1370.406 CPU-ms23.4 1.0c3.314 11.4c7.109 1.0c3.850 11.0c51.8 1.0c
exp (float32)10M0.98xto improve1.1530.432 CPU-ms12.8 1.0c1.133 12.4c11.2 1.0c1.510 10.8c12.5 numpy 1.0c
variance by key (1000 groups)50M0.96xto improve30.41.923 CPU-ms83.4 14.4c84.9 14.1c29.2 12.7c33.7 12.5c239.4 1.0c
power (float32 ** 2)power() is not implemented for float64; float32 column used50M0.95xto improve1.9070.437 CPU-ms11.8 1.0c1.821 11.4c272.4 1.0c23.9 13.7c34.6 1.0c
min(int64, 10% nulls)10M0.95xto improve0.5590.513 CPU-ms2.461 1.0c0.533 10.4c9.295 1.0c1.256 11.0c9.830 1.0c
case_when (2 conditions)50M0.94xto improve6.9211.356 CPU-ms58.3 1.0c6.516 13.5c522.2 1.0c42.8 14.6c199.1 numpy 1.0c
month10M0.93xto improve5.5610.401 CPU-ms83.7 1.0c7.164 14.3c37.8 1.0c5.171 8.8c226.0 1.0c
drop_null (int64, 10% nulls)50M0.91xto improve4.1970.912 CPU-ms24.3 1.0c3.814 13.4c96.4 1.0c11.1 12.7c166.9 1.0c
decimal compare (> scalar)10M0.90xto improve0.9180.434 CPU-ms2.989 1.0c0.825 11.9c10.9 1.0c1.364 10.3c10.9 1.0c
filter int64 (90% kept)50M0.87xto improve4.2420.907 CPU-ms26.9 1.0c3.703 13.5c96.4 1.0c19.8 13.0c141.8 1.0c
sqrt (float64)50M0.87xto improve3.9240.384 CPU-ms27.3 1.0c3.423 12.7c12.5 1.0c3.736 11.6c12.4 numpy 1.0c
decimal round (2 places)50M0.87xto improve37.30.404 CPU-ms438.0 1.0c32.4 14.9c1,080 1.0c89.9 14.7c1,105 1.0c
decimal round (2 places)10M0.87xto improve7.6370.385 CPU-ms86.8 1.0c6.612 14.8c216.8 1.0c21.0 12.5c223.4 1.0c
round (float64)10M0.86xto improve1.0060.418 CPU-ms9.354 1.0c0.862 10.4c22.1 1.0c2.796 10.4c21.6 1.0c
year10M0.85xto improve5.5470.414 CPU-ms81.2 1.0c6.916 14.3c37.6 1.0c4.695 10.1c225.5 1.0c
day10M0.84xto improve5.9860.389 CPU-ms84.0 1.0c7.156 14.4c41.9 1.0c5.058 10.2c225.7 1.0c
value_counts (int32, 1000 distinct)50M0.84xto improve17.06.922 CPU-ms115.1 1.0c115.6 1.0c309.2 1.0c14.3 13.7c58.4 1.0c
filter int64 (30% kept)10M0.84xto improve0.7780.582 CPU-ms3.408 1.0c0.651 9.3c23.9 1.0c5.327 11.3c30.6 1.0c
unique (int32, 1000 distinct)50M0.83xto improve15.76.056 CPU-ms102.3 4.6c103.4 4.5c236.4 1.0c13.0 13.6c63.8 1.0c
filter two columns + sum50M0.83xto improve3.5261.748 CPU-ms21.1 1.6c2.918 13.9c63.5 1.0c17.7 13.5c111.9 numpy 1.0c
min_max(int64)10M0.82xto improve1.5701.371 CPU-ms5.084 1.0c2.657 3.0c9.329 1.0c1.291 11.1c18.6 1.0c
negate (int64)10M0.81xto improve0.9560.404 CPU-ms4.473 1.0c0.771 9.7c2.535 1.0c0.883 9.6c10.0 1.0c
compare scalar (int64 > 0)50M0.79xto improve2.4150.411 CPU-ms4.691 1.0c1.909 11.8c6.003 1.0c3.529 11.5c6.156 1.0c
is_alpha1M0.78xto improve0.6940.375 CPU-ms8.423 1.0c1.379 7.9c4.694 1.0c0.542 10.2c4.891 1.0c
case_when (2 conditions)10M0.78xto improve2.0090.990 CPU-ms11.1 1.0c1.565 12.7c102.0 1.0c11.1 11.3c37.0 numpy 1.0c
filter(int64 > 0)1M0.75xto improve0.2260.264 CPU-ms0.260 1.0c0.170 3.7c2.163 1.0c0.341 8.8c3.945 1.0c
abs (float64)10M0.73xto improve1.0090.384 CPU-ms4.416 1.0c0.741 10.4c1.321 1.0c0.870 9.4c10.0 1.0c
divide (float64 / float64)10M0.73xto improve1.4650.475 CPU-ms5.113 1.0c1.065 11.5c16.5 1.0c2.203 10.4c46.7 1.0c
group-by after filter50M0.72xto improve6.7342.674 CPU-ms36.2 7.7c4.851 13.7c75.2 1.4c18.6 13.4c84.7 1.0c
split_pattern("_")1M0.72xto improve3.3581.817 CPU-ms26.7 1.0c3.507 9.2c22.4 1.0c2.403 11.3c22.5 1.0c
compare array (int64 > int64)10M0.71xto improve1.2340.474 CPU-ms1.282 1.0c0.874 11.7c1.739 1.0c1.000 10.3c15.0 1.0c
coalesce (2 int64 columns)10M0.69xto improve2.0060.379 CPU-ms10.3 1.0c1.391 11.5c14.1 1.0c1.890 11.2c27.4 1.0c
slice_codeunits [5:10]1M0.68xto improve1.2691.059 CPU-ms8.817 1.0c1.251 8.4c7.003 1.0c0.858 11.0c7.045 1.0c
year50M0.67xto improve26.80.408 CPU-ms406.5 1.0c32.7 15.2c177.4 1.0c17.9 13.7c1,096 1.0c
day50M0.67xto improve29.10.419 CPU-ms420.4 1.0c33.6 15.5c195.4 1.0c19.4 13.9c1,097 1.0c
month50M0.66xto improve26.90.411 CPU-ms418.7 1.0c33.5 15.3c176.9 1.0c17.8 13.9c1,095 1.0c
upper1M0.66xto improve2.5471.136 CPU-ms30.3 1.0c4.083 8.9c17.3 1.0c1.681 12.1c17.3 1.0c
lower1M0.65xto improve2.6351.178 CPU-ms36.9 1.0c4.029 10.9c17.1 1.0c1.707 11.7c17.1 1.0c
max(int64, 10% nulls)10M0.65xto improve0.8420.510 CPU-ms2.556 1.0c0.544 10.0c9.223 1.0c1.282 11.1c9.294 1.0c
if_else (bool ? int64 : int64)10M0.60xto improve2.1810.749 CPU-ms5.474 1.0c1.318 11.8c22.7 1.0c2.700 11.6c2.892 numpy 1.0c
sqrt (float64)re-measured in place after the full run flagged it (same build, quieter machine)10M0.60xto improve1.2920.390 CPU-ms4.820 1.0c0.775 10.7c2.223 1.0c0.822 10.3c2.220 numpy 1.0c
filter int64 (90% kept)10M0.59xto improve1.6440.650 CPU-ms5.216 1.0c0.971 10.2c19.4 1.0c4.508 11.3c27.8 1.0c
trim (whitespace)1M0.58xto improve2.4951.136 CPU-ms14.2 1.0c1.594 10.2c13.9 1.0c1.449 11.5c13.9 1.0c
group-by sum (1000 keys)1M0.58xto improve1.0780.973 CPU-ms2.489 9.2c2.151 10.5c3.483 1.0c0.624 8.4c2.744 1.0c
unique (int32, 1000 distinct)10M0.57xto improve5.9014.826 CPU-ms23.2 5.0c23.2 5.2c48.5 1.0c3.366 10.9c12.8 1.0c
drop_null (int64, 10% nulls)re-measured in place after the full run flagged it (same build, quieter machine)10M0.57xto improve1.5630.641 CPU-ms4.722 1.0c0.890 11.4c18.9 1.0c2.616 11.1c31.9 1.0c
to_strings (int64 -> utf8)10M0.57xto improve12.02.065 CPU-ms87.9 1.0c7.168 14.4c59.3 1.0c6.757 11.1c922.7 1.0c
value_counts (int32, 1000 distinct)10M0.55xto improve6.6705.258 CPU-ms24.9 1.0c24.7 1.0c63.9 1.0c3.701 10.8c11.9 1.0c
group-by sum (1000 keys)100,0000.51xto improve0.4990.612 CPU-ms0.800 7.8c0.785 7.4c0.413 1.0c0.255 4.8c0.346 1.0c
to_strings (int64 -> utf8)1M0.50xto improve1.5170.993 CPU-ms8.881 1.0c1.242 8.6c5.887 1.0c0.762 10.1c90.5 1.0c
group-by after filter10M0.49xto improve3.7542.634 CPU-ms8.325 7.7c1.857 10.7c17.9 1.2c4.437 11.5c16.7 1.0c
is_in (utf8, 100-value set)1M0.48xto improve2.2811.878 CPU-ms8.494 1.0c1.100 7.8c10.3 1.0c1.188 10.8c10.8 1.0c
power (float32 ** 2)power() is not implemented for float64; float32 column used10M0.44xto improve1.0690.390 CPU-ms2.270 1.0c0.471 9.5c52.3 1.0c5.809 11.2c6.511 1.0c
filter two columns + sum10M0.43xto improve1.9411.639 CPU-ms4.415 1.7c0.826 11.3c12.7 1.0c4.075 11.9c23.0 numpy 1.0c
slice (zero-copy view)10M0.40xto improve0.0010.002 CPU-ms0.000 5.0c—0.000 0.0c—0.034 1.0c
replace_with_mask (30% replaced)10M0.39xto improve2.5371.341 CPU-ms4.891 1.0c0.983 10.2c22.2 1.0c2.775 11.1c4.993 1.0c
sum(int64)1M0.39xto improve0.1990.236 CPU-ms0.077 1.0c0.130 6.9c0.078 1.0c0.113 3.8c0.081 1.0c
floor_temporal (day)10M0.39xto improve1.9170.685 CPU-ms7.796 1.0c0.740 10.7c17.7 1.0c2.249 10.5c8.603 1.0c
floor_temporal (day)50M0.38xto improve8.7391.873 CPU-ms41.1 1.0c3.349 11.9c87.5 1.0c9.710 12.5c44.8 1.0c
replace_with_mask (30% replaced)50M0.36xto improve10.51.779 CPU-ms27.2 1.0c3.776 13.4c120.3 1.0c12.4 12.5c24.6 1.0c
slice (zero-copy view)50M0.33xto improve0.0010.002 CPU-ms0.000 0.0c—0.001 2.0c—0.155 1.0c
parse (utf8 -> int64)1M0.22xto improve2.8400.438 CPU-ms4.125 1.0c0.637 7.8c5.400 1.0c0.636 10.0c43.1 1.0c
group-by sum (1000 keys)1,0000.20xto improve0.4010.513 CPU-ms0.555 6.2c0.655 5.7c0.092 1.2c0.159 2.5c0.080 1.0c
filter(int64 > 0)100,0000.17xto improve0.1620.211 CPU-ms0.028 1.0c0.095 2.7c0.181 1.0c0.142 4.7c0.376 1.0c
sin (float64)10M0.17xto improve30.20.396 CPU-ms59.0 1.0c5.061 13.8c57.2 1.0c6.344 11.1c57.0 numpy 1.0c
parse (utf8 -> int64)10M0.16xto improve19.60.759 CPU-ms40.8 1.0c3.152 14.6c52.3 1.0c5.456 11.2c439.4 1.0c
sin (float64)50M0.16xto improve150.00.445 CPU-ms311.8 1.0c23.3 15.1c299.4 1.0c25.6 13.7c301.3 numpy 1.0c
days_between10M0.15xto improve9.8941.936 CPU-ms12.3 1.0c1.464 12.4c10.4 1.0c1.724 9.1c90.0 1.0c
match_substring_regex (real regex)1M0.14xto improve16.6258.0 CPU-ms17.4 1.0c2.345 9.3c33.3 1.0c3.693 11.1c33.2 1.0c
days_between50M0.12xto improve46.32.054 CPU-ms63.4 1.0c5.746 13.8c51.3 1.0c6.665 12.7c451.0 1.0c
ln (float64)10M0.11xto improve16.50.410 CPU-ms21.2 1.0c1.815 13.0c20.0 1.0c2.521 10.5c17.9 numpy 1.0c
ln (float64)50M0.10xto improve81.30.512 CPU-ms108.0 1.0c8.461 14.2c99.6 1.0c9.846 13.3c86.9 numpy 1.0c
match_substring_regex (real regex)10M0.09xto improve167.12,542 CPU-ms174.3 1.0c14.6 14.3c337.0 1.0c29.3 13.7c331.4 1.0c
sum(int64)100,0000.05xto improve0.1260.167 CPU-ms0.007 1.0c0.153 7.5c0.009 1.1c0.099 2.4c0.010 1.1c
filter(int64 > 0)1,0000.02xto improve0.1540.192 CPU-ms0.003 1.1c0.090 2.3c0.008 1.1c0.109 3.2c0.037 1.0c
shift (lag 1, int64)10M0.01xto improve2.3740.774 CPU-ms0.038 1.9c0.030 2.2c——0.047 1.0c
shift (lag 1, int64)50M0.01xto improve3.5830.874 CPU-ms0.045 1.8c0.029 2.2c——0.152 1.0c
sum(int64)1,0000.00xto improve0.1120.148 CPU-ms0.000 5.0c0.011 1.0c0.002 1.5c0.097 2.4c0.003 1.5c
compare + filter + take [batched]same chain inside with am.batch()10M—n/a1.4450.451 CPU-ms—————
compare + filter + take [batched]same chain inside with am.batch()50M—n/a4.2550.412 CPU-ms—————
filter two columns + sum [batched]same chain inside with am.batch(); compare with the unbatched row10M—n/a1.2900.464 CPU-ms—————
filter two columns + sum [batched]same chain inside with am.batch(); compare with the unbatched row50M—n/a2.9720.487 CPU-ms—————
group-by after filter [batched]same chain inside with am.batch()10M—n/a2.0392.073 CPU-ms—————
group-by after filter [batched]same chain inside with am.batch()50M—n/a6.7452.325 CPU-ms—————
filter(int64 > 0) [batched]1,000—n/a0.1600.204 CPU-ms—————
filter(int64 > 0) [batched]100,000—n/a0.1600.204 CPU-ms—————
filter(int64 > 0) [batched]1M—n/a0.2330.276 CPU-ms—————
group-by sum (1000 keys) [batched]1,000—n/a0.4280.562 CPU-ms—————
group-by sum (1000 keys) [batched]100,000—n/a0.5270.658 CPU-ms—————
group-by sum (1000 keys) [batched]1M—n/a1.0900.987 CPU-ms—————
sum(int64) [batched]one-call batch; shows the fixed cost of the batch itself1,000—n/a0.1300.192 CPU-ms—————
sum(int64) [batched]one-call batch; shows the fixed cost of the batch itself100,000—n/a0.1450.204 CPU-ms—————
sum(int64) [batched]one-call batch; shows the fixed cost of the batch itself1M—n/a0.2140.266 CPU-ms—————
Integrations

pyarrow, numpy, Polars, DuckDB, pandas, Parquet; Swift, C, Rust, Go, TypeScript, R.

Each integration is documented from install to limits, and each has its own test file. Pick one from the list.

python/README.md

arrowmetal (Python)

ArrowMetal runs Apache Arrow compute on the Apple silicon GPU: Arrow arrays that live in Metal shared memory and GPU kernels that keep Arrow's semantics, reached from Python through a ctypes wrapper over libArrowMetalC.dylib whose input and output go through the Arrow C Data Interface, so it composes with pyarrow, Polars, pandas and DuckDB.

Apple silicon has one physical memory shared by the CPU and the GPU, so an Arrow buffer placed in a Metal shared buffer is at once a valid CPU Arrow buffer and a valid GPU buffer: a column is used where it already is, with no copy across a bus in either direction. While a kernel runs, the CPU is free for the rest of the application, and every table in the repository shows the CPU time of each call next to its wall time.

One row, the same in-process data in every column:

sum by int32 key (1000 groups), 50,000,000 rows wall ms CPU ms of that call
ArrowMetal 4.89 1.2
pyarrow Acero (Table.group_by, 16 threads) 18.45 250
Polars lazy (16 threads) 81.93 1,186
pandas 247.93 248

From Benchmarks/results/full_matrix_2026-09-07-parallel.csv, Apple M4 Max (16 CPU cores, 64 GB), best of up to five calls after one warm-up, release build; the 339-row matrix that row comes from, including the 77 rows where the CPU idiom is ahead, is in docs/BENCHMARKS_MATRIX.md.

pip install arrowmetal          # macOS 14 or later on Apple silicon; pyarrow is the only dependency
python -m arrowmetal.bench      # 30 seconds or less
python -m arrowmetal.bench --parquet data.parquet   # read, sum, filter, group-by on your own file

The second line generates 10,000,000 rows, runs sum, filter, sort and group-by sum through pyarrow (and Polars when it is installed) and through ArrowMetal, and checks every ArrowMetal answer against pyarrow's. It prints one table for your Mac, wall and CPU milliseconds per call, with a block ready to paste into a benchmark result issue or the #benchmarks channel of the Discord linked from the README; nothing is sent. The dataset (int64 values uniform in [-1,000,000, 1,000,000) with 10% nulls, standard-normal float64s, int32 keys uniform over 1,000 values) is drawn with pyarrow.compute from seeded SplitMix64 streams, so the command needs nothing beyond pip install arrowmetal.

python -m arrowmetal.bench --parquet data.parquet runs on your own file instead. It reads the file's integer, floating-point and string columns with pyarrow, Polars and ArrowMetal, then runs sum and filter > median on the largest numeric column and group-by sum keyed on the lowest-cardinality integer or string column, CPU against Metal, with the same protocol and every ArrowMetal answer checked against pyarrow's. The report, the Share it block and the prefilled issue link carry the file's row count, column count, row groups, size and codecs and the timings, never its path, column names or values. A file whose columns would take more than a quarter of physical memory is refused, with the limit printed (the bench holds the columns up to four times at once), and a file with no integer, floating-point or string column gets one line saying so.

import pyarrow as pa, arrowmetal as am
amount = am.MetalArray.from_arrow(pa.array([10.0, 20.0, 30.0, 40.0]))
region = am.MetalArray.from_arrow(pa.array([1, 2, 1, 2], pa.int32()))
print(am.group_by([region]).sum(amount).to_arrow())     # [40, 60]: GPU group-by sum, one row per region
print(amount.filter_where(">", 15).to_arrow())          # [20, 30, 40]: GPU filter, back as a pyarrow array

Repository, documentation and the other language bindings: https://github.com/singhpratech/ArrowMetal. Site: https://arrowmetal.org.

Install

Two ways: a wheel built with scripts/build_wheel.sh and installed with pip install python/dist/arrowmetal-0.5.0-*.whl (macOS 14 or later on Apple silicon; the wheel bundles libArrowMetalC.dylib and the Polars expression plugin libarrowmetal_polars.dylib, and pyarrow is its only dependency), or from this repository.

From source, the development path. Needs the Swift toolchain:

DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
  swift build -c release --product ArrowMetalC
# that builds .build/release/libArrowMetalC.dylib
pip install pyarrow
PYTHONPATH=python python -c "import arrowmetal as am; print(am.device_name())"

From a wheel, which carries both dylibs and needs no Swift or Rust toolchain at install time:

# the wheel build frontend, once
pip install build
# swift build, cargo build of the Polars plugin, then the wheel
scripts/build_wheel.sh
# pyarrow comes with it
pip install python/dist/arrowmetal-0.5.0-*.whl
# optional bridges
pip install "$(echo python/dist/arrowmetal-0.5.0-*.whl)[polars,duckdb,pandas]"
python -c "import arrowmetal as am; print(am.device_name())"

The wheel is arrowmetal-0.5.0-py3-none-macosx_14_0_arm64.whl; the build script prints its size. It is macOS arm64 only: it links Metal and holds an arm64 binary. Extras polars, duckdb, pandas and test pull in the libraries the corresponding bridges and the test suite want; none of them are needed to import arrowmetal.

The first thing to run needs nothing beyond the wheel itself (pyarrow comes with it):

import pyarrow as pa, arrowmetal as am

# this small array is copied in; large pyarrow buffers are page aligned
# and are borrowed; out is zero-copy
col = am.array(pa.array([1, None, 3, 40]))
big = col.filter_where(">", 2)                 # GPU
print(big.sum(), big.to_arrow())               # 43  [3, 40]

keys = am.array(pa.array([0, 1, 0, 2], pa.int32()))
print(keys.group_by(3).sum(col).to_arrow())    # [4, null, 40]

With the polars extra (or any installed Polars), a Series crosses through Arrow the same way:

import polars as pl
col = am.array(pl.Series([1, None, 3, 40]).to_arrow())
print(pl.from_arrow(col.filter_where(">", 2).to_arrow()))

Float64 columns (NumPy's and pandas' default) run entirely on the GPU: compare, filter, take, sum and arithmetic use a software IEEE-754 implementation that is bit-exact with the CPU. Use with am.batch(): around a chain of operations to pay one GPU round trip instead of one per call.

See Benchmarks/python_gpu_bench.py for a side-by-side with Polars, pyarrow.compute and pandas on the same data.

Building a wheel

pip install build                    # the wheel build frontend
scripts/build_wheel.sh               # swift build, then the wheel (cargo builds the Polars plugin)
# just the wheel, when .build/release/libArrowMetalC.dylib exists
python/build_wheel.sh
# install the wheel and Polars into a fresh virtualenv and run all four Polars tiers
scripts/check_wheel.sh

python/build_wheel.sh copies .build/release/libArrowMetalC.dylib into python/arrowmetal/_lib/, builds the Polars expression plugin with cargo build --release in polars-plugin/ against that same dylib and copies libarrowmetal_polars.dylib beside it, replaces the plugin's build-machine rpath with @loader_path (so it loads the packaged libArrowMetalC.dylib), re-signs both, runs python -m build --wheel in python/ with the platform tag macosx_14_0_arm64, checks that both dylibs really are inside the archive, and prints the wheel's path and size. scripts/build_wheel.sh is the same thing with swift build -c release --product ArrowMetalC in front. The version comes from one place, __version__ in arrowmetal/__init__.py; pyproject.toml reads it.

ARROWMETAL_DYLIB points the script at a dylib somewhere other than .build/release, ARROWMETAL_POLARS_PLUGIN_DYLIB at an already built plugin (then cargo is not needed), and PLAT_TAG overrides the platform tag.

scripts/check_wheel.sh [wheel] installs the wheel with its polars extra (Polars from PyPI) into a fresh virtualenv in a temporary directory, and from there, with a scrubbed environment (no PYTHONPATH, ARROWMETAL_* or DYLD_* variables, no cargo on PATH), runs a tier-1 group-by, tier-2 sum, filter_sum and device expressions, a tier-3 collect_gpu and a tier-4 MetalEngine collect against Polars' own answers, checks that the process loaded exactly one libArrowMetalC.dylib, the packaged one, and runs python -m arrowmetal.bench (10,000,000 generated rows) and python -m arrowmetal.bench --parquet on a 1,000,000-row file.

Where the dylib is found

arrowmetal looks for libArrowMetalC.dylib in this order:

  1. $ARROWMETAL_LIB, the full path to a dylib. It wins over everything else, so one specific build can be pinned; a bundled arrowmetal/_lib/ copy in the same checkout never shadows it. Setting it to a path that does not exist is an error, not a silent fall-back to some other dylib.
  2. Bundled in the installed package, arrowmetal/_lib/libArrowMetalC.dylib — what a wheel install has.
  3. The development build, .build/release/libArrowMetalC.dylib beside a source checkout (then .build/debug, then /usr/local/lib and /opt/homebrew/lib).

When none of the three exists the import fails with an OSError that names all three and how to satisfy each.

Tests

pip install pytest
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build -c release --product ArrowMetalC
PYTHONPATH=python python -m pytest python/tests -q

python/tests/test_arrowmetal.py checks import and round trip for int64/float64/float32/bool/string with nulls, reductions, compare/filter/filter_where, take, slice, cast, arithmetic (float64 included), boolean logic, group-by, the string kernels, with am.batch(): against the unbatched results, and Polars interop. Every result is compared to pyarrow.compute or plain Python. Polars tests skip when Polars is absent; the suite needs a real Metal device.

Index arrays — argsort, top_k, partition_nth_indices, lexsort_indices, am.join, rank and the window functions' row_number / rank / dense_rank — are pa.uint32(), row numbers up to 2^32 - 1 (a longer input raises). pyarrow returns uint64 for the same functions, with the same values. take accepts int32, int64 and uint32 arrays, NumPy arrays and lists (python/tests/test_index_type.py).

Not covered by the dense-key MetalArray.group_by(key_count) path: min/max on 64-bit values, mean on Float32, and Float64 values for any aggregate; python/tests pins those as expected errors. am.group_by(keys) runs every aggregate on Float64 values (python/tests/test_arrowmetal.py::test_group_by_arbitrary_keys_matches_pyarrow).

Polars

Four tiers, all in this repository — see docs/POLARS.md:

import polars as pl, arrowmetal as am

# -> dict[str, MetalArray], zero copy
am.from_polars(df)
# tier 1: the GPU around Polars
df.arrowmetal.group_by("k").sum("v")
# tier 2: inside a lazy plan (the wheel carries the plugin)
pl.col("v").arrowmetal.sum()
# tier 3: Polars runs the plan, the GPU finishes it
lf.arrowmetal.collect_gpu(query)

import arrowmetal still does not import Polars: the bridge loads on first use (or at import when Polars is already loaded). Tier 2 is a Rust plugin that a wheel built from this tree carries in arrowmetal/_lib/; from a source checkout it is one extra build, cd polars-plugin && cargo build --release. The plugin is built against the Polars 1.44 plugin ABI. The other tiers are pure Python over the Arrow C Data Interface. PYTHONPATH=python python -m pytest python/tests/test_polars.py -q runs the suite.

docs/NUMPY.md

numpy

How a numpy array reaches the GPU, what crosses without a copy and what does not, what the benchmark matrix measures against numpy, and what is not implemented. Every number here is from Benchmarks/results/full_matrix_2026-09-07.csv or from python/tests/test_numpy.py, which asserts each claim in the first two sections on every run. That CSV is the eager run, and the numpy rows quoted below are numpy's plain eager idiom; the published baseline is the parallel run in Benchmarks/results/full_matrix_2026-09-07-parallel.csv, which measures each CPU library's most parallel idiom too (numpy has none, so its numpy-parallel column is empty), and The full matrix and Where ArrowMetal needs to improve are written against it.

1. The crossing

There is no numpy bridge, because none is needed: pyarrow already speaks numpy, and ArrowMetal speaks Arrow.

import numpy as np, pyarrow as pa, arrowmetal as am

x = np.random.rand(50_000_000)          # float64, the numpy default
col = am.array(pa.array(x))             # no copy: pyarrow adopts the numpy buffer, Metal wraps it
r = col.sqrt()                          # GPU, software IEEE-754 binary64
y = r.to_arrow().to_numpy(zero_copy_only=True)   # no copy out either

Two things make this copy-free, and both are checked:

  • pa.array(x) adopts the numpy buffer for integer and float dtypes: the Arrow array's data buffer has the numpy array's address.
  • am.array(...) wraps a page-aligned buffer in a Metal buffer in place. macOS hands numpy page-aligned memory for every allocation we measured from 1,000 elements up to 50M; a buffer that is not page aligned (tiny arrays of ten or a hundred elements were not) is copied once on the way in, which costs nothing worth measuring at that size.

test_numpy.py proves the import is in place by writing to the numpy array after the import and reading the new value back through the Metal column, and proves the export is in place by comparing the address of to_numpy(zero_copy_only=True) with the Arrow buffer's.

2. What crosses and what it costs

numpy dtype copies in why
int8…int64, uint8…uint64, float32, float64 none pyarrow adopts the buffer; Metal wraps it
bool one Arrow packs booleans to one bit per value, so pyarrow has to write a new buffer
datetime64 none adopted as an Arrow timestamp
str_ (U), object strings one conversion become an Arrow string array; numpy's fixed-width UTF-32 layout is not Arrow's
a slice with a stride one Arrow has no strides; pyarrow copies to contiguous

Results come back the same way: any numeric result with no nulls is a numpy view over the Metal buffer through to_numpy(zero_copy_only=True); a result with nulls needs to_numpy(zero_copy_only=False), which materialises NaNs, or stays as Arrow.

NaN is a value, not a null. pa.array(np.array([1.0, np.nan])) has a null count of zero, and ArrowMetal treats it the way numpy does: sum and mean return NaN. That is numpy's semantics and Arrow's default; a pandas column takes the other path (from_pandas=True), where NaN becomes null and is skipped, and pandas covers it. If you want numpy's NaNs skipped on the GPU, pass from_pandas=True to pa.array and pay one pass to build the validity bitmap.

numpy has no nulls, so the GPU column has no validity bitmap and every kernel takes its no-null fast path.

3. Measured against numpy

The benchmark matrix (Benchmarks/full_matrix.py) compares each operation with pandas, and where pandas has no vectorised idiom of its own it measures numpy directly on the same values, without a validity bitmap because numpy has none. Those rows, at their largest measured size, from the 2026-09-07 run on an Apple M4 Max (numpy 2.5.3, the eager idiom, best of up to five calls after a warm-up; cores = CPU-ms / wall ms):

family operation rows numpy ms (cores) ArrowMetal ms (cores) speedup
sort lexsort (2 int32 keys) 50,000,000 28,585 (1.0) 37.4 (0.1) 763.9x
sort argsort float64 50,000,000 5,489 (1.0) 40.0 (0.0) 137.4x
sort top_k (k=100, int64) 50,000,000 328.5 (1.0) 2.685 (0.5) 122.4x
sort argsort int64 50,000,000 4,555 (1.0) 39.2 (0.0) 116.4x
sort top_k (k=10000, int64) 50,000,000 328.3 (1.0) 2.944 (0.6) 111.5x
chains compare + filter + take 50,000,000 206.7 (1.0) 4.569 (0.3) 45.2x
sort sort float64 50,000,000 1,762 (1.0) 49.6 (0.0) 35.5x
element-wise exp (float32) 50,000,000 61.9 (1.0) 1.912 (0.2) 32.4x
chains filter two columns + sum 50,000,000 113.6 (1.0) 3.545 (0.5) 32.0x
element-wise case_when (2 conditions) 50,000,000 199.5 (1.0) 6.896 (0.2) 28.9x
element-wise index_in (int64, 100-value set) 50,000,000 163.0 (1.0) 5.848 (0.6) 27.9x
sort partition_nth_indices (n/2) 50,000,000 341.6 (1.0) 19.8 (0.4) 17.2x
compare+select indices_nonzero (bool) 50,000,000 16.8 (1.0) 3.151 (0.3) 5.3x
element-wise if_else (bool ? int64 : int64) 50,000,000 14.7 (1.0) 3.699 (0.3) 4.0x
element-wise is_nan (float64) 50,000,000 4.926 (1.0) 1.320 (0.3) 3.7x
element-wise shift_left (int64 << 2) 50,000,000 6.817 (1.0) 1.915 (0.2) 3.6x
element-wise bit_wise_and (int64) 50,000,000 10.1 (1.0) 2.935 (0.2) 3.4x
element-wise sqrt (float64) 50,000,000 12.3 (1.0) 3.951 (0.1) 3.1x
element-wise sin (float64) 50,000,000 301.7 (1.0) 150.2 (0.0) 2.0x
element-wise ln (float64) 50,000,000 87.3 (1.0) 81.2 (0.0) 1.1x

Two readings. numpy runs every one of these on one core, so this is one CPU core against the GPU, with the crossing free because the memory is shared; a 16-thread CPU library is the other comparison, and it is on the same Compare tab. The transcendentals are the narrow rows: ln at 1.1x and sin at 2.0x are software binary64 on a GPU with no double hardware against a vectorised libm, and they are listed in Where ArrowMetal needs to improve as open work.

4. Limits

  • NEP 18 dispatch is not implemented: np.sum(x) on a Metal column runs numpy's kernel, not the GPU's.
  • Multi-dimensional arrays are not Arrow arrays; pass one column (x[:, i] is strided, so it copies once; np.ascontiguousarray first if you will reuse it).
  • bool costs one pass in each direction because of the bit packing.
  • Strings are converted, in and out.
  • A numpy array smaller than about a thousand elements is usually not page aligned and is copied on import; at that size the dispatch floor, not the copy, is the cost, and the GPU is not the right place for it anyway (see the latency family in the matrix).
docs/POLARS.md

ArrowMetal for Polars users

Polars is the reason most people on an Apple silicon Mac have Arrow-shaped data in memory at all. This document is how you point that data at the GPU.

There are four tiers, the first three in 0.1.0 and the engine (tier 4) in 0.2.0, and they differ in where the GPU sits relative to the Polars plan:

Tier Where the GPU runs What you write Needs
1. Bridge and namespaces Around Polars: you hand a collected frame over df.arrowmetal.group_by("k").sum("v") Python only
2. Expression plugin Inside a Polars lazy plan pl.col("v").arrowmetal.sum() Python only (the wheel carries the plugin)
3. Streaming hand-off Polars runs the plan, ArrowMetal finishes it lf.arrowmetal.collect_gpu(q) Python only
4. MetalEngine In place of whole subtrees of the optimised Polars plan lf.collect(engine=am.MetalEngine()) Python only

All four move data over the Arrow C Data Interface. For a single-chunk numeric Polars column that is no copy at all -- the GPU reads the buffer Polars already owns. A String column crosses the same way in tiers 1, 3 and 4: in Polars' own Utf8View layout, which the string kernels read directly (tier 2's plugin still asks Polars for large_string). Categoricals and multi-chunk Series each cost one conversion pass -- see Limits. The evidence is below.


Install

From the wheel

scripts/build_wheel.sh                                   # swift build, cargo build, then the wheel
pip install python/dist/arrowmetal-*.whl polars

A wheel built from this tree carries both native libraries in arrowmetal/_lib/: libArrowMetalC.dylib (the GPU library) and libarrowmetal_polars.dylib (the tier-2 expression plugin, built by cargo during the wheel build, against that same libArrowMetalC.dylib). All four tiers work from that install, with no Xcode, no cargo and no DYLD_LIBRARY_PATH. The packaged plugin has one rpath, @loader_path, so its @rpath/libArrowMetalC.dylib resolves to the copy beside it, the same file Python loads. The 0.2.0 wheel on PyPI carries libArrowMetalC.dylib only; with it, tier 2 needs the cargo build below.

scripts/check_wheel.sh checks a built wheel: it installs the wheel with its polars extra into a fresh virtualenv outside the repository, with a scrubbed environment and no cargo on PATH, runs one expression or plan per tier, checks that the process loaded exactly one libArrowMetalC.dylib, the packaged one, and runs python -m arrowmetal.bench with and without --parquet. On an M4 Max (macOS 26.6.2, Homebrew Python 3.13.9) it passed with polars 1.44.2 and pyarrow 25.0.1 as pip resolved them, NumPy not installed: the packaged plugin, built against polars 0.55 crates, loads in py-polars 1.44.2. The plugin adds 5.0 MB to the compressed wheel (3.2 MB before, 8.2 MB after) and is 21 MB on disk after strip -x.

Which Polars

pip install 'arrowmetal[polars]' installs polars>=1.44,<2.1: polars 1.44.1, 1.44.2 and 2.0.0 are the releases the engine suites passed on. Per tier:

Tier Polars
1. Bridge and namespaces any polars>=1.0 (pure Python over the Arrow C Data Interface)
3. Streaming hand-off any polars>=1.0 (pure Python over the Arrow C Data Interface)
2. Expression plugin 1.44.x and 2.0.0: the plugin is built on the polars 0.55 crates; Polars checks a plugin's ABI (polars_ffi's major/minor) when it loads it, and the 1.44-built plugin loads on 2.0.0 (its 151 tests pass there) (Version pinning, below)
4. MetalEngine tested on 1.44.1, 1.44.2 and 2.0.0 (TESTED_POLARS). On 1.44.1 and 1.44.2 the full engine suite passes and the capability table is the same on both (1.44.2 also by scripts/check_wheel.sh); on 2.0.0 test_polars_engine.py, test_polars.py and test_polars_sort_order.py pass, with the profile tests and the capability-table comparison skipped (Polars engine capabilities is the 1.44.1 table). It walks Polars' unstable IR, checked against TESTED_IR_VERSION: (14, 7) for 1.44, (15, 2) for 2.0.0. python -m arrowmetal.polars_engine check reports the installed Polars against these

A Polars outside 1.44 installed without the extra keeps tiers 1 and 3, and on 2.0.0 tier 4.

From source

# 1. The GPU library (every tier needs it)
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
    swift build -c release --product ArrowMetalC

# 2. The Python package
pip install polars pyarrow
export PYTHONPATH=python          # or: pip install ./python

# 3. Tier 2 only: the Polars expression plugin
cd polars-plugin && cargo build --release && cd ..

That is the whole build, and both halves are checked: on an M4 Max the Swift product and the cargo release build both go through from an empty target/, with no other flags and no DYLD_LIBRARY_PATH. cargo build --release is enough for the plugin -- it is a plain cdylib that Polars dlopens, not a Python extension module, so maturin is optional, and arrowmetal.polars_plugin.plugin_path() finds polars-plugin/target/release/ on its own.

plugin_path() looks, first hit wins, at $ARROWMETAL_POLARS_PLUGIN (the full path to a plugin dylib); the packaged arrowmetal/_lib/libarrowmetal_polars.dylib when Python loaded the packaged libArrowMetalC.dylib beside it, which is what a wheel install has; polars-plugin/target/release/ and target/debug/ in a source checkout; the packaged copy in any other case; and a maturin install on sys.path. When $ARROWMETAL_LIB pins a development build, a cargo build linked against it therefore wins over a packaged plugin left in _lib/ by a wheel build in the same checkout.

maturin develop --release is the untested path: polars-plugin/ has no pyproject.toml, and plugin_path() looks for a maturin install at <sys.path>/arrowmetal_polars/libarrowmetal_polars.dylib, which is not the layout maturin produces for a cdylib crate. Use cargo build --release, or set ARROWMETAL_POLARS_PLUGIN to whatever you did build.

The plugin's build.rs links libArrowMetalC.dylib by rpath. The dylib's install name is @rpath/libArrowMetalC.dylib, so polars-plugin/arrowmetal-sys/build.rs finds the directory holding it -- $ARROWMETAL_LIB_DIR, $ARROWMETAL_LIB, <repo>/.build/release, <repo>/.build/debug, /usr/local/lib, /opt/homebrew/lib, first hit wins -- and bakes it in as an LC_RPATH entry. No DYLD_LIBRARY_PATH is needed at run time. It republishes that directory to the plugin crate through its links = "ArrowMetalC" key (DEP_ARROWMETALC_LIB_DIR), because Cargo passes a build script's link arguments only to the crate that owns it.

Version pinning

polars-plugin/Cargo.toml pins polars 0.55.1 and pyo3-polars 0.28.0. Those are the Rust crates py-polars 1.44.x is built from: the polars workspace at tag py-1.44.1 carries version = "0.55.1", and pyo3-polars 0.28 is the release that depends on polars ^0.55.1. The requirement is a caret, so Cargo.lock currently resolves polars 0.55.2 and polars-ffi 0.55.2; the ABI check is on polars_ffi's major/minor, so that patch bump loads against py-polars 1.44.1 without complaint.

Polars checks the plugin ABI when it loads the library (_polars_plugin_get_version, which returns polars_ffi's major/minor packed into a u32) and refuses a mismatched pair with this Polars engine doesn't support plugin version: .... Moving to a different Polars means re-pinning both crates to whatever that release's workspace version is -- read it from https://raw.githubusercontent.com/pola-rs/polars/refs/tags/py-<version>/Cargo.toml, do not guess. Tiers 1 and 3 have no such constraint: they are pure Python over the C Data Interface.


Tier 1 -- the zero-copy bridge and the .arrowmetal namespaces

import polars as pl
import arrowmetal as am

Importing arrowmetal does not import Polars -- the package's only hard dependency is pyarrow. The Polars half arrives on first use. Any of these arms it:

import arrowmetal.polars_bridge          # explicit
am.from_polars(df)                       # first touch of a bridge function
import polars as pl; import arrowmetal   # Polars already loaded -> registered at import

Two functions

am.from_polars(series)      # -> MetalArray
am.from_polars(dataframe)   # -> dict[str, MetalArray]
am.to_polars(metal_array)   # -> pl.Series
am.to_polars({"a": ...})    # -> pl.DataFrame

Three namespaces

# Series
s.arrowmetal.sum() / .min() / .max() / .mean() / .count()
s.arrowmetal.top_k(100) / .bottom_k(100) / .sort(descending=True) / .arg_sort()
s.arrowmetal.filter(mask) / .unique() / .cum_sum() / .hash64()
s.arrowmetal.contains("x") / .starts_with("x") / .ends_with("x") / .upper() / .lower()
s.arrowmetal.to_metal()                     # keep the column on the GPU

# DataFrame
df.arrowmetal.group_by("k").sum("v")
df.arrowmetal.group_by("k", "region").agg(total=("v", "sum"), avg=("f", "mean"), n=(None, "len"))
df.arrowmetal.query(am.filter(am.col("k") == 2).sum(am.col("v")))
df.arrowmetal.sort(["k", "v"], descending=[False, True])
df.arrowmetal.top_k(10, by="v")
df.arrowmetal.filter(df["k"] > 8)
df.arrowmetal.join(other, on="k", how="inner")
df.arrowmetal.to_metal() / .device()

# LazyFrame
lf.arrowmetal.collect_gpu(query_or_callable)

Results come back as Polars objects. Scalar reductions come back as Python scalars, as pl.Series.sum() does -- with one difference, in both tier 1 and tier 2: the sum of an empty or all-null column is None, where pl.Series.sum() answers 0. am_reduce has nothing to add up and says so; min, max and mean are None on both sides.

Grouped aggregates available through .agg: sum, mean, min, max, count, len, n_unique, first, last, median, std, var, product, any, all, plus gb.quantile(column, q). One am_group_by_keys pass builds the dense group ids and every aggregate after that reuses it, so .agg(...) with six outputs costs one group-by.

What each tier-1 method runs

Method ArrowMetal entry point Note
sum / min / max / mean am_reduce integers widen to 64 bits, Arrow's rule
top_k / bottom_k am_top_k + am_take GPU radix sort
sort / arg_sort am_sort / am_argsort stable, nulls last, NaN after +inf
filter am_filter GPU stream compaction
unique am_unique first-seen order, nulls kept; Polars' plain unique() promises no order
hash64 am_hash64 (strings: am_str_unary kind 2) Arrow-equal values hash equal
contains / starts_with / ends_with am_str_match literal, not regex
upper / lower am_str_transform 2/3 simple 1:1 case mapping, see limits
cum_sum am_cumulative two-level GPU scan
df.group_by(...) am_group_by_keys + am_group_agg_ex any key type; several keys fold
df.sort(...) am_lexsort + am_take one argsort, one take per column
df.query(...) am_query the whole expression DAG as one generated kernel
df.join(...) am_index_in + am_take / am_filter see the join section

The join

The bridge does not call the C ABI's am_join hash join; df.arrowmetal.join is built out of two other kernels. am_index_in finds, for every left key, the row of the right key column it matches; am_take and am_filter then gather both sides. That is a complete inner or left join whenever the right key is unique -- the usual dimension-table shape -- and the whole thing, uniqueness check included (one am_group_by_keys: as many groups as rows means every key is distinct), runs on the GPU with no row-by-row work on the host.

Everything else falls back to pl.DataFrame.join: a duplicated right key (which changes the row count in a way index_in cannot express), a multi-column key, or an outer/semi/anti join. Pass allow_cpu_fallback=False to get an error instead, so a benchmark can be sure what it measured. Null keys never match, which is Polars' default join_nulls=False.

Column pruning in df.arrowmetal.query

query imports only the columns the query names -- it reads them out of the serialised query's (col "name") nodes. This matters more than it sounds: on the benchmark frame, importing all four columns (one of them 50M strings) cost more than the entire query did.


Tier 2 -- the expression plugin

import polars as pl
import arrowmetal.polars_plugin        # registers the namespace

lf.select(pl.col("amount").arrowmetal.sum())
lf.with_columns(pl.col("name").arrowmetal.upper())
lf.select(pl.col("amount").arrowmetal.filter_sum(pl.col("region") == 2))
lf.group_by("k").agg(pl.col("v").arrowmetal.sum())

These are real Polars expressions: they compose with select, with_columns, filter, group_by, over, and they take part in projection and predicate pushdown. That is the whole point of the tier -- tier 1 needs a materialised frame, tier 2 does not.

Expression Output Registered as
.sum() Int64 / UInt64 / Float64 returns_scalar
.min() / .max() the column's own dtype returns_scalar
.mean() Float64 returns_scalar
.filter_sum(predicate) as .sum() returns_scalar
.top_k(k) / .bottom_k(k) the column's own dtype changes_length
.hash64() UInt64 is_elementwise
.contains(p) / .starts_with(p) / .ends_with(p) Boolean is_elementwise
.upper() / .lower() String is_elementwise
.add(x) / .sub(x) / .mul(x) / .truediv(x) the column's own dtype is_elementwise
.group_by_sum(values) Struct{key, sum}, one row per group changes_length
.device() String, one row returns_scalar

.filter_sum is the shape that pays for itself: one GPU compaction plus one reduction, with the filtered column never crossing back into Polars.

What tier 2 accepts

Narrower than the bridge, and not the same list as the "Types" paragraph under Limits -- that one is about what round-trips, which is a tier-1 and tier-3 question.

Expression Dtypes
.sum() / .min() / .max() / .mean() / .filter_sum() / .top_k() / .add … / .group_by_sum() Int8/16/32/64, UInt8/16/32/64, Float32/64 -- nothing else
.hash64() those, plus Boolean, Date, Datetime, Duration, Time and String
.contains / .starts_with / .ends_with / .upper / .lower String

Everything else -- Boolean, the temporal types, Binary, Categorical, Enum, Decimal, List, Struct, Null -- raises a Polars ComputeError whose message starts arrowmetal:. Nothing panics through pyo3. There are two places tier 2 is behind tier 1. Categorical and Enum: the Python bridge recodes Polars' dictionary<uint32> index buffer to int32 for the GPU, and polars-plugin/src/bridge.rs does not, so a Categorical column reaches the kernel as-is and is refused with "dictionary indices must be int32 or int64". And Decimal: am_decimal_op backs s.arrowmetal.sum() in tier 1, and the plugin does not reach for it.

The scalar in .add / .sub / .mul / .truediv

"Integers wrap" is about the arithmetic: 127 + 1 is -128 on an Int8 column, and integer division by zero is 0. It is not about the operand. A scalar the column's type cannot hold exactly is an error, the same call the tier-1 bridge makes (it packs the scalar with struct.pack at the column's own width, and struct.pack raises):

pl.col("i8").arrowmetal.add(1000)      # raises: 1000 is out of range for an Int8 column
pl.col("u8").arrowmetal.add(-1)        # raises: -1 is out of range for a UInt8 column
pl.col("i64").arrowmetal.add(1.5)      # raises: an integer column takes an integer scalar
pl.col("i64").arrowmetal.add(2**60+1)  # exact -- the scalar does not go through an f64

A float column takes either an integer or a float, and an operand too large for Float32 becomes an infinity, which is what struct.pack("f", 1e300) gives tier 1.

group_by_sum, and what the plugin API cannot do

pl.col("k").arrowmetal.group_by_sum(pl.col("v")) returns a struct column of n_groups rows:

df.select(pl.col("k").arrowmetal.group_by_sum(pl.col("v")).alias("g")).unnest("g")

It has to be a struct because a plugin function answers with a single Series. And it runs as a projection over the whole frame, not inside df.group_by(...).agg(...): Polars' expression plugin API (polars.plugins.register_plugin_function) registers expressions, and has no hook for contributing a hash aggregate to the group-by engine itself. The flags it accepts are is_elementwise, changes_length, returns_scalar, cast_to_supertype, input_wildcard_expansion and pass_name_to_apply -- none of them says "I am an aggregation the group-by engine should call per group". df.arrowmetal.group_by(...) (tier 1) is the ergonomic spelling; pl.col("v").arrowmetal.sum() inside .agg(...) also works and is called once per group, which is the wrong granularity for a GPU on small groups.

How the plugin gets the data across

polars-plugin/src/bridge.rs, in both directions:

Series --rechunk--> polars_arrow::ffi::ArrowArray --am_import--> am_array (Metal-resident)
am_array --am_export--> ArrowArray --import_array_from_c--> Series

polars_arrow::ffi::ArrowArray and arrowmetal_sys::ArrowArray are both #[repr(C)] transcriptions of the same C struct, so the hand-off is a pointer cast. Ownership follows the C Data Interface's consumer-releases rule: am_import takes over the exported array (so the Rust side mem::forgets its copy, exactly as the Python binding does after _export_to_c), and import_array_from_c takes over the exported one coming back.

Strings go through CompatLevel::oldest() -- Arrow LargeUtf8, not the Utf8View layout Polars uses natively. That conversion is the plugin's only copy, and it is why the plugin's string row below is 2.3x where tier 1's, which takes the column as views, is 17.6x. Tiers 1, 3 and 4 hand String columns over as Utf8View, which the kernels read directly (the Strings paragraphs under Numbers and Limits).

arrowmetal-sys is a hand-written transcription of the header, not bindgen output: the surface is small, the header is stable, and a checked-in file needs no libclang on the build machine. cargo test in polars-plugin/arrowmetal-sys runs 10 tests against the real dylib (import, export, reductions, compare + filter, scalar arithmetic, top-k + take, hash64, group-by, and the error path).


Tier 3 -- the streaming hand-off, and Polars' engine hook

lf = pl.scan_parquet("trades/*.parquet").filter(pl.col("day") == "2026-09-01")

lf.arrowmetal.collect_gpu(am.filter(am.col("region") == 2).sum(am.col("amount")))
lf.arrowmetal.collect_gpu(lambda df: df.arrowmetal.group_by("k").sum("v"))
lf.arrowmetal.collect_gpu()                     # just lf.collect()

collect_gpu collects the Polars plan and then runs one ArrowMetal pass over the result. Its engine= and any other keyword go straight to LazyFrame.collect, so the Polars half can still use the streaming engine: projection pushdown, predicate pushdown and slice all happen before a single byte reaches the GPU. It is an explicit hand-off. Polars' own engine= hook is the other way in, and tier 4 uses it; this section is what the hook is, read from the installed package.

The engine= hook, as it stands in polars 1.44.1

Read from the installed package, not from memory:

  • polars/_typing.py:443 -- EngineTypeName: TypeAlias = Literal["auto", "in-memory", "streaming", "gpu"], and EngineType: TypeAlias = Union[EngineTypeName, "Engine"]. So collect(engine=...) does accept an Engine object, not only a name.
  • polars/lazyframe/engine.py:77 -- class Engine(ABC), documented as "Subclass this to plug a new backend into Polars", with an abstract name property, a plan_engine property, and collect / execute / collect_async / collect_batches / the sink_* family.
  • polars/lazyframe/engine.py:330 -- class _LocalEngine(Engine), "Base for in-process engines backed by PyLazyFrame". Its collect ends in wrap_df(ldf.collect(self.name, callback)).
  • The callback comes from _LocalEngine._post_opt_callback(*, background, eager), typed PostOptCallback | None where PostOptCallback: TypeAlias = Callable[[Any, int | None], None] (polars/_typing.py:450). The base returns None.
  • polars/lazyframe/engine.py:884 -- class GPUEngine(_LocalEngine) with _name = "gpu". Its _post_opt_callback imports cudf_polars and returns partial(cudf_polars.execute_with_cudf, config=self). It refuses background collection and opts out in eager mode.
  • polars/lazyframe/engine_config.py:28 -- SUPPORTED_ENGINE_NAMES = ("auto", "in-memory", "streaming", "gpu"), and _engine_from_name maps the string "gpu" to GPUEngine().
  • The callback's first argument is a NodeTraverser (polars/_plr.pyi:2595), whose surface is: get_exprs(), get_inputs(), version(), get_schema(), get_dtype(expr_node), set_node(node), get_node(), set_udf(function, is_pure=False), view_current_node(), view_expression(node), add_expressions(expressions), set_expr_mapping(mapping), unset_expr_mapping().

So a Metal backend is not blocked on Polars adding an API -- the API is there, and tier 4 below is built on exactly this surface. One fact about names: the engine's name is passed to Rust as ldf.collect(self.name, callback), and Rust only knows the four in SUPPORTED_ENGINE_NAMES (a fifth string raises ValueError). When a callback is supplied, Rust invokes it for any known name, "in-memory" and "streaming" included (checked on 1.44.1), and an Engine object passed to collect(engine=...) bypasses the Python-side name check. So a third-party engine runs by passing "in-memory" to Rust and reporting itself through plan_engine; what it cannot do is carry its own name through Rust, so explain and the callback's error message ('cuda' conversion failed) name the wrong engine.

collect_gpu stays the explicit form of the same idea: Polars owns the plan, ArrowMetal owns one pass over the result.


Tier 4 -- MetalEngine, a Polars engine

import polars as pl
import arrowmetal as am

engine = am.MetalEngine()
df = lf.collect(engine=engine)      # the same frame lf.collect() returns
print(engine.last_report)           # which nodes ran on Metal, and why the rest did not

MetalEngine is python/arrowmetal/polars_engine.py. Polars optimises the plan as it always does and hands the optimised IR to the engine's post-optimisation callback. The callback walks every node and expression, translates the subtrees it can into an ArrowMetal plan (the grammar in Query engine), and replaces each one with a function that runs that plan on the GPU and returns a Polars DataFrame. Everything it does not take stays with Polars' in-memory engine (on polars 2.0.0 too, where a plain collect() runs the streaming engine), which also runs whatever sits above a replaced subtree. Every plan collects; the most that can happen is that nothing moves, and then the answer is plain Polars'.

import arrowmetal still does not import Polars: am.MetalEngine loads the module on first touch, like the other three tiers.

How it plugs into Polars 1.44.1 and 2.0.0

Read from the installed package (polars 1.44.1, and where it says so 2.0.0) and checked by python/tests/test_polars_engine.py, which passes on both:

  • lf.collect(engine=<an Engine object>) passes the object through unchanged, so no Polars change is needed. The name the engine gives Rust is "in-memory": Rust accepts only its four engine names, and with a callback supplied it runs the callback for any of them; the in-memory engine is also what runs every node the callback leaves. engine.name is "in-memory", engine.plan_engine and repr(engine) say metal.
  • The callback receives the NodeTraverser and, on polars 1.44.1, a second argument that is None under collect and an integer under profile (the time since the query started, which the engine uses to place its rows in the profile). polars 2.0.0 passes the NodeTraverser alone.
  • On polars 2.0.0 collect() without engine= runs Polars' streaming engine (on 1.44.1 the in-memory engine). MetalEngine gives Rust the name "in-memory" on both, so the nodes it leaves run on the in-memory engine.
  • set_udf turns the current node into a PythonScan whose function Polars calls as f(with_columns, predicate, n_rows, should_time). That function takes no input, so a replaced subtree is a leaf: the only subtrees that can move are ones whose leaves are in-memory frames (DataFrameScan) or Parquet files the engine reads itself (Scan, below). Any other scan (PythonScan, a CSV or IPC Scan) stays with Polars, and so does everything above it.
  • view_current_node raises NotImplementedError: ipc scan for a scan_ipc node. The engine leaves such a node, and everything above it, to Polars and names it in the report.
  • Polars does not check the replacement's output. The engine does: a frame whose schema is not the one get_schema() promised raises ArrowMetalError inside the query.
  • An exception from the callback reaches the user as ComputeError: 'cuda' conversion failed: <Type>: <message>; the 'cuda' is hardcoded in Polars. The engine's own messages start with ArrowMetal MetalEngine: so they read correctly inside it, name the node (Sort#3) and the reason, and end with the way to run the plan on Polars instead (FORCE_POLARS: collect without engine=, or set ARROWMETAL_METAL_ENGINE=off).
  • LazyFrame.profile(engine=...) passes the callback only for a GPUEngine, so lf.profile(engine=MetalEngine()) profiles plain Polars. engine.profile(lf) passes the callback through profile's own keyword, and each replaced subtree appears as a metal:<Node>#<id> row. Polars 2.0 removed LazyFrame.profile; on polars 2.0.0 engine.profile(lf) raises NotImplementedError.
  • Only collect and the paths built on it run the callback; the next section lists every path and what the engine does on it.
  • The IR versions the engine was written against, TESTED_IR_VERSION = ((14, 7), (15, 2)) (polars 1.44.1 and 1.44.2 report (14, 7), polars 2.0.0 reports (15, 2)), are pinned by a test, as is every Polars surface it touches (_LocalEngine, _post_opt_callback, the NodeTraverser methods, the node classes), so a Polars upgrade that moves one fails a named test instead of changing an answer. An IR major that neither tested version has makes the engine leave the whole plan to Polars; a newer minor of a tested major runs and warns once, naming the tested version of that major. A node kind outside KNOWN_NODE_KINDS (the 20 node classes of polars 1.44.1; polars 2.0.0 has 19, the same less ExtContext, since 2.0 removed LazyFrame.with_context) or a node Polars fails to show to the engine also keeps the whole plan with Polars: the report line is The plan holds an unknown node <kind> in polars <version>, so the whole plan stays with Polars., and the query is not an error.
  • ARROWMETAL_METAL_ENGINE=off (or 0, false, no, polars) makes every MetalEngine leave every plan to Polars, raise_on_fail=True included; the report says so.

Collect paths

What each Polars 1.44.1 entry point does with a MetalEngine (eng below), read from the installed package and checked by test_every_collect_path_is_explicit and test_polars_side_entry_points_that_never_call_the_engine. last_report.path names the path; a path on which the whole plan runs on Polars says why in last_report.fallbacks and issues a MetalEngineFallbackWarning (a UserWarning) once per process. On polars 2.0.0 the same tests pass apart from the two profile rows, which they skip: Polars 2.0 removed LazyFrame.profile, and eng.profile(lf) raises NotImplementedError there.

Entry point Runs on last_report.path Warning
lf.collect(engine=eng) Metal, the subtrees the engine takes collect --
lf.head(n).collect(engine=eng), and lf.fetch(n, engine=eng) (deprecated; it is head(n).collect) Metal collect --
lf.collect() or df.lazy().collect() under pl.Config(engine_affinity=eng) or pl.Config.set_engine_affinity(eng) Metal collect --
lf.collect(engine=eng) inside any other pl.Config context (tbl_rows, engine_affinity="streaming", ...) Metal collect --
pl.collect_all(lfs, engine=eng) Metal, frame by frame: each frame is optimised and collected on its own, where Polars' collect_all optimises the frames together collect_all; last_reports holds one report per frame --
eng.profile(lf) Metal, with a metal:<Node>#<id> row per replaced subtree profile -- (Polars' own DeprecationWarning for profile)
eng.explain(lf) nothing runs: Polars' optimised plan followed by the report of what would run on Metal (each subtree it would take is still checked over a 64-row prefix) explain --
lf.collect_async(engine=eng) Polars' in-memory engine: Polars passes no engine callback on this path collect_async once
pl.collect_all_async(lfs, engine=eng) Polars' in-memory engine, for the same reason collect_all_async once
lf.collect_batches(engine=eng) Polars, for the same reason collect_batches once
lf.collect(engine=eng, background=True) Polars' in-memory engine (background collection is not supported, as for GPUEngine) background once
lf.sink_parquet, sink_ipc, sink_csv, sink_ndjson, sink_batches with engine=eng, and a lazy=True sink collected through eng Polars: its streaming sink panics on a replaced subtree ("entered unreachable code"), so a plan with a Sink node stays whole sink once
lf.explain(engine=eng) nothing runs: Polars' plan, the same text as lf.explain(); Polars reads only eng.plan_engine not written none: Polars does not call the engine
lf.profile(engine=eng) Polars: polars 1.44.1 passes the callback only to a GPUEngine not written none: Polars does not call the engine
Eager DataFrame methods (df.sort, df.group_by(...).agg, ...) under an engine affinity of eng Polars' in-memory engine, which Polars uses for every eager operation not written none: Polars does not call the engine
lf.collect(engine="in-memory") (or "streaming") under an engine affinity of eng Polars: an explicit engine name wins over the affinity not written none: Polars does not call the engine

collect(background=False) with Polars' eager optimisation flag set asks for no callback either; the report says eager and there is no warning, as for GPUEngine. For code that cannot change its collect calls, ARROWMETAL_METAL_ENGINE=off sends every path to Polars.

What it translates

Polars engine capabilities is the same boundary measured on polars 1.44.1, and ENGINE_CAPABILITIES_POLARS2.md the same generator run on polars 2.0.0 (the engine takes the same 1,326 of 2,523 plans; 63 plans that 1.44.1 accepted are rejected by 2.0.0 itself and show as n/a): 29 plan shapes (filters, projections, with_columns, slice, sorts, top-k, group-by with x as the key and as the value of each aggregate, whole-frame aggregates, the four joins, unique, and Parquet scans) over 29 input dtypes and three null patterns, each run through the engine and marked Metal or Polars with the engine's reason. It is generated by python/tests/engine_capabilities.py, and test_the_capability_table_is_current fails when the committed file differs from a fresh run.

Polars node ArrowMetal Taken when
DataFrameScan scan every column it reads (after Polars' projection pushdown) is an integer, float, Boolean, String, Date, Datetime, Duration or Time
Scan of Parquet the file read on the GPU (am.read_parquet, through the open-file cache), then scan over the columns it returned, with the scan's predicate as a filter one local file (a list or a glob that resolves to one file counts); every column it reads (Polars' projection is the reader's column list) is one of the DataFrameScan types above and a top-level column of the file; no hive partitions, row_index_name, n_rows (a head, tail or slice Polars pushed into the scan), include_file_paths, schema=, deletion files or column mapping; something above it does GPU work. See "Parquet scans" below
Filter filter the predicate translates; Polars' dynamic_pred hints (which sort().head() inserts) are dropped
Select, HStack kept virtual: each output is an s-expression over the physical columns, computed where it is used or in one select at the top every output translates and is numeric or Boolean (a bare column of any carried type is carried)
Select whose every output is an aggregate aggregate each output is one of the aggregates in the last row of the expression table
SimpleProjection no operator: a column list always
Slice limit offset >= 0 (tail counts from the end and stays with Polars)
Sort sort, with limit for a pushed-in slice (sort().head(), top_k, bottom_k) keys are columns of any carried dtype, each one plan key with Polars' null placement and float order as its options; no maintain_order=True together with a slice
GroupBy group_by keys are non-float columns; maintain_order=False; not rolling or dynamic
Join join inner, left, semi or anti; key columns of equal, non-float dtypes (String, multi-column and temporal keys included); nulls_equal=False (null keys never match, on both engines); maintain_order="none"; no pushed-in slice; the output names are the ones ArrowMetal's join gives (left columns, then right columns without a same-named key, the suffix on a collision), which covers Polars' coalescing defaults and left_on/right_on with different names
Distinct (unique) unique keep="first" or "any" (ArrowMetal keeps each group's first row, a valid "any"); maintain_order=False; no float column in the subset
everything else (Union, HConcat, Cache, MapFunction, MergeSorted, ExtContext, Sink, PythonScan, a CSV, IPC or NDJSON Scan, and right, full, cross and as-of joins) -- stays with Polars, named in the report
Expression ArrowMetal
column, alias, typed literal (a Null literal takes the type it meets) (col ...), the literal at Polars' own dtype
+ - *, true division add sub mul div, each operand cast to the result dtype Polars' get_dtype gives; a literal divisor as mul by its reciprocal, which is how Polars divides by a scalar (below); a float multiply by a scalar -1 as negate, as Polars does (below); Float32 goes through binary64 (below)
== != < <= > >= eq ne lt le gt ge; floats in Polars' total order; String against a literal by str_eq (==, != only)
&, |, ^, ~ and_kleene, or_kleene, ne of the two as integers for Boolean xor, bit_and/bit_or/bit_xor on integers, not/bit_not
when/then/otherwise if_else, a null condition taking the otherwise branch
cast only casts that cannot fail or lose a value (integer widening, unsigned to a wider signed type, integers to Float64, 8/16-bit integers to Float32, Float32 to Float64, Boolean to numbers)
is_null, is_not_null, fill_null is_null, is_valid, fill_null
is_in a literal list of 1 to 64 values is_in (numbers) or str_eq terms (strings), null for a null input; a NaN in the list matches NaN rows, as in Polars' total order ((ne x x))
str.starts_with, str.contains(literal=True) or a pattern without regex characters starts_with, contains
sum min max mean count len the plan's aggregates, with the fix-ups below (min/max of a Boolean stay with Polars; a per-group count of a Float64 or Boolean column is a sum of validity bits)

Everything else -- %, //, eq_missing, str.ends_with, regex, windows (over), rank, median, an expression over an aggregate, a String-valued output, a narrowing or fallible cast, true division by a scalar that is not a plain literal, a column name holding a NUL byte (Polars' own Arrow export panics on one) -- falls back, and the report says which one. Categorical, Enum, Decimal, List, Struct, Null, Binary and Object columns in a subtree's input keep the whole subtree on Polars.

Where the answers would differ, and what the engine emits instead

Each line is a differential case in test_polars_engine.py or test_engine_conformance.py, run against Polars itself.

  • Float comparisons. Polars compares floats in a total order: NaN equals NaN and is greater than every number, and -0.0 equals 0.0. The engine adds the NaN terms ((ne x x) is "x is NaN") so the fused comparison gives Polars' answer, nulls included. is_in matches the same way: a NaN in the value list becomes an (ne x x) term, since ArrowMetal's is_in compares with IEEE equality.
  • Float32 arithmetic. The GPU's float adds, multiplies and divides flush subnormals to zero, which Polars does not. The engine computes Float32 + - * / in ArrowMetal's correctly rounded software binary64 and rounds once back to Float32, which is the correctly rounded Float32 result for these four operations, subnormals included. Float64 + - * / is correctly rounded in both.
  • Division by a scalar. Polars divides a column by a scalar as x * (1 / c), with the reciprocal rounded in the result type; that differs from the correctly rounded x / c by at most one ulp, in a share of rows that depends on the divisor (about a third of Float64 rows for / 3.0, none for a power of two). The engine emits the same multiply, so the bits match Polars' (test_true_division_by_a_literal_is_polars_reciprocal_multiply, which also checks zero, infinite, NaN, subnormal and null divisors). A column divisor is a true division in both. Over a column of one row Polars divides element-wise instead (the scalar and the column have the same length), so there the answer is the correctly rounded x / c. The engine emits whichever of the two Polars computes for the row count of the node's input: from the in-memory frame when nothing between it and the division changes the row count by an unknown amount, and otherwise by counting that input when the plan runs, both forms in the plan and the count choosing between them (test_scalar_division_and_minus_one_follow_polars_at_every_length).
  • Multiplying by -1. Polars multiplies a float column by a scalar -1 (on either side, and divides by -1) as a negation, which flips a NaN's sign bit where a multiply keeps the input NaN. The engine emits ArrowMetal's negate there, so NaN rows carry Polars' bits too (test_multiply_by_minus_one_is_a_negation_like_polars, which compares the raw bits). Over a column of one row Polars multiplies, and the NaN keeps its sign; the engine chooses by the row count as for a division.
  • The streaming engine. The two forms above are those of Polars' in-memory engine, the one MetalEngine leaves the rest of a plan to, in polars 1.44.1 and 2.0.0 alike. Polars' streaming engine, the default of collect() on 2.0.0, evaluated frames of 2 and 9 rows element-wise on both versions (the correctly rounded x / c, and a NaN that keeps its sign under * -1), and frames of 100 rows and more as the in-memory engine does.
  • Aggregates. A sum over no values is 0 in Polars (ArrowMetal: null) and gets a fill_null; a min/max over only NaN is NaN in Polars (ArrowMetal: null over a whole frame, an infinity per group), so the engine counts the non-null and non-NaN values and decides from the two; a mean of an Int64/UInt64 column is planned over the values cast to Float64, because ArrowMetal's integer mean sums in 64-bit integers and wraps on extreme values where Polars does not (the executor runs it from the integer column in one pass when a min/max pass shows every value below 2^53 and the sum below 2^63, and over the cast values otherwise; the two give the same bits); every result is cast to Polars' dtype (UInt32 counts, the Int32 sum of an Int32 column, Float32 of a Float32, UInt32 for the sum of a Boolean). A per-group count of a Float64 or Boolean column is the sum of its validity bits, because ArrowMetal's group-by will not read those values even to count them, and min/max of a Float64 column per group stays with Polars for the same reason. Polars' min and max order -0.0 below 0.0, so a min over both zeros is -0.0 and a max 0.0; ArrowMetal treats the two as equal and returns whichever it met first over a whole frame, and 0.0 per group. The engine counts the zeros of the sign Polars prefers and takes the sign from that count (test_min_and_max_over_both_zeros_are_polars_signed_zeros).
  • Float sums and means. The one place the answers differ, and the engine leaves it: a Float32 or Float64 sum, and a Float64 mean, add the same values in the GPU's order where Polars adds in its own, so the two can differ in the last bits. Both are sums of the same values, so they differ by at most twice the rounding bound of a sum, 2(n - 1) u sum(|x|) for a sum of n values and 2 u sum(|x|) for a mean (u the unit roundoff of the type the sum accumulates in: 2^-24 for a Float32 sum, 2^-53 otherwise), and the engine conformance grid holds each such case to that bound. In the run recorded in Benchmarks/results/engine_conformance_2026-10-02_report.txt (totals in engine_conformance_2026-10-02.csv) the largest difference was 0.199 u sum(|x|) for a Float32 sum (3.55e-5 of the answer), 0.372 u sum(|x|) for a Float64 sum (2.00e-14 of the answer) and 0.0033 u sum(|x|) for a Float64 mean (7.05e-14 of the answer). A mean of an integer or Float32 column is computed in Float64 by both (the engine plans the cast as Polars does, and runs an integer mean from the integer column where that gives the same bits); in that run it matched bit for bit in every case but one int64 group mean over the integer extremes (1,000 rows), where the engine returns the correctly rounded mean of the cast values and the two differ by 8.76e-8 u sum(|x|), as much as the answer itself. Every other output of the grid is compared bit for bit. test_polars_engine.py compares these aggregates to a relative tolerance.
  • Sort order. Polars (1.44.1 and 2.0.0 alike, pinned by test_polars_sort_order.py) places the nulls per key, first unless nulls_last, in either direction; orders a float key with every NaN (of either sign) one value above every number, +inf included, in both directions, and -0.0 equal to 0.0; keeps tied rows in input order in both directions under maintain_order=True, a tie falling through to the next key; orders String keys byte-wise and Boolean keys False first. top_k is a descending sort with nulls_last=True and a slice, bottom_k the ascending one. The engine writes each Polars key as one ArrowMetal key whose options give that order: "nulls": "first" where the nulls go first, and "float_order": "nan_largest" on a float key (docs/ENGINE.md, "Sort key options"). No key column is added, and a single-key sort with a slice stays a GPU top-k.
  • Integer overflow wraps in both (checked on Int8 and Int64 extremes), and an integer true division by zero is IEEE in both.

ArrowMetal behaviours this suite found, now fixed in the engine

Each was first worked around here and pinned by a strict xfail; each is now fixed in ArrowMetal, its test in test_polars_engine.py passes, and the workaround is gone (test_engine_takes_the_plans_it_once_worked_around runs the plans the engine once changed or declined):

  1. A null String slot with bytes under it. Polars exports a null String value with the bytes the slot held (valid Arrow), and ArrowMetal's String gather copied those bytes over the next kept value (test_core_string_filter_null_slot). The engine now hands such a column over as exported.
  2. A Boolean column through the plan's sort came back with the right validity bitmap and a null count of 0 (test_core_bool_sort_null_count). Result columns are now used as ArrowMetal returns them.
  3. A filter over a scan that carries a date32 column was rejected by the expression compiler (test_core_filter_carrying_a_date). Temporal columns now go to ArrowMetal as their own types.
  4. Sorting by a String column that holds a null and a value of 8 bytes or more returned wrong rows (test_core_string_sort_with_nulls, run in a child process because one run ended in a bus error). The engine now sorts by String columns.
  5. A finite float literal of magnitude 2^63 or more trapped the process inside the expression compiler, which converted every float literal to Int64 as well. The engine now runs those plans on Metal (test_a_float_literal_of_magnitude_2_63_or_more_runs_on_metal).
  6. A UInt64 literal above 2^63 - 1 (found by the conformance grid below, not worked around first: is_in, a comparison or fill_null against a large unsigned value) was rejected by the expression parser, which read every integer literal as an Int64, and the plan stayed with Polars. The parser now keeps such a literal as its bit pattern, the way the code generator writes unsigned literals (test_a_u64_literal_above_int64_max_reaches_the_gpu, and testWideUnsignedLiteralsParsePrintAndStayUnfolded in Swift).

Which translatable subtrees it runs: the defaults

A subtree the engine can translate still has to be one where the GPU is ahead, because getting a Polars column onto the GPU is not free: a single-chunk numeric column is imported without a copy, but mapping its pages into Metal and releasing them costs time on every query. String columns are handed over in Polars' own view layout (see "Strings" under Limits), in the crossover sweep and in the benchmark of the defaults below alike. MetalEngine() (shapes="measured") decides per subtree from measured crossovers (python/arrowmetal/_engine_policy.py). Each translated subtree has shape classes:

  • rowwise -- filters and projections only;
  • aggregate:<family> (a whole-frame aggregate), group_by:<family> (one key) and group_by_multi:<family> (two or more keys), the families being sum, count (count and len), mean and minmax;
  • sort (a full sort) and top_k (a sort with a slice);
  • join:inner, join:left, join:semi, join:anti, and distinct (unique).

It also has a dtype class, string when a String column is among the columns it reads and numeric otherwise, and an input, in-memory frames or a Parquet file. A subtree runs on Metal when its input rows (the rows of its in-memory frames, or of its Parquet file as the footer states them; what a predicate will keep is not estimated) are at or above the crossover of every class in it for its dtype class and input; a group-by is judged at the bucket of its estimated number of groups ("Group-bys: the number of groups", below). A class whose row says not taken stays with Polars at every size. The decision reads nothing but the subtree's classes, dtypes, input rows and input, a group-by's estimate and the tables, and the estimate comes from a fixed-seed sample, so the same subtree over the same frame always gets the same answer (python/tests/test_engine_policy.py checks both across 200 calls and across processes). Where the policy leaves a node, the placement moves down to its inputs, so a smaller subtree below it can still be taken: under a whole-frame sum over an inner join, the join runs on Metal and Polars adds up its output.

The sweep, the fits, the tables and the benchmarks from here to On polars 2.0.0 below are polars 1.44.1's, the measurements behind the Polars 1.x table; the Polars 2.x table and its measurements are under On polars 2.0.0.

The crossovers. Benchmarks/polars_engine_bench.py --crossover ran 93 LazyFrames over in-memory frames, the 45 cases below and a group-by grid of 48 (each aggregate family over one int32 key and over two, the keys drawn from 200, 1,000, 10,000, 100,000 or 1,000,000 values or from half the frame's rows, (g1sum200) to (g2minmaxR2)), at 250,000, 500,000, 1,000,000, 2,000,000, 5,000,000, 10,000,000, 20,000,000 and 50,000,000 rows (the probe side of the joins grows with the size, the build side is 1,000,000 rows), and the four Parquet scan cases over files of 1,000,000, 2,000,000, 5,000,000, 10,000,000, 20,000,000 and 50,000,000 rows, snappy and uncompressed, each through Polars' in-memory and streaming engines and through MetalEngine(shapes="all", min_rows=0) cold, best of 7, every MetalEngine result equal to Polars': Benchmarks/results/polars_engine_crossover_2026-09-26-final.csv, run conditions (the load average, sampled each minute) in Benchmarks/results/bench_conditions_2026-09-26-final.txt. Each group-by row of the results file also records the number of groups the case's data holds at that size (groups). The in-memory sort and top-k cases ((b), (m), (n), (o), (p), (q), (x2), (x3), (y6), and (x4), a top 100 by a nullable Float64 key descending, added then) were swept again on 2026-09-29, after each Polars sort key became one plan key with Polars' null placement and float order as its options (see "Sort order" above), at the same eight sizes, best of 7: Benchmarks/results/polars_engine_crossover_2026-09-29-sort.csv holds those rows and the 2026-09-26 rows of every other case, run conditions in Benchmarks/results/polars_engine_crossover_2026-09-29-sort_conditions.txt. The group-by cases that run the grouped Float64 sum and mean or the grouped min/max ((c), (l), (t3), (t4), (t6), (t7), (v3), (v4) and the grid's mean and min + max cases; a mean of an int64 column then ran as a Float64 mean, the engine casting it first) were swept again on 2026-09-30, after both were rebuilt, at the same eight sizes, best of 7, and the grid gained a Float64 sum over each of its key sets and group counts ((g1fsum200) to (g2fsumR2), a sum of q / 1e9): Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv holds those rows and the earlier rows of every other case, run conditions in Benchmarks/results/polars_engine_crossover_2026-09-30-groupby_conditions.txt, and the table is fitted from it. Benchmarks/polars_engine_crossover.py fits it the way Benchmarks/router_table.py fits the router table (python/arrowmetal/_router_fit.py), with the MetalEngine as the GPU side and the faster Polars engine as the CPU side, over each case's input rows. A case's fit is the first size from which it is ahead at every larger size, placed between that size and the one below it where the two straight lines meet; a case ahead at the largest size alone has none, and a size where the MetalEngine's answer differed from Polars' counts as behind.

Four rules on the fit. The table keeps each case's and each bucket's fit as fit, and the rows the default uses as rows:

  • The margin (MARGIN). A case is ahead at a size when its MetalEngine time, raised by a margin, is at most the faster Polars engine's time: 15% for a numeric shape, 35% for a shape with a String column. A String shape's advantage grows slowly with size: the String sort (o) is 0.98x the faster Polars engine at 1,000,000 rows in the 2026-09-26 sweep (Benchmarks/results/polars_engine_crossover_2026-09-26-final.csv, vs_fastest_polars), 1.2x at 2,000,000, 1.37x at 5,000,000 and 1.5x at 10,000,000, so a fit at 15% would land where the two engines are within noise of each other.
  • The String floor (STRING_FLOOR). A shape with a String column is taken from 5,000,000 rows at the earliest, whatever its fit says. The floor is set from the 2026-09-26 runs, where the String sorts below it are close to Polars: (o) 1.2x and (p) 1.38x at 2,000,000 rows in the 2026-09-26 sweep, and 1.21x and 1.31x under shapes="all" at that size in the 2026-09-26 default benchmark (Benchmarks/results/polars_engine_bench_2026-09-26-final3.csv, cold), inside that benchmark's noise band (0.62x to 1.31x); from 5,000,000 rows that sweep has them at 1.37x and up. In the 0.4.0 benchmark below (Benchmarks/results/polars_engine_bench_2026-10-02.csv, cold) they are 1.39x and 1.84x under shapes="all" at 2,000,000 rows, against a noise band of 0.86x to 1.18x.
  • The headroom (HEADROOM). The default takes a shape from 1.5 times its fit (a String shape from the larger of that and the floor), and a shape whose 1.5 times passes the largest size measured is not taken. The fit interpolates between sizes measured 2 to 2.5 times apart (250,000, 500,000, 1,000,000, 2,000,000, 5,000,000 and so on), and shapes just past their crossover were within run-to-run noise of Polars: two runs of the same Polars plan differ by 0.62x to 1.31x in the 2026-09-26 default benchmark and by 0.86x to 1.18x in the 0.4.0 benchmark below (MetalEngine default, cold where it took nothing, against polars in-memory, rows of 5 ms or more in Benchmarks/results/polars_engine_bench_2026-09-26-final3.csv and Benchmarks/results/polars_engine_bench_2026-10-02.csv).
  • The default benchmark (BENCH_RATIO, BENCH). The sweep times each case back to back; the default is also held to Benchmarks/polars_engine_bench.py --idle (a 100 ms warm-up, a first run after 500 ms of idle on its own, another warm-up, then best of 5 and their median), over the files BENCH names (Benchmarks/results/polars_engine_default_groupby_raw_2026-09-30.csv, "Group-bys after the grouped Float64 sum, mean and min/max were rebuilt" below). A class, or a group-count bucket for a case judged by its estimate, of which a case the default took there is behind the faster Polars engine on the best run or on the median is taken only from the smallest benchmarked size above the largest size it was behind at, and not at all when it is behind at the largest. A case of several classes (a mean and a max in one group-by) that is behind counts against those of its classes whose one-class cases at that bucket and size are not all ahead, and against all of them only when every one of its classes has one-class cases there that are ahead, so a class that is ahead on its own keeps its rows and the case itself is still left. The table records each benchmarked row's cases and their ratios as benchmark.

A class's crossover is the largest over the cases whose classes all belong to its node, so every case that measures a class has to be ahead; if one of them has no fit, the class has no crossover. A case whose classes span two nodes, (b) (a group-by under a top-k) and (e) (a whole-frame sum over a join), measures no class. There is one fitted table per Polars major, chosen by the running Polars' major the first time the policy decides, and python -m arrowmetal.polars_engine check names the one in force (compatibility()["crossover_table"]):

Each module records its own SOURCE, BENCH and the sweep's HEADER, which names the Polars version the sweep ran on; polars_engine_crossover.py writes a major's module only from a sweep on that major, and polars_engine_crossover.py --check fails when any module and its results files disagree. The policy then takes the larger of that crossover and the kernels' own: the router table in force (am.router_table(), The crossover) for the kernels it routes, and for the sort classes the crossover of argsort int64, argsort float64 and lexsort (2 int32 keys) against the fastest CPU library in Benchmarks/results/router_2026-09-24.json, 1,000,000 rows. The fits below are the Polars 1.44 table's, from Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv.

class dtype input rows the default uses the fit it came from cases (their fit)
sort numeric in-memory 1,000,000 the sort kernels, above the table's 947,836 ((q) 631,891 x 1.5) (m) 456,986; (q) 631,891; (x2) 250,000, the smallest input measured
sort numeric Parquet 1,500,000 (s4) 1,000,000, the smallest file measured (s4) uncompressed 1,000,000; (s4) snappy 1,000,000
join:left numeric in-memory 1,875,000 (w2) 1,250,000, the smallest input measured (w2) 1,250,000
join:inner numeric in-memory 2,029,827 (w1) 1,353,218 (w1) 1,353,218
join:anti numeric in-memory 3,727,959 (w4) 2,485,306 (w4) 2,485,306
distinct numeric in-memory 5,494,090 (x1) 3,662,727 (r) 1,252,378; (x1) 3,662,727
sort string in-memory 5,000,000 the String floor, above (o) 2,439,140 x 1.5 = 3,658,710 (o) 2,439,140; (p) 1,131,461
distinct string in-memory 5,000,000 the String floor, above (y5) 2,463,352 x 1.5 = 3,695,028 (y5) 2,463,352
group_by_multi:sum string in-memory per group count (below); with no estimate, 9,744,372 (k) 6,496,248 (k) 6,496,248
group_by_multi:minmax numeric in-memory per group count (below); with no estimate, 11,675,406 (v4) 7,783,604 (v4) 7,783,604; (c) 710,717; the grid's two-key min + max 535,292 to 7,023,044
group_by:minmax numeric in-memory per group count (below); with no estimate, 19,740,807 (t4) 13,160,538 (t4) 13,160,538; (t7) 1,312,255; the grid's one-key min + max 473,148 to 13,139,506
group_by_multi:mean numeric in-memory per group count (below); with no estimate, 24,143,946 (v3) 16,095,964 (v3) 16,095,964; (c) 710,717; (l) 778,101; the grid's two-key means 635,715 to 6,615,362
group_by_multi:count numeric in-memory per group count (below); with no estimate, 26,547,327 (j) 17,698,218 (j) 17,698,218; (v2) 710,626; the grid's two-key counts 661,173 to 6,712,670
group_by:sum, group_by:count, group_by:mean, group_by_multi:sum numeric in-memory per group count (below); with no estimate, not taken each has a case with no fit: over one key the 200- and 1,000-group grid cases, (t1), (t2) and (t3); over two keys, (g2sum200)
join:semi numeric in-memory not taken (f) none (f) none; (w3) 2,464,828
aggregate:sum, :count, :mean, :minmax numeric in-memory not taken (a), (a2), (a3), (a4) none
top_k numeric in-memory not taken (x4) none (n) 6,600,767; (x3) 6,210,387; (x4) none
rowwise numeric in-memory not taken (d) none
top_k, rowwise, aggregate:sum, group_by:sum, join:inner string in-memory not taken (y6), (y2), (y3), (y1), (y4) none
group_by:sum, group_by:count, aggregate:sum, aggregate:count numeric Parquet not taken (s1) and (s3) none with both codecs; (s2) uncompressed none, snappy 1,000,000

A class with no row (a String column in any other class, and every Parquet class but these) has no measurement and is not taken. arrowmetal.polars_engine.placement_rules() returns the table under the router table in force, and group_placement_rules() the group-count buckets below.

Group-bys: the number of groups. Where the GPU group-by is ahead depends on the number of groups more than on the rows. In the sweep (Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv, vs_fastest_polars of MetalEngine all, cold) the one-key sum over 200 groups is behind at every size ((t1) at best 0.58x, (g1sum200) 0.51x); over 10,000, 100,000 and 1,000,000 groups it is ahead at 50,000,000 rows by 2.65x, 2.97x and 2.74x ((g1sum10k), (g1sum100k), (g1sum1M)); over about 0.43 times as many groups as rows it is at 1.02x there ((g1sumR2)), inside the margin. So a group-by class is judged at the bucket of its subtree's number of groups:

bucket groups
200 1 to 447
1,000 448 to 3,162
10,000 3,163 to 31,622
100,000 31,623 to 316,227
1,000,000 316,228 to 3,162,277
rows/2 at least a quarter of the input rows, whatever the count

A count between the 1,000,000 bucket and a quarter of the rows has no bucket and stays with Polars. Each bucket is fitted like a class, from the points of the group-by cases whose number of groups at that size falls in it (the grid, (c), (i), (j), (k), (l), (t1) to (t7), (v1) to (v4), (y1), (s1) and (s2)); at each size the bucket's point is the worst of its cases there, so its step is the largest of the cases' own, and a bucket ahead at its largest size alone has no crossover. The grid's 1,000,000-group cases hold a quarter of the rows or more below 5,000,000 rows, where they count in the rows/2 bucket, so the 1,000,000 bucket was measured from 5,000,000 rows, and that is where its fits start. The rows the default uses, with the fit each came from in brackets (the router table's group_by_sum row is below every one of them):

class 200 1,000 10,000 100,000 1,000,000 rows/2
group_by:sum not taken not taken 6,041,361 (4,027,574) 5,252,956 (3,501,971) 7,500,000 (5,000,000) not taken
group_by:count not taken not taken 3,123,562 (2,082,375) 3,801,232 (2,534,155) 7,500,000 (5,000,000) not taken
group_by:mean not taken not taken 2,436,520 (1,624,347) 2,524,249 (1,682,833) 7,500,000 (5,000,000) not taken
group_by:minmax 19,740,807 (13,160,538) 19,709,259 (13,139,506) 2,995,140 (1,996,760) 2,184,147 (1,456,098) 7,500,000 (5,000,000) not taken
group_by_multi:sum not taken 11,993,616 (7,995,744) 2,777,623 (1,851,749) 2,288,101 (1,525,401) 7,500,000 (5,000,000) not taken
group_by_multi:count 5,580,594 (3,720,396) 10,069,005 (6,712,670) 1,082,526 (721,684) 1,118,998 (745,999) 7,500,000 (5,000,000) not taken
group_by_multi:mean 9,923,043 (6,615,362) 9,720,262 (6,480,175) 50,000,000 (1,281,142)* 50,000,000 (635,715)* 7,500,000 (5,000,000) not taken
group_by_multi:minmax 10,534,566 (7,023,044) 8,162,302 (5,441,535) 1,171,750 (781,167) 802,938 (535,292) 7,500,000 (5,000,000) not taken
group_by_multi:sum, String column 9,744,372 (6,496,248) not taken
group_by:sum, String column not taken
group_by:sum, group_by:count, Parquet not taken

An empty cell has no measurement and is not taken. * Raised by the default benchmark (BENCH_RATIO; Benchmarks/results/polars_engine_default_groupby_raw_2026-09-30.csv, the faster Polars engine ÷ the default over every run in which the default took the case): at 2,000,000 rows the default took (c) (a mean and a max over two keys, 10,000 groups) at 0.82x of the faster Polars engine on the best run and 0.75x on the median, (g2mean10k) at 0.87x and 0.75x and (g2mean100k) at 0.65x and 0.57x (both tables' runs where the default took them), all ahead at 50,000,000 rows, so the two-key mean's 10,000 and 100,000 buckets are taken from 50,000,000 rows. (c)'s min + max class keeps its 10,000 bucket: (g2minmax10k), its one-class case there, was 1.18x and 1.09x. The rows/2 bucket is never taken (UNTAKEN_BUCKETS), whatever its fit, because the sweep (Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv) is not monotone there: the one-key count over 0.43 times as many groups as rows, (g1countR2), is 0.78x, 1.9x, 4.04x, 2.21x and 1.18x the faster Polars engine at 2,000,000, 5,000,000, 10,000,000, 20,000,000 and 50,000,000 rows, and the two-key rows/2 cases peak at 10,000,000 rows the same way ((g2countR2) 6.47x there, 2.38x at 50,000,000). (j), (v3) and (v4), whose keys range over 100,000 x 1,000 values and which hold 0.79 to 1.0 times as many groups as rows in the sweep, are 1.22x, 1.17x and 1.51x at 50,000,000 rows. The grid's Float64 sums set two sum buckets: the one-key 10,000 bucket ((g1fsum10k) 1.04x at 2,000,000 rows and 1.25x at 5,000,000, then 2.63x at 10,000,000) and the two-key 1,000 bucket ((g2fsum1k) 0.74x at 5,000,000 and 1.88x at 10,000,000). The one-key min + max over 200 and 1,000 groups is taken from about 20,000,000 rows: (g1minmax200) is 1.13x, 1.41x and 1.67x at 10,000,000, 20,000,000 and 50,000,000 rows, (g1minmax1k) 1.06x, 1.27x and 1.64x.

The probe. For a group-by whose keys are columns of one in-memory input frame, read as they are (through filters, projections, joins and unique below it, which can only drop key values, so the frame's count is an upper bound), the engine estimates the number of distinct key tuples in that frame at plan time. It reads the keys of a stratified sample of n of the frame's N rows, row i·(N / n) + h(i) with h a fixed-seed hash, so the same frame always gets the same sample; counts the distinct tuples d and those seen exactly once (f1) and twice (f2); and scales them with the bias-corrected Chao1 estimator (Chao, Biometrics 2005), D = d + f1 (f1 - 1) / (2 (f2 + 1)), clipped to [d, N]. Its range is D with f2 moved by two Poisson standard deviations (and one or two more) either way, the high end being the whole frame while fewer than about 6 pairs have been seen. The samples are 512, 2,048, 8,192 and so on, at most 65,536 rows and a quarter of the frame, and the probe stops at the first whose range the decision does not depend on: every bucket the range reaches is taken at these rows, or none is. So a few hundred groups or ten thousand settle on 512 rows, a hundred thousand on 2,048, and a count near a quarter of the rows takes more. A frame of at most 4,096 rows is counted exactly. One integer key is read as it is; several keys, and a String, Boolean or temporal key or one holding nulls, through Polars' hash. The samples' counts are cached per frame, key columns and sample size, keyed by the columns' buffers (the cache keeps those columns alive, so no other frame can take their addresses while it stands), so collecting the same frame again samples nothing and gets the same estimate; arrowmetal.polars_engine.clear_group_estimates() drops the cache. The probe runs only when some bucket of the class is taken at the input rows; below every bucket's crossover the subtree stays with Polars without one. A Parquet file's footer answers when it states a distinct count for every key column in every row group, and only when the largest row group's count and the sum over row groups fall in one bucket; the benchmark files state none. With no estimate (a key the plan computes, keys from an aggregate below, two group-bys in one subtree, a footer without distinct counts) a group-by class is judged by its row in the class table: group_by_multi:minmax from 11,675,406 rows, group_by:minmax from 19,740,807, group_by_multi:mean from 24,143,946, group_by_multi:count from 26,547,327, the (String, int32) sum from 9,744,372, and every other numeric group-by class at no size.

What the probe costs, and how close it gets. Benchmarks/group_probe_bench.py collects a group-by sum over the grid's frames (one int32 key, two, and one String key, at each group count) through MetalEngine() nine times with its caches cleared and reads the probe's time from the report: Benchmarks/results/group_probe_2026-09-26.csv, run conditions in Benchmarks/results/group_probe_2026-09-26_conditions.txt. Against the fastest of Polars' two engines and the default for the same group-by:

rows groups probe (median of 9) share of the group-by
50,000,000 200 to 100,000 66 to 199 µs 0.30% to 0.77%
50,000,000 1,000,000 279 µs (one key), 461 µs (two) 0.84%, 1.19%
50,000,000 half the rows 255 µs (one key), 458 µs (two) 0.12%, 0.10%
2,000,000 200 to 100,000, two keys 50 to 123 µs 1.09% to 3.17%
2,000,000 a quarter of the rows or more, two keys 366 and 393 µs 4.70%, 4.90%

That file was measured under the earlier group-count table. Under the table in force, a group-by at 2,000,000 rows is probed only for the two-key count and min/max, the classes with a bucket taken at that size; a one-key, String or two-key sum group-by there is not probed, and neither is a String group-by at 50,000,000 (only the (String, int32) sum has buckets). In the default benchmark below, where the probe runs in a process that holds every case's frames (probe_us in Benchmarks/results/polars_engine_bench_2026-10-02.csv, the probes of one collect summed), its median is 150.5 µs at 2,000,000 rows (2.90% of the default's time for the case; 1 of the 14 probes under 1%) and 126 µs at 50,000,000 (0.75%; 51 of 77 under 1%). The seven probes over 5 ms, 6.9 to 12.7 ms, are all at 50,000,000 rows on frames with 0.43 times as many groups as rows or more ((g1minmaxR2) 6.9 ms, 1.41% of the default's time; (g2sumR2) 12.7 ms, 1.99%), and its largest shares are 15.96% at 2,000,000 rows ((g2minmax100k), 390 µs of 2.4 ms) and 14.60% at 50,000,000 ((g2sum1M), 3.8 ms of 26.2 ms). A second collect of the same frame reads its samples from the cache. Sampled until its denominator settles instead of until the decision does (free_estimate in Benchmarks/results/group_probe_2026-09-26.csv), the estimate falls in the bucket of the true count for every frame and key set of the grid at both sizes, within 29% of it up to 1,000,000 groups at 50,000,000 rows, and 24% to 31% above it at half the rows.

Each probe is listed in the report, and the rule of a group-by the policy judged by its estimate names it:

  metal:  GroupBy#1 [GroupBy > DataFrameScan] over 50,000,000 rows, ran in <t> ms -> 100,000 rows
          rule: 50,000,000 input rows is at or above the 2,961,043-row crossover for group_by:minmax at an estimated 102,981 groups over (k1), a Chao1 estimate from a 2,048-row sample, 67,758 to 198,392 (engine table, the 100,000-group bucket, ...)
  groups: GroupBy#1 over (k1): 102,981 groups, a Chao1 estimate from a 2,048-row sample, 67,758 to 198,392 (probed in 132 us)

  polars: GroupBy#1: rule: estimated 191 groups over (region), a Chao1 estimate from a 512-row sample, 189 to 196: below the measured band for group_by:sum at 50,000,000 input rows (taken at 3,163 to 3,162,277 groups; no count in the estimate's range is; ...)
  groups: GroupBy#1 over (region): 191 groups, a Chao1 estimate from a 512-row sample, 189 to 196 (probed in 76 us)

The other classes in the sweep (Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv, vs_fastest_polars of MetalEngine all, cold):

  • Joins and unique on numeric keys: the left join is ahead from the smallest input measured, 1,250,000 rows, and taken from 1,875,000; the inner join is fitted at 1,353,218 and taken from 2,029,827, the anti join at 2,485,306 and from 3,727,959. The semi join is not taken: against a 1,000-row table, (f), it is behind at every size (at best 0.47x), though against a 1,000,000-row table, (w3), it is fitted at 2,464,828. unique is fitted at 1,252,378 rows over 10,000 groups (r) and at 3,662,727 over about as many groups as rows (x1), and taken from 5,494,090.
  • Sorts over in-memory frames are fitted at 250,000 rows (x2, the smallest input measured; 1.42x there), 456,986 (m) and 631,891 (q). The sort kernels' crossover, 1,000,000 rows, is above (q)'s 947,836 and sets sort.
  • Top-k is fitted at 6,600,767 rows by a Float64 key descending (n; 1.86x at 10,000,000 rows, 1.81x at 50,000,000) and at 6,210,387 by an int64 key (x3; 1.1x to 1.35x from 5,000,000), but the top 100 by a nullable Float64 key descending (x4), whose answer is 100 of the null rows, is ahead at no size (0.75x to 1.05x), so top_k is not taken.
  • Whole-frame aggregates and row-wise filters and projections are behind at every size: (a) to (a4) at 0.09x to 0.24x, (d) at 0.14x to 0.42x.
  • String shapes: the sorts (o) and (p) and unique (y5) are taken from the 5,000,000-row floor, and the (String, int32) group-by (k), about 1,000,000 groups from 5,000,000 rows, from 9,744,372. The filter by a String equality (y2, at best 0.13x), the prefix filter and sum (y3, 0.5x), the join on a String key (y4, 0.67x) and the top-k with a String column (y6, 0.88x) are behind at every size; the group-by on a String key with 1,000 values (y1) is ahead by the margin at no size (1.27x at 20,000,000 rows, 1.09x at 50,000,000).
  • Over a Parquet file, cold (the open-file cache cleared before each run), the sort is ahead from the smallest file measured, 1,000,000 rows, with both codecs (4.37x uncompressed and 4.9x snappy there), and taken from 1,500,000. The filter and group-by (s1) has no fit with either codec (uncompressed at best 1.09x, at 50,000,000 rows; snappy 1.42x and 1.26x at 1,000,000 and 2,000,000, then 0.79x to 0.92x), nor has the aggregate (s3) (at best 0.84x); the filter that skips row groups, (s2), is ahead at every size snappy (1.24x to 2.48x) and at none uncompressed (at best 0.99x). So the group-by and aggregate classes over a file are not taken.

The default against Polars and against shapes="all". Benchmarks/polars_engine_bench.py --scan over the 106 in-memory cases at 2,000,000 and 50,000,000 rows and the four Parquet scan cases over the 50,000,000-row files, snappy and uncompressed, 220 case-size pairs, best of 5, every result equal to Polars' (the cold runs of MetalEngine() clear the group-count cache too, so each pays its probe): Benchmarks/results/polars_engine_bench_2026-10-02.csv, run conditions in Benchmarks/results/bench_conditions_2026-10-02.txt. On polars 1.44.1 the default takes a subtree in 75 pairs (11 at 2,000,000 rows and 64 at 50,000,000), 55 of them group-bys, and every one is ahead of the faster Polars 1.44.1 engine, from 1.52x ((g1minmax200) at 50M) to 11.54x ((r) at 50M):

case rows Polars in-memory Polars streaming shapes="all", cold MetalEngine(), cold vs faster Polars
(e) inner join then sum (the join taken) 2M 7.6 ms 5.0 ms 1.8 ms 1.8 ms 2.74
(m) sort 3 columns by an int64 key 2M 12.6 ms 13.9 ms 2.5 ms 2.0 ms 6.40
(q) filter, then sort by (int32 asc, int64 desc) 2M 14.3 ms 14.3 ms 3.5 ms 3.2 ms 4.54
(v2) group-by (region, sub), count 2M 6.1 ms 4.8 ms 2.6 ms 2.7 ms 1.82
(w1) inner join, 1M-row build side 2M 7.3 ms 5.1 ms 3.2 ms 1.6 ms 3.24
(w2) left join, 1M-row build side 2M 9.8 ms 6.3 ms 1.9 ms 1.8 ms 3.46
(x2) sort by a nullable Float64 key, descending 2M 17.8 ms 18.7 ms 3.2 ms 3.2 ms 5.63
(g2count100k) group-by grid, 2 keys, 100,000 groups, count 2M 6.7 ms 5.9 ms 3.0 ms 3.8 ms 1.56
(g2count10k) group-by grid, 2 keys, 10,000 groups, count 2M 5.5 ms 5.0 ms 2.5 ms 2.7 ms 1.88
(g2minmax100k) group-by grid, 2 keys, 100,000 groups, min + max 2M 7.9 ms 6.4 ms 4.2 ms 2.4 ms 2.63
(g2minmax10k) group-by grid, 2 keys, 10,000 groups, min + max 2M 7.3 ms 5.9 ms 3.7 ms 2.4 ms 2.49
(c) group-by (region, sub) mean + max 50M 198.1 ms 104.7 ms 31.8 ms 32.0 ms 3.28
(e) inner join then sum (the join taken) 50M 21.1 ms 15.5 ms 5.9 ms 5.9 ms 2.63
(i) group-by 1 key, 100,000 groups, sum 50M 88.0 ms 46.6 ms 14.4 ms 14.8 ms 3.16
(k) group-by (String, int32), sum 50M 361.9 ms 157.2 ms 50.6 ms 50.5 ms 3.11
(l) group-by (region, sub), Float64 sum + mean 50M 206.8 ms 104.3 ms 29.7 ms 29.8 ms 3.50
(m) sort 3 columns by an int64 key 50M 380.5 ms 451.2 ms 53.6 ms 53.0 ms 7.17
(o) sort with a String column, by an int64 key 50M 395.9 ms 512.9 ms 244.1 ms 243.8 ms 1.62
(p) filter, then sort by (int32 asc, nullable Float64 desc) 50M 936.6 ms 994.0 ms 323.2 ms 316.0 ms 2.96
(q) filter, then sort by (int32 asc, int64 desc) 50M 694.1 ms 708.5 ms 88.1 ms 88.8 ms 7.82
(r) unique over (region, sub), keep first 50M 153.1 ms 162.4 ms 13.8 ms 13.3 ms 11.54
(s4) Parquet scan, sort 2 columns by a float64 key, uncompressed 50M 576.2 ms 574.9 ms 73.9 ms 74.4 ms 7.73
(s4) Parquet scan, sort 2 columns by a float64 key, snappy 50M 582.3 ms 577.4 ms 89.4 ms 94.3 ms 6.12
(t4) group-by 1 key, 200 groups, min + max 50M 16.6 ms 17.3 ms 10.2 ms 10.4 ms 1.59
(t5) group-by 1 key, 100,000 groups, count 50M 68.0 ms 44.6 ms 9.5 ms 9.8 ms 4.53
(t6) group-by 1 key, 100,000 groups, mean 50M 95.9 ms 53.2 ms 23.6 ms 23.5 ms 2.26
(t7) group-by 1 key, 100,000 groups, min + max 50M 119.8 ms 61.0 ms 16.3 ms 16.6 ms 3.68
(v1) group-by (region, sub), sum 50M 178.9 ms 107.5 ms 16.8 ms 16.3 ms 6.59
(v2) group-by (region, sub), count 50M 153.0 ms 95.8 ms 11.3 ms 11.3 ms 8.50
(w1) inner join, 1M-row build side 50M 93.2 ms 82.9 ms 22.2 ms 22.3 ms 3.71
(w2) left join, 1M-row build side 50M 269.3 ms 98.9 ms 25.3 ms 25.2 ms 3.93
(w4) anti join, 1M-row build side 50M 80.8 ms 63.8 ms 25.1 ms 24.7 ms 2.58
(x1) unique over (k1, k2), keep first 50M 755.7 ms 453.2 ms 240.1 ms 240.3 ms 1.89
(x2) sort by a nullable Float64 key, descending 50M 553.0 ms 598.1 ms 96.4 ms 97.0 ms 5.70
(y5) unique over (String, int32), keep first 50M 378.5 ms 255.8 ms 43.4 ms 42.8 ms 5.98
(g1count100k) group-by grid, 1 key, 100,000 groups, count 50M 74.0 ms 47.7 ms 9.7 ms 9.8 ms 4.85
(g1count10k) group-by grid, 1 key, 10,000 groups, count 50M 63.1 ms 56.2 ms 8.7 ms 9.0 ms 6.25
(g1count1M) group-by grid, 1 key, 1,000,000 groups, count 50M 149.5 ms 58.0 ms 11.7 ms 12.0 ms 4.85
(g1fsum100k) group-by grid, 1 key, 100,000 groups, Float64 sum 50M 97.0 ms 55.9 ms 20.0 ms 19.9 ms 2.81
(g1fsum10k) group-by grid, 1 key, 10,000 groups, Float64 sum 50M 88.1 ms 62.3 ms 19.3 ms 18.9 ms 3.29
(g1fsum1M) group-by grid, 1 key, 1,000,000 groups, Float64 sum 50M 177.3 ms 65.5 ms 26.9 ms 26.7 ms 2.46
(g1mean100k) group-by grid, 1 key, 100,000 groups, mean 50M 102.3 ms 55.2 ms 24.1 ms 24.0 ms 2.30
(g1mean10k) group-by grid, 1 key, 10,000 groups, mean 50M 89.3 ms 63.9 ms 22.9 ms 22.7 ms 2.81
(g1mean1M) group-by grid, 1 key, 1,000,000 groups, mean 50M 178.3 ms 68.4 ms 31.3 ms 31.3 ms 2.19
(g1minmax100k) group-by grid, 1 key, 100,000 groups, min + max 50M 118.0 ms 68.3 ms 16.7 ms 16.9 ms 4.03
(g1minmax10k) group-by grid, 1 key, 10,000 groups, min + max 50M 109.4 ms 73.6 ms 15.8 ms 15.7 ms 4.69
(g1minmax1M) group-by grid, 1 key, 1,000,000 groups, min + max 50M 196.3 ms 77.9 ms 19.0 ms 19.9 ms 3.92
(g1minmax1k) group-by grid, 1 key, 1,000 groups, min + max 50M 101.1 ms 18.6 ms 11.2 ms 11.1 ms 1.67
(g1minmax200) group-by grid, 1 key, 200 groups, min + max 50M 16.5 ms 16.9 ms 10.6 ms 10.8 ms 1.52
(g1sum100k) group-by grid, 1 key, 100,000 groups, sum 50M 97.3 ms 54.1 ms 15.0 ms 15.1 ms 3.58
(g1sum10k) group-by grid, 1 key, 10,000 groups, sum 50M 92.1 ms 60.6 ms 14.1 ms 14.1 ms 4.31
(g1sum1M) group-by grid, 1 key, 1,000,000 groups, sum 50M 169.8 ms 59.8 ms 22.5 ms 22.4 ms 2.67
(g2count100k) group-by grid, 2 keys, 100,000 groups, count 50M 166.9 ms 113.9 ms 12.2 ms 12.5 ms 9.12
(g2count10k) group-by grid, 2 keys, 10,000 groups, count 50M 161.6 ms 106.1 ms 11.7 ms 11.7 ms 9.08
(g2count1M) group-by grid, 2 keys, 1,000,000 groups, count 50M 355.4 ms 164.9 ms 14.1 ms 14.8 ms 11.12
(g2count1k) group-by grid, 2 keys, 1,000 groups, count 50M 150.1 ms 43.4 ms 10.1 ms 10.3 ms 4.23
(g2count200) group-by grid, 2 keys, 200 groups, count 50M 38.4 ms 38.5 ms 9.5 ms 11.8 ms 3.26
(g2fsum100k) group-by grid, 2 keys, 100,000 groups, Float64 sum 50M 202.9 ms 125.5 ms 23.3 ms 24.0 ms 5.23
(g2fsum10k) group-by grid, 2 keys, 10,000 groups, Float64 sum 50M 187.6 ms 111.1 ms 22.4 ms 22.0 ms 5.05
(g2fsum1M) group-by grid, 2 keys, 1,000,000 groups, Float64 sum 50M 387.7 ms 181.9 ms 29.8 ms 30.1 ms 6.04
(g2fsum1k) group-by grid, 2 keys, 1,000 groups, Float64 sum 50M 172.0 ms 45.2 ms 15.1 ms 15.7 ms 2.89
(g2mean100k) group-by grid, 2 keys, 100,000 groups, mean 50M 193.6 ms 134.9 ms 27.1 ms 26.8 ms 5.03
(g2mean10k) group-by grid, 2 keys, 10,000 groups, mean 50M 184.2 ms 109.9 ms 25.8 ms 25.7 ms 4.28
(g2mean1M) group-by grid, 2 keys, 1,000,000 groups, mean 50M 387.2 ms 188.4 ms 34.2 ms 34.9 ms 5.40
(g2mean1k) group-by grid, 2 keys, 1,000 groups, mean 50M 177.6 ms 46.9 ms 19.8 ms 19.4 ms 2.42
(g2mean200) group-by grid, 2 keys, 200 groups, mean 50M 41.1 ms 41.3 ms 19.4 ms 19.4 ms 2.12
(g2minmax100k) group-by grid, 2 keys, 100,000 groups, min + max 50M 216.7 ms 138.5 ms 20.0 ms 19.9 ms 6.94
(g2minmax10k) group-by grid, 2 keys, 10,000 groups, min + max 50M 211.0 ms 122.8 ms 18.9 ms 19.0 ms 6.46
(g2minmax1M) group-by grid, 2 keys, 1,000,000 groups, min + max 50M 405.5 ms 216.7 ms 22.0 ms 22.1 ms 9.79
(g2minmax1k) group-by grid, 2 keys, 1,000 groups, min + max 50M 197.0 ms 52.4 ms 13.9 ms 14.2 ms 3.70
(g2minmax200) group-by grid, 2 keys, 200 groups, min + max 50M 44.9 ms 46.3 ms 13.6 ms 13.6 ms 3.31
(g2sum100k) group-by grid, 2 keys, 100,000 groups, sum 50M 196.0 ms 126.7 ms 18.3 ms 18.0 ms 7.02
(g2sum10k) group-by grid, 2 keys, 10,000 groups, sum 50M 191.1 ms 106.7 ms 17.3 ms 17.3 ms 6.18
(g2sum1M) group-by grid, 2 keys, 1,000,000 groups, sum 50M 378.8 ms 176.2 ms 26.3 ms 26.2 ms 6.74
(g2sum1k) group-by grid, 2 keys, 1,000 groups, sum 50M 194.0 ms 44.7 ms 13.3 ms 13.2 ms 3.38

In the other 145 pairs the default takes nothing and runs Polars' in-memory plan; there its time is 0.40 to 1.42 times the polars in-memory row of the same case (wall_ms in Benchmarks/results/polars_engine_bench_2026-10-02.csv), and 0.86 to 1.18 times in the 112 of them where that row is 5 ms or more. That is the spread of two runs of the same Polars plan in this run, and the noise band the ratios above are read against. shapes="all" takes a subtree and is ahead of the faster Polars engine in 64 of those 145 (vs_fastest_polars of MetalEngine all, cold), 47 of them above 1.18x, each left by the default because its class or bucket has a crossover above that size or none:

  • at 2,000,000 rows: two-key min + max and count over about 1,000,000 groups, estimated above every bucket taken for their class at that size, (g2minmaxR2) 3.07x, (g2minmax1M) 2.54x, (g2countR2) 1.68x and (g2count1M) 1.64x; group-bys below the lowest crossover of their class, (g2fsum100k) 2.47x, (g2sumR2) 1.80x, (g2fsumR2) 1.61x, (g2sum10k) 1.58x, (v1) 1.57x, (g2sum100k) 1.51x, (g2fsum1M) 1.48x, (g2sum1M) 1.27x, (g2fsum1k) 1.26x and (g2fsum10k) 1.22x (group_by_multi:sum from 2,288,101), (g2mean100k) 2.41x, (g2mean10k) 2.05x, (l) 2.00x, (c) 1.98x, (g2meanR2) 1.67x and (g2mean1M) 1.65x (group_by_multi:mean from 7,500,000), (g1minmaxR2) 1.80x and (g1minmax1M) 1.56x (group_by:minmax from 2,184,147), (g1fsum10k) 1.75x and (g1fsum100k) 1.35x (group_by:sum from 5,252,956), (g1mean10k) 1.57x and (t6) 1.31x (group_by:mean from 2,436,520) and (g1count10k) 1.35x (group_by:count from 3,123,562); unique (r) 2.07x and (x1) 1.24x (below 5,494,090); the String shapes (p) 1.84x, (o) 1.39x and (y5) 1.31x (below the 5,000,000-row floor);
  • at 50,000,000 rows: group-bys estimated at 11,180,716 to 16,775,168 groups, above every bucket taken for their class, (g2minmaxR2) 2.26x, (g2countR2) 2.11x, (g2sumR2) 1.59x, (g2fsumR2) 1.52x, (g2meanR2) 1.51x, (v4) 1.45x and (g1minmaxR2) 1.34x; group-bys in buckets not taken for their class, (g2sum200) 3.08x and (g2fsum200) 2.70x (two-key sum, 200 groups), (t2) 1.61x and (g1count200) 1.58x (one-key count, 200 groups) and (g1count1k) 1.57x (1,000 groups); the semi join (w3) 2.60x (join:semi is not taken, (f)); the top-k (n) 1.85x and (x3) 1.23x (top_k is not taken, (x4)).

The other 17 are 1.01x to 1.18x, inside the noise band.

Sorts with one key per Polars key. The 2026-09-26 default benchmark (Benchmarks/results/polars_engine_bench_2026-09-26-final3.csv) ran when a nulls-first nullable key and a descending float key still carried an extra key column each. With Polars' order as options of the one key, the sort and top-k cases and four group-by controls were timed old against new, alternating, three rounds of best of 5 after a 100 ms warm-up (Benchmarks/results/polars_engine_sort_options_2026-09-30.csv, run conditions in …_conditions.txt); best and median of the rounds, in ms:

case rows engine before, best / median after, best / median after 500 ms idle, before → after
(x4) top 100 by a nullable Float64 key, descending 50M shapes="all", cold 129.53 / 129.89 11.07 / 11.09 168.14 → 15.84
(x4) top 100 by a nullable Float64 key, descending 50M shapes="all", warm 122.91 / 123.46 4.85 / 4.98 136.94 → 6.97
(x4) top 100 by a nullable Float64 key, descending 2M shapes="all", cold 12.31 / 13.77 2.57 / 2.63 20.43 → 6.72
(n) top 100 by Float64, descending 50M shapes="all", cold 79.93 / 80.10 11.50 / 13.00 103.92 → 33.13
(n) top 100 by Float64, descending 2M shapes="all", cold 8.56 / 10.00 2.17 / 2.36 11.48 → 4.87
(x2) sort by a nullable Float64 key, descending 50M MetalEngine(), cold 134.03 / 134.24 93.54 / 93.96 163.88 → 106.02
(x2) sort by a nullable Float64 key, descending 2M MetalEngine(), cold 15.71 / 16.64 8.79 / 10.67 20.52 → 15.77
(p) filter, then sort by (int32 asc, nullable Float64 desc) 50M MetalEngine(), cold 350.54 / 351.84 317.39 / 317.96 374.03 → 344.01
(p) filter, then sort by (int32 asc, nullable Float64 desc) 2M shapes="all", cold 25.82 / 26.77 16.01 / 16.46 43.11 → 36.91

The faster Polars engine in the same runs (best of the rounds of the new build, new_best_ms of the Polars rows): (x4) 9.75 ms at 50M and 1.21 ms at 2M (streaming), (n) 18.43 ms and 2.23 ms (streaming), (x2) 542.42 ms and 17.54 ms, (p) 978.94 ms and 22.02 ms (in-memory). The top-k cases are still left to Polars by the default (top_k has no crossover, above), so its rows for them are Polars' in either build. The sorts whose keys carry no option ((m), (o), (q), the Parquet (s4)), the top-k by an int64 key (x3), (y6), (b) and the group-by controls (c), (i), (j), (t1) are unchanged: every row that was more than 5% slower in both best and median over the three rounds, all of them on paths this change does not touch, was timed again alone over eight alternating rounds of 100 runs (the rows flagged row re-timed alone in the same file), where before ÷ after is 0.95 to 1.25 in best and 0.96 to 1.22 in median. No row's median process CPU time rose by more than 1.5x.

Group-bys after the grouped Float64 sum, mean and min/max were rebuilt. The 75 group-by cases (the grid's 60, (c), (i), (j), (l), (t1) to (t7), (v1) to (v4)) at 2,000,000 and 50,000,000 rows, MetalEngine() with the table fitted before the 2026-09-30 re-sweep and with the table fitted from it (before the default-benchmark rule), the same build, three alternating rounds of best of 5 after a 100 ms warm-up, the first run after 500 ms of idle recorded on its own: Benchmarks/results/polars_engine_default_groupby_2026-09-30.csv (per row best of the rounds and median of the rounds' medians; every run in …_raw_2026-09-30.csv, run conditions in …_2026-09-30_conditions.txt). Every answer was equal to Polars'. The re-swept table took 57 of the 150 case-size pairs (8 at 2,000,000 rows, 49 at 50,000,000), the earlier one 48, and it left none that the earlier one took. What it took that the earlier one left, in ms (best of the rounds; the earlier table's default ran Polars' in-memory plan there):

case rows faster Polars engine earlier table table in force vs faster Polars, best / median after 500 ms idle
(g2minmax10k) group-by grid, 2 keys, 10,000 groups, min + max 2M 5.50 6.37 4.66 1.18x / 1.09x 11.50
(g2minmax100k) group-by grid, 2 keys, 100,000 groups, min + max 2M 6.14 8.09 5.46 1.12x / 1.12x 13.36
(c) group-by (region, sub) mean + max 2M 5.49 6.97 6.73 0.82x / 0.75x 9.63
(g2mean100k) group-by grid, 2 keys, 100,000 groups, mean 2M 5.82 8.38 8.90 0.65x / 0.57x 14.84
(g1mean1M) group-by grid, 1 key, 1,000,000 groups, mean 50M 58.08 172.67 31.13 1.87x / 2.27x 60.58
(g2mean200) group-by grid, 2 keys, 200 groups, mean 50M 39.25 40.50 19.31 2.03x / 2.03x 58.85
(g1minmax200) group-by grid, 1 key, 200 groups, min + max 50M 16.54 16.82 12.33 1.34x / 1.09x 32.69
(t4) group-by 1 key, 200 groups, min + max 50M 15.61 15.87 12.02 1.30x / 1.13x 26.90
(g1minmax1k) group-by grid, 1 key, 1,000 groups, min + max 50M 16.05 97.29 12.94 1.24x / 1.01x 34.00

Every other pair it took was ahead of the faster Polars engine on the best run and on the median (new_vs_fastest_polars_best and _median in the same file): at 50,000,000 rows from 1.24x ((g1minmax1k)) to 10.69x ((g2count1M)), at 2,000,000 rows from 1.12x to 1.63x ((g2count10k)). Taken and behind at 2,000,000 rows were the two-key means (c) at 0.82x, (g2mean100k) at 0.65x and (g2mean10k), which the earlier table took as well, at 0.87x (5.83 against 5.08 ms; 0.73x under the earlier table). The default-benchmark rule (above) raises the two-key mean's 10,000 and 100,000 buckets to 50,000,000 rows, so the table in force takes 54 of the 150 pairs (5 at 2,000,000 rows, 49 at 50,000,000), every one at 1.0x or more of the faster Polars engine on the best run and the median in this benchmark, and leaves (c), (g2mean10k) and (g2mean100k) at 2,000,000 rows to Polars, (g2mean10k) being the one pair the earlier table took that this one leaves. Those three and (g2minmax10k), which shares (c)'s 10,000-group bucket of the min + max class, re-timed, the table before the rule against the table in force, three alternating rounds (Benchmarks/results/polars_engine_default_groupby_retime_2026-10-01.csv, run conditions in …_retime_2026-10-01_conditions.txt), in ms, best / median, against the faster Polars engine's best of 5.49, 4.98, 5.61 and 5.44 ms:

case before the rule table in force
(c) taken: 8.28 / 9.81 (0.66x / 0.58x) Polars' in-memory plan: 6.83 / 7.49 (0.80x / 0.76x)
(g2mean10k) taken: 6.54 / 7.28 (0.76x / 0.72x) Polars' in-memory plan: 6.78 / 7.22 (0.73x / 0.72x)
(g2mean100k) taken: 8.24 / 8.87 (0.68x / 0.66x) Polars' in-memory plan: 7.39 / 8.32 (0.76x / 0.70x)
(g2minmax10k) taken: 4.63 / 4.83 (1.17x / 1.17x) taken: 4.04 / 4.35 (1.34x / 1.30x)

Left to Polars, MetalEngine() runs Polars' in-memory plan, which uses the CPU cores (77 to 90 CPU-ms against 4 to 6 on the GPU path for these three, new_cpu_ms and old_cpu_ms in the retime file). In this benchmark each engine's timed runs follow Polars' runs of the same case and a 500 ms idle gap, and these three take 4.75 to 9.79 ms (best of 5) under shapes="all", cold or warm, as well (the reported rounds of …_raw_2026-09-30.csv); run back to back, 15 runs of each way of collecting in turn, three rounds and no idle gap (Benchmarks/results/polars_engine_groupby_backtoback_2026-10-01.csv), the default runs them in 2.89 ms (c), 2.41 ms (g2mean10k) and 3.03 ms (g2mean100k) against Polars' streaming engine's 5.48, 5.09 and 5.74 ms, which is where the crossover sweep, measured the same back-to-back way, put them (2.09x, 2.19x and 2.34x at 2,000,000 rows in Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv). In the 150-pair benchmark the pairs whose decision did not change ran the same plan under both tables; the rows of those that were more than 5% slower in best and median under one table than the other ((g1count10k) and (g1count1M) at 50,000,000 rows, taken by both; (g2count100k) at 2,000,000, taken by both; (g1minmax100k) and (t4) at 2,000,000, Polars' plan under both) differ between the two runs of one plan.

Float64 group sums and means at 2^24 groups. The (v3) case of the crossover sweep, a mean over two keys with about as many groups as rows, found ArrowMetal's group-by returning null for most groups at 16,777,216 groups and above (16,777,215 were right): the per-group kernels dispatched one threadgroup per group and the grid wrapped past 2^32 threads. Fixed in the core (Findings against pyarrow, round 13; python/tests/test_group_by_2_24.py), so no engine rule is needed and every group-by shape follows the policy above. The sweep and the benchmark above ran with the fix: under shapes="all" the cases the guard once kept with Polars, (c), (l), (t3), (t6), (v3) and the Parquet cases (s1) and (s2), run on Metal and equal Polars' answers. At 50M rows, under shapes="all", cold (Benchmarks/results/polars_engine_bench_2026-09-26-final3.csv), (c) is 1.78, (l) 2.03 and (t6) 1.21 times the faster Polars engine; (t3) is behind at 35.5 ms against 12.4 and (v3) at 612.0 ms against 421.7.

On polars 2.0.0. Polars 2.0.0 (released 2026-10-06) runs collect() on its streaming engine by default (1.44.1: the in-memory engine), and runs group-bys, joins and sorts faster than 1.44.1. Over the 62 cases the 1.44 default takes at 50,000,000 rows, the faster Polars engine's time on 2.0.0 is 0.72x of its time on 1.44.1 at the median, and 0.25x on (r), unique over two keys (153.1 ms to 38.5 ms); on 2.0.0 the streaming engine is the faster of the two in 56 of the 62. ArrowMetal's times on the same cases are unchanged (median ratio 1.03). Files: Benchmarks/results/polars_engine_bench_2026-10-02.csv (1.44.1) and Benchmarks/results/polars_engine_bench_2026-10-07-polars2.csv (2.0.0, conditions in bench_conditions_2026-10-07-polars2.txt), 50,000,000 rows, best of 5, AC power. A mean over an integer column now runs in one pass from the integer column (CHANGELOG, Unreleased); on 2.0.0 the two-key means (c), (g2mean200) and (g2mean1k) went from 0.98x, 0.88x and 0.97x of the faster Polars engine to 1.41x, 1.26x and 1.41x (polars_engine_bench_2026-10-07-polars2.csv before, polars_engine_bench_2026-10-08-polars2-after.csv after).

The Polars 2.x table, python/arrowmetal/_engine_crossovers_pl2.py, is fitted by the same rules from the sweep on 2.0.0 (Benchmarks/results/polars_engine_crossover_2026-10-08-polars2.csv: the 107 in-memory cases at the same eight sizes, 250,000 to 50,000,000 rows, and the Parquet scan cases at 1,000,000 to 50,000,000 rows, best of 7, one process per size, conditions in polars_engine_crossover_2026-10-08-polars2_conditions.txt) and the default benchmark on 2.0.0 (Benchmarks/results/polars_engine_default_groupby_raw_2026-10-08-polars2.csv: the group-by, unique and sort cases at 2,000,000 and 50,000,000 rows, and the sort, unique and 1,000- and 10,000-group cases at 5,000,000, 10,000,000 and 20,000,000). Where its rows differ from the 1.x table's:

class dtype input Polars 1.x table Polars 2.x table
sort numeric in-memory 1,000,000 10,000,000
distinct numeric in-memory 5,494,090 2,943,034
join:inner numeric in-memory 2,029,827 1,942,068
join:anti numeric in-memory 3,727,959 1,875,000
sort string in-memory 5,000,000 not taken
group_by_multi:sum string in-memory, 1,000,000 groups 9,744,372 9,359,932

join:left (1,875,000), the Parquet sort (1,500,000) and String distinct (5,000,000) are the same in both, and so is every class the 1.x table does not take. The group-count buckets of the 2.x table:

class 200 1,000 10,000 100,000 1,000,000 rows/2
group_by:sum not taken not taken not taken 4,610,202 9,281,986 not taken
group_by:count not taken 15,336,822 20,000,000 3,873,481 7,500,000 not taken
group_by:mean not taken not taken 50,000,000 2,397,246 7,500,000 not taken
group_by:minmax 16,863,831 17,064,774 20,000,000 2,088,660 7,500,000 not taken
group_by_multi:sum not taken 28,976,995 not taken 1,560,030 7,500,000 not taken
group_by_multi:count 15,909,808 12,127,063 20,000,000 1,024,383 7,500,000 not taken
group_by_multi:mean not taken not taken not taken 1,582,216 7,500,000 not taken
group_by_multi:minmax 10,117,528 9,447,895 1,906,210 1,485,513 7,500,000 not taken

With the 2.x table in force (Benchmarks/results/polars_engine_bench_2026-10-08-polars2-refit.csv, the 107 in-memory cases at 50,000,000 rows, best of 5, AC power, conditions in bench_conditions_2026-10-08-polars2-refit.txt) the default takes a subtree in 52, every result equal to Polars', and every one is ahead of the faster Polars 2.0.0 engine, from 1.35x ((e) at 50M) to 7.94x ((q) at 50M), none behind. Some of them:

case Polars in-memory Polars streaming MetalEngine(), cold vs faster Polars
(q) filter, then sort by (int32 asc, int64 desc) 713.18 ms 719.69 ms 89.82 ms 7.94
(y5) unique over (String, int32), keep first 384.01 ms 296.29 ms 42.14 ms 7.03
(g2count1M) group-by grid, 2 keys, 1,000,000 groups, count 378.68 ms 99.04 ms 15.17 ms 6.53
(g2mean1M) group-by grid, 2 keys, 1,000,000 groups, mean 417.40 ms 130.15 ms 24.62 ms 5.29
(r) unique over (region, sub), keep first 141.95 ms 38.01 ms 13.42 ms 2.83
(m) sort 3 columns by an int64 key 373.01 ms 148.79 ms 52.70 ms 2.82
(w1) inner join, 1M-row build side 105.04 ms 57.97 ms 22.63 ms 2.56
(x2) sort by a nullable Float64 key, descending 538.43 ms 173.75 ms 93.54 ms 1.86
(g1minmax200) group-by grid, 1 key, 200 groups, min + max 17.20 ms 17.34 ms 10.54 ms 1.63
(e) inner join then sum 20.76 ms 8.24 ms 6.12 ms 1.35

To improve on polars 2.0.0, left to Polars by the 2.x table (the sweep on 2.0.0, vs_fastest_polars of MetalEngine all, cold, 250,000 to 50,000,000 rows): whole-frame aggregates (a) to (a4) 0.11x to 0.47x; row-wise filters and projections (d) 0.16x to 0.40x; the semi join against a 1,000-row table (f) 0.42x to 0.82x, which keeps join:semi with Polars, the semi join against a 1,000,000-row table (w3, 1.21x to 2.14x) with it; top-k (n), (x3), (x4) and (y6) 0.20x to 1.02x; the join on a String key (y4) 0.24x to 0.48x; group-by sum and mean over one or two keys at 200 to 10,000 groups ((t1), (t3) and the grid's sums, Float64 sums and means) 0.36x to 0.97x at 250,000 and 500,000 rows, in the buckets the table above leaves; and the sort with a String column (o), 0.89x at 50,000,000 rows under the 1.x table on 2.0.0 (244.77 ms against Polars streaming 217.88 ms, Benchmarks/results/polars_engine_bench_2026-10-08-polars2-after.csv), which leaves the String sort class, and with it (p), with Polars. A plan the default leaves runs on Polars' in-memory engine, where a plain collect() on 2.0.0 runs the streaming engine: for the sort (m) at 5,000,000 rows, below the 2.x table's sort crossover, Polars' in-memory engine takes 32.88 ms and its streaming engine 13.55 ms (Benchmarks/results/polars_engine_default_groupby_raw_2026-10-08-polars2.csv).

am.MetalEngine()                          # shapes="measured": the crossovers and buckets above
am.MetalEngine(shapes="all")              # every translatable subtree of at least 1,000,000 rows
am.MetalEngine(shapes="all", min_rows=0)  # everything it can translate (what the tests use)
am.MetalEngine(shapes={"group_by", "sort"})  # the classes named (or their prefix), any size
am.MetalEngine(min_rows=5_000_000)        # the crossovers, and at least 5,000,000 rows
am.MetalEngine(raise_on_fail=True)        # raise instead of leaving anything to Polars

Each subtree the report lists as taken carries the rule that took it, and each node the policy left has a rule: line (here under the Polars 1.x table):

  metal:  Sort#2 [Sort > DataFrameScan] over 2,000,000 rows, ran in <t> ms -> 2,000,000 rows
          rule: 2,000,000 input rows is at or above the 1,000,000-row crossover for sort (crossover sweep, ...)
  polars: Select#3: rule: aggregate:sum was not measured ahead of Polars up to 50,000,000 input rows (...)
  polars: Join#2: rule: 900,000 input rows is below the 2,029,827-row crossover for join:inner (...)

shapes="all" is there for plans the benchmark did not cover and for moving work off the CPU cores: the CPU-ms column of the results file is the process CPU time of each run.

The import cache

Each Polars column the engine imports is kept, keyed by its Arrow type, length, offset and the address and size of every buffer, and the next query that reads the same column reuses the import. That is safe because the cached import holds the exported array and, through it, Polars' own buffer: while an entry lives, Polars can neither free that memory (so no other column can appear at the same address) nor write to it in place (Polars copies a buffer it shares before writing). Only a column that was imported without a copy is cached; a String column (handed over as views, without a copy, but outside the cache's key) and a multi-chunk column (concatenated on every export) are not. Entries are evicted least recently used above a byte budget, a quarter of physical memory by default:

from arrowmetal import polars_engine
polars_engine.import_cache_info()       # {"entries", "bytes", "limit", "hits", "misses"}
polars_engine.import_cache_limit(2 << 30)
polars_engine.clear_import_cache()      # and with it the Polars buffers it kept alive

The "warm" column of the results file is a second collect over the same frame; the defaults above were read from the cold one.

Parquet scans

lf = (pl.scan_parquet("trades.parquet")
        .filter(pl.col("price") > 500.0)
        .group_by("qty").agg(pl.col("weight").sum()))
lf.collect(engine=am.MetalEngine(shapes="all"))

A Scan of one local Parquet file becomes a leaf the engine reads itself, so the subtree above it needs no import at all: the columns are decoded on the GPU straight into Metal memory (Parquet on the GPU) and the plan runs over them.

  • Projection. The columns Polars' projection pushdown left on the Scan are the reader's column list; no other column chunk is touched. Dictionary-encoded columns are read materialised, which is what Polars reads them as.
  • Predicate. Polars pushes a filter into the Scan as its predicate. The engine translates it like any Filter and runs it on the GPU over the rows the reader returns. The comparisons in it that the file's statistics can judge the way Polars compares go to the reader as well, which skips row groups (and, with a page index, pages) that cannot hold a matching row: a comparison of a column with a literal, joined by &, on an integer column (all six operators), a String column (==, !=) or a float column (<, <= and == only, against a literal exact in the column's type). Polars orders NaN above every number, so NaN rows pass >, >= and !=, and writers leave NaN out of min/max; those three never reach the reader for a float column, so != x keeps a NaN in a row group whose statistics say x .. x (the case of apache/arrow#51491). |, functions and comparisons of two columns stay on the GPU filter only. A Filter Polars left directly above the Scan (with its predicate pushdown off) is handed to the reader the same way. scan_parquet(use_statistics=False) hands it nothing.
  • Nulls. A column is treated as nullable unless the footer's statistics say it holds no null (ParquetFile.column_null_count), so a sort key by a column without nulls carries no null placement. The read checks the footer's word, and a file whose data holds nulls its statistics deny fails the query with ArrowMetalError rather than answering differently.
  • The open file is kept. The reader goes through the open-file cache (Parquet on the GPU, "The open-file cache"), keyed by the file's path, inode, modification time and size, so the second query over a file does not map it again, and a rewritten file is read afresh. am.clear_parquet_cache() and am.parquet_cache_limit() control it.
  • Checked once per plan and file. As for in-memory inputs, the plan first runs over a prefix of the file (64 rows of its first row group) and its output schema is compared with Polars'. A file whose stored Arrow schema Polars applies and ArrowMetal's reader does not (one with a different number of fields than the file, which Arrow's own reader ignores) fails that check or names a column the file does not have, and stays with Polars.
  • A bare scan stays with Polars. A Scan with nothing above it that does GPU work is Polars' to read.

What stays with Polars, with the reason in the report: several files (a list, or a glob or directory that matches more than one), hive partition columns, a URL or cloud path (file:// included), row_index_name, n_rows (and a head, tail or slice Polars pushed into the scan), include_file_paths, schema=, deletion files, column mapping, a column whose dtype the plan does not carry (Decimal, Categorical, Enum, List, Struct, Binary, ...), a predicate that does not translate, and CSV and NDJSON scans. polars 1.44.1 cannot show an IPC scan to an engine (NodeTraverser.view_current_node raises NotImplementedError: ipc scan), so scan_ipc stays with Polars as well.

Each taken subtree's report entry lists what the reader did (scans: the file, the filters it was given, row groups read and skipped, pages skipped):

  metal:  GroupBy#2 [GroupBy > SimpleProjection > Scan] over 50,000,000 rows, ran in <t> ms -> 1,000 rows
          parquet /data/bench-none-50000000.parquet: filters [('id', '<', 5000000)], 5 row groups read, 45 skipped, 0 pages skipped

MetalEngine() judges a scan subtree by the same rule as an in-memory one, with the Parquet rows of the crossover table and the file's row count from its footer: a sort of a file of at least 1,500,000 rows is taken, and the filter, group-by and aggregate shapes over a file are not taken at any size ("Which translatable subtrees it runs: the defaults" above). The scan cases below are in line with that: the sort is ahead of both Polars engines cold and warm, and the filter, group-by and aggregate cases are behind cold.

The report

MetalEngine report for collect (polars 1.44.1, IR (14, 7))
  metal:  Sort#3 [Sort > HStack > Filter > DataFrameScan] over 10,000,000 rows, ran in <t> ms -> <n> rows
  polars: Select#1: Function rank has no ArrowMetal translation.

engine.last_report is a MetalPlanReport: taken (one entry per subtree that ran on Metal: the node at its top, the node kinds inside it, the rows it read, the ArrowMetal plan text, and after the run its wall time and output rows), fallbacks (one Kind#id: reason line per node that stayed with Polars for a reason of its own; every translation reason is a full sentence, which test_every_unsupported_reason_is_a_full_sentence checks, and a node the placement policy left has a rule: ... line, as above), walked (every node of the optimised plan), nodes, and path (the collect path it is for, "Collect paths" above). With POLARS_VERBOSE=1 the fallback lines are also issued as a PerformanceWarning. The report belongs to the engine object, so two threads collecting through one MetalEngine overwrite each other's.

Tests

PYTHONPATH=python python -m pytest python/tests/test_polars_engine.py -q
DIFF_QUICK=1 PYTHONPATH=python python -m pytest python/tests/test_polars_engine.py -q   # without the 100,003-row datasets
PYTHONPATH=python python -m pytest python/tests/test_engine_policy.py -q

test_engine_policy.py checks the default policy: the same decision for the same subtree across 200 calls and in a second process, every row of the crossover table one row below its crossover (left, with the reason) and at it (taken), a subtree needing every class's crossover, the String and Parquet rows, the router-table and sort-kernel floors, the overrides, a Parquet scan judged by its footer's row count under a filter that keeps a handful of rows, the report's rule lines, that the committed table is the fit of the results file it names, and the Float64 group-by guard at 2^24 groups. For the group-count buckets it checks that the buckets tile the counts, every bucket one row below its crossover and at it, a losing bucket named below or above the band, an estimate's range taken only where every count in it is, the fallback to the class row without an estimate, where the probe finds a group-by's keys, the probe's estimate in the true count's bucket over one int key, two and a String key at 2,000,000 rows, the same estimate across 200 calls and in a second process, the cache, and the engine taking a group-by in a winning bucket and leaving one in a losing bucket at the same rows.

The differential cases collect each LazyFrame on Polars and through MetalEngine(raise_on_fail=True, min_rows=0, shapes="all") -- so nothing may fall back -- and compare the frames: schema first, then values, exactly for integers, Booleans, Strings, nulls and element-wise floats, with a relative tolerance for float aggregates. The inputs are test_differential.py's generators at 0, 1, 33, 4,097 and 100,003 rows, null ratios 0, 0.3 and 1, its "sliced" and "special" (extremes, NaN, infinities, subnormals) flavours, and two Polars-side shapes, a two-chunk frame and a frame sliced with DataFrame.slice. Every numeric dtype runs through the comparisons, arithmetic, null logic, is_in, casts, group-by and whole-frame aggregates, sorts in both directions with nulls at both ends, and top-k; Boolean logic, String predicates and carried temporal columns have their own cases, and so do the four join kinds on integer, String and two-column keys with null keys and duplicates on both sides (a two-chunk right side among the shapes), suffix collisions, different key names, a join inside a filter-join-aggregate plan, and unique with and without a subset. Each fallback in the tables above has a case that checks the result is still Polars' and the report names the reason. One case reruns test_polars.py and test_lazy.py with every LazyFrame.collect() also collected through the engine (python/tests/metal_engine_everywhere.py) and requires the two to agree.

test_every_collect_path_is_explicit runs every row of the collect-path table and checks where the plan ran, the report's path and reason, and that the warning comes once. test_the_capability_table_is_current regenerates Polars engine capabilities and compares it with the committed file; no cell of it may be a Metal answer that differs from Polars' or an engine exception. The committed table is from polars 1.44.1, IR (14, 7); on a Polars of another IR major (2.0.0) the test is skipped. test_an_unknown_node_kind_keeps_the_whole_plan_on_polars and test_a_node_polars_cannot_describe_keeps_the_whole_plan_on_polars check the fail-closed walk, and test_the_check_command_prints_versions_and_the_capability_header the command below.

python -m arrowmetal.polars_engine check prints the installed Polars version and whether it is one of TESTED_POLARS, the IR version against TESTED_IR_VERSION, whether the callback API is present, the IR node kinds of this Polars the engine does not know, whether ARROWMETAL_METAL_ENGINE is set, and the header of the capability table (in a source checkout), with a line naming both versions when the table was generated on another Polars. It exits 1 when the callback API is missing or the IR major is not a tested one, since then every plan stays with Polars.

The Parquet scan cases (212 tests) run fourteen scan shapes -- filters over every numeric dtype, Polars' float total order, !=, String predicates, projections, group-by on one and two keys, whole-file aggregates, sorts with nulls at both ends, top-k, a join with an in-memory frame and unique -- over one 20,011-row dataset with nulls, NaN, -0.0 and infinities written by pyarrow (snappy; uncompressed with a page index and no dictionary; ZSTD with v2 pages), by Polars and by DuckDB; a filter, a sort and an aggregate over the flat columns of 30 nested fixtures from all three writers; seventeen predicate-pushdown cases on every writer, each checking the answer, the filters the reader was given and the row groups it skipped, including NaN under >, >= and != (the apache/arrow#51491 shape, also on the pageindexnan__pa_constpage fixture); every fallback reason above; the open-file cache's reuse, invalidation and bounds; and footer null counts, including a file whose statistics deny its nulls.

The conformance grid. python/tests/engine_polars_grid.py generates the cases instead of choosing them: every shape the engine translates (filters, select and with_columns expressions, slice, sorts in both directions with nulls at both ends and over two keys, top-k, group-by with each aggregate with the column as the key and as the value, whole-frame aggregates, the four join kinds, unique) over every dtype it carries (the eight integer widths, Float32, Float64, Boolean, String, Date, Datetime in ms, us and ns and with a time zone, Duration in ms, us and ns, Time), with no, 5%, 70% or all nulls, at 0, 1, 7, 1,000 and 100,000 rows, plus the special values (integer extremes, NaN, infinities, subnormals, -0.0). Each case collects through MetalEngine(shapes="all", min_rows=0) and through Polars and compares the frames bit for bit. The sorts cover each direction with the nulls first and last, a stable sort in each direction (maintain_order=True: tied rows, the two zeros and every NaN keep their input order, compared bit for bit), two and three keys with a per-key null placement, a tie on one key falling through to the next, and top-k as sort().head(), top_k, bottom_k and over two keys. In the run recorded in Benchmarks/results/engine_conformance_2026-10-02.csv (the per-shape table in engine_conformance_2026-10-02_report.txt): 15,376 cases, 15,096 identical on Metal, 33 within the float-summation bound above, none different otherwise, and 247 where Polars' optimised plan had nothing to run (all of them sorts over 0 or 1 row, which Polars leaves out). python/tests/engine_report.py --engine polars reruns it and prints the per-shape table.

To verify on your own machine

  1. DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build -c release --product ArrowMetalC, then PYTHONPATH=python python -m pytest python/tests/test_polars.py python/tests/test_lazy.py -q: tiers 1 to 3 still green.
  2. PYTHONPATH=python python -m pytest python/tests/test_polars_engine.py -q, after that fresh build.
  3. In a REPL: import sys, arrowmetal as am and check "polars" not in sys.modules; then collect one plan through am.MetalEngine(shapes="all", min_rows=0) and read engine.last_report.
  4. With POLARS_VERBOSE=1, collect a plan with an unsupported node (pl.col("v").rank()) and see the PerformanceWarning listing it.
  5. am.zero_copy_report(df["v"]) on a single-chunk numeric column of a million rows says the two addresses are the same.
  6. On polars 1.44, out, timings = engine.profile(lf) shows a metal: row, and PYTHONPATH=python python -m arrowmetal.polars_engine check prints the versions it was tested with.
  7. Run PYTHONPATH=python python Benchmarks/polars_engine_bench.py --sizes 2000000,50000000 --out a.csv twice on a quiet machine, compare the two files, and read the taken and rule columns of the MetalEngine default, cold rows against the table above. --crossover with more sizes, then python Benchmarks/polars_engine_crossover.py a.csv --print, fits the table from your own run.
  8. Read this section against engine.last_report on your machine.

Numbers

These are tiers 1 and 2, and tier 4 over a Parquet file at the end; tier 4's measurements over in-memory frames are in Benchmarks/results/polars_engine_bench_2026-10-02.csv and Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv (see "Tier 4" above).

Apple M4 Max, macOS 26.6.2, polars 1.44.1 (16 threads), pyarrow 25.0.1, ArrowMetal 0.1.0. Best of 5 runs after a warm-up, one process, one data set. Every figure below is from Benchmarks/results/polars_bench_50000000_2026-09-07.txt, except the String rows: those are ArrowMetal 0.2.0 with String columns handed over in Polars' view layout, best of 5, Benchmarks/results/polars_bench_strings_view_2026-09-26-quiet.txt (run conditions in Benchmarks/results/bench_conditions_2026-09-26-quiet.txt). Reproduce with:

PYTHONPATH=python python Benchmarks/polars_bench.py 50000000 5

Columns: k Int32 with 1000 distinct values, v Int64, amount Float64, name String drawn from 4096 distinct values. The "Polars" column is Polars' eager idiom; the lazy engine and pyarrow's Acero are on the Compare tab and in the benchmark matrix.

50M rows

Operation Polars tier 1 namespace tier 2 plugin GPU-resident
sum(Int64) 4.1 ms / 4.1 CPU-ms 10.1 ms (0.4x) 8.5 ms (0.5x) 1.1 ms (3.8x)
filter(k == 2) + sum(v) 4.3 ms / 9.4 CPU-ms 13.9 ms (0.3x) 11.8 ms (0.4x) 0.8 ms (5.1x)
group-by sum(v) by 1000 keys 79.3 ms / 1121 CPU-ms 20.1 ms (4.0x) 19.7 ms (4.0x) 1.9 ms, aggregate only, group ids cached
top_k(100) 60.0 ms / 60.1 CPU-ms 18.2 ms (3.3x) 18.0 ms (3.3x) 10.5 ms (5.7x)
string contains (literal) 671.1 ms / 670.8 CPU-ms 38.1 ms (17.6x) 290.4 ms (2.3x) 6.0 ms (112.4x)
string contains (literal), string_layout="offsets" 671.1 ms / 670.8 CPU-ms 316.7 ms (2.1x) 6.3 ms (106.9x)

The String hand-off of that column, 50M rows, in the same run:

String hand-off ms CPU-ms
pl.Series -> Metal, view layout (0 bytes copied, 40 data buffers) 30.0 27.7
pl.Series -> Metal, offsets (large_string) 309.0 308.9
Metal -> pl.Series, view layout 0.0 0.0

Reading the table

  • The "Polars" column is Polars' eager idiom, which is what Benchmarks/polars_bench.py measures and what Benchmarks/results/polars_bench_50000000_2026-09-07.txt records. The published baseline is the parallel run, Benchmarks/results/full_matrix_2026-09-07-parallel.csv, which adds Polars' lazy engine (polars-lazy, a median of 11.5 of the 16 cores); ratios there are lower on the rows where the lazy engine parallelises. The full matrix has both.
  • "GPU-resident" is the same kernel with the column already in Metal memory -- the import is outside the timed region, and, for the group-by rows, the am.group_by([k]) key-mapping pass as well: those rows time the aggregate only. It is what a pipeline that stays on the GPU sees, and it is the column that shows what the kernels are worth.
  • The group-by row is not a like-for-like ratio against Polars, whose 79.3 ms includes its whole hash group-by. The comparable end-to-end figure is in the benchmark matrix: sum by int32 key (1000 groups) at 50M rows is 4.89 ms against Polars lazy's 81.93 ms, 16.8x (Benchmarks/results/full_matrix_2026-09-07-parallel.csv), and 1.73 ms against 20.24 ms, 11.7x, at 10M. The fastest CPU idiom on that row is pyarrow's Acero (pyarrow-threaded), 18.45 ms at 50M, which puts the ratio at 3.8x.
  • The hand-off is the whole difference between the middle columns and the right one. At 50M rows the import is 5.25 ms and the export 0.01 ms, and the run records the import as a no-copy -- the Polars source buffer and the Metal buffer are the same address (zero copy: ... -> SAME). Every tier-1 and tier-2 row pays it once per call, so on a single sum Polars is ahead, and a group-by is 4x.
  • CPU-ms is the other half of the story. The 50M group-by costs Polars 1121 CPU-ms across 16 threads; ArrowMetal costs 14.0 CPU-ms end to end and 0.4 CPU-ms resident. On a laptop that is battery, and on a shared box it is 16 cores left free for something else.
  • Strings cross in Polars' own Utf8View layout: 50M rows hand over in 30.0 ms with no bytes copied, against 309.0 ms through large_string (offsets + bytes), which is a copy. Through the namespace contains is 17.6x with views and 2.1x with offsets; resident it is 112.4x (106.9x over offsets). The tier-2 plugin still asks Polars for large_string and is 2.3x. Keeping a string column resident (s.arrowmetal.to_metal()) takes the 38.1 ms call to 6.0 ms.

Where each tier is worth using

Row Use it when
Tier 1, one call The kernel is expensive relative to 400 MB of page mapping: group-by, sort, top-k, string search. Not a bare sum.
Tier 1, resident You run several kernels over the same column. to_metal() once, then every kernel in the table above is 0.8-10.5 ms.
Tier 2 The GPU op belongs inside a plan you want Polars to keep optimising -- scans, pushdown, and lazy composition still apply.
Tier 3 Polars should do the IO and the reshaping and ArrowMetal should do one heavy pass at the end.
Tier 4 You want Polars' own collect() and its answers, with the parts of the plan the GPU is measured ahead on (by default, from their measured crossovers: sorts, numeric-key inner, left and anti joins, unique, group-bys by their number of groups, sorts of Parquet files, and with a String column sorts, unique and the two-key group-by sum) run there.

Tier 4 over a Parquet file, 50M rows

pl.scan_parquet(file) under a filter, a group-by, an aggregate or a sort, over the 50,000,000-row, 8-column files of Benchmarks/parquet_bench.py (snappy, 1.65 GB, and uncompressed, 2.23 GB), collected by Polars' in-memory and streaming engines and by MetalEngine(shapes="all", min_rows=0). "cold" clears the open-file cache before every run, so the engine opens and maps the file each time; "warm" keeps it open between runs. Polars reads the file on every run; the file stays in the OS page cache throughout. Best of 5, every engine result equal to Polars', Benchmarks/results/polars_engine_scan_2026-09-26-quiet.csv (run conditions in Benchmarks/results/bench_conditions_2026-09-26-quiet.txt).

case codec Polars in-memory Polars streaming MetalEngine cold MetalEngine warm MetalEngine() default, cold
(s1) filter price > 500, group-by qty (1,000 keys), sum + count snappy 98.7 ms 45.6 ms 101.9 ms 91.3 ms 99.7 ms (Polars)
none 87.6 ms 36.1 ms 47.2 ms 31.6 ms 90.3 ms (Polars)
(s2) filter id < 5,000,000 (45 of 50 row groups skipped), group-by, sum snappy 21.8 ms 12.1 ms 35.0 ms 33.4 ms 24.1 ms (Polars)
none 16.2 ms 5.1 ms 8.5 ms 6.3 ms 17.2 ms (Polars)
(s3) filter on two columns, sum + count snappy 14.0 ms 13.7 ms 21.0 ms 13.9 ms 14.7 ms (Polars)
none 13.8 ms 13.1 ms 13.3 ms 5.9 ms 14.0 ms (Polars)
(s4) sort 2 columns by a Float64 key snappy 542.3 ms 559.4 ms 123.7 ms 116.3 ms 123.5 ms (Metal)
none 562.0 ms 563.2 ms 73.7 ms 64.7 ms 75.0 ms (Metal)
  • The sort is ahead cold and warm: 4.4x (snappy) and 7.6x (uncompressed) the faster Polars engine cold, 4.7x and 8.7x warm, and the default takes it (4.4x and 7.5x).
  • Cold, the other three are behind and to improve, from 35.0 ms against 12.1 ((s2), snappy) to 13.3 ms against 13.1 ((s3), uncompressed) (vs_fastest_polars). The cold runs include what the open-file cache removes: opening the file and handing the pages the query reads to Metal; a one-column read of these files is 6.9-12.2 ms through a fresh open and 3.2-7.6 ms through the cache (Parquet on the GPU, "The open-file cache"). The default leaves them to Polars.
  • Warm, over the uncompressed file (s3) is ahead of Polars' streaming engine (5.9 ms against 13.1, 2.2x) and so is (s1) (31.6 ms against 36.1, 1.1x); (s2) is behind at 6.3 ms against 5.1. Over the Snappy file, which the GPU decompresses first (Parquet on the GPU, "What the numbers say"), (s1) is behind at 91.3 ms against 45.6, (s2) at 33.4 against 12.1 and (s3) at 13.9 against 13.7.
  • CPU time: the warm engine runs cost 2.9-7.8 ms of process CPU, against 37.5-6733.8 ms for Polars (cpu_ms).
PYTHONPATH=python python Benchmarks/polars_engine_bench.py --scan-only --scan-rows 50000000 \
    --scan-codecs snappy,none --out scan.csv

Limits

Types. This paragraph is about what the bridge round-trips, which is a tier-1 and tier-3 question; tier 2's expressions accept a shorter list, set out in "What tier 2 accepts" above. Every dtype Polars and Arrow share round-trips: all signed and unsigned integer widths, Float32/64, Boolean, Date, Datetime (all units), Time, Duration, String, Binary. Categorical and Enum also work, but Polars encodes them as dictionary<uint32> and dictionary<uint8> while ArrowMetal wants int32 or int64 indices, so the bridge recodes the index buffer -- 4 bytes a row, values untouched. An Enum comes back from to_polars as a Categorical: the dictionary crosses, the fact that its value set was closed does not. List and Struct cross and round-trip, but only the structural kernels operate on them -- you cannot sum or group by one. Object is not bridged.

Chunking. am.from_polars takes one Arrow array. A multi-chunk Series is rechunked once, which does copy; am.from_polars(s, rechunk=False) raises instead, so the copy is never silent. The MetalEngine hands a multi-chunk column of an in-memory frame over as its own chunks, which the chunked import (am_import_chunks) copies straight into the final Metal buffers without Polars concatenating them first; Categorical, Enum and nested columns are still concatenated by to_arrow.

Order. Group order is ArrowMetal's, not Polars': ascending by key for numeric, boolean, temporal and decimal keys, first-seen for utf8 and binary, lexicographic in column order for several keys. Polars' group_by promises no order at all, so sort both sides before comparing. s.arrowmetal.unique() is first-seen, the order Polars' unique(maintain_order=True) gives, and it keeps a null as one of the distinct values rather than dropping it. Sorts put nulls last in both directions, where Polars' ascending default is nulls first -- pass nulls_last=True when comparing.

Strings. A String or Binary column crosses in Polars' own Utf8View / BinaryView layout (to_arrow(compat_level=newest)): its 16-byte views and data buffers are mapped into Metal without a copy, and export hands them back to Polars the same way. s.arrowmetal.to_metal().string_layout says "view". The string kernels read the views directly (the list is am.STRING_VIEW_KERNELS); the rest (am.STRING_VIEW_CONVERTS: str_concat, the splits, match_like, strptime, the byte-counting pads, utf8_zero_fill, the byte slices and reversals, casts from strings, the file writers) convert the column to offsets + bytes once, on the GPU, and keep that form, which string_layout then reports as "view (converted)". am.from_polars(s, string_layout="offsets") asks Polars for large_string instead, the layout used before the kernels read views; the tier-2 plugin still uses that. upper/lower are Unicode's simple 1:1 case mapping over every script (the GPU table covers U+0000–U+017F exactly; a row holding anything above is mapped on the host), so Greek and Cyrillic come back mapped. Simple, not full: the multi-character expansions are not applied, so U+00DF becomes ẞ where Polars' str.to_uppercase() gives SS, and fi stays put. contains / starts_with / ends_with are literal, not regex.

Arithmetic. .add/.sub/.mul/.truediv in tier 2 keep the column's own type and follow Arrow's unchecked rules for the operation: integers wrap, and integer division by zero yields 0 where Polars raises. The operand is checked -- one the column's type cannot hold raises rather than being clamped or truncated, and an integer operand is exact past 2^53. See "The scalar in .add" above.

Joins. GPU path only for a single-column inner or left join against a unique right key; everything else falls back to Polars (or raises with allow_cpu_fallback=False).

Aggregation inside group_by. Tier 2's group_by_sum is a projection, not a hash aggregate Polars calls per group -- the plugin API has no hook for that. See the tier-2 section.

Threads. ArrowMetal serialises command-buffer commits behind its own lock and keeps its error state thread-local, so Polars is free to call the plugin from several worker threads. The plugin takes no lock of its own.

Version coupling. Tier 2: the plugin is pinned to polars 0.55.1 / pyo3-polars 0.28 for py-polars 1.44.x. Tiers 1 and 3 speak the C Data Interface and are not coupled to a Polars version. Tier 4 is pure Python but reads Polars' optimised IR through an API Polars calls unstable; it was written against IR version (14, 7) of polars 1.44.1 and (15, 2) of polars 2.0.0, a test pins them, and an IR major outside these makes it leave every plan to Polars.


Tests

PYTHONPATH=python python -m pytest python/tests/test_polars.py -q     # 90 tests
cd polars-plugin/arrowmetal-sys && cargo test --release               # 10 tests
PYTHONPATH=python python -m pytest python/tests/test_polars_engine.py -q   # tier 4

test_polars_engine.py is described under "Tier 4", "Tests".

test_polars.py covers round trips for every shared dtype (plus Categorical, Enum, empty, all-null and chunked), the namespace methods against native Polars at five sizes from 0 to 100,003 rows, the plugin expressions inside lazy plans, the join against pl.DataFrame.join, zero-copy assertions on buffer addresses, and 50M-row timings as assertions with bounds generous enough not to flake. The plugin tests skip when the Rust library has not been built, so a checkout without a Rust toolchain still runs green -- build it before you trust a green run, or 28 of the 90 are skips (62 passed, 28 skipped).

The tier-2 block at the end of the file is the hostile-input pass: the scalar operand against the tier-1 bridge, nulls against native Polars, a sliced Series and a two-chunk one, an empty frame, an all-set validity bitmap with no nulls, the twelve dtypes the numeric expressions refuse (each one a Polars error carrying ArrowMetal's wording, never a pyo3 panic), and the expression inside group_by().agg() and inside collect(engine="streaming").

docs/DUCKDB.md

ArrowMetal and DuckDB

DuckDB is an excellent query engine. It reads Parquet, plans joins, pushes down predicates, keeps statistics, spills to disk, and saturates every core you have. ArrowMetal is not a query engine at all: it is a pile of Metal kernels that run one columnar operation very fast on the GPU that is already sitting in your Mac.

Those are complementary, and they meet for free. DuckDB hands results out over the Arrow C Data interface; ArrowMetal takes Arrow buffers in. On Apple silicon the CPU and GPU share one physical memory pool, so am_import copies nothing - it wraps DuckDB's own pages in a Metal buffer. The address on the far side is the same address. python/tests/test_duckdb.py asserts it. Getting to one contiguous chunk does copy when DuckDB returns many; see §5.

The summary. The Python bridge, at 50M rows: a 100,000-key group-by is 16x faster than DuckDB's once the column and its group ids are already on the GPU (1.1x for a single cold query), and a string LIKE scan is 9.3x resident (0.6x cold). DuckDB is ahead on a plain sum over a column, which is memory-bound and which DuckDB does while it scans. The loadable extension works and matches DuckDB's answers exactly; it is behind DuckDB's own SQL as measured, and §4 says why.

A third piece needs no change to the SQL at all. The rewrite extension is a DuckDB optimizer extension: loaded into a connection, it moves eligible aggregates of ordinary queries onto the GPU, with DuckDB's exact answers, and in its default mode only for the shape classes whose every benchmarked query was ahead of DuckDB's own operators, from the size where that held. §4b has what it rewrites, the feasibility finding behind it, and where it is ahead and where it is not.


1. Which tier you want

Row Tier 1: Python bridge Tier 2: loadable extension Tier 3: rewrite extension
You write Python around SQL SQL only SQL only, unchanged
Install pip install duckdb, nothing else build a .duckdb_extension, connect with allow_unsigned_extensions build it against the installed DuckDB release (build_rewrite.sh), connect with allow_unsigned_extensions, or am.duckdb_connect()
Data crossing zero-copy where DuckDB returns one chunk DataChunks assembled into one buffer (a copy) DuckDB's scan output copied into page-aligned buffers the GPU reads in place
Types everything DuckDB emits, including strings, decimals, lists, structs, maps the fixed-width numeric types, DATE, TIMESTAMP integer columns (MIN/MAX also DATE, TIMESTAMP); an integer, DATE, TIMESTAMP or VARCHAR group key
Speed resident: 16.0x on 100k-key group-by, 9.3x on LIKE, 2.2x on sort; one-shot (crossing included): 1.1x, 0.6x, 0.7x (§5) behind DuckDB in 0.1.0, see §4 in auto, rewrites only the shape classes whose every benchmarked query was ahead of DuckDB, from that size (§4b)
Larger than memory yes, am.duckdb_batches no no: the aggregate's input is gathered in memory (a streamed plan holds only the blocks in flight, §4b)

If you are reading this to make something faster: use tier 1, or tier 3 when the SQL must stay as it is and its aggregates fall in §4b's table. Tier 2 exists because "call it from SQL" is a real requirement for some people.


2. Install

Both tiers need the library built:

DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build -c release --product ArrowMetalC

Tier 1

pip install duckdb
PYTHONPATH=python python -c "import duckdb, arrowmetal as am; print(am.device_name())"

The bridge is imported lazily, so import arrowmetal still works with no duckdb installed; the am.from_duckdb attribute is what pulls it in.

Tier 2

./duckdb-extension/build.sh

That compiles one translation unit, links libArrowMetalC.dylib, and appends the 512-byte metadata footer DuckDB checks before it will dlopen anything. On the first run it downloads duckdb.h and duckdb_extension.h into duckdb-extension/third_party/; afterwards it needs no network. duckdb-extension/CMakeLists.txt drives the same compile if you would rather use cmake:

cmake -S duckdb-extension -B duckdb-extension/build -DCMAKE_BUILD_TYPE=Release
cmake --build duckdb-extension/build

Then load it. Unsigned extensions have to be allowed explicitly, and that is a per-connection config setting, not a SET you can issue afterwards:

import duckdb
con = duckdb.connect(config={"allow_unsigned_extensions": "true"})
con.execute("LOAD 'duckdb-extension/build/arrowmetal.duckdb_extension'")
con.sql("SELECT arrowmetal_version(), arrowmetal_device()").show()

From the CLI: duckdb -unsigned, then LOAD '.../arrowmetal.duckdb_extension';.

Versions. Built and tested against DuckDB 1.5.6 (source_id 069cc9f9b5), platform osx_arm64, pyarrow 25.0.1, on an Apple M4 Max. The extension declares the C extension API version v1.2.0, not the DuckDB version - a C_STRUCT extension is compatible with any DuckDB whose C API is at least what it declares, so it is not pinned to 1.5.6. The platform string is checked strictly: an osx_arm64 build will not load into an osx_amd64 DuckDB. Rebuild with DUCKDB_PLATFORM=... ./duckdb-extension/build.sh to target another one.

Tier 3

PYTHON=/path/to/the/python/with/duckdb ./duckdb-extension/build_rewrite.sh

It reads the DuckDB release and source_id from that Python's duckdb module, shallow-clones the matching DuckDB tag into duckdb-extension/build/duckdb-<version>/ on first run (only the headers are used; nothing of DuckDB is compiled), checks the clone's commit against the source_id, compiles src/arrowmetal_rewrite.cpp, checks that the module exports every DuckDB symbol the extension needs, and stamps the footer with the CPP ABI and that exact release. The result is duckdb-extension/build/arrowmetal_rewrite.duckdb_extension. Built and tested against DuckDB 1.5.6 (source_id 069cc9f9b5) in the duckdb 1.5.6 Python module.

import arrowmetal as am
con = am.duckdb_connect()     # duckdb.connect + allow_unsigned_extensions + LOAD + SET arrowmetal_rewrite = 'auto'

or by hand: duckdb.connect(config={"allow_unsigned_extensions": "true"}), then LOAD '.../arrowmetal_rewrite.duckdb_extension'.


3. Tier 1: the Python bridge

python/arrowmetal/duckdb_bridge.py. Nine entry points, all reachable straight off am; six of them are below, and the other three are duckdb_reader, duckdb_table and duckdb_is_zero_copy.

Pull a result onto the GPU

import duckdb, arrowmetal as am
con = duckdb.connect()

cols = am.from_duckdb(con.sql("select region, amount from sales where placed_at >= date '2024-01-01'"))
# -> {"region": MetalArray, "amount": MetalArray}

from_duckdb takes a DuckDBPyRelation, a SQL string with con=, a connection holding a pending result, or any pyarrow table or stream. Columns whose type ArrowMetal cannot lift are left as pyarrow arrays in the same dict; pass on_unsupported="raise" to be told instead.

test_every_duckdb_type_round_trips checks 25 of DuckDB's types: every integer width and signedness, both floats, BOOLEAN, VARCHAR, BLOB, DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL, DECIMAL(10,2), DECIMAL(38,2), HUGEINT, LIST, STRUCT and MAP. UUID is checked as its VARCHAR rendering; the native type is not exercised.

Push results back

gb = am.group_by([cols["region"]])
rel = am.to_duckdb(con, "gpu_totals", {"region": gb.keys()[0], "total": gb.sum(cols["amount"])})

con.sql("select * from gpu_totals order by total desc limit 10").show()
con.sql("select r.name, g.total from gpu_totals g join regions r on r.id = g.region").show()

to_duckdb registers through con.register, which takes the Arrow table over the C Data interface - DuckDB reads the GPU-produced buffers in place. The view behaves like any other relation: join it, filter it, insert from it. It lives until con.unregister(name).

Both halves at once

rel = am.duckdb_gpu_query(
    con,
    """select o.customer, o.amount
       from orders o join customers c on c.id = o.customer
       where o.placed_at >= date '2024-01-01'""",
    then=lambda cols: {
        "customer": am.group_by([cols["customer"]]).keys()[0],
        "total":    am.group_by([cols["customer"]]).sum(cols["amount"]),
    },
    name="per_customer")

rel.order("total desc").limit(10).show()

DuckDB does the scan, the predicate and the join. ArrowMetal does the aggregate. That is the whole idea, and it is the arrangement that pays: the join is a planning problem DuckDB is good at, and the group-by over the join's output is a throughput problem the GPU is good at.

A fused query over a DuckDB result

am.query compiles a whole filter-and-aggregate expression into one Metal kernel, so the column is read once no matter how many operators the expression has (see Fused expressions):

q = am.filter((am.col("amount") > 100) & am.col("region").is_in([2, 7])).aggregate([
        ("sum", "total", am.col("amount")),
        ("count", "n", None)])
am.query(am.from_duckdb(con.sql("select region, amount from sales")), q)
# {"total": ..., "n": ...}

Tables larger than memory

fetch_record_batch gives an Arrow C Stream that produces batches lazily. am.duckdb_batches iterates it as {name: MetalArray} with only one batch resident at a time:

total = 0
for batch in am.duckdb_batches(con.sql("select amount from huge_parquet"), rows_per_batch=1 << 22):
    total += batch["amount"].sum()

Two wrappers do the merging for you:

am.duckdb_aggregate(con.sql("select amount, region from huge"),
                    {"total": ("sum", "amount"), "n": ("count", "amount"),
                     "regions": ("count_distinct", "region")},
                    rows_per_batch=1 << 22)

am.duckdb_group_by(con.sql("select region, amount from huge"), "region",
                   {"total": ("sum", "amount"), "avg": ("mean", "amount")})

What is exact, and what is refused. This is the part that usually goes wrong quietly elsewhere, so the bridge is explicit about it:

aggregate exact across batches? how it merges
sum, count, min, max yes the merge is the same associative operator as the aggregate
mean yes, to float64 rounding total sum over total count - never a mean of batch means, which is wrong for unequal batches
count_distinct yes the host unions each batch's distinct values, so memory is proportional to cardinality, not to the batch
median, quantile, stddev, mode, tdigest no refused with an error. These have no exact merge from independent partials, and returning an approximation under the exact aggregate's name would be a lie. Compute them over a whole batch, or over a sample.

test_streaming_aggregate_is_batch_size_independent runs the same sum at 3,000 / 30,000 / 1,000,000 rows per batch and requires all three answers to be identical, and identical to DuckDB's.


4. Tier 2: the loadable extension

duckdb-extension/. Built against DuckDB's C extension API, which means it needs no DuckDB source tree, no C++ ABI match with the host engine, and no rebuild for each DuckDB patch release. The loader hands it a struct of function pointers and everything after that is plain C.

The functions

SELECT arrowmetal_version();                          -- '0.5.0'
SELECT arrowmetal_device();                           -- 'Apple M4 Max'

SELECT * FROM arrowmetal_agg('sales', 'amount');      -- sum, count, min, max, mean
SELECT * FROM arrowmetal_group_by('sales', 'region', 'amount');  -- key, count, sum, min, max
SELECT * FROM arrowmetal_top_k('sales', 'amount', 100);          -- the 100 largest, descending
SELECT * FROM arrowmetal_sort('sales', 'amount');                -- every value, ascending
SELECT * FROM arrowmetal_query('sales',
    '(query (filter (gt (col "amount") (int 100)))
            (aggregate (sum "total" (col "amount")) (count "n")))');

The results are ordinary relations, so they join:

SELECT r.name, g.sum
FROM arrowmetal_group_by('sales', 'region', 'amount') g
JOIN regions r ON r.id = g.key
ORDER BY g.sum DESC;

arrowmetal_query takes the s-expression from Fused expressions and returns one row per aggregate: name, value (a DOUBLE) and exact (a BIGINT, non-null when the aggregate is integral). Both columns exist because a sum past 2^53 is exact in exact and rounded in value, and pretending otherwise would silently corrupt large integer totals.

Aggregate functions - SELECT am_sum(x) FROM t - are deliberately not registered even though duckdb_create_aggregate_function exists. DuckDB would call them once per 2048-row DataChunk, and a 2048-row GPU dispatch is all latency and no work; the CPU would be ahead and it would look like ArrowMetal's fault. Table functions get the whole column at once, which is the only shape where a GPU dispatch amortises.

It is correct, and DuckDB's own SQL is ahead of it

Every function's answer matches DuckDB's own SQL exactly, nulls included - python/tests/test_duckdb.py checks each one against the equivalent query on the same connection.

The header compiles: am_plan_source is the typedef and am_plan_source_create the function. test_the_public_c_header_compiles compiles the public header as C on every run; the @extension tests skip only when the extension has not been built. Tier 1 does not go through the header and is unaffected either way.

And plain SQL is ahead of it. The extension's timings are not recorded: Benchmarks/duckdb_bench.py measures the Python bridge only, and python/tests/test_duckdb.py checks the extension's answers without timing them.

The same GPU group-by from the Python bridge runs in 4.5 ms at 50M rows (§5), so the time is in the extension's path. Three things account for it:

  1. The DataChunk assembly is single-threaded. DuckDB emits ~2048-row chunks and one ArrowMetal array is one contiguous buffer, so the extension memcpys chunk after chunk on one thread while DuckDB's own aggregate is running on all of them. This is the dominant cost.
  2. duckdb_query materialises the whole result first, so the rows are copied once inside DuckDB before the extension has touched them.
  3. The extension re-reads the table on every call, because a table function has nowhere to cache. The bridge pays the crossing once and then runs twenty operations on resident data.

4b. Tier 3: ordinary SQL, rewritten

duckdb-extension/src/arrowmetal_rewrite.cpp is a DuckDB optimizer extension. The query does not change. After DuckDB's own optimizers have run, the extension looks at every aggregate in the plan, and where the shape is one it answers exactly (and, in the default mode, the size is one where the GPU was measured ahead) it puts ARROWMETAL_AGGREGATE where DuckDB's HASH_GROUP_BY or UNGROUPED_AGGREGATE would have been. The scan, the pushed-down filters and the projections below it are still DuckDB's, planned and run exactly as before; so is everything above it.

LOAD 'duckdb-extension/build/arrowmetal_rewrite.duckdb_extension';

SELECT region, sum(amount), count(*), max(amount) FROM sales GROUP BY region;   -- as written

EXPLAIN SELECT region, sum(amount) FROM sales GROUP BY region;   -- ARROWMETAL_AGGREGATE when rewritten
SELECT * FROM arrowmetal_rewrites();    -- every decision, with the reason, the threshold and the GPU path

SET arrowmetal_rewrite = 'auto';        -- the default: supported shapes, at the sizes measured faster
SET arrowmetal_rewrite = 'off';         -- plain DuckDB
SET arrowmetal_rewrite = 'force';       -- every supported shape, whatever its size

The three values are read in any letter case, and 'off' is also spelled 'false' or '0'. Any other value ('on', 'true', ' force' with a space, NULL) is an error from the SET itself, arrowmetal_rewrite: unrecognised value 'on'; expected 'auto', 'off' (also 'false' or '0') or 'force', and the mode stays what it was. RESET arrowmetal_rewrite returns to 'auto'.

From Python, am.duckdb_connect() opens a connection with the extension loaded, am.duckdb_is_rewritten(con, sql) says whether the plan of sql has an ARROWMETAL_AGGREGATE, and am.duckdb_rewrites(con) returns the decision log as a pyarrow.Table.

Why a C++ extension: the feasibility finding

  • DuckDB's C extension API has no optimizer hook. Tier 2 is built on it. Its header (duckdb_extension.h, the v1.2.0 C API that DuckDB 1.5.6 ships) has one planner-adjacent entry point, duckdb_add_replacement_scan, which swaps a table function in for a table name before binding; nothing in it sees or changes a logical plan.
  • The C++ API has one. OptimizerExtension (duckdb/optimizer/optimizer_extension.hpp) takes an optimize_function that DuckDB calls with the whole logical plan after its own optimizers, registered with OptimizerExtension::Register(DBConfig &, ...). LogicalExtensionOperator and PhysicalOperator let the extension put its own operator into the plan.
  • A C++ extension loads into the Python module. It leaves DuckDB's symbols unresolved and takes them from the process that loads it, and the duckdb 1.5.6 Python module exports DuckDB's C++ symbols, OptimizerExtension::Register among them. Compiled against the v1.5.6 headers (the module's source_id, 069cc9f9b5) with -undefined dynamic_lookup and stamped CPP / v1.5.6, the extension loads into that module with allow_unsigned_extensions. No DuckDB build is needed, only its headers. build_rewrite.sh checks the symbols on every build, because a missing one would abort the process at its first call instead of failing the LOAD.
  • So the Python-level alternative is not needed. Intercepting queries in a wrapper and routing them by their EXPLAIN would work only from Python and only for queries sent through the wrapper; the optimizer route works for any client of the loaded connection. am.duckdb_connect only loads it.

The price of the C++ route is the pin: DuckDB loads a CPP extension only into the exact release in its footer, where the C-API extension of tier 2 loads into any DuckDB with C API 1.2 or later. A new DuckDB release means rebuilding with build_rewrite.sh, which fetches that release's headers, and possibly changing the source, since DuckDB's C++ API is not stable across releases: against the 1.4.5 headers it does not compile (OptimizerExtension::Register and PhysicalOperator::GetDataInternal are not there yet).

What is rewritten

Row Eligible
Aggregates none (SELECT k FROM t GROUP BY k), or any of: sum and avg over integer columns of every width and signedness except UBIGINT (which DuckDB sums through a cast to HUGEINT); min and max over integer, DATE and TIMESTAMP columns; count(x) over a column of one of those types, count(*). Without GROUP BY, a query whose aggregates are all counts stays DuckDB's (the log's reason: "only counts, which need no kernel"). The argument is a column, or a column under the widening integer cast DuckDB inserts itself (sum over TINYINT is sum(CAST(x AS BIGINT))). No DISTINCT, FILTER or ORDER BY inside the aggregate.
Grouping none, or one column that is an integer, DATE, TIMESTAMP or VARCHAR column. No GROUPING SETS, ROLLUP or CUBE.
Input projections and filters over one table function whose row count DuckDB's planner knows: a table's seq_scan, read_parquet. A join, a window or another aggregate below the aggregate, or a source that does not report its size (a Python-registered Arrow table's arrow_scan), leaves it to DuckDB, and so does an input DuckDB has already replaced with an empty result (a WHERE v IS NOT NULL over a column its statistics know to be all NULL).

What DuckDB has already done to the plan stays done: the filters it pushed into the scan, its compressed materialization of the group key, its rewrite of sum(x + 1) into sum(x) plus a count, and its switch from sum to sum_no_overflow where its statistics prove the total fits in 64 bits. test_unsupported_shapes_are_left_alone covers the shapes left to DuckDB and the reason the log gives for each; test_a_parquet_scan and test_a_registered_arrow_table_is_left_to_duckdb the two sources.

Exactly DuckDB's answers

python/tests/test_duckdb_rewrite.py runs every query twice on the same connection, with arrowmetal_rewrite off and forced, and requires the same column types and the same values bit for bit, avg included; only the order of an unordered GROUP BY may differ, since SQL does not define it, and with ORDER BY the order is compared too. Every test that takes a connection runs twice, once where its table fits one block and once with 2,048-row blocks, so the streamed path is covered by the same queries. The tables come from hash() of the row number and reach both ends of every integer type.

  • Integer sums are exact to HUGEINT. DuckDB's sum over an integer column returns HUGEINT. A 64-bit column is summed as its high and low 32-bit halves, each of which fits in 64 bits over fewer than 2^31 rows, and the halves are recombined in 128 bits; where DuckDB's statistics already proved the total fits in 64 bits (sum_no_overflow), it is summed directly, and narrower columns cannot leave 64 bits. test_sums_past_int64_are_exact_hugeints sums values at both ends of BIGINT.
  • avg uses DuckDB's own arithmetic: double(sum) / double(count) over SMALLINT, and Hugeint::Cast<long double>(sum) / count over INTEGER and BIGINT, the two finalizers in DuckDB's avg.cpp, so the doubles are the same doubles.
  • NULLs: count(x) skips them; sum, avg, min and max of a group with no value are NULL; an empty input gives one row of zero counts and NULLs without GROUP BY and no rows with it; the NULL key is one group; a table whose keys are all NULL is one group. Each has a test.
  • Floating-point sum and avg are not rewritten: DuckDB's own float sums depend on how its threads split the input, so there is no single answer to match. min and max over floats are not rewritten either: DuckDB orders NaN above every number, where ArrowMetal's min/max skip NaN.

The conformance grid. python/tests/engine_duckdb_grid.py generates the same comparison over a grid instead of chosen queries: sum, avg, min, max, count(v), count(*), all of them in one query, and a GROUP BY with no aggregate, over the ten value types the extension rewrites and six it leaves to DuckDB (DOUBLE, DECIMAL(18,3), HUGEINT, BOOLEAN, TIMESTAMP_NS, TIMESTAMPTZ), with no key or one key of each kind it takes (INTEGER with NULLs, a wide BIGINT, a negative SMALLINT, UTINYINT, DATE, TIMESTAMP, VARCHAR with NULLs), no filter or one of two, value columns with no, 5%, 70% or all NULLs, and tables of 0, 1, 7, 1,000 and 100,000 rows (the last also in 2,048-row blocks), plus integer extremes. Each query runs with the rewrite 'off' and 'force' on one connection. In the run recorded in Benchmarks/results/engine_conformance_2026-10-04.csv, on DuckDB 1.5.6, 33,376 queries: 21,844 rewritten and identical to DuckDB's answer, 11,532 left to DuckDB by the extension (and identical, as they must be), and none different. The per-shape counts are in Benchmarks/results/engine_conformance_2026-10-04_shapes.csv; Coverage summary has the summary.

How it runs

  • The sink. ARROWMETAL_AGGREGATE is a parallel sink. Each DuckDB thread reserves row positions for its chunk with one atomic add and copies the aggregate's input columns into page-aligned buffers without a lock; a NULL clears its bit in an Arrow validity bitmap. A string key reserves its rows and bytes together under a lock, so the offsets stay in row order.
  • Slabs. A fixed-width column's buffer comes from a pool kept across queries. Each slab is imported into ArrowMetal once, over its whole capacity; a query writes its rows into the same memory and hands ArrowMetal a zero-copy slice of the first rows, so the GPU wrapping is made once per slab rather than once per query. The pool keeps at most 4 GB of mappings, least recently used out first.
  • Three GPU paths. Without GROUP BY, one fused query computes every aggregate in one pass (am_query, Fused expressions). With an integer key whose values span at most 2^20, the fused dense group-by keeps one slot per key value, split into several passes when the table would not fit in threadgroup memory. Anything else - a VARCHAR key, a wider key range, a 64-bit min/max under a GROUP BY - takes the hash group-by (am_group_by_keys and am_group_agg_ex). A short VARCHAR key that DuckDB's compressed materialization has already turned into an integer arrives as that integer and takes the integer paths.
  • Streamed plans. An ungrouped aggregate, and a group-by whose key DuckDB's statistics put within 65,536 values (with min/max over at most 32-bit columns), is processed in blocks of arrowmetal_rewrite_block_rows rows (default 16,777,216). Each block goes to a GPU worker thread the moment its last row lands, while DuckDB is still scanning; Finalize runs the last, partly filled one and merges the per-block results on the host, exactly (sums in 128 bits). The other plans take up to 2^31 - 1 rows, the GPU kernels' 32-bit row index; a streamed plan is not held to that, since each block is smaller and the partial sums are added in 128 bits.
  • Rows past the reservation. The buffers are sized from the source's row count at planning time. Rows beyond that - a prepared statement run after the table grew - are kept aside and appended in Finalize (test_a_prepared_statement_after_the_table_grew, test_a_streamed_plan_past_its_block_directory).
  • One GPU. Rewritten queries on different connections take turns on the GPU, under one process-wide lock (test_concurrent_queries_on_two_connections).

When auto rewrites

Two conditions, both recorded for each decision in arrowmetal_rewrites():

  1. The router's crossover. DuckDB's estimate of the rows reaching the aggregate is at or above the largest crossover among the query's aggregates in Benchmarks/results/router_2026-09-24.json: the reductions rows without GROUP BY, the group-by rows with it, in the 1,000-group or 100,000-group class by DuckDB's estimate of the group count (split at 10,000, the geometric middle), and the utf8 rows for a VARCHAR key. test_crossovers_match_the_router_sweep holds the constants to the JSON. The constants are read from Benchmarks/results/router_2026-09-24.json, which supersedes the 2026-09-17 sweep; of the rows used here only min(int64, 10% nulls) moved between the two, 50,000,000 then and 10,000,000 now. min without GROUP BY is gated by the ungrouped class's floor of 50,000,000 rows below (or not rewritten, with one or two aggregates), so no auto decision changes; at 10,000,000 rows the reason for such a query now reads "below the measured floor" where the results file, measured before the change, says "below the crossover".
  2. A measured floor. The router's crossovers compare kernels on data already on the GPU. Here every row is first copied out of DuckDB's scan, and DuckDB's own aggregate runs while it scans, so the query's shape class must also have been measured faster than DuckDB's operators, from the size in this table, by Benchmarks/duckdb_rewrite_bench.py:
Shape class auto from
no GROUP BY, with three or more of sum/min/max/avg 50,000,000 rows
fused group-by, an estimated 10,000 groups or more (with no aggregates too) 10,000,000 rows
fused group-by, fewer groups, three or more of sum/min/max/avg 50,000,000 rows
hash group-by, an estimated 10,000 groups or more (with no aggregates too) 50,000,000 rows
no GROUP BY with one or two of sum/min/max/avg; fused group-by with fewer groups and at most two; hash group-by with fewer groups; a VARCHAR key not rewritten in auto

The benchmark's queries in each class are the rows of the results file whose shape_class column names it; a query the file does not list is in auto because of its class, not because it was timed. test_measured_floors_are_in_the_benchmark_results requires every row of a rewritten class to be ahead at the class's floor, and no row of any other class to be rewritten.

The measurements are in Benchmarks/results/duckdb_rewrite_2026-10-04-ac.csv, a quiet run on DuckDB 1.5.6 on AC power (its conditions in Benchmarks/results/duckdb_rewrite_2026-10-04-ac_conditions.txt): for each query and size (1M, 10M, 50M rows), DuckDB's time and the rewrite's in three alternating rounds, each mode's untimed warm-up of at least 100 ms followed by five timed runs: the best wall time with its CPU time, the median, the best first run after 500 ms of idle, the GPU path taken, and what auto decided. Its header records the load average at the start and the end of the run. test_measured_floors_are_in_the_benchmark_results requires every class in the table to be faster in that file at its floor. The floors were first fitted to a run taken while other work shared the machine, kept as history in Benchmarks/results/duckdb_rewrite_2026-09-23_provisional.csv, and checked by a second run on 2026-09-24 (Benchmarks/results/duckdb_rewrite_2026-09-24.csv, one warm-up and the best of five, its conditions in Benchmarks/results/bench_conditions_2026-09-24.txt); the run of 2026-10-02 on DuckDB 1.5.5 (Benchmarks/results/duckdb_rewrite_2026-10-02.csv) and the two runs of 2026-10-04 on DuckDB 1.5.6, on battery power (Benchmarks/results/duckdb_rewrite_2026-10-04.csv) and on AC power, give the same floors; DuckDB's own best times in the AC run are 0.96 to 1.12 times those of the 1.5.5 run, query for query. Reproduce with:

./duckdb-extension/build_rewrite.sh
PYTHONPATH=python python Benchmarks/duckdb_rewrite_bench.py        # 1M, 10M and 50M rows

Each floor is the smallest of the three sizes at which every query of the class is ahead of DuckDB, at that size and above, on the best and on the median run. Every query auto rewrote is ahead of DuckDB, by 1.08x (sum, max, avg (BIGINT) at 50M rows; 1.04x on the median) to 5.32x (100k INTEGER keys alone (no aggregates) at 50M rows; 3.76x on the median). One query of each rewritten class keeps the class out of auto at the size below its floor: sum, max, avg (BIGINT) at 10M rows (0.87x) for the ungrouped class, 100k INTEGER keys: sum, min, max, avg at 1M (0.71x) for the fused group-by with many groups, 1k INTEGER keys: sum, count, min, max, avg at 10M (0.55x) for the fused group-by with fewer groups, and ~1M wide BIGINT keys: sum, count at 10M (0.94x) for the hash group-by with many groups.

The classes left to DuckDB are the ones where its own operators came out ahead in that file, or not ahead consistently: a single sum (DuckDB aggregates while it scans, and the rewrite has to copy the column out first), few groups with at most two aggregates, and VARCHAR keys that DuckDB keeps as strings. In each of them at least one query is behind DuckDB at every size, 50M rows included: sum(INTEGER) at 0.32x, 1k INTEGER keys: sum at 0.59x, ~3k wide BIGINT keys: sum at 0.92x and 1k long VARCHAR keys: sum, count at 0.22x. Some of their queries were ahead: sum and sum, avg over full-range BIGINT values (1.92x and 3.76x at 50M rows), 10k INTEGER keys: sum, count (1.48x at 50M, 0.85x at 10M) and 1k short VARCHAR keys: sum, count (1.12x at 50M). In the ungrouped case DuckDB's time depends on the values, which the plan does not show: avg and sum, avg over non-negative BIGINT values are to improve (0.64x and 0.83x at 50M rows), while the same aggregates over full-range BIGINT values are ahead. Below a rewritten class's floor, or below the router's crossover, some queries were ahead as well, for example sum, count, min, max (BIGINT, 10% NULL) at 10M rows (1.44x) and 100k INTEGER keys alone (no aggregates) at 1M (1.52x); the floor holds each class to its slowest query.

To improve: on the first run after 500 ms of idle, four of the thirteen rewritten query-size pairs are behind DuckDB's first run after the same idle, all at 50M rows: sum, max, avg (BIGINT) 22.38 ms against 8.55 ms (0.38x), sum, count, min, max (BIGINT, 10% NULL) 29.39 against 23.72 ms (0.81x), sum, min, max, avg (INTEGER) 12.54 against 10.34 ms (0.82x) and 1k INTEGER keys: sum, count, min, max, avg 36.68 against 32.61 ms (0.89x). The other nine are 1.09x to 2.26x ahead on that run.

Limits

  • Built for one DuckDB release and one platform string at a time; unsigned.
  • The decision is made when the plan is: a prepared statement keeps the decision made at PREPARE, and EXPLAIN records a decision of its own.
  • A plan keeps the key statistics it was made with. Where DuckDB would have run the group-by as its PERFECT_HASH_GROUP_BY (an integer key whose statistics span at most 2^perfect_ht_threshold - 2 values, 12 bits by default), a key that lands past the table those statistics sized raises DuckDB's own error, Perfect hash aggregate: aggregate group N exceeded total groups M. This likely means that the statistics in your data source are corrupt., as DuckDB does with the rewrite off. That happens when a prepared statement runs after keys outside the planned range were inserted: the plan's compressed materialization narrowed the key to key - min in a smaller unsigned type, in which such keys wrap. Where DuckDB would have used its hash group-by, which does not check, the rewrite does not either, and both answer over the same narrowed keys (test_a_prepared_statement_whose_key_outgrew_its_statistics, test_where_duckdb_does_not_check_the_statistics_neither_does_the_rewrite).
  • One known difference in that error: for an 8-bit key (TINYINT, UTINYINT, which the plan does not narrow) below the planned minimum, the group number. Such a key's slot is negative, key - min + 1; DuckDB prints it as 2^128 plus the slot and the rewrite as 2^64 plus the slot, so statistics of [10, 14] and a key of 3 read group 340282366920938463463374607431768211450 from DuckDB and group 18446744073709551610 from the rewrite. The difference is deterministic; the exception class (InvalidInputException) and the rest of the text are the same, and keys of 16 bits or more, or above the maximum, give the same number. A key exactly one below the minimum has slot 0, which is the slot DuckDB keeps for the NULL key: DuckDB returns that key's rows as the NULL group, with no error, and the rewrite returns them under the key itself (test_an_8_bit_key_below_the_planned_minimum).
  • The log keeps the last 1,024 decisions of the process and is shared by every connection; a rewritten plan's path, rows_seen, groups and gpu_ms describe its latest run.
  • The aggregate's input is held in memory: the buffers are reserved for the source's row count and filled as rows arrive (a streamed plan holds only its blocks in flight). Freed slabs stay mapped in the pool, up to 4 GB.
  • The shapes in "What is rewritten" only: one group key, no floating-point sum/avg, no DECIMAL, HUGEINT or BOOLEAN values, no min/max over strings.

5. Numbers

Apple M4 Max, DuckDB 1.5.5, pyarrow 25.0.1, ArrowMetal 0.1.0. Best of 5, wall ms, with the process's CPU time beside it. Every figure in this section is from Benchmarks/results/duckdb_bench_50000000_2026-09-07.txt. Reproduce with:

PYTHONPATH=python python Benchmarks/duckdb_bench.py 50000000 5

Both sides compute the same answer from the same in-memory DuckDB table, and the benchmark checks that they agree before it reports a time.

50M rows

operation DuckDB CPU-ms GPU (resident) CPU-ms GPU + crossing resident one-shot
filter + sum 1.1 13.7 3.1 0.8 42.8 0.4x 0.0x
filter + sum, fused query 1.1 13.6 1.1 0.4 40.6 1.0x 0.0x
group-by sum, 1k keys 7.6 112.2 2.1 0.6 68.8 3.6x 0.1x
group-by sum, 100k keys 71.4 1056.2 4.5 0.6 65.9 16.0x 1.1x
top_k(100) 5.7 12.2 9.7 3.4 54.1 0.6x 0.1x
sort 39.2 570.4 17.6 1.4 57.3 2.2x 0.7x
count(s like '%user1%') 47.4 718.5 5.1 0.4 84.7 9.3x 0.6x
avg(f) 2.8 40.8 2.5 0.4 42.8 1.1x 0.1x

Look at the CPU-ms columns as well as the wall-ms. DuckDB's 100k-key group-by at 50M rows costs 1,056 CPU-ms to produce 71.4 ms of wall time - it is using about fifteen cores. The GPU's costs 0.6 CPU-ms. If anything else on the machine wants those cores, that difference is the real one.

What the crossing costs

At 50M rows, one BIGINT column (406 MB):

to_arrow_table       19.7 ms   (407 chunks - DuckDB handing over pointers, not bytes)
combine_chunks       26.6 ms   (a real copy: 407 chunks into one buffer, 15.3 GB/s)
am_import             5.7 ms   (buffer shared, not copied - the pointer is unchanged)
                    -------
total                52.1 ms

The Metal import copies nothing; combine_chunks is the largest single item. It is the price of ArrowMetal wanting one contiguous array per column and DuckDB producing many. am.duckdb_batches avoids it entirely by keeping the chunks separate. Streaming a sum through the GPU one record batch at a time, never materialising the column:

rows per batch wall ms CPU-ms
262,144 98.7 114.1
1,048,576 67.5 78.9
4,194,304 76.1 76.0

All three are exact. On this run the whole-table crossing (52.1 ms) is ahead of the streaming path (67.5 ms at its best batch size), so streaming is a memory-footprint argument rather than a speed one.

This is also why the "one-shot" column above is mostly below 1.0x: for a single operation the crossing dominates, and DuckDB is ahead. The GPU pays off when you do several things to one dataset, or one expensive thing.

50M rows out of Parquet

con.execute("copy (select i::BIGINT amount, (hash(i) % 1000)::INTEGER region "
            "from range(50000000) r(i)) to 'big.parquet' (format parquet)")

con.sql("select sum(amount) from read_parquet('big.parquet')")
am.from_duckdb(con.sql("select amount from read_parquet('big.parquet')"))["amount"].sum()
am.duckdb_aggregate(con.sql("select amount from read_parquet('big.parquet')"),
                    {"total": ("sum", "amount")}, rows_per_batch=1 << 22)

All three give 1249999975000000. DuckDB is ahead here: a single sum over a Parquet file is exactly the case where DuckDB aggregates during the scan and never materialises anything. Reach for the GPU when the aggregate is the expensive part, not the scan.

The matrix rows, DuckDB alongside

The full matrix has no DuckDB column, because DuckDB is a query engine rather than an array library: the comparison needs a table, a query, and a decision about where the result lands. Benchmarks/duckdb_matrix.py makes those decisions explicit and measures DuckDB on the matrix's sort, group-by, filter and sum rows, with the matrix's own generators, sizes and best-of-five protocol, in four idioms: the same SQL over a registered Arrow table in one chunk and in sixteen batches, and over a native DuckDB table with the result exported to Arrow or kept in a DuckDB temp table. Every result asserts its row count. Reproduce with:

PYTHONPATH=python python Benchmarks/duckdb_matrix.py 10000000 50000000
python Benchmarks/duckdb_matrix.py --report Benchmarks/results/duckdb_matrix_2026-09-12.csv

DuckDB rows measured 2026-09-12: duckdb 1.5.5, threads=16, preserve_insertion_order=True, pyarrow 25.0.1, python 3.13.9, Apple M4 Max, macOS 26.6.2, loadavg at start 2.5, 2026-09-12T23:34:30. ArrowMetal, Polars and pyarrow are the published 2026-09-07 matrix rows (Benchmarks/results/full_matrix_2026-09-07-parallel.csv), not re-measured. Wall ms, best of up to 5 after one warm-up, process CPU ms in brackets. "Best" is the faster of a library's eager and parallel idioms. The last column is the fastest CPU number on the row, any library or idiom, over ArrowMetal.

family op rows ArrowMetal Polars best pyarrow best DuckDB native, to Arrow DuckDB native, stays in DuckDB DuckDB Arrow scan, 16 batches DuckDB Arrow scan, 1 chunk fastest CPU / ArrowMetal
reductions sum(int64, 10% nulls) 10,000,000 0.30 (0) 0.57 (6) 1.29 (14) 1.07 (15) 1.08 (14) 1.90 (18) 12.0 1.9x
compare+select filter int64 (30% kept) 10,000,000 0.78 (1) 0.65 (6) 5.33 (60) 35.9 (53) 8.52 (88) 39.9 (73) 42.4 0.8x
sort argsort int64 10,000,000 7.81 (1) 57.6 (655) 841.2 (841) 212.7 (647) 52.3 (674) 248.6 (688) 403.7 6.7x
sort argsort float64 10,000,000 8.11 (1) 78.6 (1,004) 984.1 (983) 231.4 (725) 60.1 (767) 266.2 (779) 460.0 7.4x
sort sort float64 10,000,000 6.85 (1) 26.2 (250) 1,006.5 (1,006) 204.9 (628) 54.6 (718) 223.6 (669) 410.9 3.8x
sort lexsort (2 int32 keys) 10,000,000 7.29 (2) 111.4 (1,217) 1,122.7 (1,122) 197.5 (635) 49.9 (660) 264.6 (893) 625.9 6.8x
group-by sum by int32 key (1000 groups) 10,000,000 1.73 (1) 20.2 (242) 4.61 (51) 2.09 (28) 2.11 (27) 7.67 (80) 53.7 1.2x
group-by mean by int32 key (1000 groups) 10,000,000 2.21 (1) 20.3 (232) 4.63 (51) 2.36 (33) 2.36 (32) 8.31 (79) 55.2 1.1x
group-by count by int32 key (1000 groups) 10,000,000 1.52 (1) 21.2 (166) 4.43 (48) 2.14 (25) 2.03 (26) 6.70 (72) 48.4 1.3x
group-by sum by two int32 keys (~1024 groups) 10,000,000 2.19 (2) 34.0 (385) 5.50 (60) 3.04 (41) 2.98 (40) 9.25 (100) 69.5 1.4x
group-by sum by int32 key (100000 groups) 10,000,000 1.95 (1) 22.7 (275) 16.4 (98) 20.5 (292) 21.0 (295) 29.3 (357) 116.4 8.4x
group-by mean by int32 key (100000 groups) 10,000,000 2.48 (1) 23.3 (284) 16.2 (98) 24.1 (345) 24.3 (344) 29.4 (351) 112.0 6.6x
group-by count by int32 key (100000 groups) 10,000,000 1.96 (1) 27.4 (208) 13.0 (81) 19.8 (279) 19.9 (282) 23.3 (287) 90.1 6.6x
group-by sum by int32 key (10000000 groups) 10,000,000 9.46 (2) 69.5 (942) 312.4 (627) 187.4 (815) 38.0 (524) 178.0 (880) 278.4 4.0x
reductions sum(int64, 10% nulls) 50,000,000 1.11 (0) 2.30 (26) 5.09 (66) 4.40 (65) 4.67 (64) 7.26 (77) 58.9 2.1x
compare+select filter int64 (30% kept) 50,000,000 2.23 (1) 2.85 (36) 22.6 (298) 186.8 (242) 32.1 (416) 204.8 (337) 216.3 1.3x
sort argsort int64 50,000,000 39.1 (1) 302.0 (3,574) 5,196.8 (5,196) 1,525.2 (3,954) 320.7 (4,340) 1,471.2 (4,009) 2,082.6 7.7x
sort argsort float64 50,000,000 39.9 (1) 420.1 (5,531) 5,885.3 (5,885) 1,403.5 (3,854) 344.0 (4,349) 1,479.2 (4,014) 2,358.4 8.6x
sort sort float64 50,000,000 32.1 (1) 132.9 (1,284) 6,044.5 (6,044) 1,308.4 (3,377) 293.5 (3,866) 1,248.9 (3,483) 2,038.2 4.1x
sort lexsort (2 int32 keys) 50,000,000 37.6 (2) 902.9 (9,948) 7,773.5 (7,773) 1,253.8 (3,088) 264.9 (3,532) 1,557.7 (4,595) 3,378.6 7.0x
group-by sum by int32 key (1000 groups) 50,000,000 4.89 (1) 81.9 (1,186) 18.5 (250) 9.00 (135) 9.07 (135) 26.2 (357) 262.6 1.8x
group-by mean by int32 key (1000 groups) 50,000,000 4.91 (1) 81.5 (1,180) 18.6 (251) 10.1 (151) 10.5 (159) 30.4 (350) 265.5 2.0x
group-by count by int32 key (1000 groups) 50,000,000 4.23 (1) 89.0 (810) 17.4 (235) 8.35 (126) 8.37 (126) 25.6 (318) 241.0 2.0x
group-by sum by two int32 keys (~1024 groups) 50,000,000 5.91 (2) 164.2 (2,216) 23.9 (322) 13.9 (199) 14.0 (201) 37.3 (434) 344.6 2.3x
group-by sum by int32 key (100000 groups) 50,000,000 7.41 (1) 99.4 (1,461) 46.4 (542) 76.1 (1,087) 75.9 (1,130) 92.3 (1,248) 444.2 6.3x
group-by mean by int32 key (100000 groups) 50,000,000 7.39 (1) 99.2 (1,458) 47.0 (540) 86.5 (1,277) 85.5 (1,291) 93.9 (1,290) 445.1 6.4x
group-by count by int32 key (100000 groups) 50,000,000 4.92 (1) 120.7 (1,060) 38.3 (421) 64.9 (974) 67.2 (992) 70.8 (937) 400.0 7.8x
group-by sum by int32 key (10000000 groups) 50,000,000 46.1 (2) 321.7 (4,585) 1,303.3 (3,682) 324.4 (2,356) 148.6 (2,210) 360.1 (2,712) 1,371.0 3.2x
sort sort utf8 1,000,000 - - - 25.8 (84) 11.3 (98) 30.6 (104) 70.4 -
sort sort utf8 10,000,000 23.0 (7) 133.1 (1,326) 1,864.9 (1,865) 295.0 (852) 79.4 (1,063) 345.7 (926) 723.7 3.5x

SQL per operation, over the native table; the Arrow-scan idioms run the same text over the registered Arrow table, and the stays-in-DuckDB idiom wraps it in CREATE OR REPLACE TEMP TABLE r AS:

  • sum(int64, 10% nulls): SELECT sum(x) FROM tn (DuckDB sum(BIGINT) returns HUGEINT)
  • filter int64 (30% kept): SELECT x FROM tn WHERE m
  • argsort int64: SELECT i FROM tn ORDER BY x (DuckDB has no argsort)
  • argsort float64: SELECT i FROM tn ORDER BY x (DuckDB has no argsort)
  • sort float64: SELECT x FROM tn ORDER BY x
  • lexsort (2 int32 keys): SELECT i FROM tn ORDER BY a, b (DuckDB has no argsort)
  • sum by int32 key (1000 groups): SELECT k, sum(x) FROM tn GROUP BY k
  • mean by int32 key (1000 groups): SELECT k, avg(x) FROM tn GROUP BY k
  • count by int32 key (1000 groups): SELECT k, count(x) FROM tn GROUP BY k
  • sum by two int32 keys (~1024 groups): SELECT a, b, sum(x) FROM tn GROUP BY a, b
  • sum by int32 key (100000 groups): SELECT k, sum(x) FROM tn GROUP BY k
  • mean by int32 key (100000 groups): SELECT k, avg(x) FROM tn GROUP BY k
  • count by int32 key (100000 groups): SELECT k, count(x) FROM tn GROUP BY k
  • sum by int32 key (10000000 groups): SELECT k, sum(x) FROM tn GROUP BY k
  • sort utf8: SELECT s FROM tn ORDER BY s

What the table says:

  • DuckDB is the fastest CPU engine on low-cardinality group-by and on multi-key sort, and it is a long way ahead of Polars on both: 9.0 ms against Polars' 81.9 for sum by int32 key at 1,000 groups and 50M rows, 265 ms against 903 for lexsort. Against DuckDB those two ArrowMetal rows are 1.8x and 7.0x, where the matrix shows 3.8x (against Acero; 16.8x against Polars) and 24.0x. At 10M rows and 1,000 groups the margin is 1.2x. This is the honest group-by story: the GPU is ahead by 3x or more from 100,000 groups upward and roughly even with DuckDB at a thousand groups.
  • DuckDB does not win every CPU row. At 100,000 groups Acero is faster than DuckDB (46.4 ms against 75.9 at 50M rows), on sort float64 Polars is (133 against 294), and on the filter and the plain sum Polars lazy is the fastest CPU idiom by a wide margin. The fastest-CPU column picks the winner per row.
  • Exporting the result to Arrow is a large part of DuckDB's wall time on wide results. Sorting 50M float64 takes 294 ms inside DuckDB and 1,308 ms delivered as an Arrow table; the argsorts add about 1.1 to 1.2 s the same way. A Python caller pays that; ArrowMetal's result is already an Arrow buffer. For a group-by result of a thousand rows the two idioms are the same.
  • The CPU-time gap does not narrow. DuckDB's 1,000-group sum at 50M rows costs 135 CPU-ms across about fifteen cores, its sorts 3,100 to 4,350; the ArrowMetal rows cost 1.2 to 2.4 CPU-ms.
  • A registered Arrow table in one chunk scans on two cores. That idiom is 1.2x slower than the native table on the filter and 29x slower on the 1,000-group sum. Register a chunked table, or create a DuckDB table, before benchmarking DuckDB over Arrow data.

Two things to carry with the numbers. The ArrowMetal, Polars and pyarrow rows were measured on 2026-09-07 and the DuckDB rows on 2026-09-12, on the same machine with the same protocol, so the day differs. And the sort row in the 50M table above (39.2 ms) sorts v = i, a column that is already in order; on uniform random float64 of the same size DuckDB's sort takes 294 ms.


6. When this is worth it, and when it is not

Worth it

  • High-cardinality group-by over a resident column. 100,000 keys at 50M rows: 16x, at 0.6 CPU-ms against DuckDB's 1,056; a single cold query, crossing included, is 1.1x.
  • String matching over a column you are keeping resident. LIKE '%...%' at 50M rows: 9.3x resident; a single cold query is 0.6x.
  • Sorting a resident column. 2.2x at 50M rows (17.6 ms against DuckDB's 39.2), and 1.4 CPU-ms against 570.
  • Several operations over one dataset. The crossing is paid once; every kernel after that is free of it. This is the single biggest lever - from_duckdb once, then loop.
  • Anything where you want the cores back. The GPU group-by uses 0.6 CPU-ms where DuckDB uses 1,056.
  • Fused filter-and-aggregate expressions, which read the column once no matter how long they are.

Not worth it

  • A single sum/avg over one column, whether from memory or Parquet. Memory-bound, and DuckDB does it while scanning.
  • Sorting from cold. The GPU radix sort is ahead of DuckDB's parallel sort on a resident column (2.2x) but behind once the crossing is included (0.7x one-shot).
  • Small data. Below a few million rows the dispatch latency is most of the time — about 60-70 µs measured (The persistent-kernel result), 110-160 µs per call for sum and filter at 1,000 rows in the matrix's latency family; use am.batch() to amortise it, or do not bother.
  • Anything the extension can do, if speed is the reason. See §4.

The rule of thumb: the GPU is ahead when the compute is large relative to the bytes. Group-by with many keys and string matching are compute-heavy per byte; sum is not.


7. Limits

Tier 1

  • One MetalArray is one contiguous array, so a multi-chunk DuckDB result is concatenated on the way in. At 50M rows that is 26.6 ms (§5). am.duckdb_batches skips it.
  • to_duckdb registers a view on one connection; other connections to the same database will not see it. Use CREATE TABLE ... AS SELECT * FROM the_view to make it durable.
  • Nested columns (list, struct, map) import and round-trip, but only the structural kernels operate on them - you cannot group by a struct.
  • Streaming refuses median, quantile, stddev, mode and tdigest by design (§3).

Tier 2

  • Behind DuckDB's own SQL as measured (§4).
  • The eight integer widths, FLOAT, DOUBLE, DATE and TIMESTAMP only -- exactly what arrow_format_for in duckdb-extension/src/arrowmetal_extension.cpp lists. BOOLEAN is not among them; nor are VARCHAR, DECIMAL, TIME, TIMESTAMPTZ, HUGEINT or the nested types. All of them are refused by name at bind time with a message pointing here, and the bridge handles every one.
  • The table functions take a table or view name, not a subquery - DuckDB's C table-function API has no way to accept a relation. A Python-registered relation (con.register(...)) is connection-local and the extension's own connection cannot see it; CREATE VIEW first.
  • arrowmetal_group_by returns key as BIGINT or DOUBLE, so a key column wider than that (there is none among the supported types) would not fit.
  • arrowmetal_query accepts only an (aggregate ...) terminal. (project ...) and (group_by ...) produce columns, which this function's fixed output schema cannot carry; use arrowmetal_group_by or the bridge.
  • The extension opens one DuckDB connection when it loads and keeps it for the life of the process, serialised by a mutex. It cannot connect lazily instead: the duckdb_database handle the loader passes does not outlive the load call. That connection is never closed, so the database it belongs to is not freed until the process exits.
  • Unsigned. allow_unsigned_extensions has to be set when the connection is created.
  • Built for one platform string at a time; osx_arm64 unless you set DUCKDB_PLATFORM.

Tier 3

  • §4b, "Limits".

Files

Path What
python/arrowmetal/duckdb_bridge.py tier 1, the whole bridge; duckdb_connect, duckdb_rewrites, duckdb_is_rewritten for tier 3
duckdb-extension/src/arrowmetal_extension.cpp tier 2, the extension
duckdb-extension/src/arrowmetal_rewrite.cpp tier 3, the optimizer extension
duckdb-extension/build.sh builds the tier 2 extension without cmake
duckdb-extension/build_rewrite.sh builds the tier 3 extension against the installed DuckDB release
duckdb-extension/CMakeLists.txt the tier 2 build, for cmake
duckdb-extension/scripts/append_metadata.py writes DuckDB's 512-byte extension footer (C_STRUCT or CPP)
python/tests/test_duckdb.py tiers 1 and 2, with DuckDB's own SQL as the oracle
python/tests/test_duckdb_rewrite.py tier 3, differential against DuckDB with the rewrite off
Benchmarks/duckdb_bench.py the tables in §5
Benchmarks/duckdb_rewrite_bench.py tier 3 against DuckDB's own operators at 1M, 10M and 50M rows
Benchmarks/results/duckdb_rewrite_2026-10-04-ac.csv its quiet run on DuckDB 1.5.6 on AC power, which the auto floors cite; run conditions in duckdb_rewrite_2026-10-04-ac_conditions.txt
Benchmarks/results/duckdb_rewrite_2026-10-04.csv its run on DuckDB 1.5.6 on battery power the same day, kept as history; run conditions in duckdb_rewrite_2026-10-04_conditions.txt
Benchmarks/results/duckdb_rewrite_2026-10-02.csv its quiet run on DuckDB 1.5.5, kept as history
Benchmarks/results/duckdb_rewrite_2026-09-24.csv its run of 2026-09-24 (one warm-up, best of five), kept as history
Benchmarks/results/duckdb_rewrite_2026-09-23_provisional.csv its first run, on a shared machine, kept as history
docs/PANDAS.md

pandas on the Apple silicon GPU

ArrowMetal gives pandas two ways onto the GPU, and you pick how much of your code changes.

Tier You write What it does Falls back?
1. Accessor s.am.sum(), df.am.groupby("k").sum("v") Explicit. Always dispatched to the GPU, with a documented per-row host fallback for the Unicode case transforms on non-Latin rows. Results come back Arrow-backed, so they go straight out again with no copy. No — an unsupported dtype raises, so you always know where the work ran.
2. Accel mode import arrowmetal.pandas_accel; arrowmetal.pandas_accel.install() then unchanged pandas Patches a documented set of pandas methods. Routes to the GPU only when the dtype, the size and the arguments all qualify. Yes — always, silently, with the reason in stats().

Both live in the same package and can be used together.

# macOS arm64. pandas is not a hard dependency.
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
    swift build -c release --product ArrowMetalC
export PYTHONPATH=python

import arrowmetal never imports pandas: the bridge is loaded the first time you touch am.from_pandas, am.to_pandas, am.pandas_bridge or am.pandas_accel.


Tier 1: the .am accessor

import pandas as pd, arrowmetal as am
import arrowmetal.pandas_bridge      # registers .am on Series and DataFrame

df = pd.read_parquet("trades.parquet", dtype_backend="pyarrow")   # Arrow-backed: zero-copy

df["price"].am.sum()                    # scalar
df["price"].am.nlargest(100)            # Series, index taken along
df["symbol"].am.contains("BRK")         # boolean Series
df.am.groupby("symbol").sum("size")     # DataFrame indexed by the key
df.am.sort_values(["symbol", "price"])
df.am.merge(reference, on="symbol")
df.am.query(am.filter(am.col("price") > 100).sum(am.col("size")))   # one fused kernel

Series accessor

Method Notes
sum min max mean product median any all sum of an empty or all-null column is 0, as in pandas — each bridge follows its own host library, so the Polars bridge answers None there (Polars)
std(ddof=1) var(ddof=1) count() nunique(dropna=True)
value_counts(sort, ascending, dropna) counts descending, ties in first-seen order — pandas' own order
sort_values(ascending) stable, nulls last; the index is taken along
argsort(ascending) uint32 positions
top_k(k, largest) / nlargest(n) / nsmallest(n) pandas' keep="first" tie behaviour
isin(values) abs() round(ndigits=0) round is half-to-even, as pandas is
compare(op, other) and > >= < <= == != scalar or another column
filter(mask) boolean-mask selection, index taken along
contains startswith endswith upper lower len utf8
to_metal() to_arrow() zero_copy() the escape hatches

DataFrame accessor

Method Notes
groupby(by, dropna=True, sort=True, as_index=True).sum/mean/min/max/count(cols) and .size() one or several key columns of any type
sort_values(by, ascending) one or several columns, per-column direction, stable, nulls last
filter(mask)
merge(right, on=..., how="inner") needs a unique, null-free key on the right frame
query(expr) a fused ArrowMetal expression query over the whole frame — see Fused expressions
to_metal() zero_copy()

Tier 2: zero-code-change accel mode

import arrowmetal.pandas_accel
arrowmetal.pandas_accel.install()        # or set ARROWMETAL_PANDAS_ACCEL=1

# ... your existing pandas script, unchanged ...

print(arrowmetal.pandas_accel.stats())

or without touching the script at all:

python -m arrowmetal.pandas_accel my_script.py
ARROWMETAL_PANDAS_ACCEL=1 python my_script.py

A call is routed to the GPU only when all four hold:

  1. dtype — the column is numeric, boolean or utf8, in any of the three pandas storage flavours (numpy-backed, pd.ArrowDtype / int64[pyarrow], or the masked nullable dtypes Int64, Float64, boolean, string), including pandas 3's default str dtype;
  2. size — the frame is at least threshold rows, 2,000,000 by default (install(threshold=...), set_threshold(...), or ARROWMETAL_PANDAS_ACCEL_THRESHOLD). Below that the launch latency costs more than the kernel saves. The measured row counts behind that judgement, per operation, are in The crossover: where the GPU path overtakes the fastest CPU library, and where it overtakes the engine's own CPU loop. This threshold is the pandas caller's veto on top of the engine (it counts the one-time page mapping of a pandas-owned column, which a column already resident in Metal does not pay); a call that passes it still goes through the engine's CPU/GPU router (Design), which gives byte-identical output on either path;
  3. arguments — the call uses arguments the kernels implement exactly (the "not routed" column below);
  4. the operation is worth it — see the next section. Five reductions, abs and the scalar comparisons are intercepted but deliberately left to pandas.

Everything else runs in pandas, unchanged. Any Exception inside the GPU path is caught, recorded in stats(), and the original pandas method is run instead, so a GPU failure does not surface as a program failure. The one exception is repeated install()/uninstall() cycling — see Limits.

Why some operations are intercepted but not routed

Handing a pandas column to Metal is zero-copy — the GPU reads the very bytes pandas holds, and am.zero_copy_report proves it by address — when the buffer is page aligned, which it is for every allocation at these sizes; a small unaligned buffer costs one copy. But it is not free: the pages have to be mapped into the device's address space once. On an M4 Max, for a 50M-row (400 MB) int64[pyarrow] column:

Step Time
Series.array._pa_array → pyarrow.Array 0.00 ms (the same buffer)
that buffer mapped into Metal (MetalArray.from_arrow) 6.7 ms
sum on the GPU, column already mapped 1.07 ms (374 GB/s)
Series.sum() in pandas 4.9 ms

The kernel is 4.6x faster than pandas. The map is not, and one sum cannot amortise it. So the default routing table leaves alone every operation that reads each byte once and writes at most one byte back — sum, min, max, mean, count, abs, and the six scalar comparisons — and routes the ones that do enough per byte to pay for the map many times over: sorts, hash group-bys, merges, string scans, round, isin, nunique, value_counts, boolean-mask selection.

That table is data, not a hard-coded rule:

accel.NEVER_BY_DEFAULT     # ('sum', 'min', 'max', 'mean', 'count', 'abs', 'eq', 'ne', ...)
accel.ROW_FACTOR           # op -> rows needed as a multiple of the threshold, or None for "never"
accel.route_all()          # route everything anyway
accel.ROW_FACTOR["sum"] = 1

The .am accessor has no such table: it always dispatches to the GPU — bar the per-row host fallback the Unicode case transforms take on non-Latin rows — and s.am.to_metal() gives you the mapped column so that a chain of operations pays the map once. Nothing about correctness changes either way — only where the work runs.

What is intercepted

pandas call Routed to the GPU when Not routed (falls through)
Series.sum/min/max/mean numeric column, and route_all() by default, always — see above; also skipna=False, min_count>0, numeric_only
Series.count numeric, boolean or utf8, and route_all() by default, always — see above
Series.nunique numeric, boolean or utf8 —
Series.value_counts numeric, boolean or utf8 normalize=True, bins=
Series.sort_values numeric, boolean or utf8 key=, na_position="first", inplace=True
Series.nlargest/nsmallest numeric keep != "first"
Series.isin numeric or utf8 any NaN/None in values (pandas matches NaN to NaN; Arrow's is_in does not)
Series.abs numeric, and route_all() by default, always — see above
Series.round float integer columns (pandas' answer is the identity, so there is nothing to win)
Series.__eq__ __ne__ __lt__ __le__ __gt__ __ge__ numeric vs a numeric scalar, or utf8 vs a string for ==/!=, and route_all() by default, always — see above; also another Series (pandas aligns indexes first), NaN as the operand, ordering comparisons on strings
Series[bool_mask] any supported dtype, mask of the same length and index anything but a full-length boolean mask
Series.str.contains literal pattern a real regex, case=False, flags=, explicit na=
Series.str.startswith/endswith a single string pattern a tuple of patterns
Series.str.upper/lower the column is pure ASCII (checked on the GPU: byte length == code point count) any non-ASCII value
Series.str.len utf8 —
DataFrame[bool_mask] every column a supported dtype a mixed frame with, say, a datetime column
DataFrame.sort_values one or several supported columns key=, na_position="first", inplace=True
DataFrame.merge how="inner", one key column, unique and null-free on the right outer/left/right joins, several key columns, duplicate or null right keys, indicator, validate
DataFrame.groupby(...).sum/mean/min/max/count key columns of a supported dtype, numeric value columns min_count, numeric_only, a callable or array grouper, level=, non-numeric values

Anything not in that table — apply, rolling, pivot_table, resample, describe, arithmetic between columns, and every dtype outside the list (categorical, datetime, timedelta, object, interval, period) — is untouched pandas.

Control

from arrowmetal import pandas_accel as accel

accel.install(threshold=5_000_000)
accel.set_threshold(1_000_000)
with accel.disabled():                  # a block of guaranteed plain pandas
    ...
accel.stats()                           # what ran where
accel.stats().to_frame()                # the same as a DataFrame: gpu / cpu / errors per op
accel.reset_stats()
accel.uninstall()                       # every original method back
accel.REGISTRY                          # the contract, as data
accel.ROW_FACTOR                        # the routing table, as data
accel.route_all()                       # route the operations pandas is otherwise faster at

stats() reports four things per operation: gpu (routed), cpu (fell through, with the reason implied by the table above), errors (the GPU path raised and pandas ran instead — each with the exception text), and intercepted, the total number of calls that reached a patched method at all.


Zero-copy: what happens

Series.array._pa_array is a pyarrow.ChunkedArray for any Arrow-backed pandas column. The bridge takes its single chunk and hands those buffers to Metal — the same bytes, no copy. Apple silicon's unified memory means the GPU then reads the pages the CPU wrote, with no transfer either.

am.zero_copy_report(df) does not guess: it compares the buffer address pandas holds with the one handed over.

>>> am.zero_copy_report(pd.Series(pd.array([1, 2, 3], dtype="int64[pyarrow]")))
{'zero_copy': True, 'reason': 'Arrow-backed pandas column, buffers shared',
 'dtype': 'int64[pyarrow]', 'arrow_type': 'int64'}

The "none" rows are copy-free when the buffer is page aligned, which it is for every allocation at these sizes; a small unaligned buffer costs one copy.

pandas column Copy? Why
pd.ArrowDtype / int64[pyarrow], one chunk none the Arrow buffers are handed straight over
pandas 3 default str dtype (ArrowStringArray) none same — it is a large_string Arrow array
Arrow-backed but split into several chunks one combined once into a contiguous array; reported as "...split into N chunks; combined once"
numpy int*/uint* none pyarrow adopts the numpy buffer; there is no validity bitmap to build
numpy bool one Arrow packs booleans to one bit per value, so the buffer cannot be adopted
numpy float*, datetime64 values none, validity one pass the values buffer is adopted, and one pass builds the validity bitmap from the NaNs/NaTs
Int64, Float64, boolean, string (masked nullable) one pandas keeps values and mask in two arrays; Arrow needs a bitmap
Categorical one becomes an Arrow dictionary array, indices widened to int32

Results come back the same way: am.to_pandas(...) and every .am method wrap the Metal buffers in a pd.arrays.ArrowExtensionArray, so the returned Series shares memory with the GPU result. Accel mode adds one cast on top, to give you back the dtype pandas itself would have produced.

Nulls, NaN and the index

  • A numpy float column has no null, so NaN becomes Arrow null on the way in. That is exactly what skipna=True — pandas' default — already means, so sum, mean, min and max agree.
  • An Arrow-backed float column has both, and the bridge does not touch either: a NaN stays a NaN and a null stays a null.
  • Coming back, accel mode restores pandas' own convention: a null becomes NaN for a numpy float result, False for a plain bool result, True for a plain bool result of != (NaN != x is True in numpy), and the dtype's own NA for a nullable dtype. The rule is not hand-written per operation: each wrapper runs the original pandas method over a two-row probe of the same dtype and copies the dtype and NA convention it sees.
  • The index is carried along by the same permutation as the values — sort_values, nlargest, nsmallest and boolean-mask selection all return the original index rows, in the new order. ignore_index=True gives a fresh RangeIndex, as in pandas. Group-by results are indexed by the key (or by a MultiIndex for several keys), and as_index=False resets it, as in pandas.
  • Group-by follows pandas: null keys are dropped unless dropna=False, groups come back sorted by key unless sort=False (then in first-seen order), and a group whose values are all null sums to 0.
  • Inner merge preserves left-row order and gives the result a fresh RangeIndex, as pandas does, with the same _x/_y suffixing for overlapping columns.

The one deliberate difference

sort_values on the GPU is stable. pandas' default is kind="quicksort", which is not, so rows with equal sort keys can come back in a different order than pandas would give them (the same order as kind="stable"). Every other value, dtype and index matches. If that matters, use the accessor explicitly or keep sort_values out of the accelerated set with accel.disabled().

Numbers

Measured with PYTHONPATH=python python Benchmarks/pandas_bench.py <rows> <iters> on an Apple M4 Max, pandas 3.0.5 / pyarrow 25.0.1 / Python 3.13, best of five, on the same in-process frame. The accessor and accel rows include the conversion the dtype forces, the map into Metal, the GPU work, and the trip back into a pandas object — nothing is pre-converted or cached between iterations, so these are the numbers a pandas user gets, not kernel times.

routes to is where accel mode sent the call with the default table; the accessor column is always the GPU, so it also shows what those operations would cost if you routed them.

Arrow-backed frame (pd.ArrowDtype, what read_parquet(dtype_backend="pyarrow") gives you)

Operation 10M pandas 10M accessor 10M accel 10M accel x 50M pandas 50M accessor 50M accel 50M accel x routes to
sum (int64) 1.0 2.4 1.0 1.01x 4.9 10.5 4.8 1.02x pandas
mean (float64) 1.2 2.6 1.2 0.96x 5.6 14.5 5.7 0.98x pandas
s > 0 1.5 2.6 1.4 1.14x 6.4 11.1 6.5 0.99x pandas
abs (float64) 1.6 2.6 1.6 0.98x 7.6 13.0 7.5 1.02x pandas
round(2) 22.3 4.1 4.9 4.55x 110.9 18.5 22.6 4.91x GPU
isin (10 values) 66.8 3.3 8.8 7.62x 337.4 13.8 39.3 8.59x GPU
nunique 35.5 9.8 9.6 3.69x 176.1 18.8 19.2 9.17x GPU
value_counts (utf8) 95.5 38.9 39.5 2.42x 476.6 157.7 170.4 2.80x GPU
groupby-sum, 1k keys 99.4 15.4 15.9 6.26x 504.3 32.4 35.6 14.18x GPU
groupby-sum, 100k keys 103.0 17.7 16.4 6.26x 450.7 34.9 35.2 12.80x GPU
sort_values (int64) 392.7 69.9 62.4 6.30x 2312.1 324.9 305.3 7.57x GPU
nlargest(100) 53.3 7.7 8.0 6.64x 264.0 28.9 29.0 9.10x GPU
str.contains 97.6 3.4 3.7 26.06x 492.6 16.3 18.8 26.22x GPU
df[df.i > 0] 58.2 35.6 30.0 1.94x 284.6 124.6 105.2 2.71x GPU
merge, 100k int keys 233.4 37.9 38.0 6.15x 1258.5 132.6 134.8 9.34x GPU

numpy-backed frame (plain pd.DataFrame({...}))

Every column has to be converted to Arrow first, and pandas' numpy kernels are much faster than its pyarrow ones, so the speedups are smaller — and round/isin are left to pandas here.

Operation 10M pandas 10M accessor 10M accel 10M accel x 50M pandas 50M accessor 50M accel 50M accel x routes to
sum (int64) 1.1 2.7 1.1 1.00x 5.1 11.2 5.2 0.97x pandas
mean (float64) 4.1 23.3 4.1 0.99x 21.1 116.3 20.6 1.02x pandas
s > 0 1.2 3.0 1.1 1.04x 5.4 12.6 5.5 0.99x pandas
abs (float64) 1.5 22.7 1.5 1.00x 7.4 110.8 7.3 1.01x pandas
round(2) 4.1 25.6 4.1 0.99x 20.3 120.5 20.6 0.98x pandas
isin (10 values) 8.0 4.6 7.7 1.05x 41.5 14.2 41.2 1.01x pandas
nunique 16.7 9.6 9.8 1.69x 89.8 20.3 19.0 4.72x GPU
value_counts (utf8) 91.5 43.4 43.2 2.12x 460.6 178.1 185.1 2.49x GPU
groupby-sum, 1k keys 38.7 9.4 9.3 4.16x 198.9 33.2 36.7 5.42x GPU
groupby-sum, 100k keys 77.8 18.9 18.6 4.19x 362.7 40.9 36.8 9.85x GPU
sort_values (int64) 1068.4 95.6 89.5 11.94x 6178.0 473.1 434.6 14.22x GPU
nlargest(100) 52.3 11.3 11.1 4.73x 267.6 31.3 29.8 8.98x GPU
str.contains 98.0 8.4 13.0 7.53x 520.2 32.0 56.6 9.19x GPU
df[df.i > 0] 56.7 61.4 57.7 0.98x 274.2 259.5 237.1 1.16x GPU
merge, 100k int keys 147.6 120.5 77.6 1.90x 638.0 262.1 285.3 2.24x GPU

Read it this way: an Arrow-backed frame is where the GPU pays. Group-by, sort, top-k, merge and string scanning are 6x to 26x on a 50M-row frame with no code change at all; the operations pandas already does at memory bandwidth are left alone and cost nothing; and the whole-frame operations (sort_values on a DataFrame, df[mask], merge) carry every column across, which is why they land lower than the single-column ones.

Two caveats visible in the numbers. The GPU CPU-ms column (in the benchmark output, not repeated here) is roughly a third of the wall time on group-by and sort, so the cores are free while the GPU works — a real advantage the wall-clock ratio understates. And a numpy float column costs about 2 ms per million values to convert, because building the validity bitmap from the NaNs is a single-threaded pyarrow pass: that is the whole difference between the accessor's 13 ms for abs on an Arrow-backed 50M-row float column and its 111 ms on the numpy one, and it is why every single-pass float operation stays in pandas there.

Limits

  • Only these dtypes. Numeric, boolean and utf8. Categorical, datetime, timedelta, object, interval and period columns are left to pandas: their semantics are richer than the kernels here, and a wrong answer is worse than a slow one. (The bridge converts categoricals and datetimes fine — am.from_pandas handles them; it is the accel layer that will not route them.)
  • merge is the validate="m:1" inner join. Duplicate or null keys on the right frame, and any outer/left/right join, fall back. Many-to-many joins are not supported in accel mode.
  • str.upper/str.lower are ASCII-guarded in accel mode. The kernels implement Unicode's simple 1:1 mapping over every script, but pandas applies the full mapping (ß → SS), so accel mode checks for pure ASCII on the GPU and falls back otherwise; it never returns a different string than pandas would. The .am accessor runs the simple mapping on anything.
  • str.contains with a real regex falls back. A literal pattern runs on the GPU.
  • Float reductions add in a different order. A GPU tree reduction is not bit-identical to pandas' pairwise sum; expect agreement to about 1e-12 relative, not to the last bit.
  • Below the threshold nothing is accelerated. At a million rows most of these operations are already sub-millisecond in pandas and the GPU launch cannot pay for itself. The default of two million rows is deliberately conservative; lower it if your columns are wide.
  • Single-pass operations are not accelerated at all, at any size: sum, min, max, mean, count, abs and the scalar comparisons, plus round and isin on a numpy-backed column. The measurement and the reasoning are above; route_all() overrides it, and the .am accessor never applies it.
  • A numpy float column costs about 2 ms per million values to convert, because pyarrow builds the validity bitmap from the NaNs in a single-threaded pass. Arrow-backed columns skip it entirely — which is the single biggest thing you can do to make this faster: read your data with dtype_backend="pyarrow".
  • Install once. install() and uninstall() rewrite pandas' method slots. Cycling them a hundred-odd times in one process can leave an already-specialized x[mask] call site bound to whichever __getitem__ CPython first saw — an interpreter-level artifact of repeatedly rewriting a dunder. Use accel.disabled() to turn the layer off for a block instead.
  • Threads. The wrappers are re-entrancy-guarded per thread, but the GPU queue underneath is a single device queue; several Python threads calling into it will serialise. disabled(), set_threshold() and route_all() are process-wide, not per thread: while one thread is inside a disabled() block every thread runs in pandas. The answers do not change — only where the work happens, and those calls land in neither stats().gpu nor stats().cpu.

Tests

swift build -c release --product ArrowMetalC
PYTHONPATH=python python -m pytest python/tests/test_pandas.py -q

python/tests/test_pandas.py covers: a GPU round trip for twenty pandas dtypes; zero-copy proved by buffer address for each Arrow-backed flavour and disproved for each copying one; every accessor method against its pandas equivalent; and, for accel mode, forty-plus operations run twice — once in plain pandas, once with the layer installed — over numpy-backed, Arrow-backed and masked frames carrying nulls, NaNs, strings and duplicate keys, with assert_series_equal / assert_frame_equal checking values, dtype and index. Each of those also asserts that the GPU path really was taken, so a test cannot pass by quietly falling back. The rest of the file is the fallback surface: unsupported dtypes, unsupported arguments, regex patterns, non-ASCII case changes, NaN in isin, duplicate and null merge keys, an injected exception inside the GPU path, the row threshold in both directions, uninstall() restoring every original, and the ARROWMETAL_PANDAS_ACCEL=1 and python -m arrowmetal.pandas_accel entry points in real subprocesses.

docs/PARQUET.md

Parquet on the GPU

ArrowMetal reads Apache Parquet with Metal compute kernels: from file bytes to Arrow arrays in shared memory. The CPU reads column data only to decompress it: the ZSTD, GZIP and BROTLI pages, and the Snappy and LZ4 pages it decodes faster than the GPU (token-dense pages; the GPU takes the literal ones), all straight into the shared buffer the GPU decoders read, while the GPU decompresses its own share. Definition levels, dictionary indices, every value encoding, the delta encodings and BYTE_STREAM_SPLIT are all kernels. The host parses the Thrift footer and the per-page Thrift headers — metadata, not data — and everything after decompression runs on the GPU.

cuDF does this for NVIDIA.

The pipeline

For one leaf column, across every selected row group at once:

  page headers (host, Thrift only, in parallel across the read's column chunks)
    -> decompression       SNAPPY / LZ4 / LZ4_RAW split page by page between the host and the GPU,
                           both at once; host for ZSTD / GZIP / BROTLI; nothing for UNCOMPRESSED
    -> pq_page_layout      finds each page's level and value sections *inside* the page
    -> pq_decode_levels    definition levels -> one byte per row, plus each row's rank
    -> pq_page_scan        per-page offsets into the chunk's dense value section
    -> one kernel per encoding, each writing dense values
    -> pq_scatter          dense values -> row positions (skipped when the column has no nulls)
    -> pq_levels_to_bitmap definition levels -> an Arrow validity bitmap

The file's bytes are the GPU's bytes

ParquetFile mmaps the whole file, read-only and shared (PROT_READ, MAP_SHARED). mmap always returns a page-aligned address, so any range of the mapping can be wrapped with makeBuffer(bytesNoCopy:) — the same trick MetalArrowBuffer.wrapOrCopy uses for zero-copy Arrow import. A column chunk's pages are then addressable by a kernel as byte offsets into that buffer. An uncompressed file needs no staging buffer at all: the mapped file is the page buffer the decoders read, so the values go from page cache to Arrow array without the host ever loading them. Every kernel binds the page bytes device const; nothing writes to the mapping.

Creating the wrap costs next to nothing (under 0.1 ms for 2 GB). The first kernel that reads a wrapped range makes all of it resident for the GPU, and that costs time in proportion to the range: 7-10 ms per gigabyte for this read-only shared mapping on an M4 Max, against 67-78 ms per gigabyte for a private writable one, which the reader keeps only as the fallback for a device that will not wrap read-only memory (and on virtualised GPUs). So what a read wraps matters:

  • A read whose columns fill most of the bytes they span (at least four fifths) wraps that span once, up front. Column chunks are interleaved by row group, so in a file with fifty row groups one column's chunks already span nearly the whole file, and wrapping per column would map the same pages once per column.
  • A sparse projection — one column of a wide file — maps its column's chunks side by side into one reserved range of address space (MAP_FIXED over each chunk, MappedView in ParquetFile.swift) and wraps only them, so the GPU makes resident the bytes it reads and not the other columns' chunks in between.

Wraps and views are cached on the handle, and so are the parsed page headers, so holding the ParquetFile across queries still saves time; see the benchmarks. Closing a file unmaps it on a background queue.

Pages are addressed with 32-bit offsets from their binding point, so file size is not a limit: a column whose chunks span more than 4 GiB of a larger file reads through a view. A column whose chunks in one read total 4 GiB or more raises ParquetError.unsupported.

ARROWMETAL_PARQUET_PROFILE=1 prints, for every open and read, the wall time and minor page faults of each phase to stderr (waiting for the GPU at each phase boundary, so a phase's kernels are charged to it).

Page layout is computed on the GPU, not the host

A v1 data page stores <4-byte length><repetition levels><4-byte length><definition levels><values>. Those two lengths live inside the page, which for a compressed column means they only exist after decompression. pq_page_layout therefore runs as a kernel: one thread per page, filling in the byte offsets of the three sections and, for a dictionary page, the index bit width stored in its first byte. A v2 page carries the two lengths in its Thrift header, so the host fills those in and the kernel only does the arithmetic.

The parallel RLE strategy

Definition levels, repetition levels and dictionary indices all use Parquet's RLE / bit-packing hybrid: a sequence of runs, each introduced by a varint header whose low bit picks the run kind (0 = a repeated value, 1 = a group of 8·n bit-packed values). Run boundaries are only known after the previous header has been read, so the headers cannot be parsed in parallel. The values inside them can, and that is where all the work is.

One threadgroup (256 threads) owns one page and walks it in batches of up to 2048 values:

  1. Thread 0 scans forward over run headers only. It never touches the packed value bytes — for a bit-packed run it computes the run's byte length from the width and skips it — until it has covered 2048 values or filled its table of 256 run records. A record is (kind, value or byte offset of the packed data, values of this run already emitted, where this run starts inside the batch). A run that spills past the batch is left half-consumed and resumed by the next batch, so runs of any length work and a page of any size works.
  2. A threadgroup barrier publishes the table.
  3. Every thread takes a contiguous slice of 8 batch positions and binary-searches the run table (256 entries, threadgroup memory, a few cycles) for the run each position falls in, then decodes its level: a constant for an RLE run, an LSB-first bit-field read for a bit-packed one. All of the bit unpacking happens here, 256-wide.
  4. The same pass scans "is this level the max definition level?" across the batch — 8 values serially per thread, then simd_prefix_exclusive_sum across the 32 lanes of each SIMD group, then a 256-wide combine. That gives every row its rank: the index of its value in the page's dense, null-free value section.

Ranks are the load-bearing idea. Because every row knows where its value sits in the dense section, every value decoder can be a pure gather, no decoder has to think about nulls, and a column with no nulls skips the scatter entirely because dense positions and row positions coincide.

A page therefore costs one serial header walk of a few instructions per run, and everything else runs 256-wide while thousands of pages run at once across the GPU.

Dense values, then one scatter

Every value decoder writes its page's values densely: value j of a page lands at nonNullOffset + j. One pq_scatter at the end moves the dense values into their row positions using the ranks. Keeping the two apart means each encoding needs exactly one kernel, and a chunk whose pages mix encodings — which is what a writer produces when a dictionary outgrows its budget and the remaining pages fall back to PLAIN — just runs two kernels over two slices of the page list into the same output buffers.

Variable-length values never get copied twice. Each encoding only fills, for every dense value slot, where its bytes are and how many, always as offsets into the page buffer (or, for DELTA_BYTE_ARRAY, into a dense staging buffer built by its own kernel). One prefix sum over per-row lengths produces the Arrow offsets buffer, and one gather moves every byte of the column in parallel.

Several row groups, one dictionary

A dictionary column is decoded across every selected row group in a single pass even though each row group has its own dictionary page: the dictionaries are concatenated, and pq_decode_rle_values adds each page's dictionary base to its codes as it writes them. The result is one Arrow dictionary array over a merged (not deduplicated) dictionary, which is exactly what Arrow allows.

Decompression

Snappy and LZ4 are byte-oriented LZ77: a stream of tokens, each either "copy N literal bytes from the input" or "copy N bytes from N' bytes back in the output". A single block cannot be parsed in parallel — but a Parquet file has thousands of pages, each an independent block, and that is the parallelism.

However a page is decoded, one worker walks its token stream from start to end. The GPU has many slow workers for that and the host a few fast ones, so a read splits its Snappy and LZ4 pages between the two, page by page, and runs both at once.

What a page costs on each side. The columns of the 50 M-row benchmark files, 2,525 pages of about 160 KB of output each (int64: id sequential, qty below 1,000; ts timestamps; price random doubles), host decoders on one core and on all 16, GPU kernels over the column's pages in one dispatch:

column codec ratio host, 1 core, per page host, 16 cores, 2,525 pages GPU, a SIMD group per page, 2,525 pages GPU, a thread per page, 2,525 pages
id Snappy 2.00 60 µs 12.7 ms 68.4 ms 47.1 ms
qty Snappy 2.72 81 µs 15.7 ms 57.7 ms 50.5 ms
ts Snappy 1.32 58 µs 11.8 ms 74.5 ms 41.0 ms
price Snappy 1.00 2.2 µs 2.5 ms 4.1 ms 44.5 ms
id LZ4 2.00 41 µs 8.7 ms 66.9 ms 31.4 ms
qty LZ4 2.53 45 µs 9.0 ms 51.5 ms 45.0 ms
ts LZ4 1.31 41 µs 8.3 ms 56.3 ms 36.8 ms
price LZ4 1.00 2.4 µs 2.6 ms 3.9 ms 39.5 ms

A token-dense page (id, qty, ts) is tens of thousands of tokens of a few bytes. On the GPU its cost is the time one worker takes to walk it: 12-15 ms on a SIMD group and 37-60 ms on a thread, the same for 32 pages in a dispatch as for 512, and a dispatch lasts as long as its slowest page. The 12,625 pages of a file that compress 1.25x or more (these three columns and two of long runs) take the SIMD- group kernel 159-190 ms and the page-per-thread kernel 51-65 ms, and 16 host threads 44 ms (Snappy) and 33 ms (LZ4) reading a freshly opened file. A literal page (price) is the other way round: it is one long copy, 1.2 GB of such pages take the GPU 10 ms, and the host 19 ms from a fresh mapping (the first touch of each 16 KB page of a mapping is a minor fault; 5.6 ms once touched). A column of literal pages split between the two read in 19.6 ms, against 12.1 ms all on the GPU and 13.7 ms all on the host: both sides copy at memory speed.

The router (DecodeRouter in Kernels/DecodeSplit.swift) sees every Snappy and LZ4 page of a read — all the columns staged together — before any is decoded, with only its page header: compressed and uncompressed sizes, hence its ratio. A page at or below 1.0 is literal; above 1.05 it is token-dense (an LCG-generated int64 column compresses only 1.05x with Snappy yet is short tokens throughout, 32-35 ms on the GPU against 8-17 ms on the host for 160 MB); far above 4 it is a few long runs again. Each class has a cost per output byte on each side (DecodeCost), from the measurements above:

Row literal page token-dense page
host, one core (fresh mapping) 1.0 ns Snappy 0.42 ns, LZ4 0.37 ns
GPU, SIMD group: the page alone 4 ns 130 ns
GPU, SIMD group: added to a full dispatch 0.008 ns 0.12 ns
GPU, page per thread: the page alone — Snappy 370 ns, LZ4 250 ns

The pages go in order, densest first; the host takes a prefix of them and the GPU the rest, and the router picks the prefix whose later finishing time is earliest: the host's pages spread over its cores (12 performance cores and a quarter of each efficiency core, less a tenth; 16 threads decoded token- dense pages 12.4 times as fast as one), never less than its largest page, against the GPU's slowest page plus what every other page adds (with the page-per-thread kernel for the token-dense part when that is predicted faster). ZSTD, GZIP and BROTLI pages are host-only and count against the host's side; literal pages cost the host more than the GPU and stay on the GPU. On the 50 M-row files the host takes 6,924 of the 20,250 Snappy pages and 7,102 of the LZ4 ones — the pages of id, qty and ts but for 470-650 — and the GPU the rest; on the 10 M-row, 7-column file (pyarrow's defaults) the host takes its 1,338 token-dense pages, 790 KB dictionary pages among them (97 ms each on a SIMD group), and the GPU its 2,274 literal ones.

Both at once. The GPU's pages go out as one dispatch per codec and kernel, slowest pages first, and the command buffer is committed before the host starts; the host then decodes its pages on every core (DispatchQueue.concurrentPerform, most expensive first) while the GPU runs, and the read waits once for both before any value kernel reads a page. The two sides never write the same memory: a column's staging layout puts the GPU's pages first and the host's from the next page boundary, so no page of memory holds bytes of both. A header that does not parse or a page that fails to decompress, on either side, drops the batch, and each column then stages its own pages (routed the same way, column by column), so errors are raised exactly as a column-by-column read raises them.

The result, whole-file reads through a fresh open, best of 5, phases from ARROWMETAL_PARQUET_PROFILE (the previous build decompressed every Snappy and LZ4 page on the GPU but for dictionary pages and chunks of a few pages):

file decompression, GPU only decompression, split of which the host whole read, GPU only whole read, split
50 M rows x 8 columns, Snappy 83.6 ms 43.0 ms 42.4 ms, 6,924 pages 106.6 ms 67.2 ms
50 M rows x 8 columns, LZ4 71.7 ms 32.9 ms 32.4 ms, 7,102 pages 94.4 ms 56.5 ms
10 M rows x 7 columns, Snappy, pyarrow defaults 56.0 ms 11.5 ms 11.3 ms, 1,338 pages 67.5 ms 25.4 ms

In all three the GPU finishes its share before the host finishes its own. One column of token-dense pages read on its own with the handle kept went from 49.7 to 15.2 ms (id, Snappy), 53.5 to 18.1 (qty), 44.0 to 15.3 (ts), and 34.3 to 11.2, 48.0 to 12.2 and 39.4 to 10.9 with LZ4; literal columns read as before (price 7.0 and 6.8 ms). The host's share costs CPU time: a whole-file read takes 674 (Snappy) and 508 (LZ4) CPU-ms, against 80 and 78 for the GPU-only build and against Polars' 1,283 and 1,007 and pyarrow's 1,164 and 987 in the same run (Benchmarks, below).

The host decoders. SnappyHost and LZ4Host (Decompress.swift) check every read against the page's input and every write against its output slot, so a damaged page is an error, never an access outside either; LZ4Host makes the GPU kernels' checks in their order, with their outcomes, and recognises the Hadoop framing the same way. Back-references move 8 bytes at a time (a pattern shorter than 8 bytes first repeats to its first multiple of 8) and short literals 16 at a time, only where the whole step lies inside the page and the slot. ZSTD, GZIP and BROTLI are decompressed on the host too, straight into the shared-memory buffer the GPU decoders read. ZSTD pages go out in runs of up to eight, each decoded with one ZSTD_DCtx: ZSTD_decompress allocates and frees a context (about 160 KB) on every call, which for a column of thousands of small pages cost more than the decompression. GZIP and BROTLI go through Foundation's Compression framework (COMPRESSION_ZLIB is raw DEFLATE, so the gzip container is stripped first). The macOS SDK has no COMPRESSION_ZSTD, so libzstd is looked up with dlopen at first use — $ARROWMETAL_ZSTD, then /opt/homebrew/lib, /usr/local/lib, /usr/lib. When it is not installed a ZSTD column raises ParquetError.unsupported naming the missing library rather than returning wrong data; that is the documented host fallback.

The GPU decoders. One SIMD group (32 lanes) owns one page. Lane 0 walks the token stream and broadcasts each parsed token with simd_broadcast; all 32 lanes then move that token's bytes. A 256-thread threadgroup per page was tried and is worse — a threadgroup barrier per token costs more than the extra lanes are worth, because real pages have tens of thousands of small tokens rather than a few large ones. Two details do help:

  • The token stream is staged in threadgroup memory. Every tag byte lane 0 reads is a dependent device-memory load, and one of those costs hundreds of cycles. All 32 lanes cooperatively stage the next 8 KB of the compressed stream into threadgroup memory and the parse reads from there; payload bytes are still read straight from device memory, where the reads are wide and coalesced and off the dependency chain.
  • Overlapping back-references are still copied in parallel. When offset < length (which is how both formats encode runs) the copy repeats a pattern of offset bytes, so output byte i is out[dst - offset + (i % offset)] and every one of those source bytes was written before this token began. simdgroup_barrier(mem_flags::mem_device) before a back-reference orders the stores; a literal token reads only the input and skips the barrier.

snappy_decompress_lane and lz4_decompress_lane give each thread its own page instead: it reads the token stream 16 aligned bytes at a time into registers and keeps the last 256 bytes it wrote in its own slice of threadgroup memory, so the back-references of token-dense pages (a value or two back) are served from there rather than from device memory. They check the same things in the same order and return the same status codes.

Per-block status codes are written back so corrupt input becomes an error instead of silently wrong data. A page that decompresses to nonsense is a separate question: the value decoders clamp what they read to the page they were given (a BYTE_ARRAY length field, for instance), so damaged bytes produce wrong values but never an out-of-bounds access and never an unbounded loop.

ARROWMETAL_PARQUET_DECODE=host, gpu or lane sends every Snappy and LZ4 page of a process to one decoder; the tests read every fixture, and 240 damaged files, on each (Correctness, below).

Supported matrix

Encodings

Encoding Types Where Notes
PLAIN all physical types GPU fixed width is a page-sized copy; BYTE_ARRAY is a per-page length walk plus a parallel gather
RLE definition and repetition levels, BOOLEAN values GPU the batched run-table decoder above
PLAIN_DICTIONARY, RLE_DICTIONARY all GPU returns an Arrow dictionary array, or materialised values with dictionaryEncoded: false
DELTA_BINARY_PACKED INT32, INT64 GPU thread 0 parses block headers, all threads unpack their miniblock deltas, a log-step threadgroup scan turns deltas into values
DELTA_LENGTH_BYTE_ARRAY BYTE_ARRAY GPU delta-packed lengths, then one prefix sum and one gather
DELTA_BYTE_ARRAY BYTE_ARRAY GPU prefix and suffix lengths delta-packed; the prefix chain is serial, so one threadgroup walks its page's values in order while all 256 threads move each value's bytes
BYTE_STREAM_SPLIT FLOAT, DOUBLE, FIXED_LEN_BYTE_ARRAY GPU byte k of value j is at plane k, slot j
BIT_PACKED (deprecated level encoding) levels — rejected with ParquetError.unsupported; no writer has emitted it since 2015

Codecs

Codec Where Notes
UNCOMPRESSED GPU (no work) the mapped file is the page buffer
SNAPPY GPU and host, page by page literal pages on the GPU (one SIMD group per page), token-dense pages on the host, both at once ("Decompression")
LZ4 GPU and host, page by page as SNAPPY; the Hadoop framing (big-endian sizes) is recognised by both decoders
LZ4_RAW GPU and host, page by page as LZ4
ZSTD host dlopen of libzstd; a clear error when it is missing; one context per run of pages
GZIP host Compression framework, gzip container stripped
BROTLI host COMPRESSION_BROTLI
LZO — ParquetError.unsupported

Types

Parquet Arrow Notes
BOOLEAN bool bit-packed PLAIN or RLE
INT32 int32
INT32 + UNKNOWN (Arrow's null type) null
INT32 + INT(8|16|32, signed) int8 / int16 / int32 narrowed with the existing cast kernels
INT32 + INT(8|16|32, unsigned) uint8 / uint16 / uint32
INT32 + DATE date32
INT32 + TIME(MILLIS) time32[ms]
INT32 + DECIMAL(p,s) decimal128(p,s) widened on the GPU
INT64 int64
INT64 + INT(64, unsigned) uint64
INT64 + TIMESTAMP(unit) timestamp[unit], UTC when isAdjustedToUTC the stored zone instead of UTC when ARROW:schema has one, below
INT64 + TIME(MICROS|NANOS) time64[us|ns]
INT64 + DECIMAL(p,s) decimal128(p,s)
INT96 timestamp[ns] Julian day + nanoseconds, converted on the GPU
FLOAT / DOUBLE float32 / float64
BYTE_ARRAY binary
BYTE_ARRAY + STRING / JSON / ENUM utf8
FIXED_LEN_BYTE_ARRAY fixed_size_binary(n)
FIXED_LEN_BYTE_ARRAY + DECIMAL(p,s) decimal128(p,s) big-endian, sign-extended on the GPU
FIXED_LEN_BYTE_ARRAY + FLOAT16 float16
list<T> (3-level and 2-level) list<T> Dremel assembly on the GPU, below; T may itself be nested
struct (a group without a LIST / MAP annotation) struct<...> reassembled from its leaves; its leaves also read on their own by dotted path
map (MAP / MAP_KEY_VALUE) map<K, V> a list of key/value entries; V may be nested

Nesting

Structs, maps and lists nest to any depth — list<list<T>>, list<struct<...>>, struct<list<...>>, map<K, list<V>>, list<map<K, V>> — with nulls and empty lists at every level, in the three-level (group (LIST) { repeated group list { element } }) and two-level (group (LIST) { repeated element }) list shapes. Every Arrow-level field carries three numbers from the schema: the definition level at which it is present, the largest repetition level that starts a new element of it, and the definition level of its nearest repeated ancestor. Over the level entries of any one leaf beneath the field, an entry is one slot of the field when its repetition level is at most the first and its definition level reaches the third, and the slot is non-null when the definition level reaches the second. So:

  • a leaf is its decoded array compacted to its own slots with the package's existing filter;
  • a struct is its children plus a validity bitmap, read off one leaf's entries at the struct's slots;
  • a list is its child plus offsets: at each of the list's slots, the number of child slots before it, which is a prefix sum of the child's slot flags read at the list's own slots;
  • a map is a list whose child is the key/value entries struct.

Each of those is one flag kernel over the entries (pq_nest_flags), one prefix sum and one scatter (pq_nest_scatter), and every leaf is decoded once however many fields read its levels (ParquetNested.swift). A one-level list<primitive> keeps the dedicated kernel pair it had before, which reads the same levels the same way. A struct's leaves still read on their own by dotted path: am.read_parquet(path, columns=["addr.city"]) returns the city leaf as a flat column, null wherever addr or city is null.

The stored Arrow schema

Parquet has no time zone (only isAdjustedToUTC), no duration, no decimal32 / decimal64, no fixed-size list, no dictionary type, no extension types and no per-field metadata. Arrow writers — pyarrow, Arrow C++, Polars, arrow-rs — therefore store the original Arrow schema in the file's key/value metadata under ARROW:schema (base64 of an IPC Schema message), and the reader decodes it with the IPC FlatBuffers views in IPC/FlatBuffers.swift and puts back what the Parquet schema lost (ParquetArrowSchema.swift):

Stored Arrow type Parquet stores Comes back as
timestamp[unit, tz=Z] TIMESTAMP(isAdjustedToUTC) timestamp[unit, tz=Z], on the stored unit (a seconds timestamp is stored, and read, as milliseconds)
duration[unit] plain INT64 duration[unit]
decimal32(p,s) / decimal64(p,s) INT32 / INT64 DECIMAL decimal32(p,s) / decimal64(p,s)
fixed_size_list<T>[n] a list fixed_size_list<T>[n]; a null row gets n null child slots
dictionary<K, T> over strings or binaries (a pandas categorical, a Polars Categorical or Enum) the values dictionary<int32, T>, unordered, dictionary encoded whatever the dictionary switch says (pyarrow keeps the stored index type K and the ordered flag; see Limits); a dictionary over any other value type (integers, timestamps, dates) reads as that value type, as pyarrow reads it
an extension type its storage the storage wrapped in MetalExtensionArray, so a consumer that knows the type rebuilds it
field custom_metadata — ParquetFile.arrowFieldMetadata(column:), am_parquet_field_metadata, f.field_metadata(column), and on every field of read_parquet_table's Table

Time zones and durations are restored inside structs, lists and maps too. The file's other key/value metadata is the schema metadata (arrowSchemaMetadata, am_parquet_schema_metadata, f.schema_metadata), and a Parquet field_id shows up as PARQUET:field_id, as it does in pyarrow. The null type needs no stored schema: Parquet annotates a null column UNKNOWN, which reads as null, at the top level and inside lists, maps and structs (list<null>, map<string, null>, list<struct<a: null, ...>>). The stored fields are matched to the Parquet columns by position, as Arrow's reader matches them; a stored schema with a different number of top-level fields is ignored, and its ARROW:schema key stays in the schema metadata, as pyarrow keeps it. A stored type the column cannot take (a dictionary claim over a struct, say) is ignored for that column. A file without ARROW:schema reads exactly as its Parquet schema describes it. A file whose ARROW:schema is not base64, or does not decode as a Schema message, reads the same way, with the undecodable value left in the schema metadata; pyarrow refuses to open such a file (Invalid base64 input, Corrupted metadata length), and test_parquet_nested.py checks both sides. A stored field nested more than 64 levels deep is read down to 64 levels and taken as a type no column matches, so that column reads as its Parquet schema says and the other stored fields still apply, as in pyarrow (test_a_stored_field_nested_past_64_levels_is_ignored_like_pyarrow). A leaf read on its own by dotted path always reads as the Parquet schema describes it.

Projection and predicate pushdown

let f = try ParquetFile(path: "trades.parquet")
let batch = try f.read(ParquetReadOptions(
    columns: ["price", "qty"],
    rowGroups: [0, 1],
    filters: [ParquetFilter(column: "price", op: .gt, value: .int(100))]))
cols = am.read_parquet("trades.parquet", columns=["price", "qty"],
                       filters=[("price", ">", 100)])
# already on the GPU, dictionary-encoded or not
total = cols["price"].sum()

# Across several queries, keep the handle: mapping the file is a per-open cost.
f = am.ParquetFile("trades.parquet")
for day in days:
    px = f.read(columns=["price"], filters=[("day", "==", day)])["price"]

# Or let the open-file cache hold it (below).
px = am.read_parquet("trades.parquet", columns=["price"], cache=True)["price"]

Only the requested column chunks are ever touched — the other columns' pages are never even faulted in. filters is evaluated against the footer's min_value / max_value statistics per row group; a row group whose range cannot contain a match is skipped without reading a page. When the file also has a column index and an offset index, the same filters then skip pages inside the row groups that remain (next section). Either way the result is a superset of the matching rows: follow it with filter (or a fused am.query) to get exactly the rows. A filter value is a string, a boolean, an integer or a float; a date or timestamp column is filtered by its stored integer (days since the epoch, or ticks in the column's unit), and Python raises on any other value (a datetime.date, a Decimal). A literal of another kind than the column's (a string against a number, in the C and Swift filter text) never rules a row group or page out. A string value may hold any character: Python quotes it and escapes " and \, so a ;, a quote or an operator inside it is part of the value; a column name cannot hold =, !, <, > or ;, and Python raises on one that does. An integer above the int64 range stays exact and is compared as unsigned against a uint64 column's statistics, which are read as unsigned (test_uint64_literals_past_the_signed_range, test_string_literals_with_quotes_semicolons_and_backslashes). String and binary statistics are compared the way Parquet orders them, by unsigned bytes: the min / max stay raw bytes and a string literal is compared as its UTF-8 bytes, so a composed and a decomposed accent, Z before a before é, and an emoji above every BMP character are all ordered as pyarrow orders them, row groups and page index alike (test_string_statistics_are_ordered_by_bytes). An integer statistic and a float literal are compared exactly, never through a double: 2^53 + 1 is above the literal 2^53 (test_int64_statistics_against_a_double_literal_near_2_pow_53). A decimal column's statistics hold its unscaled integer, so they never rule a row group or page out (test_decimal_statistics_stored_as_integers_are_not_compared_unscaled). On a float or double column != never rules a row group or page out: writers leave NaN out of min / max, so a range of one value equal to the literal can still hold a NaN, which != keeps. pyarrow's read_table(filters=...) rules out a row group whose min and max both equal the literal, and so leaves out that group's NaN rows; ArrowMetal returns them (test_not_equal_keeps_a_nan_row_group_where_pyarrow_drops_it). ParquetFile.selectedRowGroups(_:) / ParquetFile.selected_row_groups(...) report what the row-group statistics keep without reading anything, and ParquetFile.column_null_count(column) (am_parquet_column_null_count) a top-level column's null count: 0 for a required column, else the sum of the row groups' null_count statistics, or None when a row group does not record it.

Page-level skipping

A writer may store, after the row groups, a column index (each data page's min, max and whether it holds only nulls) and an offset index (where each data page starts and its first row). pyarrow writes them with write_page_index=True; Polars writes them by default. With them a filter narrows each row group to candidate row ranges (ParquetPageIndex.swift):

  1. For each filter on a flat column, a page whose [min, max] cannot satisfy it, or that holds only nulls, rules out its rows. The ranges the filters leave are intersected; a row group left with none is not read at all, even when its row-group statistics let it through. A page counts as all-null only when the index's null count for it covers every row: Polars (1.44) flags each page that holds a NaN as a null page with a null count of 0, and such a page is kept whole. A NaN min or max rules nothing out, as the format asks. Polars also leaves the flagged pages out of the row group's min / max, so when a chunk's column index shows such a page, that column's row-group statistics do not drop the row group; pyarrow.parquet.read_table(filters=...) trusts them and returns no rows for a filter outside the unflagged pages' range, where ArrowMetal returns every match (test_polars_row_group_statistics_leave_nan_pages_out).
  2. Every flat column with an offset index decodes only the data pages that overlap a candidate range. The page list comes from the offset index, so a skipped page is never decompressed, never decoded, and its header is never read.
  3. Pages have different boundaries in different columns, so every column — nested ones and those without an offset index included, which decode their row groups whole — is trimmed to exactly the candidate rows with one filter. All columns of the result cover the same rows.

The rows that match a filter are the same with and without the index; the index only makes the superset smaller. ParquetFile.usePageIndex (f.use_page_index in Python, am_parquet_set_page_index in C) turns it off, and lastReadStatistics (f.last_read_stats, am_parquet_last_read_stats) reports what the last read did: row groups read and skipped by statistics, by the page index and by bloom filters (below), data pages decoded and skipped, rows returned. test_parquet_nested.py compares the exact matches with and without the index against pyarrow over fifteen filter sets on five files (pyarrow in three page layouts and once without an index, and Polars), and six filter sets on a float column with a NaN every 97th row, written by Polars and by pyarrow, and != on pages of 64 rows where a NaN hides in a page whose min and max both equal the literal; ParquetPageIndexTests also checks that the skipped and decoded pages of flat columns add up to the pages of the row groups read. A filter on a column inside a list does not narrow pages; the row-group statistics still apply to it.

Bloom filters

A writer may also store a split-block bloom filter per column chunk (pyarrow with bloom_filter_options, DuckDB for its dictionary-encoded columns). An == filter hashes its literal the way the writer hashed the column's values — xxHash64 of the PLAIN encoding — and a row group whose filter does not have all 8 bits of that hash set is dropped before any page is read, even when its min/max statistics let the value through (ParquetBloomFilter.swift). A set of bits is only a maybe, so the row group is then read as usual; a bloom filter never drops a row group that holds the value. Only literals with one exact encoding are looked up — integers stored as INT32 / INT64, strings and binaries, and floating-point values exactly representable in the column's type other than zero and NaN (which have two encodings each) — and anything else keeps the row group. ParquetFile.useBloomFilters (f.use_bloom_filters, am_parquet_set_bloom_filters) turns it off, and the read statistics count the row groups it dropped. ParquetBloomFilterTests checks xxHash64 against its published vectors and every value of the fixture against its row group's filter; test_parquet_nested.py looks up values in seven columns of a pyarrow file and one of a DuckDB file and requires pyarrow's exact matches with the filters on and off.

The open-file cache

Opening a file maps it, and the first read of a column hands the mapped pages it covers to Metal; a ParquetFile keeps both for as long as it lives (above, "The file's bytes are the GPU's bytes"). A caller that cannot hold the handle itself — one read_parquet call per query, or the Polars engine, which sees a file path in every plan — reads through a process-wide cache of open files instead:

cols = am.read_parquet("trades.parquet", columns=["price"], cache=True)   # opens and maps
cols = am.read_parquet("trades.parquet", columns=["price"], cache=True)   # the same open file

am.parquet_cache_info()     # {"entries", "bytes", "max_entries", "max_bytes", "hits", "misses",
                            #  "invalidations", "files"}
am.parquet_cache_limit(max_entries=4, max_bytes=8 << 30)
am.clear_parquet_cache()
  • Keyed by the file's identity and state: its real path, device, inode, modification time in nanoseconds and size. A file rewritten in place (new size or new modification time) or replaced by another under the same name (new inode) is opened afresh on its next read, and its stale entry is dropped then (invalidations counts those).
  • Bounded: least recently used entries are evicted above max_entries (16 by default) or above max_bytes, the sum of the cached files' sizes (a quarter of physical memory by default). A file larger than max_bytes is read without being cached; max_entries=0 turns the cache off.
  • Shared: reads of one cached file are serialised by a lock per file, so last_read_stats belongs to the read that took it. Evicting or clearing an entry closes the file once the last read holding it returns.
  • read_parquet without cache=True opens and closes the file on every call, as before.

python/tests/test_polars_engine.py checks reuse, invalidation on a new size, a new modification time with the same size, a touched file and a replaced file, LRU order, both bounds and clear.

Cold is the first read in the process (the cache cleared before each run; the file itself stays in the OS page cache), warm is the next read of the same file through the cache. 50,000,000 rows x 8 columns, the files of Benchmarks/parquet_bench.py, best of 5, Benchmarks/results/parquet_cache_2026-09-26-split.csv (run conditions in Benchmarks/results/bench_conditions_2026-09-26-split.txt):

codec file measure cold ms warm ms
snappy 1.65 GB open only 0.2 0.01
snappy read price (400 MB of values) 12.2 7.0
snappy read price + sum 13.9 8.5
snappy read all 8 columns 66.7 53.9
lz4 1.67 GB open only 0.2 0.01
lz4 read price 12.3 7.0
lz4 read price + sum 13.5 7.9
lz4 read all 8 columns 54.5 42.9
none 2.23 GB open only 0.3 0.01
none read price 7.2 2.7
none read price + sum 7.9 4.4
none read all 8 columns 38.3 16.0

Opening the file (the footer and the mapping) is 0.2-0.3 ms. A cold one-column read is 7.2-12.3 ms against 2.7-7.0 ms warm: the cold read also parses the column's page headers, a minor fault each on a fresh mapping, and makes the column's own chunks resident for the GPU (above, "The file's bytes are the GPU's bytes"). A cold read of all 8 columns is 38.3-66.7 ms, 16.0-53.9 ms warm.

PYTHONPATH=python python Benchmarks/parquet_bench.py --rows 50000000 --codecs snappy,lz4,none \
    --cache --skip-main --keep --repeat 5 --cache-out parquet_cache.csv

Benchmarks

Measured on an Apple M4 Max (Mac16,6, 64 GB), macOS 26.x, release build. 50,000,000 rows x 8 columns (int64, int64, int32, float64, float64, dictionary-encoded string, timestamp[us], bool), 1 MB data pages. Best of 5 in-process runs, caches warm. The tables below are from Benchmarks/results/parquet_bench_2026-09-26-split.txt (run conditions in Benchmarks/results/bench_conditions_2026-09-26-split.txt).

wall ms is elapsed time for the whole read; CPU ms is process CPU time over the same interval, so GPU work does not appear in it and ArrowMetal's host share of the decompression does; ttfc is time to first compute — read one float64 column and sum it, which is the smallest query anyone actually runs.

Whole-table read, 50 M rows x 8 columns

codec reader wall ms CPU ms MB/s ttfc ms
snappy arrowmetal 65 674 25373 14
snappy pyarrow.parquet 160 1164 10314 85
snappy polars 95 1283 17455 20
snappy pandas 239 1346 6915 89
lz4 arrowmetal 54 508 31062 14
lz4 pyarrow.parquet 159 987 10516 83
lz4 polars 73 1007 22854 19
lz4 pandas 226 1164 7396 90
none arrowmetal 38 83 58724 9
none pyarrow.parquet 162 746 13721 80
none polars 63 790 35232 16
none pandas 237 910 9412 88

On wall time ArrowMetal is ahead of pyarrow on all three files (2.5x Snappy, 2.9x LZ4, 4.3x uncompressed) and ahead of Polars on all three (1.5x Snappy: 65 ms against 95; 1.4x LZ4: 54 against 73; 1.7x uncompressed: 38 against 63). It takes 1.7-2.0x less CPU time than pyarrow and Polars on the two compressed files (674 and 508 ms against 987-1,283), where the host decodes the token-dense pages, and 9.0-9.5x less on the uncompressed one (83 ms against 746-790), where the decode is work the host never does. Time to first compute, which opens the file afresh for every query, is 9-14 ms against Polars' 16-20 ms and pyarrow's 80-85 ms. With the handle kept, which is what a query engine does:

One float64 column (400 MB of values), file handle kept open

codec reader read ms sum ms
snappy arrowmetal 7 2
snappy pyarrow.ParquetFile 48 6
lz4 arrowmetal 7 2
lz4 pyarrow.ParquetFile 48 6
none arrowmetal 3 2
none pyarrow.ParquetFile 42 6

That is the shape a query actually has — open once, project a column, compute — and ArrowMetal is 6.9-14.0x ahead on the read (6.9x Snappy, 6.9x LZ4, 14.0x uncompressed). The reduction takes 2 ms against 6 on all three files, because the values are already in GPU memory when it starts. 400 MB decoded in 2.7 ms (the warm price read of the open-file cache table above) is 150 GB/s.

Decompression on its own

One int64 column of 20 M rows, deliberately structured data (an LCG sequence, so Snappy finds many short matches and emits many tokens — the hard case for a GPU), 1 MB pages:

codec file MB am ms am MB/s decode alone
none 160 3.2 50032 —
snappy 152 8.1 19670 32.4 GB/s
lz4 161 4.2 38027 (pyarrow wrote it barely compressed)
zstd (host, libzstd) 120 10.6 15079 21.6 GB/s
gzip (host) 111 29.7 5382 6.0 GB/s

Page size, the same column, MB/s of decoded output:

page size none snappy lz4 zstd (host) gzip (host)
1 MB 50032 19670 38027 15079 5382
256 KB 55896 19016 33799 16009 5347
64 KB 59473 20219 32228 15921 5447

These Snappy pages compress 1.05x and are short tokens throughout, so they decode on the host; the LZ4 pages (under 1.0x at 1 MB and 256 KB, one long literal each) decode on the GPU, and at 64 KB (1.12x) on the host.

Reproduce with:

PYTHONPATH=python python Benchmarks/parquet_bench.py --rows 50000000 --codecs snappy,lz4,none --codec-scan --repeat 5

Nested reads — a struct, a list, a list of lists, a map and a list of structs, at 1 M and 10 M rows, against pyarrow, Polars and DuckDB — have their own script. Its numbers here are from Benchmarks/results/parquet_nested_2026-09-24.csv. The run conditions at its start are recorded in Benchmarks/results/bench_conditions_2026-09-24.txt; this run started at a load average of 6.10, busier than the other benchmarks of that day. Wall time in that file, with the fastest CPU reader of each row:

shape ArrowMetal, 1 M fastest CPU, 1 M ArrowMetal, 10 M fastest CPU, 10 M
struct 10.35 ms 11.44 ms (pyarrow) 19.99 ms 27.14 ms (Polars)
list 6.1 ms 4.96 ms (pyarrow) 17.54 ms 14.46 ms (Polars)
list of list 7.39 ms 9.34 ms (pyarrow) 25.56 ms 26.16 ms (Polars)
map 9.69 ms 17.65 ms (pyarrow) 36.0 ms 39.21 ms (Polars)
list of struct 10.76 ms 6.09 ms (pyarrow) 24.14 ms 25.17 ms (pyarrow)
all five 42.81 ms 19.8 ms (pyarrow) 115.24 ms 91.38 ms (pyarrow)

ArrowMetal is ahead on the struct, list of list and map reads at both sizes and on the list of struct read at 10 M rows. To improve: the list read (6.1 ms against pyarrow's 4.96 at 1 M rows, 17.54 ms against Polars' 14.46 at 10 M), the list of struct read at 1 M rows (10.76 ms against 6.09), and the read of all five columns together (42.81 ms against pyarrow's 19.8 at 1 M rows, 115.24 ms against 91.38 at 10 M). The host CPU time is lower on every row: all five columns at 10 M rows cost 34.99 ms of CPU against pyarrow's 593.14 and Polars' 1269.84 (cpu_ms). Every ArrowMetal row matches pyarrow's table (match). An earlier smoke run, recorded while other work shared the GPU, is kept as history in Benchmarks/results/parquet_nested_2026-09-23_provisional.csv.

PYTHONPATH=python python Benchmarks/parquet_nested_bench.py --rows 1000000,10000000 --repeat 3 --out results.csv

What the numbers say

  • Both processors decode, and the CPU still does less. A whole-file read of the compressed tables spends 508-674 ms of CPU, the host's share of the decompression, against 987-1,346 ms for the CPU readers; the uncompressed one 83 ms against 746-910. Everything but the decompression of token-dense pages is compute the process never does.
  • The arrays land in GPU memory already. Summing the column ArrowMetal just decoded takes 2 ms; the CPU reader pays 6 ms and had to materialise the array first. There is no import step, because the decode wrote into Metal shared memory in the first place.
  • Opening the file costs little; the first read of a column costs a little more. A fresh open is 0.2-0.3 ms, and a cold one-column read 7.2-12.3 ms against 2.7-7.0 ms with the handle kept: the cold read parses the column's page headers and makes its chunks resident for the GPU. Holding the ParquetFile across queries, which is what a query engine does, keeps both.
  • Snappy and LZ4 decode on whichever side is faster for the page. An LZ77 token stream is serial, so a page is one worker's job and its cost scales with the number of tokens, not with bytes. Incompressible pages are one huge literal and the GPU moves them at memory speed — the price column, 400 MB of random doubles, comes back in 7 ms with the handle kept. Token-dense pages go to the host's cores, which walk a token stream about a thousand times as fast as a GPU thread, while the GPU decodes the rest (Decompression, above): the whole Snappy table in 65 ms against Polars' 95, the LZ4 one in 54 against 73.
  • Where the GPU is unambiguously ahead is the uncompressed and dictionary paths, which is also where a GPU-resident analytics stack wants to be: 50-59 GB/s for a plain int64 column, the whole uncompressed table 1.7x Polars, and a dictionary column that comes back as an Arrow dictionary array without materialising a single string.

The writer

ParquetWriter (and am.write_parquet, am_parquet_write) is deliberately small: it exists so ArrowMetal can round-trip its own output, not to compete with pyarrow's writer. It runs on the host — writing is not the part of Parquet that needed a GPU.

  • PLAIN for fixed-width columns, PLAIN or RLE_DICTIONARY for byte arrays.
  • RLE definition levels; every column is written as optional, so nulls round-trip.
  • Uncompressed or Snappy (a compact hash-table Snappy encoder in ParquetWriter.swift).
  • Data page v1, one data page per column chunk, rowGroupSize rows per row group.
  • Types: all integers, float32, float64, bool, utf8, binary, fixed_size_binary, date32, time32, time64, timestamp. Dictionary columns are materialised first. Decimals, lists, structs and the other nested types are not written.

Files it writes read back with identical values, nulls and types under ArrowMetal and pyarrow — over int64, float64, string, bool and timestamp columns, uncompressed and Snappy; ParquetWriterTests and python/tests/test_parquet.py::test_writer_round_trip check both directions.

Correctness

  • Tests/Fixtures/generate_parquet.py writes 44 small fixtures (1.8 MB total, committed): every logical dataset once per encoding/codec/page-version variant, plus long strings (up to 64 KB), lists of int64 and of string, several row groups, decimals stored both as FIXED_LEN_BYTE_ARRAY and as INT32 / INT64, a struct column, all-null columns, an empty file, a single-row file and INT96 timestamps.
  • Tests/ArrowMetalTests/ParquetTests.swift reads every variant of a dataset and compares them element for element, which checks the encodings against each other, plus known-value, type, list, statistics and projection tests.
  • python/tests/test_parquet.py reads every fixture twice — once on the GPU, once with pyarrow.parquet — and asserts the values, nulls and types are identical: 88 checks over the fixture set, plus projection, row-group selection, statistics pushdown, dictionary output, struct leaves and a 50 M-row round trip behind ARROWMETAL_PARQUET_BIG=1.
  • Every Snappy and LZ4 decoder reads the same bytes. ParquetTests.testEveryDecoderAgrees reads every Snappy and LZ4 fixture, whole and one row group at a time, with every page sent to the host, to the SIMD-group kernel and to the page-per-thread kernel, against the routed split; test_every_fixture_reads_like_pyarrow_on_every_decoder reads every fixture and a Snappy, an LZ4 and a ZSTD file of small pages on each decoder against pyarrow; testDamagedPagesOnEveryDecoderRaiseOrReturn reads 240 damaged Snappy and LZ4 files on each decoder and on the split, requiring every read to raise or return. DecompressSplitTests checks the host decoders against byte-at-a-time reference decoders (overlapping copies at every offset from 1 to 16, runs ending exactly at the end of the slot, the Hadoop framing) and on damaged blocks placed between inaccessible pages, so a read or write outside a block would end the process; it also checks the router's decisions and that the staging layout never puts a host page and a GPU page in the same page of memory.
  • python/tests/test_parquet_robustness.py damages a file two hundred ways — truncation, a broken magic, a footer length larger than the file, single-byte damage in the footer and in the pages, a hand-built footer nesting Thrift structs 60,000 deep, a 2^64 length, a num_children past the schema — and requires every one of them to raise rather than crash, hang or read outside the mapping.
  • ParquetWriterTests and the Python writer test close the round trip.
  • Tests/Fixtures/generate_parquet_nested.py writes the nested fixtures under Tests/Fixtures/nested (committed): structs three levels deep, structs of strings and binaries, map<string, int64>, map<int32, string>, maps of structs and of lists, list<list<T>>, list<list<list<int32>>>, list<struct<...>>, struct<list<...>> and list<map<...>>, with nulls and empty lists at every level — written by pyarrow in four encoding / codec / page-version variants with 512-byte pages (so levels cross hundreds of page boundaries), by DuckDB (COPY ... TO ... (FORMAT parquet)) and by Polars wherever it can express the shape (it has no map type).
  • Tests/ArrowMetalTests/ParquetNestedTests.swift checks known values against the generator's formulas and every writer's and variant's file against every other's, row by row.
  • python/tests/test_parquet_nested.py reads every nested fixture with ArrowMetal and with pyarrow.parquet.read_table and requires the same values and the same types, apart from the three differences listed under Limits (32-bit offsets and no view layouts, nullable struct members, the index type and ordered flag of a restored dictionary), each of which has its own test showing the difference is exactly that; it also damages nested files 200 ways and requires every read to raise or return rather than crash.
  • The same generator writes the ARROW:schema fixtures: time zones on every unit (named zones, a fixed offset, UTC, naive), the null type (also below lists, maps and structs), durations, decimal32 / decimal64, a fixed-size list, categoricals over strings, binaries, integers, timestamps and dates (and a pandas categorical, a Polars Categorical and a Polars Enum), field metadata and a field_id, a registered (arrow.uuid) and two unregistered extension types, zones and durations inside a struct, a list and a map, the view and 64-bit-offset layouts, and the same columns with no ARROW:schema (from pyarrow and DuckDB). DuckDB's KV_METADATA writes the crafted ones: an ARROW:schema that is not base64, one that is a truncated message, stored schemas with fewer and more fields than the file and one whose first field is renamed, and a dictionary claim over a struct. ParquetArrowSchemaTests and the ARROW:schema half of test_parquet_nested.py check types, values, field metadata and schema metadata against pyarrow.
  • It also writes the page-index fixtures (pyarrow with write_page_index=True in three page layouts and once without, and Polars; plus a float column with NaN from Polars and pyarrow, a NaN inside a page whose other values all equal the filter literal, and uint64 values past the int64 range) and the bloom-filter fixtures (pyarrow with bloom_filter_options, and DuckDB), which ParquetPageIndexTests, ParquetFilterEdgeTests, ParquetBloomFilterTests and the second half of test_parquet_nested.py read; that file also damages the page indexes 180 ways and reads them with filters, requiring every read to raise or return, and reads 40,000 rows of repeated columns three times over.
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test --filter "Parquet"
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test -c release --filter "Parquet"
swift build -c release --product ArrowMetalC
PYTHONPATH=python python -m pytest python/tests/test_parquet.py python/tests/test_parquet_nested.py python/tests/test_parquet_robustness.py -q
python Tests/Fixtures/generate_parquet_nested.py      # regenerate the nested, schema, page-index and bloom fixtures
ARROWMETAL_PARQUET_BIG=1 PYTHONPATH=python python -m pytest python/tests/test_parquet.py -q -k fifty

Limits

  • Duplicate column names are kept, not merged. read hands back a ColumnSet — a dict that is positional underneath — so read_parquet(path, columns=["a", "a"]) returns both columns and a file with two columns of the same name reads both of them. Indexing by name gives the first; names, columns, items() and iteration walk all of them in order.
  • BIT_PACKED (the deprecated level encoding) and LZO are rejected.
  • Encrypted files are not supported.
  • A column whose chunks in one read total 4 GiB or more is rejected (the file itself has no size limit).
  • Statistics pushdown returns a superset of the matching rows: row-group granular, or page granular when the file has a page index. The deprecated min/max fields are only used when min_value/max_value are absent (they use a signed byte order that is wrong for strings, which is why Parquet deprecated them).
  • ZSTD needs libzstd installed; see above.
  • The members of a struct and the element of a list are exported as nullable. A writer that declares a struct member required gets not null on that member from pyarrow.parquet.read_table; the values are the same, and the member reads as nullable here.
  • A restored dictionary type has int32 indices and no ordered flag. The engine's dictionary arrays carry int32 codes only, so a stored dictionary<int8 | uint8 | uint32, T> (pandas stores a categorical with int8 indices, Polars a Categorical with uint32 and an Enum with uint8, ordered) reads here as dictionary<int32, T>, unordered, where pyarrow.parquet.read_table keeps the stored index type and flag. The values are the same (test_restored_dictionaries_have_int32_indices_and_no_ordered_flag).
  • Custom metadata on a nested field is not carried. A top-level column's field metadata comes back (above); metadata on a struct member, a list element or a map value does not, because the engine's nested arrays have no per-field metadata. The types still compare equal.
  • large_string, large_binary and large_list come back 32-bit, and the view types as their non-view twins. ArrowMetal narrows 64-bit offsets everywhere and has no view layouts, so a column whose stored Arrow type is large_string or string_view reads here as string, large_list or list_view as list, with the same values. Polars records large_string / large_list for every string and list column it writes.
  • decimal256 (precision above 38) is rejected, as is any Arrow type ArrowMetal does not carry.
  • A dictionary-encoded column decodes itself where the kernels cannot use the codes. read_parquet returns dictionary-encoded columns encoded (dictionary=False, which read_parquet_table passes, materialises them instead), and the entry points that need the values rather than the codes decode once on the way in: the reductions and statistical aggregates, arithmetic, comparison, cast, the sorts and top_k, and the element-wise maths kernels. The decode is a GPU take of the values by the codes, so it costs one pass; dictionary=False at read time is still the cheaper way to run many operations over the same column. The one thing that does not decode for you is the fused expression compiler behind am.query / am.scan(...), which reads flat columns only: hand it a column read with dictionary=False.
  • PLAIN BYTE_ARRAY pages are walked by one thread each to find the value boundaries — the format gives no other option — so a byte-array column with a handful of very large pages has less parallelism than a wide one.
  • Arrays are capped at 2^32 elements, as everywhere else in ArrowMetal.
docs/SWIFT.md

Swift

The native API. The Python package, the C ABI and every other binding call these types. Everything on this page is in Getting started, Sources/ArrowMetalExamples/main.swift or the tests under Tests/ArrowMetalTests, and the numbers are the ones those cite.

Install

// Package.swift
dependencies: [.package(url: "https://github.com/singhpratech/ArrowMetal", from: "0.5.0")],
targets: [.target(name: "MyApp", dependencies: [.product(name: "ArrowMetal", package: "ArrowMetal")])]

macOS 14 or later, iOS 17 or later (Package.swift); Swift 6.3.3 is the toolchain the tests run under. Build and test from a checkout with the Xcode toolchain selected:

export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
swift build -c release
swift test -c release        # release is required: a release-only miscompile has bitten this project once

Example

import ArrowMetal

let prices = try MetalArray<Float>([9.5, nil, 12.0, 3.25])       // nullable, lives in Metal shared memory
let mask   = try prices.compare(.gt, 5)                          // GPU, Arrow boolean bitmap out
let picked = try prices.filter(mask)                             // GPU stream compaction
print(try picked.sum(), try picked.max(), picked.nullCount)      // .float(21.5), 12.0, 0

// Multi-column batches behave like an Arrow RecordBatch.
let orders = try MetalRecordBatch(names: ["region", "amount"], columns: [.int32(region), .float32(amount)])
let hits = try orders.filter(try region.compare(.eq, 2).and(try amount.compare(.gt, 100)))
let window = try hits.slice(offset: 32, length: 1000)             // zero-copy view

// Chains share one GPU round trip.
let total = try MetalContext.shared.batch {
    try amount.filter(try region.compare(.eq, 2).and(try amount.compare(.gt, 100))).sum()
}

batchAsync is the same without the wait; return a pending array from the body and use the async accessors (sumAsync and friends) for scalars. Six end-to-end scenarios (analytics on a record batch, feature preparation, Float64 with NaN, C Data Interface interop, sliced windows, one batched command buffer) run with swift run -c release arrowmetal-examples.

What the API is

Type What it is Where it is documented
MetalArray<T> one Arrow array in Metal shared memory: reductions, compare, arithmetic, cast, filter, take, slice, sort, top-k, strings, temporal, decimal Design, Coverage summary
AnyMetalArray the type-erased array every entry point in the C ABI hands around Arrow functions, one by one
MetalRecordBatch named columns with filter, take, slice and group-by across them Query engine
MetalContext the device, the command queue, batch and batchAsync Design, The persistent-kernel result
Expressions and LazyFrame a fused expression compiler and a lazy engine with an optimizer, six join kinds and an as-of join Fused expressions, Query engine
ParquetFile Parquet decoded on the GPU Parquet on the GPU
StreamQuery larger-than-memory scans, disk through the GPU Streaming
importArrowArray, exportArrowArray, exportArrowDeviceArray (CArrowABI) the Arrow C Data, C Device and C Stream interfaces; ImportResult.zeroCopy says whether the import wrapped or copied Coverage summary

The copy rule

Copy-free out, always. Copy-free in when the producer's buffers are page aligned; one copy otherwise. importArrowArray reports which it did. Details and the measurements behind them are in Design and numpy.

Tests

1,046 test functions in 87 files under Tests/ArrowMetalTests, run in release; the oracles are plain-Swift CPU references, hand-computed vectors and pyarrow answers pinned as literals (Testing). Tests that need a real GPU skip on virtual Metal devices.

Numbers

The Swift-level baselines (one core, all 16 cores, Accelerate) are in Getting started and Benchmark method; the operation-by-operation matrix is measured from Python over the same kernels (The full matrix).

Limits

  • One GPU, one device; no multi-GPU.
  • The kernels are generated Metal Shading Language, compiled at first use and cached per process, so the first call of each kernel family pays a compile of a few milliseconds.
  • Everything the other pages list under limits applies here first, since this is the layer they wrap.
  • Index arrays (argsort, topK, partitionNthIndices, lexsortIndices, hashJoin, the ranks) are MetalArray<UInt32>, row numbers up to 2^32 - 1; a call past that throws. take accepts MetalArray<Int32>, MetalArray<Int64> and MetalArray<UInt32> (Design).
docs/C.md

C

One header, one dynamic library, no Swift in sight for the caller. include/arrowmetal.h declares 286 entry points over libArrowMetalC.dylib; every other binding in this repository (Python, Rust, Go, TypeScript, R, the Polars plugin, the DuckDB extension) is built on it, so it is tested by all of their suites as well as by python/tests, which calls it through ctypes, and by a test that compiles the header as C.

Build the library

export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
swift build -c release --product ArrowMetalC        # .build/release/libArrowMetalC.dylib

Conventions

  • Handles: am_array* is an opaque, Metal-resident Arrow array; am_release frees it.
  • Every function returns 0 on success and non-zero on error; am_last_error() has the message, thread-local, valid until the next call on that thread. Argument guards return 2 without setting a message.
  • In and out through the Arrow C Data Interface: am_import moves the caller's ArrowArray (its release is nulled) and borrows the schema; am_export fills an ArrowArray/ArrowSchema pair whose release callback owns the buffers. am_import_device/am_export_device speak the C Device interface with ARROW_DEVICE_METAL; am_stream_* speak the C Stream interface.
  • Scalars are passed as a pointer to a value of the array's compute element type: am_compute_format names it (for a dictionary array am_format reports the index type, and the kernels compute on the values).
  • Copy-free out, always. Copy-free in when the producer's buffers are page aligned; one copy otherwise.
  • Index arrays are uint32 (I): am_argsort*, am_top_k*, am_lexsort*, am_partition_nth*, am_join and the ranks (am_window ops 0-2, am_rank_ex, a plan's row_number / rank / dense_rank). A call whose row numbers would pass 2^32 - 1 returns an error. am_take accepts int32, int64 and uint32 indices. Dictionary codes, index_in, group ids, inverse_permutation and list_parent_indices stay int32; indices_nonzero is uint64 (Design).
  • A column held as several arrays (a ChunkedArray, one column of a stream of RecordBatches) imports in one call: am_import_chunks(schema, arrays, n, &out) takes n contiguous ArrowArray structs of the one type schema describes and returns one array of their total length, the same array am_import of their concatenation gives. Each chunk's offset, length, validity bitmap (or its absence) and null count (-1 included) are honoured, and each chunk is copied straight into the final buffers on the CPU cores in parallel, with no concatenated copy in between. On success every chunk is moved; on failure a chunk whose release is still set is still the caller's. It takes the integer, float, float16, boolean, null, decimal, temporal, interval and fixed_size_binary types, utf8 / large_utf8 / binary / large_binary (a total over 2 GB is refused, as for one large_utf8 array) and utf8_view / binary_view; for any other type (dictionary, nested, run-end encoded, extension) it returns 3 and reads and moves nothing, and am_import_chunks_supported(schema) answers the question up front. One chunk is am_import of it.

Example

#include "arrowmetal.h"

struct ArrowSchema schema; struct ArrowArray array;   /* filled by any Arrow producer */
am_array* col = NULL;
if (am_import(&schema, &array, &col) != 0) { fprintf(stderr, "%s\n", am_last_error()); return 1; }

int64_t two = 2; am_array* mask = NULL; am_array* hits = NULL;
am_compare_scalar(col, 4 /* gt */, &two, &mask);
am_filter(col, mask, &hits);

int64_t sum; double f; int kind, is_null;
am_reduce(hits, 0 /* sum */, &sum, &f, &kind, &is_null);

struct ArrowSchema out_schema; struct ArrowArray out_array;
am_export(hits, &out_schema, &out_array);             /* copy-free; the consumer calls out_array.release */
am_release(hits); am_release(mask); am_release(col);

What is in the header

Reductions, element-wise compare and arithmetic, casts, boolean logic, selection, sorts and top-k, group-by with its aggregates, strings, temporal, decimal, nested types, hashing and set lookup, window functions, joins, the fused expression runner (am_query), the JSON plan runner (am_plan_*), Parquet, the streaming executor (am_stream_*), batching (am_batch_*) and the device interfaces. The header's comments are the reference for each; Arrow functions, one by one maps every Arrow function name to its entry point.

Limits

  • Thread-local errors mean a green-threaded runtime must read am_last_error on the thread that made the call (the Go binding pins the OS thread around every call for this reason).
  • am_import does not report whether it wrapped or copied; a caller infers it from buffer alignment.
  • macOS arm64 only: the library links Metal.
docs/RUST.md

ArrowMetal for Rust users

arrow-rs is where Rust keeps Arrow-shaped data. This document is how you point that data at the GPU.

Two crates live in rust/:

Crate What it is
arrowmetal-sys Raw extern "C" declarations over include/arrowmetal.h, plus the build.rs that finds and links libArrowMetalC.dylib.
arrowmetal The safe crate. An arrow::array::ArrayRef goes in, an ArrayRef comes out; every failure is a Result carrying am_last_error()'s message.

Both are on crates.io at 0.5.0; a path or git dependency on a checkout works too.

For Apache DataFusion, datafusion/ holds datafusion-arrowmetal, built on the arrowmetal crate: a physical optimizer rule that runs full ORDER BY sorts on the GPU with DataFusion's answers (DataFusion).

Everything below was run in this repository on 2026-09-07 on an Apple M4 Max, macOS 26.6.2, rustc 1.95.0, arrow-rs 59.3.0, ArrowMetal 0.1.0.


Install

ArrowMetal is a Metal library. It needs an Apple silicon Mac; there is no other target.

# 1. Build the GPU library. This is the only non-cargo step.
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
    swift build -c release --product ArrowMetalC

That produces .build/release/libArrowMetalC.dylib. Then, in your own crate:

# Cargo.toml
[dependencies]
arrow = "59"
arrowmetal = "0.5.0"          # crates.io; or { path = "../ArrowMetal/rust/arrowmetal" } from a checkout

and build with the dylib's location known:

ARROWMETAL_LIB=/path/to/libArrowMetalC.dylib cargo build --release

The dylib comes from the swift build above, or from the Python wheel: pip install arrowmetal places it at site-packages/arrowmetal/_lib/libArrowMetalC.dylib, and the crates were checked against that copy (a scratch crate depending on arrowmetal = "0.1.0" from crates.io, built with ARROWMETAL_LIB pointing at the wheel's dylib, prints 0.1.0 on Apple M4 Max).

Finding the dylib

arrowmetal-sys/build.rs searches, first hit wins:

  1. $ARROWMETAL_LIB — the full path to the dylib. This is the one to set.
  2. $ARROWMETAL_LIB_DIR — a directory holding it.
  3. <repo>/.build/release, then <repo>/.build/debug — a SwiftPM build in the ArrowMetal checkout.
  4. /usr/local/lib, /opt/homebrew/lib.

Anything else is a hard build error naming every path it looked in and the swift build line, rather than a link that succeeds and a dyld failure at run time.

The dylib's install name is @rpath/libArrowMetalC.dylib, so the directory that was found is baked into arrowmetal's own binaries, examples and tests as an LC_RPATH entry. Your binary needs the same entry, and then no DYLD_LIBRARY_PATH at run time: without it the link succeeds and dyld reports no LC_RPATH's found on launch (measured with the published crate). Because Cargo hands a build script's link arguments only to the crate that owns it, arrowmetal-sys republishes that directory through its links = "ArrowMetalC" key (DEP_ARROWMETALC_LIB_DIR) and arrowmetal/build.rs repeats the -rpath for its own binaries, examples and tests. A crate that depends on arrowmetal and builds a binary should do the same:

// your-crate/build.rs
fn main() {
    // DEP_ARROWMETALC_LIB_DIR is visible only to crates that depend on arrowmetal-sys directly,
    // so read the same variable the build searched with.
    println!("cargo:rerun-if-env-changed=ARROWMETAL_LIB");
    if let Ok(lib) = std::env::var("ARROWMETAL_LIB") {
        if let Some(dir) = std::path::Path::new(&lib).parent() {
            println!("cargo:rustc-link-arg=-Wl,-rpath,{}", dir.display());
        }
    }
}

Without a build script, one environment variable at build time does the same:

RUSTFLAGS="-C link-arg=-Wl,-rpath,$(dirname "$ARROWMETAL_LIB")" cargo build --release

Both were run against the published arrowmetal = "0.1.0" and the wheel's dylib; the binary then starts from any directory. If you move the dylib after building, set DYLD_LIBRARY_PATH or rebuild.


Example

This is the body of rust/arrowmetal/examples/quickstart.rs, so it is compiled by every cargo build --examples. Run it with cargo run --release --example quickstart; it prints 2 groups: [0, 1] -> [10, 27] and sum of everything: Some(Int64(42)).

use arrow::array::Int64Array;
use arrowmetal::{group_by, Array, CompareOp};

fn main() -> Result<(), arrowmetal::Error> {
    let region = Int64Array::from(vec![0i64, 1, 0, 2, 1]);
    let amount = Int64Array::from(vec![Some(10i64), Some(20), None, Some(5), Some(7)]);

    let region = Array::from_arrow(&region)?;   // to the GPU
    let amount = Array::from_arrow(&amount)?;

    let big = amount.compare_scalar(CompareOp::Gt, 5i64)?;   // boolean mask
    let kept_amount = amount.filter(&big)?;                  // 10, 20, 7
    let kept_region = region.filter(&big)?;

    let gb = group_by(&[&kept_region])?;
    let totals = gb.sum(&kept_amount)?.to_arrow()?;          // back to arrow-rs
    let keys = gb.keys(0)?.to_arrow()?;

    let ints = |a: &dyn arrow::array::Array| -> Vec<i64> {
        a.as_any().downcast_ref::<Int64Array>().unwrap().values().to_vec()
    };
    println!("{} groups: {:?} -> {:?}", gb.group_count(), ints(&keys), ints(&totals));
    println!("sum of everything: {:?}", amount.sum()?);
    Ok(())
}

The copy rule

Copy-free out always; copy-free in when the producer's buffers are page aligned, one copy otherwise.

The decision is made per buffer, so a nullable column can have its values wrapped and its validity bitmap copied. That case is common; see the table.

Out (Array::to_arrow) is always copy-free. ArrowMetal's buffers are MTLBuffers in shared memory; it hands their pointers straight to the C Data Interface with a release callback, and arrow-rs reads the GPU's memory in place. Measured over inputs that all carry nulls, so both buffers are exercised: the exported values and validity pointers were page aligned in every case (tests/copy_rule.rs::arrowmetal_exported_buffers_are_page_aligned), and the export call itself took 0.001 ms (median) for a 10M-row column.

In (Array::from_arrow) is copy-free only when the buffer pointer is aligned to a 16 KiB page, because that is what MTLDevice.makeBuffer(bytesNoCopy:) requires; otherwise that buffer is copied once. Whether an arrow-rs array clears the bar is a property of the allocator, not of arrow-rs, so it is measured rather than assumed. 32 allocations at each size, exported through arrow::ffi and read back as the pointers am_import receives:

Column length Values bytes Values page aligned Validity bitmap page aligned
16 128 B 1/32 0/32
512 4 KiB 8/32 0/32
8,192 64 KiB 32/32 2/32
131,072 1 MiB 32/32 32/32
1,250,000 10 MB 32/32 32/32
10,000,000 80 MB 32/32 32/32

So in practice:

  • Values are wrapped from a few thousand rows up, and copied below that.
  • A nullable column of fewer than roughly 130,000 rows normally has its validity bitmap copied, even when its values are wrapped. The bitmap is one bit per row, so the copy is about a kilobyte at 8,192 rows and about 16 KB at the point it stops happening — small, but real.

Both are the system allocator serving a large enough request with mmap and handing back page-aligned memory. The bitmap is eight times smaller than the values, so it crosses that threshold eight times later. None of it is a guarantee — a custom global allocator, a different macOS, or a buffer sliced out of an arena can all change it. Run cargo test --test copy_rule -- --nocapture in rust/ to print the table for your own machine.

Copy-free is not free: at 10M int64 rows the import still cost 1.11 ms, which is Metal mapping 80 MB of pages into the GPU's address space. See the timing below.

Two more claims, inferred rather than tested:

  • A slice does not cost a copy either way. An Arrow offset is carried on the handle rather than applied, so array.slice(7, n) should import the same buffers the unsliced array would.
  • An array that came out of ArrowMetal and goes back in is never copied in either buffer, whatever the original alignment: ArrowMetal recognises its own exported buffers and shares the MTLBuffer objects directly.

Both follow from reading Sources/ArrowMetal/CInterop.swift plus the alignment measurement above, and both have tests behind their observable halves — a sliced array round-trips exactly (round_trip_of_a_sliced_array, a_slice_survives_the_round_trip), and an ArrowMetal export is always page aligned, which is the precondition for the no-copy path (arrowmetal_exported_buffers_are_page_aligned). But whether a given import copied is not observable through the C ABI: the Swift side computes an ImportResult.zeroCopy flag and am_import discards it. The ABI does not report it, so these two are arguments, not measurements, and are labelled as such.


What is covered

Each row is backed by a test in rust/arrowmetal/tests/ against arrow-rs's own kernel on the same data, at lengths 0, 1, 33, 1024, 1025, 100,001 and 1,000,001, with and without nulls.

Rust ArrowMetal ABI Oracle
Array::from_arrow / to_arrow am_import / am_export round trip equals the input, including sliced arrays
Array::from_arrow_chunks, Source::from_batches am_import_chunks, am_import_chunks_supported equals Array::from_arrow of arrow::compute::concat and the concatenation itself, over 11 types (integers, Float64, Boolean, Date32, Timestamp, Decimal128, Utf8, LargeUtf8, Utf8View, Binary) and chunk layouts (empty, one-row, sliced, all-null and no-null chunks, one chunk, 10,000 chunks), then through sum, sort, argsort and group_by (tests/chunked.rs)
len, null_count, format am_length, am_null_count, am_format arrow::array::Array
sum, min, max, mean am_reduce arrow::compute::{sum, min, max}; mean against arrow's exact sum over the valid count
compare_scalar, compare (6 ops) am_compare_scalar, am_compare_array arrow::compute::kernels::cmp
filter am_filter arrow::compute::filter, including a mask with nulls
take am_take arrow::compute::take, with repeated, out-of-order and null indices
slice am_slice arrow::array::Array::slice
sort, argsort am_sort, am_argsort arrow::compute::sort with nulls_first: false; argsort index for index against sort_to_indices (both UInt32Array) on distinct values at 1, 1,025 and 1,000,001 rows
sort_with, argsort_with, top_k_with with SortOptions { descending, nulls_first, float_order }; lexsort(columns, options) am_sort_ex2, am_argsort_ex2, am_top_k_ex, am_lexsort_ex2 arrow::compute::sort / lexsort_to_indices with every SortOptions, bit for bit on Float64 and Float32 columns holding NaN of both signs, ±0.0, ±inf and subnormals (tests/sort_options.rs)
cast am_cast arrow::compute::cast
group_by(keys) with sum, min, max, mean, count, count_all; keys(i), ids(), agg_raw am_group_by_keys, am_group_agg_ex a plain HashMap fold — arrow-rs's arrow crate has no hash aggregation (it lives in DataFusion)
Source, run_plan, explain_plan, PlanResult am_plan_source_create, am_plan_run, am_plan_explain, am_plan_column* the same plan assembled by hand from arrow-rs kernels
batch(|| …) am_batch_begin / am_batch_end the same chain unbatched, and arrow's answer; plus deferred-failure, nesting and panic-unwind cases

Element types: Int64 and Float64 are swept against arrow-rs. Boolean arrays are exercised as comparison output and filter masks, and UInt32 as sort indices: argsort, argsort_with, top_k_with and lexsort return an arrow UInt32Array, the type sort_to_indices returns (row numbers up to 2^32 - 1; a longer input is an error), and take accepts Int32, Int64 and UInt32 indices. Every other Arrow type the ABI supports reaches the GPU through the same entry points but has no test in this crate — the Swift and Python suites cover them (Testing).

Errors are Result<T, arrowmetal::Error>, and the message is am_last_error()'s, read on the spot because that slot is thread-local.

Handles (Array, GroupBy, Source, PlanResult) are !Send and !Sync on purpose: the ABI's error slot and its command-buffer batching are both thread-local. Use one set of handles per thread. That is asserted at compile time (assert_not_impl_any!), not just documented.

Scalar operands, and the one type that is refused

The ABI reads sizeof(element type) bytes through a void*, so a scalar of the wrong width would be an out-of-bounds read. Every scalar entry point therefore checks the Rust type against the array's own am_format string first: compare_scalar(Gt, 1i32) on an Int64Array is an Err, not a read off the end.

That check is only as good as am_format, and there is exactly one Arrow type for which am_format does not describe what the kernels compute on. A dictionary-encoded array reports its index type ("i") while every kernel decodes the dictionary and computes on the value type. A 4-byte i32 scalar would be accepted for a Dictionary(Int32, Float64) column whose kernel then reads 8 bytes — unsound from safe Rust, and wrong besides (compare_scalar(Lt, 2i32) returns [false, false, false] where arrow-rs says [true, false, false], and the correct 2.0f64 scalar is rejected).

Array::from_arrow therefore refuses DataType::Dictionary outright, with an error naming the key and value types and telling you to decode first:

let decoded = arrow::compute::cast(&dict, &DataType::Float64)?;
let gpu = arrowmetal::Array::from_arrow(decoded.as_ref())?;   // now type-checks correctly

This is a limitation of the C ABI, not of the Arrow type — the kernels handle dictionaries correctly, and Swift and Python callers use them. It is an ABI defect on ArrowMetal's side: am_format reports the C Data Interface's top-level format, which for a dictionary is its index type. The entry point that reports the type the kernels compute on exists (am_compute_format, include/arrowmetal.h:32); this crate does not use it and refuses dictionaries, which keeps the sentence above true for everything this crate accepts. tests/compute.rs::dictionary_arrays_are_refused_at_import pins the rejection and the decode path.

Sort order: arrow-rs's, on request

sort and argsort keep ArrowMetal's default order, which is Arrow C++'s: nulls last in both directions, -0.0 tied with +0.0, and NaN next to the nulls. arrow-rs orders differently — nulls where SortOptions::nulls_first says, floats by total_cmp (-NaN < -inf < … < -0.0 < +0.0 < … < +inf < +NaN) — and the _with forms take that order as an option:

use arrow::compute::SortOptions as ArrowSortOptions;
let opts: arrowmetal::SortOptions = ArrowSortOptions { descending: true, nulls_first: true }.into();
let idx = gpu.argsort_with(opts)?;          // what arrow::compute::sort_to_indices orders
let top = gpu.top_k_with(100, opts)?;       // its first 100 rows, by GPU selection
let multi = arrowmetal::lexsort(&[&a, &b], &[opts, opts])?;

From<arrow::compute::SortOptions> sets float_order: FloatOrder::Total; SortOptions::default() is the plain sort. FloatOrder::NanLargest is the third float order, Polars': every NaN one value above +inf in both directions, -0.0 tied with +0.0. Neither option adds a pass to the GPU sort: the null placement is where the partition that takes the null rows out of the radix sort puts them, and totalOrder is the key map in front of the radix passes. tests/sort_options.rs compares every combination against arrow::compute::sort bit for bit.

One divergence from arrow-rs, found and pinned

min / max on a float column containing NaN. The two libraries use different, each internally consistent, rules — neither is a bug:

Input (float64) ArrowMetal arrow-rs 59.3 pyarrow
[NaN, 2.0, null, -1.0, NaN] — min -1.0 -1.0 -1.0
the same — max 2.0 NaN 2.0
[NaN, NaN] — min and max null NaN NaN

arrow-rs orders NaN at the top of a total order. Both min and max say so in the same sentence — "For floating point arrays any NaN values are considered to be greater than any other non-null value" (arrow_arith::aggregate, 59.3.0) — so min returns the smallest non-NaN and max returns NaN. That was reported as apache/arrow-rs#101 and closed as intended behaviour in 2022.

ArrowMetal skips NaN in both, the way it skips a null, and reports "no valid value" when every valid element is NaN. That follows Arrow C++ / pyarrow on the mixed case; on an all-NaN column pyarrow returns NaN where ArrowMetal returns null, which the C header already documents.

tests/compute.rs::nan_handling_diverges_from_arrow_rs_and_is_pinned asserts the ArrowMetal and arrow-rs columns and fails if either moves. The pyarrow column is not asserted from Rust — pyarrow is not a dependency of this crate; it is pinned in the Python suite, by test_nan_is_skipped_by_min_and_max_in_both and test_all_nan_min_max_is_null_in_arrowmetal_and_nan_in_pyarrow in python/tests/test_differential.py. With no NaN in the data the two agree exactly, which is what every other reduction test relies on.


What is not wrapped

include/arrowmetal.h has 286 entry points. arrowmetal-sys declares 41 of them — every one called by the safe crate, none declared and unused — and the safe crate covers the list above. Everything below is reachable from Swift, Python and the C ABI, and not from this crate. There is no technical obstacle to any of it; it is unwrapped because it is untested here, and an untested wrapper does not go into 0.1.0.

ABI area Entry points
Strings am_str_unary, am_str_match, am_str_transform, am_str_concat, am_str_equals_array, am_str_dictionary_encode, am_string_predicate, am_string_transform, am_string_is_in, am_string_index_in, am_str_extra, am_to_strings, am_parse, am_byte_transform, am_binary_join, am_join_element_wise
Regex, LIKE, splitting am_regex, am_split, am_extract_struct
Temporal am_temporal_extract, am_temporal_cast_unit, am_temporal_math, am_temporal_extra, am_round_temporal_ex, am_assume_timezone, am_local_timestamp, am_utc_offset, am_to_timezone, am_add_interval, am_interval_between, am_interval_field
Decimals am_decimal_op, am_decimal_widen, am_decimal_narrow
Nested types (list, struct, map, union) am_list_value_length, am_list_flatten, am_list_element, am_list_slice, am_list_parent_indices, am_list_parent_indices64, am_struct_field, am_make_struct, am_child, am_child_count, am_map_lookup
Element-wise arithmetic and math am_arith_scalar, am_arith_array, am_unary, am_binary, am_trig, am_logical, am_float_class, am_math_extra, am_unary_checked, am_binary_checked, am_cumulative, am_cumulative_checked
Selection and casting variants of what is wrapped am_filter_where (fused compare + filter), am_group_by (the dense-key group-by; am_group_by_keys + am_group_agg_ex are wrapped instead), am_cast_ex (cast with child formats and a safety flag)
Boolean and Kleene logic am_bool_and, am_bool_or, am_bool_not, am_and_kleene, am_or_kleene
Structural and conditional am_is_null, am_is_valid, am_fill_null, am_fill_null_direction, am_drop_null, am_if_else, am_coalesce, am_case_when, am_choose, am_replace_with_mask, am_indices_nonzero, am_true_unless_null
Set lookup and hashing am_is_in, am_index_in, am_is_in_ex, am_index_in_ex, am_hash64, am_fixed_binary_hash64, am_fixed_binary_compare
Sorting and selection beyond the sorts above am_sort_ex, am_top_k, am_lexsort, am_lexsort_ex, am_argsort_ex, am_partition_nth_indices, am_partition_nth_ex, am_rank, am_rank_ex, am_rank_quantile_ex, am_inverse_permutation, am_scatter
Window functions and rolling am_window
The rest of the aggregates am_reduce_ex, am_reduce_ex2, am_count_all, am_first_last, am_winsorize, am_group_pivot_wider, am_pivot_wider
Dictionaries, run-end, uniqueness am_dictionary_encode, am_dictionary_encode_ex, am_dictionary_decode, am_run_end_encode, am_run_end_decode, am_unique, am_unique_ex, am_value_counts, am_value_counts_ex
Joins am_join
The fused expression query runner am_query, am_query_column*, am_query_scalar*, am_query_canonical
Parquet am_parquet_open, am_parquet_read, am_parquet_read_ex, am_parquet_write and the 15 metadata accessors
The streaming engine all 30 am_stream_* entry points
Device interop am_import_device, am_export_device (CPU am_import/am_export are wrapped)
Extension types, float16, null arrays, random am_extension_*, am_cast_float16, am_null_array, am_random
Type introspection am_compute_format (the type the kernels compute on, which for a dictionary is its value type)
Execution-mode knobs am_resident_mode, am_resident_mode_available, am_resident_mode_reason, am_low_latency_wait, am_spin_microseconds

Wrapping any of them is mechanical: add the extern "C" line to arrowmetal-sys/src/lib.rs, the method to arrowmetal/src/lib.rs, and a test to arrowmetal/tests/compute.rs against arrow-rs (or a hand oracle where arrow-rs has no counterpart). tests/signatures.rs will check the new declaration against the header on the next cargo test.


The measured timing

One run, 2026-09-07, Apple M4 Max, macOS 26.6.2, rustc 1.95.0, arrow-rs 59.3.0, ArrowMetal 0.1.0.

Method. One 10,000,000-element Int64Array of pseudo-random values in [-1_000_000, 1_000_000), no nulls, built once and shared by every row. Filter predicate x > 0, which keeps 5,000,125 of the 10,000,000 rows (50.0%). std::time::Instant around the call, wall time, single-threaded, nothing subtracted, std::hint::black_box on every input and result. "Single-threaded" describes the harness: it spawns no threads and pins nothing, and arrow-rs's kernels ran with their default parallelism, which for sum and filter is none — arrow-arith and arrow-select depend on no thread pool and these are single-threaded, autovectorised kernels, so arrow-rs got everything it has here. Three untimed warm-up iterations, then five timed ones; the table is the best of the five (medians were within a few percent, and are in rust/README.md). Both libraries' answers are asserted equal before anything is timed. Outside a batch every ArrowMetal call commits its command buffer and waits, so a "kernel" number is a complete GPU round trip, not an enqueue. Source: rust/arrowmetal/examples/bench.rs; reproduce with cargo run --release --example bench in rust/.

Operation, 10M Int64 arrow-rs ArrowMetal, kernel ArrowMetal, end to end
sum 0.91 ms 0.28 ms 2.11 ms
filter (mask ready) 3.55 ms 0.64 ms —
compare + filter 4.58 ms — 2.97 ms
  • kernel — the GPU call on an array already imported, mask already on the GPU. This is what each step of a longer chain costs.
  • end to end — what one operation on an arrow-rs array costs, import included. What that covers differs by row: the sum row is import + the reduction and has no export, because a reduction returns a scalar through out-parameters rather than an array; the compare + filter row is import + compare + filter + to_arrow. Supporting numbers (best of 5): import 1.11 ms, export 0.000 ms.

Where arrow-rs is ahead. On a single sum over an arrow-rs array, arrow::compute::sum is ahead by 2.3×: 0.91 ms against ArrowMetal's 2.11 ms. The kernel is 3.3× faster; the gap is entirely the 1.11 ms it takes to hand 80 MB (decimal MB; 76 MiB) to Metal, plus about 0.7 ms of handle setup and first-touch. The import is copy-free at this size (the values buffer came back aligned to 4 MiB), so that is page mapping, not a memcpy — and it is still 1.1 ms that one cheap kernel does not earn back.

The break-even is roughly "more than one pass over the data": compare + filter is two passes and ArrowMetal is already 1.5× faster end to end. Import once, chain, export once. A single wrapped reduction does not earn its import back.


Limits

  • Apple silicon only. arrowmetal-sys/build.rs refuses any non-macOS target; on Intel macOS it fails at the dylib search instead.
  • Two element types are tested against arrow-rs: Int64 and Float64, plus Boolean masks and Int32 indices. Anything else works through the same entry points but is untested from Rust.
  • One thread per handle set. Handles are !Send and !Sync; am_last_error and batching are thread-local.
  • No RecordBatch type of its own. Columns go across one at a time. The plan runner's Source takes a named set of columns, or a slice of arrow-rs RecordBatches (Source::from_batches, each column imported with Array::from_arrow_chunks).
  • arrowmetal-sys declares 41 of the ABI's 286 entry points, and every one of them is called by the safe crate (nothing is declared and unused). The table above lists what is missing.
  • The docs.rs build of 0.1.0 failed. Under DOCS_RS the -sys build script returns before it emits anything, so the env!("ARROWMETAL_SYS_LIB_DIR") and env!("ARROWMETAL_LINKED_LIB_DIR") lookups in the two crates have nothing to read and rustdoc stops. The documentation for 0.1.0 is this file and the crate-level comment in rust/arrowmetal/src/lib.rs.
  • arrow is pinned to major version 59. The C Data Interface structs are ABI-stable, so a different arrow-rs major would very likely work, but it is not tested here.
  • A batch defers validation. Inside batch(|| …) an operation that would fail — take with an out-of-range index, say — returns Ok at the call and fails the whole batch at the end, which batch reports as an Err. The value the closure produced is discarded: a readback (a reduction, an export) forces a mid-batch flush, so some kernels in a failed batch have run and some have not, and the ABI does not say where the line fell. Nested batch calls are safe — only the outermost opens and commits one — but that is counted in this crate, not in the ABI: am_batch_end closes whatever is open regardless of nesting.
  • No async. Every call is synchronous: outside a batch each one commits its command buffer and waits. The ABI has an async path (Sources/ArrowMetal/Async.swift); this crate does not use it.

Testing

cd rust
ARROWMETAL_LIB=/path/to/libArrowMetalC.dylib cargo test --release

66 tests, plus 5 no_run doc-tests (compiled, not executed); 0 failures on 2026-10-02 (Testing). What each file compares against is in rust/README.md and in Testing.

docs/GO.md

ArrowMetal for Go users

A Go module that hands an arrow.Array from Apache Arrow Go to the GPU and takes the answer back, over the Arrow C Data Interface. It wraps a deliberately small part of the C ABI: import and export (one array or a column in chunks), the four reductions, compare, filter, take, sort, argsort, top-k and lexsort (with per-key null placement and float order), group-by, and the JSON plan runner. Everything it wraps has a test against Arrow Go's own compute or against a plain Go loop on the same data. Everything it does not wrap is listed under What is not wrapped.

Module path: github.com/singhpratech/ArrowMetal/go/arrowmetal. Source: go/arrowmetal/.


Install

# 1. The GPU library. It is a build product of the Swift package; `go get` cannot fetch it.
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
    swift build -c release --product ArrowMetalC

# 2. The module.
go get github.com/singhpratech/ArrowMetal/go/arrowmetal

The module is tagged go/arrowmetal/v0.5.0: the module lives in a subdirectory, so the Go module proxy wants the tag prefixed with the module's path within the repository, not a bare vX.Y.Z. A checkout works too: clone the repository and add

replace github.com/singhpratech/ArrowMetal/go/arrowmetal => /path/to/ArrowMetal/go/arrowmetal

to your own go.mod, or just run inside go/arrowmetal.

Requirements: macOS on Apple silicon, a Metal device, and Go 1.25.0 or newer (the module's go directive, which is what arrow-go/v18 requires) with cgo enabled — CGO_ENABLED=1, the default when a C toolchain is present; Xcode's clang is what this was built and tested with. The module depends on github.com/apache/arrow-go/v18 and nothing else. Everything here was measured on Go 1.27.1.

Finding the dylib

The binding does not link libArrowMetalC.dylib at build time. It dlopens it the first time you use the package, so the location is a run-time decision and a missing library is a clear Go error rather than a dyld message. The search, in order:

  1. $ARROWMETAL_LIB — the full path to the dylib, not a directory. If it is set and does not load, that is the error; there is no fallback.
  2. .build/release/libArrowMetalC.dylib, and the same at one, two and three levels up, relative to the working directory. (go test runs in the package directory, so ../../.build/release is the repository's own build; a program run from the repository root finds .build/release.)
  3. The same four paths relative to the directory holding the running binary.
  4. Plain libArrowMetalC.dylib, letting dyld search DYLD_LIBRARY_PATH, /usr/local/lib and the rest.

When none of them loads, the error names ARROWMETAL_LIB, every path it tried and why each failed, and the swift build line that produces the file. arrowmetal.Init() performs the load and returns that error, so a program can fail early with it; every other entry point calls Init first. arrowmetal.LibraryPath() reports where it came from.


Example

package main

import (
    "fmt"

    "github.com/apache/arrow-go/v18/arrow/array"
    am "github.com/singhpratech/ArrowMetal/go/arrowmetal"
)

func main() {
    // PageAlignedAllocator is a memory.Allocator whose buffers ArrowMetal can borrow (see Copy rule).
    b := array.NewInt64Builder(am.NewPageAlignedAllocator())
    defer b.Release()
    b.AppendValues([]int64{5, 3, 9, 1}, []bool{true, true, false, true}) // 9 is null

    src := b.NewArray()
    defer src.Release()

    gpu, err := am.Import(src)             // arrow.Array -> GPU
    if err != nil {
        panic(err)
    }
    defer gpu.Release()

    sum, _ := gpu.Sum()                    // nulls skipped, like Arrow
    mask, _ := gpu.CompareScalar(am.Gt, int64(2))
    kept, _ := gpu.Filter(mask)
    out, _ := kept.Export()                // back to arrow-go, no copy
    defer out.Release()

    fmt.Println(sum, out)                  // 9 [5 3]
}

Run it with ARROWMETAL_LIB=/path/to/.build/release/libArrowMetalC.dylib go run ..


Sort options

Argsort, Sort, TopK and Lexsort keep the order they always had: nulls last and NaN after +Inf in both directions, -0.0 tied with +0.0. Argsort, TopK, Lexsort and their With forms return uint32 index arrays (row numbers up to 2^32 - 1; a longer input is an error), and Take accepts int32, int64 and uint32 indices. The With forms take a SortOptions per key:

type SortOptions struct {
    Descending bool
    Nulls      NullPlacement // NullsLast (the default) or NullsFirst, in either direction
    FloatOrder FloatOrder    // FloatIEEE (the default), FloatTotal or FloatNanLargest
}

func (a *Array) ArgsortWith(o SortOptions) (*Array, error)
func (a *Array) SortWith(o SortOptions) (*Array, error)
func (a *Array) TopK(k int64, largest bool) (*Array, error)
func (a *Array) TopKWith(k int64, o SortOptions) (*Array, error) // o.Descending: the k largest
func LexsortWith(columns []*Array, keys []SortOptions) (*Array, error)

FloatIEEE is Arrow C++'s order, which arrow-go's compute.SortIndices also uses: -0.0 ties +0.0, every NaN is one value, and the NaN rows sit next to the nulls in both directions. FloatTotal is IEEE 754 totalOrder, the order arrow-rs and Rust's total_cmp use: -NaN < -Inf < … < -0.0 < +0.0 < … < +Inf < +NaN, NaNs by payload, and a descending sort is its exact mirror. FloatNanLargest is Polars' and NumPy's order: -0.0 ties +0.0, and every NaN is one value greater than every number, +Inf included, in both directions (last ascending, first among the values descending); the null placement is independent of it. Integer, string and temporal keys ignore the float order. The zero SortOptions gives what Argsort(false) gives. TopKWith(k, o) answers with the first k indices ArgsortWith(o) gives. Neither option adds a pass to the GPU sort.

// src is [2, null, NaN, -0, 7]
plain, _ := gpu.Argsort(true)       // [4 0 3 2 1]: nulls and NaN stay last
opts := am.SortOptions{Descending: true, Nulls: am.NullsFirst, FloatOrder: am.FloatTotal}
total, _ := gpu.ArgsortWith(opts)   // [1 2 4 0 3]: the null, then +NaN > 7 > 2 > -0
top, _ := gpu.TopKWith(2, opts)     // [1 2]
polars, _ := gpu.ArgsortWith(am.SortOptions{Descending: true, FloatOrder: am.FloatNanLargest})
                                    // [2 4 0 3 1]: NaN > 7 > 2 > -0, the null last

(ExampleArray_ArgsortWith in example_test.go runs this.) A plan's sort key takes the same options as JSON: {"column": "x", "descending": true, "nulls": "first", "float_order": "total"} or ["x", true, {"nulls": "first", "float_order": "nan_largest"}] (Query engine).

Chunked import

A column held as several arrow.Arrays of one type imports as one Array of their total length, without array.Concatenate:

func ImportChunks(chunks []arrow.Array) (*Array, error)
func ImportChunked(c *arrow.Chunked) (*Array, error)
func ImportColumn(batches []arrow.RecordBatch, i int) (*Array, error)
func NewSourceFromBatches(name string, batches []arrow.RecordBatch) (*Source, error)
func ChunksSupported(dt arrow.DataType) (bool, error)
gpu, err := am.ImportChunked(chunked) // chunks [1 2] and [3 null 5]
sum, _ := gpu.Sum()                   // gpu.Len() 5, gpu.NullCount() 1, sum 11

(ExampleImportChunks runs this.) Every chunk crosses the C Data Interface on its own and am_import_chunks copies its buffers straight into the final Metal buffers, on the CPU cores in parallel. Each chunk's offset, length, validity bitmap (or its absence) and null count are honoured. One chunk is Import, with its copy rule. A type the chunked import does not take (dictionary, nested, run-end encoded, extension; ChunksSupported reports it) is concatenated with array.Concatenate and imported. The chunks stay the caller's: ImportChunks hands each one to C exactly once, pins its buffers for the call, and the library releases each export it took; the caller releases the chunks as usual. NewSourceFromBatches registers every column of a run of record batches this way, and the Source keeps those handles until its own Release.


Copy rule

The project's rule, unchanged: copy-free out always; copy-free in when the producer's buffers are page aligned, one copy otherwise.

Concretely, on the way in ArrowMetal wraps a buffer with MTLBuffer(bytesNoCopy:) when the buffer's start address is a multiple of the page size — 16384 bytes on Apple silicon, which arrowmetal.PageSize() reports — and copies the bytes into a page-aligned Metal buffer otherwise. On the way out the exported arrow.Array references the same unified memory the GPU wrote, always.

What Arrow Go's default allocator does — measured

memory.DefaultAllocator is memory.NewGoAllocator(), the Go heap, unless the program is built with the mallocator build tag — the same object, not a similar one, which is why only one of them appears below. The Go runtime backs large allocations with whole spans of its own 8 KiB pages, so an Arrow buffer from it is 8 KiB aligned and only incidentally 16 KiB aligned.

TestAllocatorAlignment builds an Int64 array 20 times per size and records the values buffer's offset past a page boundary. On an M4 Max, Go 1.27.1, arrow-go v18.7.0:

Allocator Offset past a 16384-byte page Page aligned?
memory.NewGoAllocator() (the default) only ever 0 or 8192, never any other value a coin flip
arrowmetal.NewPageAlignedAllocator() always 0 always

The hit rate is not a property, so it is not quoted here. Across fresh runs of the same test the count at 10M int64 came back 5/20, 15/20 and 14/20 — it depends on the state of the heap when the buffer is allocated, and it will differ on your machine and between two runs on mine. What is reproducible, and what the test asserts, is the offset: a Go-heap buffer is either exactly on a page boundary or exactly 8192 bytes past one. That follows from Go's 8 KiB page meeting Apple silicon's 16 KiB one, and it means a copy-free import with the default allocator is a coin flip whose outcome you cannot observe from Go.

arrowmetal.PageAlignedAllocator implements memory.Allocator on top of posix_memalign at page granularity, so the import can always borrow.

Go pointers, cgo, and why pinning is not optional

Alignment and legality are separate questions, and PageAlignedAllocator answers only the first.

cdata.ExportArrowArray writes &buf.Bytes()[0] straight into a malloc'd buffer array (cdata_exports.go:388). With the default allocator that address is in the Go heap, so this is an unpinned Go pointer stored into non-Go memory — which the cgo pointer rules forbid, and which GOEXPERIMENT=cgocheck2 turns into a hard fatal error: unpinned Go pointer stored into non-Go memory. This is apache/arrow-go#70, closed upstream without the default path being made legal. arrow-go's own answer is C memory: memory/mallocator (libc malloc, no build tag needed to use it, NewMallocatorWithAlignment(n) for a chosen power-of-two alignment) and, for programs linked against C++ Arrow, CgoArrowAllocator behind the ccalloc build tag. A mallocator buffer at alignment 16384 is page aligned and outside the Go heap, so it satisfies both the cgo rules and ArrowMetal's copy rule; PageAlignedAllocator is the same idea in forty lines, kept so the binding has no build-tag story to tell.

Import therefore pins every buffer it publishes with runtime.Pinner (Go 1.21+) before handing anything to C, and keeps the Pinner inside the returned Array until Release, unpinning only after am_release — ArrowMetal may have been borrowing those exact bytes. Pin ignores anything outside the Go heap, so a buffer from PageAlignedAllocator costs nothing here and needs nothing.

The consequence for you: any allocator is safe, including the default. The suite is run under GOEXPERIMENT=cgocheck2 as well as normally, and removing the pin makes it fail on the first round trip. Choose PageAlignedAllocator when you want the copy avoided, not to make the code legal.

What the copy costs, measured

Less than you would expect, because at 76 MB the copy is not the dominant cost of crossing the boundary; setting up the Metal buffer is. From the table below: importing 10M int64 rows takes 1.13 ms when the buffer can be borrowed and 1.41 ms when it must be copied — the copy adds about 0.3 ms to a 1.4 ms operation, roughly 20%. Export is 917 ns, three orders of magnitude cheaper, which is what "copy-free out always" buys.

There is no way to ask the library whether a given import copied. The Swift core computes it (ImportResult.zeroCopy) but the C ABI's am_import does not return it, so this binding cannot report it either and the table above measures alignment in Go instead.


Timing

10M Int64 rows (76 MB, i.e. MiB — 80 MB decimal), no nulls, one M4 Max, one process, wall clock, best of 5 timed runs after one untimed warm-up. Reproduce with go run ./cmd/amtiming from go/arrowmetal; the source of every row is cmd/amtiming/main.go.

ArrowMetal 0.1.0, Go 1.27.1, arrow-go v18.7.0. The filter predicate is x > 0 and keeps 50.0% of the rows.

Op Method Best of 5 Notes
Import arrow-go → ArrowMetal, page-aligned buffer 1.13 ms borrowed, no copy
Import arrow-go → ArrowMetal, buffer 64 B past a page 1.41 ms one copy of 76 MB
Import arrow-go → ArrowMetal, memory.NewGoAllocator 1.40 ms this run's buffer was 8192 B past a page
Export ArrowMetal → arrow-go 917 ns always copy-free
Sum plain Go loop over []int64 2.46 ms
Sum Arrow Go arrow/math (NEON) 1.16 ms arrow-go registers no sum compute function
Sum ArrowMetal, array already resident 296 µs 3.9× the Arrow Go kernel
Sum ArrowMetal, end to end from a page-aligned arrow.Array 2.22 ms Arrow Go is ahead
Sum ArrowMetal, end to end with a copying import 1.87 ms Arrow Go is ahead
Filter plain Go loop (one fused pass into a []int64) 29.18 ms
Filter Arrow Go compute (greater then filter) 48.93 ms
Filter ArrowMetal, array already resident 1.51 ms 32× Arrow Go compute, 19× the Go loop
Filter ArrowMetal, end to end from a page-aligned arrow.Array 3.22 ms 15× Arrow Go compute
Filter ArrowMetal, end to end with a copying import 2.98 ms 16× Arrow Go compute

Reading this table

arrow-go's compute is single-threaded and ArrowMetal's kernels run on the whole GPU, so the multiples above are not per-core figures.

  • Arrow Go is ahead at Sum end to end. 2.22 ms against Arrow Go's 1.16 ms. A single 76 MB sum is a memory-bandwidth problem that the CPU is already good at, and the ~1.2 ms of import overhead is most of the ArrowMetal number. Only when the array is already on the GPU is ArrowMetal ahead on Sum, 296 µs against 1.16 ms. If your program's shape is "load an arrow-go array, sum it once, throw it away", this binding is the wrong tool.
  • ArrowMetal is ahead at Filter, in every shape. Even paying import and export on every call, 3.22 ms against 48.93 ms is 15×; resident it is 32×. Filter does more work per byte than Sum and the fixed cost stops dominating.
  • The end-to-end rows with a copying import came out faster than the page-aligned ones in this run (1.87 vs 2.22 ms for Sum, 2.98 vs 3.22 ms for Filter), which is the opposite of what the Import rows say. The gap is a few tenths of a millisecond either way, the same size as the copy itself, and it is run-to-run noise from Metal's buffer allocation. The defensible statement is the narrow one: at 76 MB, borrowing saves about 0.3 ms of a 1.4 ms import, and end to end that saving is inside the noise. It is not on its own a reason to change allocators — and it is not a correctness reason either, since Import pins whatever it is given.
  • arrow/math.Int64.Sum ignores nulls, and the data here has none. It is the fastest thing arrow-go has for this and it is what the Sum row compares against, because arrow-go's compute package registers no aggregate function at all — no sum, mean or min_max in its registry. TestArrowGoHasNoAggregates records this and will log a nudge if a later release adds them. Reported as apache/arrow-go#1296; the maintainer asked for the kernels, and a design note with a tested prototype of sum, mean, min_max, min, max, count, any and all is on that issue (the research behind it is Round 10 in Findings against pyarrow). Two documentation pull requests from the same work, #1302 on allocator alignment and #1303 on what the compute registry holds, were merged the day they were opened.
  • The plain Go filter loop writes into a []int64 rather than building an arrow.Array, so it is doing strictly less work than the other two Filter rows. It is included because it is what a Go programmer writes when they have not reached for Arrow yet.

Chunked import against concatenating first

go run ./cmd/amchunks: a column in chunks, each chunk its own allocation from Arrow Go's default allocator, imported with ImportChunks against array.Concatenate then Import, both end to end with the handle (and the concatenation) released inside the timed call. M4 Max, 2026-09-29, Go 1.27.1, arrow-go v18.7.0; three rounds, each row warmed for 100 ms and then timed 10 times; best of the three rounds, and the median of the per-round medians in parentheses; CPU is process CPU time per call. Source: Benchmarks/results/bindings_chunked_import_2026-09-29.csv.

Column Rows Chunks Concatenate + Import ImportChunks CPU ms (concatenate / chunked)
float64, 10% null 10,000,000 153 2.18 (3.79) ms 0.73 (1.04) ms 3.9 / 8.5
float64, 10% null 10,000,000 10 2.21 (4.01) ms 0.63 (0.95) ms 4.1 / 8.6
float64, 10% null 50,000,000 763 8.76 (15.41) ms 3.06 (3.75) ms 15.2 / 37.1
float64, 10% null 50,000,000 50 8.14 (15.57) ms 2.78 (3.20) ms 14.0 / 39.0
int64 10,000,000 153 1.83 (3.40) ms 0.59 (0.67) ms 3.5 / 6.9
int64 10,000,000 10 1.83 (3.28) ms 0.54 (0.58) ms 3.3 / 5.9
int64 50,000,000 763 8.22 (15.60) ms 2.55 (2.79) ms 14.5 / 31.2
int64 50,000,000 50 8.01 (15.27) ms 2.21 (2.69) ms 14.3 / 31.4

The chunked import copies each chunk once, straight into the Metal buffers, on the CPU cores in parallel; the concatenation copies every byte once, and the import then borrows or copies the result by the copy rule above. The parallel copy takes 1.8x to 2.8x the CPU time of the concatenation per call (the last column).

The existing calls, before and after

go run ./cmd/ambench times the calls that existed before the sort options and the chunked import, built from the previous commit and from this one against the same libArrowMetalC.dylib, in four alternating rounds (each row warmed for 100 ms, a 500 ms idle and one call timed on its own, then 30 calls); best of the four rounds, the median of the per-round medians in parentheses. The Go code of these calls is unchanged; the shim resolves seven more entry points at load. At 10M rows:

Call Previous commit This commit
Import + Release, page-aligned int64 0.03 (0.06) ms 0.01 (0.02) ms
Argsort(false), float64 with 10% nulls 7.35 (7.93) ms 7.16 (8.11) ms
Argsort(true), int64 7.95 (8.59) ms 7.84 (8.64) ms
Sort(false), float64 with 10% nulls 6.98 (7.69) ms 6.70 (7.64) ms
Lexsort, int64 then float64 descending 18.28 (18.85) ms 18.32 (18.58) ms

At 1,000 and 1,000,000 rows three rows were slower in both best and median in the four rounds (Argsort(true) at 1,000 rows, Lexsort at 1,000 and 1,000,000 rows); timed again alone, eight alternating rounds of 100 calls, this commit over the previous one is 1.02 / 1.14 (best / median) for Argsort(true) at 1,000 rows (0.39 / 0.50 ms against 0.38 / 0.44 ms) and 0.97 / 1.04 and 1.02 / 1.02 for Lexsort at 1,000 and 1,000,000 rows. Every row, with first-call-after-idle and CPU time: Benchmarks/results/bindings_call_overhead_2026-09-29_summary.csv.


What is covered

include/arrowmetal.h and include/arrow_abi.h in the module are copies of the repository's include/ headers; refresh them (cp include/arrowmetal.h go/arrowmetal/include/) whenever the header changes, or python/tests/test_header_copies.py and TestHeadersMatchRepository fail.

Every item below has at least one test in go/arrowmetal; the oracle is named. 66 test functions and three Examples, 69 runnable; 584 cases counting subtests, all green, plain, under -race and under GOEXPERIMENT=cgocheck2.

Surface Go API Oracle
C Data Interface in and out Import, (*Array).Export value-and-null round trip at lengths 0, 1, 1000, 1,000,001, plain and sliced
Empty input Import, ImportChunks, (*Array).Export zero-row arrays of 12 types (integers, floats, boolean, utf8), built with no rows, all-null and as zero-length slices at offsets 0 and 1: length and type out through Export, an empty ArgsortWith, and ImportChunks with empty chunks alone and next to a non-empty one
Sliced input (offset != 0) the same array.NewSlice at offsets 1, 7, 31, 32, 33, 63, 64, 1000 against the same rows read directly
Sum, Min, Max, Mean (*Array).Sum/Min/Max/Mean plain Go loops (arrow-go has no aggregates); Int64 and Float64, with and without nulls
Null and NaN rules the same all-null and empty arrays report an invalid Scalar; min/max skip NaN; an all-NaN column is null
Compare against a scalar (*Array).CompareScalar compute.CallFunction("equal"/"not_equal"/"less"/"less_equal"/"greater"/"greater_equal"), all six, with nulls
Scalar range checking the same 28 cases: integer overflow at both edges of int8 and uint8, float32 overflow and underflow, integers past 2^24 and 2^53, ordinary rounding still allowed, plus a string and a bool scalar rejected by Arrow format string; the values that do fit agree with a Go loop
Compare two arrays (*Array).CompareArray length-mismatch error path
Filter (*Array).Filter compute.FilterArray with SelectionDropNulls
Take (*Array).Take compute.TakeArray, including null indices
Sort (*Array).Sort compute.SortArray, ascending and descending, nulls at end
Argsort (*Array).Argsort compute.SortIndicesArray, index for index, on data with heavy ties
Lexsort Lexsort sort.SliceStable over the same two columns
Sort options ArgsortWith, SortWith, TopK, TopKWith, LexsortWith index for index against a stable sort.SliceStable reference of the documented order, every direction × null placement × float order (FloatIEEE, FloatTotal, FloatNanLargest), on Float64 and Float32 columns holding NaN of both signs and several payloads, ±0.0, ±Inf and subnormals, with nulls, at 0 to 100,001 rows; the IEEE order also against arrow-go's compute.SortIndicesArray / SortIndicesRecordBatch with SortNullsAtStart; SortWith bit for bit; TopKWith against the head of ArgsortWith at k = 0 to past the length; the zero options against the plain calls; FloatNanLargest on a hand-picked column with NaN of both signs, ±0.0, ±Inf and a null against its expected indices for all four calls; a plan sort key with nulls and float_order, and "float_order": "nan_largest" in each key form and as the sort-level default, with and without a limit
Chunked import ImportChunks, ImportChunked, ImportColumn, NewSourceFromBatches, ChunksSupported array.Equal against the concatenation and against Import of it, over 15 types (integers, floats, boolean, date32, timestamp, decimal128, fixed_size_binary, utf8, large_utf8, binary, utf8_view) and five layouts (empty, one-row, sliced, all-null and no-null chunks mixed; one chunk; all empty; all null; 1,000 small chunks), built with arrow-go's checked allocator so every chunk has to be released exactly once; dictionary chunks through the concatenation; sum and every sort option on a 40-chunk column; a plan over record batches
Group-by NewGroupBy, .Sum/.Count/.CountAll/.Mean/.Min/.Max/.Key/.IDs plain Go maps; one and two key columns, 1 to 1000 groups, null keys, float values, zero rows
JSON plan runner NewSource, RunPlan, ExplainPlan, PlanResult.RecordBatch plain Go; the header's own group-by/sort/limit example, optimized against unoptimized, a type-check failure
Slice on the GPU (*Array).Slice the same rows read from the source
Errors *arrowmetal.Error a length mismatch and a bad plan both carry am_last_error() text
Allocator PageAlignedAllocator alignment at 1M and 10M, allocate/reallocate/free bookkeeping, a full round trip
Allocator alignment the Go heap's shape every Go-heap offset past a page is 0 or 8192 and nothing else, at 1M and 10M, 20 trials each
cgo pointer rules Import the whole suite again under GOEXPERIMENT=cgocheck2; removing the pin makes it die on the first round trip
Handle lifecycle Release repeated import of one arrow.Array; a long-lived handle alongside short-lived ones; a released handle errors rather than crashing
Leaks the whole chain 2,000 import/compare/filter/export/release round trips at 200k rows, in a child process so the baseline is its own; TASK_VM_INFO.phys_footprint has to stay inside 16 MB (it grows a few MB, and leaking just the mask handle grows 35 MB — checked in the test order in 0.1.0)
The loader Init, LibraryPath a child process with ARROWMETAL_LIB pointing at nothing, and a child with nothing set in an empty directory: the error has to name the variable, the paths and the swift build line
Docs the example in this file compiled and run as Example(), so it cannot drift from the API

Sizes are at or below 10M elements throughout.


What is not wrapped

The C ABI has 286 entry points; this binding resolves 40 of them (34 it needs, and 6 newer ones it uses when the loaded library has them: an older library still loads, and the calls that need them return an error naming the missing entry point). Not wrapped, and not tested from Go:

  • Arithmetic and math: am_arith_scalar, am_arith_array, am_unary, am_binary, am_cumulative, am_window, the checked variants.
  • Strings and text: am_str_unary, am_str_match, am_str_transform (all 26 ops), am_str_concat, am_str_dictionary_encode, the regex surface.
  • Temporal: am_temporal_extract, am_temporal_cast_unit, am_round_temporal_ex, am_add_interval.
  • Types: decimals, dictionaries beyond what import/export carries through, nested types (list, struct, map, union), run-end encoding, and the _ex option variants other than the sorts' (am_rank_ex, am_is_in_ex, …).
  • Structural and conditional: am_is_null, am_fill_null, am_drop_null, am_if_else, am_coalesce, am_is_in, am_index_in, the Kleene operators.
  • Aggregates beyond the four: am_reduce_ex (product, variance, stddev), and the grouped aggregates past sum/count/mean/min/max — the Agg method takes any op number from the header's table, but only those five are tested from Go.
  • Batching: am_batch_begin / am_batch_end. A Go binding for these needs the batch and every call inside it pinned to one OS thread, which the current one-call-at-a-time pinning does not give you across calls.
  • Whole subsystems: Parquet (am_parquet_*), streaming (am_stream_*), the device interface (am_import_device / am_export_device), joins (am_join).
  • arrow.Table has no entry point of its own; its columns go in as arrow.Chunked (ImportChunked), and a run of arrow.RecordBatches as a Source (NewSourceFromBatches).

Adding any of these is mechanical: a prototype in amshim.h, a pointer and a forwarder in amshim.c, a method in Go, and a test with an oracle.


Limits

  • macOS on Apple silicon only. The module compiles anywhere cgo does, but the dylib it needs exists only for Apple silicon, so on any other platform Init() returns the loader's error.
  • cgo is required. CGO_ENABLED=0 builds will fail to compile the package.
  • One goroutine at a time per handle. An Array, GroupBy, Source or PlanResult is not safe for concurrent use. Separate handles on separate goroutines are fine.
  • Every call pins its goroutine to an OS thread for the duration. am_last_error() is thread-local, so the failing call and the message read have to happen on the same thread; Go is otherwise free to move a goroutine between calls. The cost is a runtime.LockOSThread pair per call, which is tens of nanoseconds against kernels measured in hundreds of microseconds.
  • Release your handles. A finalizer is set as a backstop, but GPU memory should not wait for the garbage collector, and the finalizer runs at an unpredictable time.
  • Go heap buffers stay pinned for the life of the handle — and only that long. Importing an array built with Arrow Go's default allocator hands C a pointer into the Go heap that ArrowMetal keeps until the handle is released, so Import pins those buffers and Release unpins them (see Go pointers, cgo, and why pinning is not optional). Pinned objects cannot be moved or freed, so a handle you forget to release holds its source buffers as well as its GPU memory. The converse is the open edge: an arrow.Array you exported from a borrowed Go-heap import still names those bytes after Release has unpinned them — they stay alive, through arrow-go's own reference chain, but they are no longer pinned, so the binding is back to relying on Go's collector not moving heap objects for as long as that exported array lives. Pinning does not close that window. Importing from PageAlignedAllocator does, since C memory is never the collector's to move.
  • Scalar comparison covers the primitive types only, and refuses a scalar it cannot represent. CompareScalar accepts a Go value for int8…int64, uint8…uint64, float32, float64 and bool, range-checked against the column's element type. Refused, with an error naming the value and the Arrow type: an integer outside the column's width (int64(1000) against int8 is an error, not a silent −24); a finite float too large for the column (1e300 against float32, which would become +Inf); a non-zero float too small for it (5e-46 against float32, which would become 0 and match every zero in the column); and an integer past the mantissa width, 2^24 for float32 and 2^53 for float64, which would land on a neighbouring value. Ordinary rounding is not refused: 0.1 against a float32 column compares against float32(0.1), as it does in every Arrow implementation. A string, decimal or temporal scalar returns an error naming the Arrow format string rather than guessing.
  • The vendored headers can drift. go/arrowmetal/include/arrowmetal.h and arrow_abi.h are copies of the repository's include/, because a Go module can only see files inside its own directory. TestHeadersMatchRepository compares them when it is run inside a checkout and skips otherwise.

Testing

See Testing. In short, from go/arrowmetal:

ARROWMETAL_LIB=$PWD/../../.build/release/libArrowMetalC.dylib go test ./...

# and once more with the cgo pointer checker armed, which is where an unpinned
# Go pointer handed to C shows up as a hard failure rather than as luck:
ARROWMETAL_LIB=$PWD/../../.build/release/libArrowMetalC.dylib \
    GOEXPERIMENT=cgocheck2 go test -count=1 ./...

The library path is optional inside the repository — ../../.build/release is on the search list — but it is the reliable way to say which build you mean.

docs/TYPESCRIPT.md

TypeScript / Node.js

ArrowMetal from TypeScript: an N-API addon over the one C ABI (include/arrowmetal.h), an Arrow C Data Interface bridge to and from Apache Arrow JS, and a typed API with .d.ts. The package lives in node/; this page is the whole contract.

Every number on this page was measured on 2026-09-07 on an Apple M4 Max, macOS, node v24.9.0, apache-arrow 21.2.0, ArrowMetal 0.1.0 built -c release.

The browser is out of scope. There is no Metal in a browser, and this package loads a .dylib through dlopen. It is macOS on Apple silicon, in Node, or nothing. There is no WASM fallback.

Install

The package is not published; build it from the checkout.

export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
swift build -c release --product ArrowMetalC     # produces .build/release/libArrowMetalC.dylib

cd node
npm install                                      # node-gyp builds the addon during install
npm run build:ts                                 # tsc -> dist/, with .d.ts
npm test

npm install needs a C++ toolchain (Xcode command line tools) and python3 for node-gyp; both come with Xcode. apache-arrow is a peer dependency: everything except fromArrow / toArrow works without it.

Finding the dylib

The addon resolves libArrowMetalC.dylib at runtime, in this order:

  1. $ARROWMETAL_LIB — a full path to the dylib, not a directory
  2. <package>/../.build/release/libArrowMetalC.dylib

If neither exists the error names both paths and tells you the swift build line. There is no bundled binary and no download step.

export ARROWMETAL_LIB=/opt/arrowmetal/libArrowMetalC.dylib

import { info } from 'arrowmetal' reports which one this process bound to, plus the ArrowMetal version, the Metal device name and the page size.

Example

import { MetalArray, groupBy, PlanSource, runPlan } 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   (BigInt: int64 is BigInt everywhere)
amount.mean();                                   // 175    (mean is always a number)
amount.filter(amount.gt(60n)).toArrow();         // Vector<Int64> [100n, 250n, 300n]
amount.sort(true).toArrow();                     // Vector<Int64> [300n, 250n, 100n, 50n]
amount.take(amount.argsort()).toArrow();         // the same as sort()

const gb = groupBy(region);
[...gb.keys(0).toArrow()];                       // ['e', 'w']
[...gb.sum(amount).toArrow()];                   // [400n, 300n]

Sort options

argsort, sort and topK take either the boolean they always took or an options object, and lexsort takes one options object per key:

type NullPlacement = 'last' | 'first';
type FloatOrder = 'ieee' | 'total' | 'nan_largest';
interface SortOptions { descending?: boolean; nulls?: NullPlacement; floatOrder?: FloatOrder }
interface TopKOptions { largest?: boolean; nulls?: NullPlacement; floatOrder?: FloatOrder }

argsort(options?: boolean | SortOptions): MetalArray
sort(options?: boolean | SortOptions): MetalArray
topK(k: number, options?: boolean | TopKOptions): MetalArray   // a boolean is `largest`, default true
lexsort(columns: MetalArray[], keys?: boolean[] | SortOptions[]): MetalArray

A boolean, or nothing, is the order the plain calls have always had: nulls last and NaN after +Infinity in both directions, -0 tied with +0. nulls: 'first' puts the null rows first in either direction. floatOrder: 'ieee' (the default) is Arrow C++'s order: -0 ties +0, every NaN is one value, and the NaN rows sit next to the nulls in both directions. floatOrder: 'total' is IEEE 754 totalOrder, the order arrow-rs and Rust's total_cmp use: -NaN < -Infinity < … < -0 < +0 < … < +Infinity < +NaN, and a descending sort is its exact mirror. floatOrder: 'nan_largest' is Polars' and NumPy's order: -0 ties +0, and every NaN is one value greater than every number, +Infinity included, in both directions (last ascending, first among the values descending); the null placement is independent of it. Integer, string and temporal keys ignore the float order. topK(k, options) answers with the first k indices argsort gives in that direction with the same options. argsort, topK and lexsort return uint32 index arrays (toTypedArray() gives a Uint32Array; row numbers stop at 2^32 - 1 and a longer input throws), and take accepts Int32, BigInt64 and Uint32 indices.

const x = MetalArray.fromArrow(vectorFromArray([2, null, NaN, -0, 7], new Float64()));
[...x.argsort(true).toTypedArray()];                                  // [4, 0, 3, 2, 1]
[...x.argsort({ descending: true, nulls: 'first', floatOrder: 'total' }).toTypedArray()];
                                                                      // [1, 2, 4, 0, 3]
[...x.topK(2, { nulls: 'first', floatOrder: 'total' }).toTypedArray()]; // [1, 2]
[...x.argsort({ descending: true, floatOrder: 'nan_largest' }).toTypedArray()];
                                                                      // [2, 4, 0, 3, 1]
lexsort([region, amount], [{ nulls: 'first' }, { descending: true }]);

A plan's sort key takes the same options as JSON: { column: 'x', descending: true, nulls: 'first', float_order: 'total' } or ['x', true, { float_order: 'nan_largest' }] (Query engine).

Chunked columns

MetalArray.fromChunks(chunks) imports a column held as several chunks of one type — Data chunks, Vectors of any number of chunks, or a mix — as one array of their total length, with no concatenation in JS. fromArrow of a Vector with more than one chunk (a column of a Table built from several record batches) takes the same path.

const table = new Table([batch1, batch2, batch3]);   // or tableFromIPC(bytes)
const k = MetalArray.fromArrow(table.getChild('k')!); // one array, every batch's rows
const c = MetalArray.fromChunks([vectorFromArray([1n, 2n], new Int64()),
                                 vectorFromArray([3n, null, 5n], new Int64())]);
c.length;                                            // 5
c.sum();                                             // 11n

Each chunk crosses the C Data Interface on its own and am_import_chunks copies its buffers straight into the final Metal buffers, on the CPU cores in parallel. Each chunk's offset, length and validity are honoured, and each chunk's V8 buffers stay pinned until ArrowMetal releases that chunk, as for fromArrow. One chunk in total is fromArrow, with its copy rule. Chunks of different types are refused with both types named.

The copy rule

Copy-free out, always. Copy-free in when the producer's buffers are page aligned, one copy otherwise. That is the project's rule and this binding does not add a copy of its own to either side.

In

Arrow JS has no C Data Interface export, so the addon builds the ArrowSchema and ArrowArray structs itself:

  • Wrapped, never copied by this binding. Every buffer pointer is the V8 backing store's own address (ArrayBuffer.Data() + byteOffset). The addon holds a JS reference to each producer buffer in the ArrowArray's own private_data, and drops it when ArrowMetal calls the release callback, and at no other time. That is later than you might expect: on a page-aligned import ArrowMetal wraps the V8 pages with makeBuffer(bytesNoCopy:), and a slice, a group-by or a registered plan source built from that array retains the import, so the pages stay pinned until the last of those is gone. Releasing the handle does not unpin them — see Lifetime below.
  • Then ArrowMetal decides. It wraps those pages when they are page aligned and copies once when they are not. MetalArray#wrappedProducerBuffers reports which happened, per handle.
  • Buffers involved: the values buffer, the validity bitmap when there are nulls, and the int32 offsets buffer for utf8. All three are wrapped by the addon; a copy, when there is one, is ArrowMetal's.

One wrinkle, handled here rather than left to you. Arrow JS's Data.slice advances the numeric values buffer and the utf8 offsets buffer, but leaves the validity bitmap alone and keeps the row offset in Data.offset. The C Data Interface applies one offset to every buffer. So on import this binding rewinds the two advanced buffers to their row-0 origin (a typed-array view at a different byteOffset — no bytes move) and lets the C offset do the work; on export it advances them again. Sliced and doubly sliced vectors, with and without nulls, are tested.

If a hand-built Data has offset > 0 and a buffer that cannot be rewound (byteOffset smaller than offset × BYTES_PER_ELEMENT), the import throws and tells you to materialise the vector. No Arrow-JS-produced Data reaches that path.

Out

am_export hands back an ArrowArray; each of its buffers becomes an external ArrayBuffer sharing one refcount. The release callback runs when the last of them is garbage collected. So toArrow() and toTypedArray() return views over GPU-resident memory and copy nothing. (toArray() copies, by construction — it builds a JS array.)

Lifetime

Who keeps what alive, precisely:

Object Pinned until
The producer's V8 TypedArray (import) ArrowMetal calls the ArrowArray release callback — i.e. 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 garbage collected

The first row is the one with teeth. 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 the JS references must outlive the handle, and they do: they live in the ArrowArray's private_data, not on the handle. col.release() frees the handle and nothing else; the derived slice, group-by or plan source keeps reading the right bytes. Four tests in test/lifetime.test.js drop every JS reference to the source typed array, force two collections, allocate 40 more 1M-element arrays over the freed pages, and then read through the derived object.

Measured: are V8's typed-array buffers page aligned?

Page size 16,384 bytes on this machine. "wrapped" is wrappedProducerBuffers — ArrowMetal kept the buffers instead of handing them 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, both types, the answer is yes — V8 backs a large typed array with its own mmaped pages, so the import is a wrap and not a copy. Small typed arrays are packed into the heap and land wherever they land; the 1,000-element Float64Array above was not aligned and was copied. The test suite does not pin an address: it pins the consequence. A wrapped import sees a later write through the typed array (sum changes); a copied one does not.

A view whose byteOffset is not a multiple of the page size is never page aligned and is always copied. A byteOffset that is a multiple of the page size, into an already-aligned buffer, still lands on a page boundary and is still wrapped.

Covered

Area Surface Backed by
Interop MetalArray.fromArrow, fromTypedArray, toArrow, toTypedArray, toArray am_import / am_export
Types carried Int8/16/32/64, Uint8/16/32/64, Float32, Float64, Bool, Utf8 round-trip test per type, with nulls
Reductions sum, min, max, mean am_reduce
Compare compare(op, scalar), compareWith(op, array), eq ne lt le gt ge am_compare_scalar / am_compare_array
Arithmetic arith('+' | '-' | '*' | '/', scalar) am_arith_scalar
Cast cast(format) am_cast
Selection filter, take, slice am_filter, am_take, am_slice
Sorting argsort, sort, topK, lexsort([...]), each with per-key nulls and floatOrder am_argsort, am_sort, am_top_k, am_lexsort; with options am_argsort_ex2, am_sort_ex2, am_top_k_ex, am_lexsort_ex2
Chunked columns MetalArray.fromChunks, fromArrow of a multi-chunk Vector am_import_chunks, am_import_chunks_supported
Group-by groupBy(keys).sum / mean / min / max / count, .keys(i), .groups am_group_by_keys + am_group_agg_ex
Query engine PlanSource.create, runPlan, explainPlan, PlanResult#column am_plan_source_create, am_plan_run, am_plan_explain
Diagnostics info, bufferAddress, isPageAligned, wrappedProducerBuffers —

Errors from ArrowMetal are thrown as JS Errors carrying the am_last_error() message verbatim, except argument-guard rejections (return code 2), which carry the binding's own message: the C ABI's guards return 2 without setting am_last_error, so reading it there would report the previous call's failure. Handles are freed by the garbage collector; release() frees one now.

Not covered

The C ABI has 286 entry points. This binding wraps the ones above and no others. Not wrapped:

  • strings beyond utf8 import/export — no am_str_unary, am_str_match, am_str_transform, am_regex, am_to_strings, am_parse
  • temporal, decimal128/256, nested (list, struct, map, union), dictionary and run-end types
  • window functions, cumulative ops, am_reduce_ex / am_reduce_ex2 statistics, am_unary / am_binary op tables, Kleene logic and conditionals
  • joins (am_join), IPC, Parquet, the streaming API (am_stream_*), the device C interface (am_import_device / am_export_device), batching (am_batch_begin / am_batch_end)
  • Arrow JS Table and RecordBatch as a whole; their columns import one at a time, through fromArrow / fromChunks.
  • every call is synchronous on the JS thread. There is no worker or async variant, so a long kernel blocks the event loop.

Timing

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. 10,000,000 Int64 rows, no nulls, values i % 1000.

Method: five fresh processes, each building the dataset once, warming each row up 3 times and then timing 5 calls (process.hrtime.bigint() around the call) and keeping its best. The table reports 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.

The spread is the point. A single process's best-of-5 is not a stable measurement here — an earlier one-process run of this same benchmark put filter end to end at 8.62 ms, roughly double the median below — so anything reported as one number would be over-claiming. Two rows whose ranges overlap cannot be ordered by this measurement, and the benchmark prints those pairs itself.

Two ArrowMetal rows: one with the column already on the device, one that re-imports the BigInt64Array on every call (a page-aligned wrap at this size, so no copy) and, for filter, wraps the result back as a typed array.

sum over 10,000,000 Int64 rows (answer 4995000000)

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

filter x >= 500 over 10,000,000 Int64 rows (5,000,000 kept)

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

The only overlapping pair the benchmark found is plain typed-array loop and Arrow JS, vector.toArray() then loop in the sum table, and they overlap because they are the same code: toArray() on an unsliced Int64 vector hands back the same BigInt64Array.

Reading the tables:

  • The two filter rows are not measuring 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 again on the host — whereas the plain loop writes 5,000,000 BigInts into a JS array as it goes. Some of the 2.2x is the GPU and some of it is that the comparison hands the CPU a materialisation the GPU path does not have to repeat.
  • Vector.get(i) is 170–184 ms because Arrow JS allocates a BigInt per row. It is in the table because it is what a naive Arrow JS user writes, not because it is a fair kernel.
  • The 0.27–0.36 ms resident sum is 80 MB read in about 0.3 ms, roughly 265 GB/s, which is in range for an M4 Max. It is not a cached answer: a test mutates the wrapped buffer and watches the sum change.
  • On these two operations at this size ArrowMetal is ahead in every row. The small-array crossover is not measured here.

Numbers are from an M4 Max on 2026-09-07. node bench/spread.mjs re-runs the whole thing; node bench/bench.mjs runs a single process and prints tables.

Chunked columns against concatenating first

node --expose-gc bench/chunks.mjs: a column in chunks, each chunk its own Arrow JS Data, imported with MetalArray.fromChunks against what a program writes without it (copy the chunks into one typed array with TypedArray.set, assemble one validity bitmap bit by bit when there are nulls, then fromTypedArray), both end to end with the handle released inside the timed call. M4 Max, 2026-09-29, node v24.9.0, apache-arrow 21.2.0; three rounds, each row warmed for 100 ms and then timed 10 times; best of the three rounds, the median of the per-round medians in parentheses; CPU is process CPU time per call. Source: Benchmarks/results/bindings_chunked_import_2026-09-29.csv.

Column Rows Chunks Concatenate + import fromChunks CPU ms (concatenate / chunked)
float64, 10% null 10,000,000 153 16.16 (17.41) ms 0.88 (1.16) ms 22.1 / 8.9
float64, 10% null 10,000,000 10 16.99 (17.33) ms 0.77 (1.10) ms 22.6 / 11.0
float64, 10% null 50,000,000 763 81.68 (82.80) ms 3.04 (3.77) ms 86.0 / 39.0
float64, 10% null 50,000,000 50 80.17 (81.26) ms 2.58 (3.27) ms 86.4 / 40.5
int64 10,000,000 153 1.88 (2.54) ms 0.70 (0.83) ms 4.8 / 9.4
int64 10,000,000 10 1.93 (2.38) ms 0.55 (0.59) ms 4.4 / 8.9
int64 50,000,000 763 7.49 (8.03) ms 2.65 (2.98) ms 11.4 / 32.8
int64 50,000,000 50 7.28 (8.14) ms 2.29 (2.75) ms 10.4 / 33.3

The chunked import copies each chunk once, straight into the Metal buffers, on the CPU cores in parallel. For int64 it takes 2.0x to 3.2x the CPU time of the concatenation per call; for the nullable float64 column, where the concatenation assembles the validity bitmap in JS, it takes less.

The existing calls, before and after

node --expose-gc bench/overhead.mjs times the calls that existed before, built from the previous commit and from this one against the same libArrowMetalC.dylib, in four alternating rounds (each row warmed for 100 ms, a 500 ms idle and one call timed on its own, then 30 calls); best of the four rounds, the median of the per-round medians in parentheses. At 10M rows:

Call Previous commit This commit
fromArrow(vector).release(), int64 0.016 (0.049) ms 0.008 (0.015) ms
fromTypedArray(values, { validity }).release(), float64 with 10% nulls 0.014 (0.063) ms 0.021 (0.058) ms
argsort(false), float64 with 10% nulls 5.74 (5.90) ms 5.72 (5.84) ms
argsort(true), int64 7.33 (7.51) ms 7.32 (7.71) ms
sort(false), float64 with 10% nulls 5.69 (6.10) ms 5.60 (5.86) ms
lexsort, int64 then float64 descending 15.72 (16.33) ms 15.98 (16.26) ms

In the four rounds fromArrow took 1.5x to 1.6x the CPU time per call at 1,000 and 1,000,000 rows (a few microseconds), so it was timed again alone, eight alternating rounds of 100 calls: best 0.37x, 1.00x and 0.93x at 1,000, 1,000,000 and 10M rows, median 0.71x, 0.61x and 1.74x, and 1.83x the CPU time at 10M rows (0.016 against 0.029 ms per call). Two further rounds of 1,000 calls give 0.96x best, 0.81x median and 0.72x the CPU time at 10M rows, and 2.7x best at 1,000 rows (0.0032 against 0.0012 ms): in this build's process the 8 KB buffer of the 1,000-row vector was not page aligned, so ArrowMetal copied it (wrappedProducerBuffers false), where the previous build's process had it aligned and borrowed it; the alignment table above shows that this varies at small sizes. Every row, with first-call-after-idle and CPU time: Benchmarks/results/bindings_call_overhead_2026-09-29_summary.csv.

Limits

  • macOS on Apple silicon, in Node. No browser, no WASM, no Intel, no Linux.
  • int64 is BigInt. sum, min, max on an Int64 or Uint64 column return a BigInt; mean always returns a number. Scalars passed to compare / arith on an int64 column should be BigInts (amount.gt(60n), not amount.gt(60)); a number is accepted and truncated toward zero. Arrow JS's Int64 vectors are BigInt64Array-backed, which matches.
  • Column by column. A Table or RecordBatch is imported one column at a time; a chunked column goes through the chunked import.
  • Synchronous. Every call blocks the event loop for the length of the kernel.
  • Handles are GC-managed. A MetalArray holds GPU memory until it is collected; call release() in a loop that makes many of them. release() is safe at any point: it frees the handle only, and anything derived from it keeps working, because the producer's buffers stay pinned until ArrowMetal itself lets go. See Lifetime.
  • No prebuilt binary. npm install compiles the addon locally, and the dylib must already exist.
  • Not published. binding.gyp adds ../include so the addon can include the repository's arrow_abi.h; that path is outside the package, so an npm pack tarball installs and then fails to compile. The package is therefore marked "private": true and has no files, os or cpu fields. main and types are kept, so a local file: or npm link install resolves.

Tests

84 tests, node:test, oracles are Apache Arrow JS and plain JS over the same rows. npm test sets NODE_OPTIONS=--expose-gc, which the lifetime tests need.

cd node
ARROWMETAL_LIB=/path/to/libArrowMetalC.dylib npm test
File Tests What it pins
test/interop.test.js 21 round trip for all 12 carried types with nulls; sliced, doubly sliced, sliced-with-nulls, sliced utf8 and bool; a chunked vector imported as one array, and chunks of two types and an unsupported type rejected by message; the alignment table above; wrapped-vs-copied proved by mutating the source buffer; short-buffer rejection with byte counts for validity, values, utf8 offsets, utf8 values and bool; utf8 offsets that decrease or start below zero, named by index; impossible nullCounts; type-tagged handles rejected across kinds; an argument-guard rejection never reporting a stale message; 10,000 import/compute/export cycles
test/reductions.test.js 10 sum/min/max/mean against plain-JS oracles for Int64 and Float64, with nulls, all-null, empty; 1,000,001 rows; Kahan-summed float oracle to 1e-9 relative; validity bitmaps with a known and an unknown null count
test/compute.test.js 20 all six comparison ops against JS; null masks; take with Int32, BigInt64 and Uint32 indices; Uint32 index arrays; filter on empty and at 1,000,001 rows; sort and argsort with nulls last and stable ties; sort at 1,000,001 rows against Array.prototype.sort; take, slice, arith, cast; groupBy sum/mean/min/max/count against a JS Map, including a null key group and 1,000,001 rows; lexsort
test/plan.test.js 7 a filter → group_by → sort plan against the same steps in JS; optimized vs unoptimized agree; explain; a plan that does not type-check throws the engine's own message; an out-of-range column named by index and by name
test/lifetime.test.js 4 a slice, a plan source and a group-by all still read the right bytes after the parent handle is released, every JS reference to the source array dropped, two collections forced and the freed pages trampled; and an exported Vector after its handle is released
test/sortoptions.test.js 11 argsort / sort with every direction × null placement × float order (ieee, total, nan_largest), index for index and bit for bit against a stable plain-JS reference of the documented order, on Float64 columns holding NaN of both signs and several payloads, ±0, ±Infinity and subnormals, with nulls, at 0 to 100,001 rows; Int64 and Float32 columns; the boolean forms and the defaults equal to the options forms; topK against the head of argsort at k = 0 to past the length; lexsort with per-key options; nan_largest on a hand-picked column with NaN of both signs, ±0, ±Infinity and a null, against its expected indices for argsort, sort, topK and a two-key lexsort; a plan sort key with nulls and float_order, and float_order: 'nan_largest' in each key form and as the sort-level default, with and without a limit; the examples on this page
test/empty.test.js 4 zero-row input: fromTypedArray of every typed-array type (empty, over a zero-byte ArrayBuffer, zero-length views into a non-empty buffer, with an empty validity bitmap), fromArrow of an empty vector, of makeData({ type, length: 0 }) and of a zero-length slice for all 12 types, fromChunks of empty chunks alone, next to a non-empty chunk and from a table with no batches; each back out through toArrow / toTypedArray, and argsort, sort, topK and sum on the empty array; utf8 rows that are all empty strings over a zero-byte values buffer
test/chunks.test.js 5 fromChunks and fromArrow of a multi-chunk Vector against the import of the concatenation, over 9 types and five layouts (empty, one-row, sliced, all-null and no-null chunks mixed; one chunk; all empty; all null; 300 small chunks); sum, max and sorts on a 30-chunk column; a Table of five record batches through a plan; refusals by message; chunk buffers kept pinned across collections
test/workers.test.js 2 four worker_threads workers importing, deriving and releasing concurrently, each answering correctly; and workers that exit with references still parked, so the env cleanup hook has to drain them

Sizes are 0, small, and 1,000,001 — a length that crosses a threadgroup boundary. Nothing above 10,000,000 elements.

docs/R.md

R

r/arrowmetal is an R package that runs ArrowMetal's GPU kernels on columns held by the arrow R package. Columns cross through the Arrow C Data Interface; the package links against nothing at build time and opens libArrowMetalC.dylib with dlopen when it loads.

Every number on this page was measured in one session on an idle Apple M4 Max (16 cores, 64 GB unified memory), macOS 26.6.2, R 4.5.3, arrow 25.0.0, ArrowMetal 0.1.0.

Install

You need the dylib first:

DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
  swift build -c release --product ArrowMetalC   # produces .build/release/libArrowMetalC.dylib

Then either:

R CMD INSTALL r/arrowmetal
remotes::install_local("r/arrowmetal")

The package's configure script looks for ../../.build/release/libArrowMetalC.dylib relative to the package source and compiles the absolute path it finds into the shim, so an in-tree install needs nothing else. Otherwise, point ARROWMETAL_LIB at the dylib:

export ARROWMETAL_LIB=/path/to/libArrowMetalC.dylib

The search order at load is ARROWMETAL_LIB, then the path configure recorded, then ../../.build/release/libArrowMetalC.dylib relative to the working directory. When none of them opens, the package still loads and every compute call raises an error listing all three paths and why each failed; am_available(), am_load_error() and am_lib_path() report the same thing without raising.

A conda-built R names its own compiler in Makeconf (here arm64-apple-darwin20.0.0-clang), which lives in the environment's bin but is only on PATH once the environment is activated. Calling such an R by absolute path without activating it fails with sh: arm64-apple-darwin20.0.0-clang: command not found. Either put that bin on PATH, or set CC = clang in ~/.R/Makevars to use Xcode's clang instead; the shim compiles clean under both (conda LLVM 23.1.0 and Apple clang 21.0.0), and the only non-standard C it uses is __typeof__, which both provide.

Example

library(arrowmetal)
library(arrow)

x  <- Array$create(runif(1e7))                       # an arrow Array
h  <- am_array(x)                                    # onto the GPU
am_sum(h); am_min(h); am_max(h); am_mean(h)          # scalar reductions, nulls skipped

hits <- am_filter(h, am_compare(h, ">", 0.5))        # boolean mask, then gather
sorted <- am_sort(h, descending = TRUE)              # stable radix sort, nulls last
idx <- am_argsort(h)                                 # zero-based uint32 indices
top <- am_take(h, am_slice(idx, 0, 10))

g <- am_group_by(sample(c("north", "south"), 1e7, TRUE))
as.vector(as_arrow_array(g$keys()))                  # one row per group
as.vector(as_arrow_array(g$sum(h)))                  # grouped sum

src <- am_plan_source("sales", list(region = c("a", "b", "a"), amount = c(1, 2, 3)))
res <- am_plan_run('{"op":"limit","count":2,"input":{"op":"scan","source":"sales"}}', src)
as.vector(as_arrow_array(res$amount))                # back to plain R

Sort options

am_argsort(), am_sort(), am_top_k() and am_lexsort() take Arrow's null_placement ("at_end", the default, or "at_start") and a float_order ("ieee", the default, "total" or "nan_largest"):

x <- c(2, NA, NaN, -0, 7)
am_argsort(x, descending = TRUE)                                  # 4 0 3 2 1: NA and NaN last
am_argsort(x, descending = TRUE, null_placement = "at_start",
           float_order = "total")                                 # 1 2 4 0 3
am_top_k(x, 2, null_placement = "at_start", float_order = "total")   # 1 2
am_lexsort(list(c(1, 1, 2), c(3, NA, 1)), descending = c(FALSE, TRUE),
           null_placement = c("at_end", "at_start"))              # 1 0 2

(Each is shown through as.vector(); test-sort-options.R and test-chunks.R run these lines.) With the defaults each function runs the call it always ran: nulls last and NaN after +Inf in both directions, -0 tied with 0. "at_start" puts the nulls first in either direction. float_order = "ieee" is Arrow C++'s order: -0 ties 0, every NaN is one value, and the NaN rows sit next to the nulls in both directions. "total" is IEEE 754 totalOrder, the order arrow-rs and Rust's total_cmp use: -NaN < -Inf < ... < -0 < 0 < ... < Inf < NaN, and a descending sort is its exact mirror. "nan_largest" is Polars' and NumPy's order: every NaN is one value above +Inf in both directions (last ascending, first descending) and -0 ties 0. Integer, string and temporal columns ignore float_order. am_top_k(x, k, largest, ...) is the first k indices of am_argsort(x, descending = largest, ...). In am_lexsort() each option is one value for every key or a vector with one per key. A plan's sort key takes the same options as JSON: {"column": "x", "descending": true, "nulls": "first", "float_order": "total"} (Query engine).

Chunked columns

am_array_chunks() imports a column held in chunks — an arrow::ChunkedArray, or a list of Arrays of one type such as one column of several record batches — as one am_array, with no concatenated copy first. am_array() keeps concatenating a ChunkedArray first, as it always has (measured below, that is the faster path in R at 10M and 50M rows).

ca <- arrow::chunked_array(c(1, NA), numeric(0), c(3, 4, 5))
h <- am_array_chunks(ca)
length(h); am_null_count(h); am_sum(h)    # 5, 1, 13

The column crosses the C Data Interface once, as an ArrowArrayStream of one-column record batches (arrow::as_record_batch_reader()), so the R side does the same work for 10 chunks as for 1,000. The shim reads the stream in C, moves each batch's column into one block of ArrowArray structs it owns and releases the batch, and am_import_chunks copies each chunk's buffers straight into the final GPU buffers, on the CPU cores in parallel, honouring each chunk's offset, length and validity. Each chunk's release callback runs exactly once, by ArrowMetal or, for a chunk it did not take, by the shim. One chunk is am_array(). A list of arrays becomes a ChunkedArray first, which checks that the chunks share one type. A type the chunked import does not take (dictionary, nested, run-end encoded, extension) is concatenated and imported, as am_array() always did.

The copy rule

Out is always copy-free. as_arrow_array() hands arrow a C Data Interface array pointing at the Metal buffers; its release callback keeps them alive, so the am_array handle may go out of scope first.

In is copy-free when the producer's buffers are page aligned, and one copy otherwise. What that means for arrow R, measured with am_buffer_alignment() (page size 16,384 bytes):

Buffer 1M elements 10M elements
Array$create(<R double>) values offset 48, not aligned (20/20 trials) offset 48, not aligned (20/20 trials)
Array$create(<R integer>) values offset 48, not aligned offset 48, not aligned
int64 values (type = int64()) offset 0, page aligned offset 0, page aligned
validity bitmap of a column with nulls offset 0, page aligned offset 0, page aligned
a buffer arrow allocates ($cast(), string offsets and data) offset 0, page aligned not measured
an mmapped Arrow IPC file offset 0, page aligned not measured

arrow R borrows an R double or integer vector instead of copying it — Array$create(x) twice on the same x returns the identical buffer address — and R's vector data starts 48 bytes past a malloc block that is page aligned at these sizes, because R's vector header is 48 bytes on 64-bit. So at 1M and 10M elements a float64 or int32 column that came from an R vector takes the copying path in, while an int64 column, a validity bitmap, a cast result, a string column and anything read from a file take the copy-free path.

That size qualifier matters: a buffer is page aligned only when it is large enough for arrow to give it its own allocation instead of a slice of a pool, so a small column's buffers are not page aligned and take the copying path whatever their type. Where the changeover happens is allocator behaviour — it differs by type and is not monotone in n — so no threshold is quoted here. The table above is what was measured, at 1M and 10M; for anything else, run am_buffer_alignment() on your own data rather than inferring. At small sizes the copy costs nothing worth measuring.

The copy costs 1.39 ms (median) for 80 MB (10M float64; decimal MB, 76 MiB), about 57 GB/s.

Timing

10,000,000 float64, no nulls.

Method. The benchmark is committed at r/arrowmetal/inst/bench/timing.R with its driver run.R, so the table below can be re-derived rather than taken on trust:

ARROWMETAL_LIB=$PWD/.build/release/libArrowMetalC.dylib \
  Rscript r/arrowmetal/inst/bench/run.R 5 2

2 replicates × 5 fresh R processes × microbenchmark(times = 20), each process building its own data. Every expression is timed two ways, because they disagree by enough to move a headline:

  • isolated — each expression in its own microbenchmark() call, after one warm-up call of that expression. The most favourable measurement.
  • interleaved — all expressions in one microbenchmark() call, so the runs are shuffled and each expression meets the cache and buffer-pool state the others leave behind. The conservative measurement, and the right one for a table whose rows are compared with each other.

min is the fastest of all 200 runs, median the median of the 10 per-process medians. "resident" means the column is already an am_array, "import" means the timing starts from an arrow::Array and includes the transfer. All variants were checked to give the same answer in the same script.

sum:

Method isolated min / median interleaved min / median
am_sum(h) resident 0.52 / 0.82 ms 1.37 / 1.70 ms
arrow::call_function("sum", a) 1.09 / 1.16 ms 1.12 / 1.31 ms
am_sum(a) import + sum 3.29 / 6.36 ms 2.75 / 3.20 ms
sum(x) base R 11.15 / 11.60 ms 11.15 / 11.61 ms

Resident sum is not separable from arrow's, and no verdict is claimed for it. It measures between 0.57 and 1.54 ms depending on the process. In the isolated mode: the per-process medians were 0.57, 0.59, 0.60, 0.60, 0.78 in one replicate and 0.86, 1.38, 1.38, 1.39, 1.54 in the next, while arrow stayed tight at 1.14–1.27 across all ten. One replicate makes ArrowMetal look twice as fast, the next makes it look the other way; the two are the same speed to within the noise of this measurement.

sum including the import is a row where arrow is ahead, in every replicate: 3.20 ms against arrow's 1.31 ms interleaved, 2.4×. A sum is one bandwidth-bound pass over 80 MB (decimal MB; 76 MiB) with no arithmetic to hide the transfer behind.

filter (x > 0.5, about 5M rows out):

Method isolated min / median interleaved min / median
am_filter(h, am_compare(h, ">", 0.5)) resident 1.09 / 1.19 ms 1.12 / 1.58 ms
am_filter(a, am_compare(a, ">", 0.5)) import + filter 4.19 / 11.79 ms 3.95 / 4.49 ms
arrow greater then filter 20.80 / 22.27 ms 20.84 / 21.92 ms
x[x > 0.5] base R 36.12 / 37.57 ms 34.79 / 37.88 ms

ArrowMetal is ahead on filter, but the multiple depends on how it is measured: about 14× against arrow resident and about 4.9× including the import, on the conservative interleaved medians. The resident row ranges 1.12–1.64 ms across processes here and independent runs on the same machine have put it above 2 ms, which would make it nearer 9×. Treat it as roughly an order of magnitude, not a precise multiple.

The import rows are the one place isolated timing is worse than interleaved (11.79 ms against 4.49 ms for filter): twenty imports back to back give the buffer pool no chance to recycle.

am_array() alone on the same column: 1.32 ms min, 1.39 ms median interleaved (80 MB, about 57 GB/s).

arrow's kernels are multi-threaded and base R's are not, so a comparison against base R is not a per-core figure. Timings on a loaded machine are noise; these ran on an idle machine.

Chunked columns against concatenating first

Rscript inst/bench/chunks.R: a column in chunks, each chunk its own Array, imported with am_array_chunks() against am_array(do.call(arrow::concat_arrays, chunks)), which is what am_array() of a ChunkedArray does. M4 Max, 2026-09-29, R 4.5.3, arrow 25.0.0; three rounds, each row warmed for 100 ms and then timed 10 times; best of the three rounds, the median of the per-round medians in parentheses; CPU is process CPU time per call. Source: Benchmarks/results/bindings_chunked_import_2026-09-29.csv.

An am_array is freed when R's collector finalizes it. With gc() run before every timed call (outside the timed region), each call starts with the previous call's handle and GPU memory gone, as a program that releases its columns sees it:

Column Rows Chunks concat_arrays + am_array() am_array_chunks() CPU ms (concatenate / chunked)
float64, 10% null 10,000,000 153 1.82 (1.89) ms 1.20 (1.31) ms 2.1 / 8.4
float64, 10% null 10,000,000 10 1.47 (1.51) ms 1.12 (1.24) ms 1.7 / 8.0
float64, 10% null 50,000,000 763 8.36 (9.03) ms 3.68 (4.01) ms 10.5 / 34.6
float64, 10% null 50,000,000 50 6.02 (6.12) ms 3.07 (3.35) ms 7.2 / 37.0
int64 10,000,000 153 1.59 (1.66) ms 1.09 (1.23) ms 1.8 / 6.9
int64 10,000,000 10 1.26 (1.31) ms 0.97 (1.05) ms 1.3 / 6.7
int64 50,000,000 763 7.74 (8.42) ms 3.37 (3.81) ms 8.6 / 32.3
int64 50,000,000 50 5.57 (5.71) ms 2.69 (2.98) ms 5.9 / 30.4

Without the gc() calls, the handles of earlier calls hold their GPU memory until the collector runs, and every chunked import writes into newly allocated GPU memory, while arrow's concatenation is page aligned and borrowed. There the chunked import is behind at 10M rows (see To improve below):

Column Rows Chunks concat_arrays + am_array() am_array_chunks() CPU ms (concatenate / chunked)
float64, 10% null 10,000,000 153 2.87 (3.52) ms 5.15 (5.71) ms 4.2 / 93.8
float64, 10% null 10,000,000 10 2.37 (4.89) ms 4.57 (5.03) ms 5.0 / 81.2
float64, 10% null 50,000,000 763 13.42 (14.34) ms 7.23 (12.44) ms 15.3 / 119.4
float64, 10% null 50,000,000 50 11.31 (15.78) ms 7.70 (86.55) ms 16.2 / 185.4
int64 10,000,000 153 4.51 (5.22) ms 4.36 (5.68) ms 5.6 / 90.8
int64 10,000,000 10 4.32 (5.12) ms 4.85 (5.59) ms 5.6 / 93.9
int64 50,000,000 763 16.96 (21.79) ms 9.73 (10.27) ms 21.3 / 133.8
int64 50,000,000 50 10.76 (11.72) ms 10.12 (10.83) ms 12.0 / 151.0

am_array() of a ChunkedArray therefore keeps concatenating first, as it did.

The existing calls, before and after

Rscript inst/bench/overhead.R times the calls that existed before, installed from the previous commit and from this one against the same libArrowMetalC.dylib, in four alternating rounds (each row warmed for 100 ms, a 500 ms idle and one call timed on its own, then 30 calls); best of the four rounds, the median of the per-round medians in parentheses. At 10M rows:

Call Previous commit This commit
am_array(), int64 Array 0.011 (0.029) ms 0.011 (0.015) ms
am_array(), float64 Array with 10% nulls 4.15 (4.82) ms 4.15 (4.86) ms
am_array(), one-chunk ChunkedArray 3.76 (4.50) ms 3.91 (4.59) ms
am_argsort(x), float64 with 10% nulls 6.47 (7.74) ms 6.48 (7.92) ms
am_argsort(x, TRUE), int64 3.74 (5.46) ms 4.29 (5.28) ms
am_sort(x), float64 with 10% nulls 7.40 (8.07) ms 7.26 (9.50) ms

No row at 1,000, 1,000,000 or 10M rows was slower in both best and median. Every row, with first-call-after-idle and CPU time: Benchmarks/results/bindings_call_overhead_2026-09-29_summary.csv.

To improve

  • am_array_chunks() in a loop that leaves its handles to R's collector: at 10M rows it takes 0.97x to 1.93x the time of concat_arrays + am_array() (best of three rounds; int64 in 153 chunks 4.36 against 4.51 ms, float64 in 10 chunks 4.57 against 2.37 ms), and at 50M rows float64 in 50 chunks has a median of 86.5 ms against 15.8 ms. It uses 81 to 185 CPU-ms per call there, against 7 to 37 with gc() between calls.

Covered

Area Functions
Import / export am_array(), am_array_chunks() (a ChunkedArray or a list of arrays, am_import_chunks), as_arrow_array(), as.vector(), length(), am_null_count(), am_format()
Reductions am_sum(), am_min(), am_max(), am_mean()
Element-wise am_compare() — ==, !=, <, <=, >, >=, against a scalar or a column
Selection am_filter(), am_take(), am_slice()
Sorting am_argsort(), am_sort(), am_top_k(), am_lexsort(), each with null_placement and float_order
Grouping am_group_by() over any number of key columns of any supported type, with $sum $min $max $mean $count $count_all $first $last $product $var $sd $median $quantile and $agg() for the remaining am_group_agg_ex ops
Query engine am_plan_source(), am_plan_run(), am_plan_explain() — the full JSON plan grammar
Environment am_available(), am_load_error(), am_lib_path(), am_version(), am_device_name(), am_buffer_alignment()

Types exercised by the tests: float64, float32, int32, int64, boolean and utf8; sliced views of float64, boolean and utf8 at offsets 0, 1 and 3, plus a slice of a slice and selection on a sliced column; and multi-chunk, single-chunk and empty ChunkedArrays.

Not covered

The binding resolves 42 of the ABI's 286 entry points (36 it needs, and 6 newer ones it uses when the loaded library has them). Not wrapped, and reachable only from Python or Swift for now:

  • arithmetic (am_arith_*, am_unary, am_binary, checked variants), casts (am_cast), boolean logic and Kleene logic;
  • every string kernel (am_str_*, am_string_*, am_regex, am_split), temporal kernels, decimal, list, struct, map and extension types;
  • am_rank, am_unique, am_value_counts, am_is_in, am_cumulative, am_window, am_hash64, am_if_else, am_coalesce, am_fill_null;
  • joins (am_join), the dense-key am_group_by, am_query (the fused expression compiler), Parquet (am_parquet_*), the streaming engine (am_stream_*), C Device interop (am_import_device / am_export_device), batching (am_batch_begin / am_batch_end) and resident mode.

There is also no dplyr backend and no RecordBatch/Table surface: everything is per-column.

Limits

  • int64. R's integer is 32-bit and its double is 64-bit; there is no native 64-bit integer. An int64 Arrow column is carried end to end, but a scalar result comes back as a double, exact only to 2^53. am_sum(x, integer64 = TRUE) returns an exact bit64::integer64 instead, and a plain call warns when the result is outside 2^53. arrow itself follows the same convention: Scalar$as_vector() on an int64 gives an R integer when it fits and a bit64::integer64 when it does not. Note that as.vector() on a bit64::integer64 strips the class and reinterprets the bit pattern as a double (2474723182 becomes 1.22e-314); use as.numeric().
  • The arrow package is a hard dependency. It builds every column and reads every result back; am_array(c(1, 2, 3)) calls arrow::Array$create() for you.
  • Zero-based indices in am_argsort(), am_take() and am_slice(), following Arrow rather than R. am_argsort(x) matches order(x, na.last = TRUE) - 1L. am_take() rejects a fractional index or one outside [0, 2^32) with an error rather than coercing it to NA.
  • Index arrays are uint32 (am_argsort(), am_top_k(), am_lexsort(), the ranks). R has no unsigned 32-bit type, so they read back the way arrow reads a uint32 array: an R integer vector when every value is at most 2^31 - 1, a double vector when one is larger (exact up to 2^32 - 1).
  • as_arrow_array() is arrow's own generic. arrow exports as_arrow_array(x, ..., type = NULL) with eight methods; this package registers a ninth for am_array and re-exports the generic unchanged, rather than defining a second one. A second generic of that name would mask arrow's (breaking its methods) or be masked by it (never dispatching for am_array), depending on the order the packages are attached.
  • An NA scalar in am_compare() returns an all-null boolean mask, matching base R (c(1, 5) > NA) and arrow's greater kernel. The ABI scalar is a raw value with no validity flag, so this case is resolved in R and never reaches the GPU. NaN is a value, not a null, and does go to the kernel.
  • Group order is not first-seen order: ascending by key for numeric, boolean, temporal and decimal columns (nulls last), first-seen for strings and binary, lexicographic in column order for several columns. Label rows with $keys().
  • By default sorts put nulls and NaN last in both directions, so a descending sort is not the exact reverse of an ascending one; null_placement = "at_start" and float_order = "total" change that (Sort options).
  • macOS on Apple silicon only. The dylib is not included in the package.

Tests

src/arrowmetal.h and src/arrow_abi.h are copies of the repository's include/ headers; refresh them (cp include/arrowmetal.h r/arrowmetal/src/) whenever the header changes, or python/tests/test_header_copies.py and test-header-copy.R fail.

88 test_that() blocks in the sources (89 as testthat runs them: the one in test-dispatch.R runs once per attach order), 920 expectations, against base R and against arrow's own kernels on the same data: nulls, all-null and empty columns, sliced input at three offsets, lengths of 1, 33, 1024, 65537 and 1,000,001 (crossing a threadgroup boundary), one group per row and one group for everything, int64 above 2^53, float32 accumulation, and every documented error path.

ARROWMETAL_LIB=$PWD/.build/release/libArrowMetalC.dylib \
  Rscript -e 'testthat::test_local("r/arrowmetal")'
Streaming

Larger than memory, one pass, on the GPU.

Data flows from disk through the GPU with reads and compute overlapped, and the group-by and top-k state resident on the GPU, measured to 30.21 GB on a 64 GB machine. The page below is the streaming documentation in full.

docs/STREAMING.md

Out-of-core streaming execution

Datasets larger than memory, answered on the Apple GPU. The rows flow from disk through the GPU one record batch at a time; the CPU touches value columns only where §5 and §10 say it does: a utf8 sort key and the external sort's k-way merge. Only the answer grows with the input, so RAM tracks the answer rather than the scan: measured to 30 GB on a 64 GB machine, where the private running state ranges from a few scalars to a 940 MB group table (§4.1) and peak RSS is dominated by mapped file pages (§9).

let rows = try StreamQuery(ipc: "/data/events")
    .filter(col("amount") > 100)
    .groupBy(["region"], [StreamAggregate(.sum, "amount")])
import arrowmetal as am
am.scan_ipc("/data/events").filter(am.col("amount") > 100) \
  .group_by("region").agg([("sum", "amount", "total")])

Source files: Sources/ArrowMetal/Stream/, C ABI in Sources/ArrowMetalC/ArrowMetalC_Stream.swift and the block at the end of include/arrowmetal.h, Python in python/arrowmetal/stream.py.


1. The pipeline

Three stages run at the same time on three threads, connected by bounded queues:

   ┌──────────────┐   batch i+1   ┌─────────────┐   result i   ┌──────────────┐
   │  stage 1     │ ────────────► │  stage 2    │ ───────────► │  stage 3     │
   │  read        │   queue(3)    │  GPU        │   queue(2)   │  merge       │
   │              │               │             │              │              │
   │ mmap + import│               │ one batch   │              │ fold a small │
   │ next batch   │               │ of kernels  │              │ result into  │
   │ (own thread) │               │ (this one)  │              │ running state│
   └──────────────┘               └─────────────┘              └──────────────┘
        SSD                        Metal                        host memory
  • Stage 1 is PrefetchingSource. It runs the wrapped source on its own thread and keeps up to depth batches ready (3 by default). Backpressure is two-sided: the reader blocks when depth batches are queued or when the queued batches exceed budgetBytes (2 GB by default), whichever comes first, so a stream of very wide batches cannot pin more memory than the budget.
  • Stage 2 is the caller's thread inside StreamingExecutor.run. It pulls one batch, records the operator's kernels into a single Metal command buffer via MetalContext.batch { }, and hands the small result on. It never waits for the merge unless the merge queue is full.
  • Stage 3 is the merge thread. It folds each batch's result into the running state, one at a time, in batch order.

Every stage records its own busy time. StreamStats.overlap is (read + gpu + merge) / wall: 1.0 means the stages ran one after another, and it approaches 3.0 when all three are saturated at once. readStallNanos is how long stage 2 waited for a batch the reader had not finished; mergeStallNanos is how long it waited for the merge to drain.

Why the merge stage is cheap

The GPU stage always produces a small result — a handful of scalars, one row per group, k rows for a top-k, a 16 KB sketch. That is the whole reason streaming works: the merge's cost is a function of the answer's size, not the dataset's. The one exception is documented below (the external sort's k-way merge, which is O(rows) on the host by construction).

Buffer budget

What Where Size
Batches in flight MetalContext.pool (page-aligned shared memory) prefetchDepth + 1 batches, capped by budgetBytes (2 GB default)
Per-batch kernel scratch MetalContext.pool recycled by exact length, so a steady stream of equal-shaped batches allocates once
Running state host or GPU, per operator see the operator table
Sink buffer one batch the IPC sink writes each message straight through

MetalContext.pool recycles page-aligned MTLBuffers by exact byte length. Batches of the same shape hit that cache on every read after the first, so an out-of-core scan does not pay mmap and page-fault costs per batch. Its cap defaults to a quarter of the device's recommended working set, at most 8 GB (MetalContext(poolLimitBytes:)).


2. Sources

Sources/ArrowMetal/Stream/BatchSource.swift.

Source Reads Notes
IPCFileSource one Arrow IPC file Memory mapped. Body buffers are borrowed without a copy where the file's layout allows; Arrow only guarantees 8-byte body alignment, so a buffer that does not start on a page boundary is copied into shared memory instead. Readahead with fcntl(F_RDADVISE) on a second descriptor plus F_RDAHEAD, which warms the unified buffer cache ahead of the read cursor.
IPCDirectorySource a directory of them Sorted by filename, opened lazily one at a time, so a directory larger than memory never has more than one file mapped.
CStreamSource any ArrowArrayStream A pyarrow RecordBatchReader, a pyarrow.dataset scanner (so Parquet, CSV and partitioned datasets work today through pyarrow's readers), a Polars LazyFrame, DuckDB — anything that speaks the Arrow C Stream ABI. Buffers are imported zero-copy when page aligned.
ParallelIPCSource a directory, several files at once readers threads, each with its own IPCFileSource and its own files, feeding one bounded queue. Batch order is not preserved — see below.
ChunkedTableSource batches already in Metal memory For tests and in-memory tables.
PrefetchingSource wraps any of the above Stage one of the pipeline, one thread.

One reader thread is not enough

PrefetchingSource hides the read behind the GPU, but it is still one thread doing the mapping and the import. One core copies at roughly 10 GB/s; Polars and DuckDB scan the same directory at roughly 50-60 GB/s because they read it with every core. ParallelIPCSource closes that gap: it hands each of readers threads its own files, so the read stage scales with cores.

The cost is batch order. Batches arrive interleaved across files, in whatever order the threads finish. Every operator here is order independent in its answer — aggregates, the HLL sketch, group-by, top-k, the external sort's run generation, both joins — though a float64 sum's last bits depend on batch order (§10). So the only thing that needs readers: 1 is a filter -> sink_ipc (or to_reader) that must preserve the source's row order.

am.scan_ipc("/data/events", readers=8).group_by("region").agg([("sum", "amount", "t")])
try StreamQuery(ipc: "/data/events", readers: 8)

Parquet files reach the streaming executor through scan_arrow(pyarrow.dataset(...)); am.read_parquet is the GPU reader for in-memory reads (Parquet on the GPU).

3. Sinks

Sources/ArrowMetal/Stream/StreamingSink.swift.

  • IPCStreamSink writes the Arrow IPC stream encapsulation incrementally: a schema message, one message per record batch, then the 8-byte end-of-stream marker. That encapsulation is a pure concatenation, which is what makes it appendable — the bytes for each message come from ArrowIPCWriter.encode, with the schema prologue (measured once by encoding the schema with no batches) and the end-of-stream marker trimmed off each call. pyarrow.ipc.open_stream, polars.scan_ipc_stream and this package's own ArrowIPCReader all read the result. Dictionary-encoded columns are decoded before writing, because an incremental sink cannot promise one dictionary per column across batches.
  • CallbackSink hands each batch to a closure.
  • CollectingSink keeps the batches (only for results known to be small).
  • ArrowStreamExporter exports a query as an ArrowArrayStream. get_next pulls exactly one source batch through the whole pipeline, so pyarrow or Polars can consume an out-of-core ArrowMetal query lazily and never materialise more than one batch — Stream.to_reader() in Python.

4. Operators

Operator Exact or approximate GPU work Host work Running state
filter / project → sink exact the predicate and every numeric projection compile into one fused kernel per batch (docs/EXPR.md); a string, temporal or decimal output takes the two-step path below writing the sink none
sum, count, min, max exact one fused aggregate kernel per batch one scalar combine per batch a few scalars
mean exact decomposed to sum + count on the GPU combine, divide once at the end 2 scalars
variance, stddev exact decomposed to sum(x), sum(x·x) in float64 and count, all in one fused kernel combine 3 scalars
count_distinct_approx approximate — relative standard error 1.04 / sqrt(2^p): 0.81 % at the default p = 14, 0.41 % at p = 16 a new HyperLogLog kernel: one atomic_fetch_max per row into a 2^p-register table element-wise max of two register arrays 2^p bytes (16 KB at p = 14), whatever the dataset's size
group_by (dense integer key) exact GroupBy per batch, then element-wise add / min / max folds the batch result into the GPU-resident global table none K accumulators per aggregate on the GPU; spills to the host table above gpuStateBudgetBytes (512 MB default)
group_by (one integer key, sum / count / mean) exact a dense key encodes per batch and folds into the GPU-resident hash table; a sparse one skips the encoding entirely — one thread per row probes and inserts into that table directly (§4.1) none one slot per distinct key on the GPU
group_by (any other key or aggregate) exact GroupByKeys turns the key columns into dense ids on the GPU, then the aggregates fold one row per group into a host table sharded by key hash across mergeShards threads one entry per distinct group, not per row
top_k exact one comparison kernel rejects the batch against the running k-th value (§4.2); survivors go through the GPU top-k and are folded into the GPU-resident k rows none k rows on the GPU
quantile approximate — see §6 one radix argsort plus one gather per batch; only compression values reach the host merge two weighted centroid lists and re-compress ≤ compression centroids (1000 default)
sort with limit n exact top-n, not a sort: the same threshold prune, then the GPU top-k selection over the survivors (§5) none n rows on the GPU; nothing spills
sort (external, no limit) exact one radix argsort per batch; each output chunk is assembled with take the k-way merge is on the CPU (see §5) one batch per run + one output batch
join (broadcast) exact the existing GPU hash join per probe batch writing the sink the build side, in memory
join (broadcast) + sum / count / min / max / mean exact one kernel probes, gathers the build-side value and accumulates (§8.1); the build table is built once for the scan none the build table plus two 64-bit words per aggregate, on the GPU
join (broadcast) + group_by exact the hash join, then a gather of the key and value columns of the matched pairs only, into the resident group table (§4.1) as the group-by's own path the build table plus the group table
join (grace hash) exact partition on the GPU, then the GPU hash join per partition writing the partition files one partition pair at a time

Null handling follows Arrow throughout: a null key forms its own group, count(expr) counts non-null values, sum/min/max skip nulls and return null for an all-null input, and a filter drops rows where the predicate is null.

Filter and project: two paths

The fused Expr compiler evaluates every expression into a fixed-width register, so it can read a utf8 column but cannot write one. streamFilterProject picks between:

  • Fused — every output is numeric or boolean: predicate and projections compile into one kernel, the batch is read once, nothing intermediate is materialised.
  • Two-step — some output is a plain utf8 / binary / temporal / decimal column. The predicate still compiles into one fused kernel, producing a boolean mask; that mask drives the GPU filter, which carries any column type, and only the computed outputs go back through the compiler.

Streaming group-by: three global tables

  • Dense integer key (denseKeyCount given, one key column already inside [0, K)): the global table lives on the GPU as one accumulator array per aggregate, K entries wide. Each batch runs GroupBy over the batch's keys and the per-batch result is folded into the global arrays with element-wise add / min / max kernels — no host round trip at all. When K * aggregates * 16 bytes exceeds gpuStateBudgetBytes the state spills to the host table, and partials recorded before the spill are folded into it, so a 10-million-key group-by still runs inside a fixed GPU budget.
  • One integer key with sum / count / mean: the resident hash table of §4.1, on the GPU for the whole scan. A dense key encodes per batch and folds one row per group into it; a sparse one skips the encoding and puts the rows in directly.
  • Anything else (several key columns, a string or float key, min / max / variance): GroupByKeys (which turns any key columns into dense ids on the GPU) plus the aggregates give one row per group — typically thousands of rows for a batch of millions. The merge folds those rows into a host dictionary keyed by the key value itself. Exact, order independent, and its size is the number of distinct groups.

Every path returns the groups in ascending key order, so the result is deterministic across batch layouts and runs. (This differs from pyarrow, which returns first-seen order; sort both sides before comparing.)

4.1 The resident group table

Sources/ArrowMetal/Stream/StreamGroupTable.swift. For one integer key with sum / count / mean aggregates, the global table is an open-addressing hash table in device memory that lives for the whole scan: one slot per distinct key, and per aggregate a 64-bit sum and a 64-bit count. Ten million groups with one aggregate is about 940 MB at a load factor of one third. Nothing crosses to the host until finish().

Publishing a 64-bit key with 32-bit atomics

Metal has no 64-bit atomic add or compare-exchange — checked on this M4 Max: atomic_ulong admits only fetch_min and fetch_max on device memory — and only guarantees memory_order_relaxed on the 32-bit ones. So the usual "claim the slot, then publish the key beside it" handshake is not safe: nothing orders the key's store against the claim, and a reader that sees a claimed slot may read a key that is not there yet. Kernels/HashTable.swift avoids that by storing row + 1 in the atomic and reading the key out of an array nothing writes, which a persistent table cannot do.

This table publishes the key through the atomics instead. A slot is three 32-bit words holding the key's 64 bits split 22 / 21 / 21, each stored as field + 1, so 0 means "not written" and every one of the 2^64 keys maps to three non-zero words. There is no reserved sentinel value, and so no key a caller may not use — 0, -1, Int64.min and Int64.max are ordinary keys, and the tests say so. Each word is written with compare_exchange(0 -> field + 1), which gives three properties:

  • a word only ever goes from 0 to its final value, and never changes again;
  • a thread accepts a slot only when all three words equal its own fields, and because those words are immutable once set, that decision is permanent and every thread agrees with it;
  • a thread that mismatches any word walks on, which is what linear probing wants it to do.

The case a naive scheme gets wrong — two threads with the same key racing on one empty slot — resolves without a spin (which can deadlock a divergent SIMD group) and without a duplicate slot: the loser's compare-exchange fails with the winner's value, which is its own field, so it reads the failure as a match and stops on the same slot. Two threads with different keys resolve the same way, because the loser sees a value that is not its field and walks on. A thread that wins word 0 and then loses word 1 to another key leaves a slot some other thread completes — the thread that won a word is still inside its own probe step and goes on to write the rest — so no slot is left half written when a dispatch ends.

Nothing in that argument needs an ordering Metal does not give, and nothing needs the keys in a dispatch to be distinct. So one thread per row is safe, which is what removes the per-batch dense encoding from a sparse, high-cardinality scan.

Three ways in

Path When Per batch
distinct keys the key column is dense enough for GroupByKeys' range scan (a thousand regions, a date column, dictionary codes) GroupByKeys gives one row per group, one insert dispatch places them, one accumulate folds them
rows, atomic sparse key, and every aggregate is a count or an integer sum / mean one thread per row inserts, one thread per row folds itself into its slot
rows, dense ids sparse key with a float64 or float32 sum in it the insert also stamps a batch-local id onto every slot it touched; the batch's aggregates then run on the ordinary GroupBy and fold into distinct slots

The chooser is one min/max over the key column per batch: a span small enough for GroupByKeys.rangeIsWorthIt keeps the per-batch encoding, because a thousand groups fit in threadgroup memory and beat a million atomic adds landing on a thousand slots; anything sparser goes row-level. Both give the same answer, so it is only ever a choice of plan.

The atomic accumulate builds a 64-bit add out of two 32-bit ones: add the low word, take the carry out of it into the high word. That is exactly two's-complement 64-bit addition, so it wraps like Int64 does, and being plain integer addition it is independent of the order the rows reach a slot in. Counts and integer sums are therefore bit-identical to the per-batch path and to the host table, run after run, whatever the batch layout — which the tests assert at cardinalities 1, 1k, 100k and 2M.

A float64 sum cannot take that path. There is no 64-bit atomic to compare-exchange a double into, and no emulation of one rounds a binary64 addition correctly or reproducibly. It takes the dense-id branch instead, which still never sorts the key column — the resident table is the dictionary — and keeps the correctly-rounded software adder d_add. The batch-local id is assigned by whichever thread first swaps this batch's stamp into a slot, so exactly one thread per slot per batch assigns one, and the ids are read back in a later dispatch, after the barrier that makes them visible. The cost is two uint arrays the size of the table (about 270 MB at ten million groups), allocated only when that branch is used.

The per-group float64 sum is one thread per group, not the one threadgroup per group the segmented reduction gives it: at a million groups of one row that was 256 threads per row, and three quarters of the batch. Its answer has to match the old one bit for bit, because a binary64 sum depends on the order it was added in and the host table is checked against it — and it does, because the shape of that reduction is fixed. Every butterfly step whose stride is at or above the run length does nothing, so a run of L rows only uses the first P lanes (P the power of two at or above L); and the butterfly over P lanes is the balanced tree whose leaves, left to right, are the lanes in bit-reversed order. Walking the lanes in that order and merging equal-rank partial results on a stack of at most nine rebuilds the same tree, including the rule that an empty accumulator is replaced rather than added to. O(L) per group, same additions, same order. Runs long enough that one thread each is the wrong shape keep the threadgroup version; the mean run length decides, and since both give the same bits it is only a choice of dispatch.

Growth

Growth doubles the table and rehashes on the GPU under the same claim rule, and the row path checks the load factor after its insert, when the number of new keys is known: the pre-emptive growth is sized by what the last batch added, and a batch that outruns it grows the table and re-runs the insert, which finds the keys that did land already present and claims the rest. Being wrong about the estimate costs one extra pass, never an answer.

min / max / variance, multi-column keys and non-integer keys keep the host table, and the tests check every resident path against it bit for bit — a null-key group, all-null groups, duplicate keys per batch, empty and ragged batches, growth across the rehash threshold, keys at both ends of the int64 range, and three integer key widths.

Both fold the same per-batch partials in the same order, one with Swift's + and one with d_add, so the float64 sums agree to the last bit.

4.2 Threshold pruning

Once a top-k (or an ORDER BY ... LIMIT n) has k rows resident, the k-th value is a lower bound on anything that can still enter the answer. Every later batch therefore starts with one comparison kernel over the key column; the rows that pass are gathered — usually none, or a handful — and only they reach the selection and the fold. The expected number of survivors in batch i of a scan for the top k is k / i, so the work per batch collapses to a single pass over one column.

Two details make it exact rather than merely close:

  • The comparison is >=, not >. A row that ties the running k-th value can still be the row the total order picks (topK breaks ties by row index), and for a multi-key sort it can still win on the second key. Keeping ties is what makes the pruned answer the answer.
  • A threshold is only taken when the k-th row's key is non-null and not NaN. Nulls sort last, so once k non-null rows are resident no null row can displace one; while fewer than k are, nothing is pruned. With a sort key's nulls first (§5), a batch that holds nulls is not pruned, and where the key's float order puts NaN ahead of the values the NaN rows pass the prune as well.

The fold runs on the GPU thread rather than the merge thread, which leaves the merge stage idle and means the threshold the next batch prunes with is always the newest one.


5. External sort

Each batch is sorted on the GPU (the existing radix argsort) and written to its own Arrow IPC stream file — one run per batch. finish() merges the runs.

The merge is on the CPU, deliberately. A k-way merge is a branch-per-row loop with no parallelism to give a GPU: at 20 runs the whole merge is one comparison of 20 heap entries per output row. What is on the GPU is the gather. For each output chunk the merge decides the order, then:

  1. the rows needed from each run are gathered with one take per run, giving rows grouped by run;
  2. one permutation take puts them back in merged order.

So no column data is ever rebuilt value by value on the host, and memory during the merge is one batch per run plus one output batch.

ORDER BY ... LIMIT n never gets here. With a limit the answer is n rows, so nothing has to spill: the running n rows stay Metal-resident and each batch folds into them on the GPU thread, under the threshold prune of §4.2. That is the same argument the bounded fan-in below already used — the global first n are inside the union of each part's first n — applied one batch earlier, where it is worth a thousand times more: a run used to be a whole million-row batch written to disk and read back, and now there are no runs at all. A single-key head uses the GPU top-k selection, which returns exactly the first n indices argsort would (same order-preserving key, same tie-break by row index), so it is a cheaper way to compute the same prefix and not a different answer.

Runs are merged with a bounded fan-in. 570 batches means 570 runs, and opening them all at once would want 570 file descriptors and 570 resident batches; instead the merge runs in passes of at most mergeFanIn (32 by default), writing intermediate runs and deleting their inputs as it goes, so both stay constant however many runs there are. A limit is applied to every pass — the global first n rows are always inside the union of each group's first n — which is what makes ORDER BY ... LIMIT cheap over hundreds of runs.

Each sort key carries the options of a plan sort key (Query engine, "Sort key options"): its direction, its null placement (nullsFirst, last by default) and, for a float key, its float order (ieee by default, total, nan_largest). The per-batch GPU sort and the CPU merge order by the same rules, so the runs and the merged output agree, and every NaN is placed as the order says (ieee: next to the nulls). ExternalSortOperator.Key(_:descending:nullsFirst:floatOrder:) in Swift, am_stream_sort_ex in the C ABI and Stream.sort(..., null_placement=, float_order=) in Python.

Sort keys use the GPU radix sort for numeric, boolean and temporal columns. utf8 and binary columns have no order-preserving GPU key in the streaming sort, so they fall back to a host sort of the string values (correct, but the one place a sort touches every row on the CPU).

6. Streaming quantiles

An exact quantile needs the whole column ordered, which an out-of-core query cannot afford. Instead a mergeable digest keeps a bounded set of weighted centroids: each says "about w values sit near v". Two digests merge by concatenating their centroids, ordering them and compressing back to the budget, which is associative and order independent.

Per batch the GPU does the work: sort the non-null values with the radix argsort, then gather compression sample points with one take. The sample positions are not uniform in rank — they follow the inverse of the t-digest k1 scale function k(q) = (C / 2π) · asin(2q − 1), which packs samples into the tails, where a uniform sample is least accurate. The merge re-compresses under the same scale function, so a centroid near q = 0.99 keeps far less weight than one near q = 0.5.

A centroid covering rank interval [r0, r1) answers any quantile inside it with rank error at most (r1 − r0) / 2: about n / C at the median and n·π / (2C²) near the extremes, so p99 is roughly C times more accurate than the median. The tests assert a rank error under 1 % at C = 1000 across p1 … p99.

7. HyperLogLog on the GPU

Sources/ArrowMetal/Stream/HyperLogLog.swift. Every row is hashed to 64 bits (splitmix64 for fixed-width values, FNV-1a then splitmix64 for bytes); the top p bits pick a register and the position of the first 1 in the rest updates that register with a max. One thread per row, atomic_fetch_max_explicit on a 2^p-entry uint array in device memory (64 KB at p = 14). Contention is low: the hash spreads rows across 16,384 registers and a max only ever moves a register upward, so most atomics are no-ops the hardware resolves without a retry.

Two sketches merge by element-wise max, which makes the result independent of batch boundaries and batch order — the tests assert bit-identical registers for a whole-array sketch and the same data split into 17 chunks. The estimator is the standard one with linear counting below 2.5·m.

-0.0 and 0.0 hash the same; nulls are skipped, as Arrow's count_distinct does.

8. Streaming joins

  • Broadcast — the build side is read once into one Metal-resident batch and every probe batch runs the existing GPU hash join against it, straight to the sink. Memory is the build side plus one batch. The right plan whenever one side fits, however large the other one is.
  • Grace hash — both sides are streamed once and partitioned by ((key · 0x9E3779B97F4A7C15) >>> (63 − log₂P)) & (P − 1) into P Arrow IPC files per side. The hash takes the high bits of the product so an id column that is a multiple of P does not land entirely in one partition. Equal keys always land in the same partition, so the join of the whole is the union of the per-partition joins, and each partition is small enough for the in-memory GPU hash join. Two passes over each input plus one write and one read of each; memory is one partition pair. Row order differs from an in-memory join, as it does for any hash join; the test compares the result as a set of pairs.

Join keys are int32 or int64 on both sides, which is what the GPU hash join takes. Null keys never match.

8.1 A join followed by an aggregate is one kernel

Sources/ArrowMetal/Stream/StreamJoinAggregate.swift.

A join -> sum used to be two operators with a whole joined table between them: every probe batch ran the hash join, gathered every column of both sides into a new record batch, wrote it to a sink, and the caller summed the rows it got back. The joined rows are the largest thing in that pipeline and none of them is the answer.

try StreamQuery(ipc: path).filter(col("region") < 50)
    .join(dim, on: "region", buildKey: "region")
    .sum("amount")
am.scan_ipc(path).filter(am.col("region") < 50).join(dim, on="region") \
  .agg([("sum", "amount", "total"), ("sum", "weight", "w")])

The terminal picks the plan. .sink / .collect still stream the joined rows out; .sum, .aggregate and .groupBy fuse:

  1. The build table is built once. MetalRecordBatch.join builds its hash table per call, which for a streamed join is per batch. The build side does not change across a scan, so BroadcastBuildTable builds it at construction with the same hj_clear / hj_build kernels and every probe batch reads it.
  2. The filter projects down to what the kernel reads — the join key plus the probe-side value columns. A filtered join used to gather every column of the surviving rows, including a utf8 one.
  3. jfa_probe probes, gathers and accumulates in one kernel. One thread per probe row walks its bucket chain and, for each match, reads the aggregate's value — from the probe row it is standing on, or from the build row the chain points at — and folds it into thread registers. A threadgroup tree reduction turns 256 of those into one partial per accumulator, and jfa_fold folds the partials into a device-resident accumulator pair that lives for the whole scan. Nothing proportional to the number of matches is allocated, nothing is gathered, and nothing crosses to the host until finish() reads sixteen bytes per aggregate. The merge stage does nothing.

Every accumulator is a raw ulong — a signed sum, an unsigned sum, or an IEEE-754 binary64 bit pattern folded by the correctly-rounded software adder d_add, since Metal has neither a double type nor a 64-bit atomic add. The kernel is generated per (key type, aggregate list) and unrolled, so there is no per-aggregate branch in the probe loop. A slot's count doubles as its "is empty" flag, which is what makes min / max free of a per-type sentinel and an all-null input come out as null rather than as an infinity.

sum, count, min, max and mean fuse; up to seven of them at once (Metal binds 31 buffers and the probe itself needs nine). A column name is resolved against the probe side first and then the build side, so sum("weight") reaches a build-side column; a build column shadowed by a probe column of the same name is reachable as name_right, matching the materialising join's output names.

The grouped form (join(...).groupBy(keys, aggs)) keeps the join's index pairs, because a per-group accumulator needs a table probed once per matched pair, and the resident group table's race-free insert relies on every key in a dispatch being distinct (§4.1), which rows are not. It gathers only the key columns and the aggregated values of the matched pairs — two or three columns, never the joined batch's eight — and folds those into the ordinary streaming group-by, whose global table is already resident. A key may come from either side: a probe-side key is gathered with the left index array, a build-side key with the right one.

Semantics are the inner join's and match Polars and DuckDB: duplicate build keys multiply rows, a null key on either side never matches, and a probe row with no match contributes nothing. A left join followed by an aggregate raises, naming what to use instead, rather than returning a number that counts unmatched rows wrongly; so do variance, stddev, count_distinct_approx, a grace hash join, and a non-numeric value column.

Tests/ArrowMetalTests/StreamJoinFusionTests.swift checks the fused answer against the in-memory join followed by the in-memory aggregate — bit for bit for integer aggregates, within one ulp per element for float64 sums — over duplicate build keys, null keys on both sides, empty batches, a batch with no matches at all, a build side of one row and of a million, a group-by key from each side, and int32 and int64 keys.


9. Numbers

Measured on an M4 Max (16 cores, 64 GB unified memory, internal SSD), Swift release build, over a 30.21 GB Arrow IPC directory: 570,000,000 rows, 30 files, 570 record batches of 1M rows, 8 columns (id int64 unique, region int32 with 1000 values, bigkey int64 with 10M values, label utf8 with 50 values, amount float64, qty int32, ts int64, flag int8). Generated and run by Benchmarks/streaming_bench.py, which runs every (workload, engine) cell in its own subprocess so peak RSS is that engine's alone and an out-of-memory kill costs one cell rather than the run.

The machine has 64 GB of RAM and the dataset is 30 GB, so after the first pass it is in the page cache: the GB/s figures are memory bandwidth as much as SSD bandwidth, and they are the same for every engine.

The row-level group-by: 4 GB, before and after

Measured on a 4.03 GB run of the same generator (--size-gb 4: 76,000,000 rows, 4 files, 76 batches of 1M rows) with readers = 2, best of three interleaved A/B rounds — before, after, then the reference engines, three times round, one cell process at a time, same dataset and same page cache, and only the loaded library changes between "before" and "after". "before" is the per-batch dense encoding of the previous section; "after" is the row-level path of §4.1.

Workload before after Polars DuckDB RSS before RSS after Polars RSS DuckDB RSS
group by bigkey -> sum (10M groups) 2,156 614 393 638 4.12 GB 4.74 GB 2.90 GB 4.63 GB
group by region -> sum, count (1k groups) 199 200 106 240 2.30 GB 2.32 GB 2.32 GB 0.86 GB

groupby_10m is 3.5x faster and is now ahead of DuckDB; it was 5.5x behind Polars and is 1.6x behind. groupby_1k is unchanged to the millisecond, which is the point of the chooser: a thousand contiguous values is exactly the shape the range encoding is for, the row path is never taken, and nothing about that workload moved.

Where the batch's time went, per stage, at 76 batches:

Stage before after
read (two reader threads) 0.40 s 0.43 s
GPU stage 2.05 s 0.27 s
merge stage 0.23 s 0.50 s
merge stall (GPU stage waiting on the merge queue) 0.00 s 0.22 s
overlap 1.26x 2.07x

The work moved rather than only shrank: the resident table can only be touched from the merge stage, so the insert, the aggregates and the fold all run there now and the GPU stage is left with the read and the filter. Total GPU-side work went from 2.28 s to 0.77 s. What remains in the merge is the insert of a million rows into a 33-million-slot table, the batch's GroupBy, and the fold.

Two intermediate points, measured the same way on the same dataset, showing what each half bought:

group by bigkey wall merge stage
per-batch encoding (before) 2,156 ms 0.23 s
row-level, dense ids, segmented sum 1,523 ms 1.47 s
row-level, dense ids, one thread per group 614 ms 0.50 s
the same query as count(*) (atomic path) 456 ms 0.31 s
the same query as sum(qty), int32 (atomic path) 330 ms 0.22 s

The last two lines are the point of the atomic path: with an integer aggregate the batch never builds dense ids, never sorts anything and never runs a second kernel over its groups, and the whole query is read-bound. A float64 sum pays for the dense ids and the per-group reduction because Metal has no 64-bit atomic to fold a double with.

Peak RSS rises by 0.62 GB on groupby_10m, and that is the price of the dense-id branch: two uint arrays the size of the table (2 x 134 MB at 33.5 M slots) plus the batch's segment arrays. The atomic path allocates neither. Nothing else in the table moved.

The earlier measurement: 8 GB, before and after the resident-state work

The 30 GB table this section used to carry is kept below, because it is the size at which the read stops being the limit. The current numbers are from an 8.06 GB run of the same generator (--size-gb 8: 152,000,000 rows, 8 files, 152 batches of 1M rows) with readers = 2, best of three interleaved A/B rounds in which only the loaded library changes — same benchmark script, same dataset, same page cache, one cell process at a time. "before" is main; "after" is §4.1, §4.2 and §5. Wall-clock milliseconds, then peak RSS.

Workload before after Polars DuckDB RSS before RSS after Polars RSS DuckDB RSS
sum(amount) where region < 100 325 324 158 230 2.30 GB 2.30 GB 1.86 GB 0.97 GB
group by region -> sum, count (1k groups) 509 513 164 268 2.52 GB 2.52 GB 2.26 GB 1.08 GB
group by bigkey -> sum (10M groups) 7,629 4,110 743 864 5.14 GB 4.14 GB 5.31 GB 8.04 GB
top 100 by amount 527 386 176 240 2.51 GB 2.51 GB 1.79 GB 0.81 GB
count_distinct(id) 329 331 694 223 2.30 GB 2.30 GB 4.78 GB 0.79 GB
order by amount desc limit 1000 2,297 524 177 241 3.85 GB 2.52 GB 2.91 GB 0.83 GB
broadcast join + sum 564 562 281 244 2.47 GB 2.48 GB 2.15 GB 0.95 GB

sort_limit is 4.4x faster and spills no runs at all (peak RSS falls by 1.3 GB with them), groupby_10m is 1.86x faster on a gigabyte less memory, topk is 1.4x faster. filter_sum and count_distinct are unchanged within noise, which is the point: they were already right.

The four cross-checked workloads (filter_sum, groupby_1k, topk, sort_limit) are compared against Polars' answer with a 1e-9 relative tolerance on float sums, and all four pass.

Where the pipeline's time goes now

overlap is 1.0 for a serial pipeline, and above that by however much the stages ran at the same time. With readers = 2 the read column is the sum across both reader threads, so it can exceed wall on its own. The stall columns are the GPU stage waiting: for a batch the readers had not finished, and for the merge queue to drain.

Workload Overlap Read (s) GPU (s) Merge (s) before Merge (s) after Read stall (s) Merge stall (s) Wall (s)
filter_sum 2.19x 0.57 0.09 0.00 0.00 0.21 0.00 0.32
groupby_1k 2.58x 0.56 0.48 0.30 0.22 0.02 0.00 0.51
groupby_10m 1.24x 0.72 4.01 2.35 0.34 0.01 0.00 4.11
topk 2.60x 0.60 0.34 0.48 0.00 0.03 0.00 0.39
count_distinct 2.19x 0.57 0.09 0.00 0.00 0.21 0.00 0.33
sort_limit 2.13x 0.57 0.49 1.19 0.00 0.01 0.00 0.52
broadcast_join 2.42x 0.62 0.29 0.00 0.00 0.02 0.00 0.56

The merge stage is zero on every operator that had one, which was the point: nothing folds a batch's answer on the host any more except the host group-by table (§4), and that table is only reached by the keys and aggregates the resident one does not take. The merge stall is 0.00 s everywhere, so the bounded queue between the GPU stage and the merge never fills, and peak RSS stays flat at 2.3 to 2.5 GB over 8 GB of data.

Where the CPU engines are ahead, and why.

  • filter_sum and count_distinct are read-bound at readers = 2. Their GPU stage is 0.09 s of a 0.32 s wall and the read stall is 0.21 s of it, so what is being measured there is two reader threads moving 8 GB against Polars reading the same directory with every core at 51 GB/s. The 30 GB numbers below, taken with readers = 8, are what the same operators look like once the read is not the limit.
  • groupby_10m was still 5.5x behind Polars at this point, and the reason was precisely located: the merge was 0.34 s, but the GPU stage was 4.0 s, and essentially all of it was the per-batch GroupByKeys that turns a million int64 keys into dense ids. bigkey spans ten million values over a million-row batch, too sparse for the range-encoding path, so every batch paid a full radix sort of its key column plus an atomic group-by min to pick each group's representative row — with a host loop over that batch's ~950,000 groups inside it. That is what the row-level path of §4.1 removed; the 4 GB table above is the result.
  • groupby_1k, topk, sort_limit and the join are within 2x to 3.2x of Polars and DuckDB, and are read- or fixed-cost-bound rather than dominated by any one stage.
  • The broadcast join is unchanged because the benchmark streams its joined rows back to Python and sums them there. Fusing the aggregate into the probe, so the join's output never leaves the GPU, is measured separately below.

The fused join + aggregate, measured

A separate 4.03 GB run (--size-gb 4 --ipc-format file: 76,000,000 rows, 4 files, 76 batches of 1M rows) with readers = 2, best of three interleaved rounds, one cell process at a time. Both sides are the same binary over the same dataset in the same run: broadcast_join_unfused is the old plan (gather every matched pair into a record batch, collect it, sum it in Python) and broadcast_join is the fused one (§8.1). Both compute sum(amount) from the probe side and sum(weight) from the build side after region < 50, and both agree with Polars to the last two digits of a double (the benchmark's own cross-check passes on every round).

Cell Wall Peak RSS GPU stage Merge stage Read stage Read stall
broadcast_join unfused 379 ms 2,462 MB 0.14 s 0.00 s 0.325 s 0.03 s
broadcast_join fused 336 ms 2,283 MB 0.04 s 0.00 s 0.315 s 0.13 s
Polars 233 ms 1,836 MB
DuckDB 229 ms 862 MB

The GPU stage is 3.5x smaller — 0.14 s to 0.04 s — which is what the fusion did: the gather of every column of every matched pair is gone, and so is the joined batch. Peak RSS falls by 180 MB and host CPU by 170 ms (698 to 529 ms), because nothing is collected and nothing is summed in Python. (The fused GPU stage measures between 0.04 and 0.09 s across rounds; the spread is the one-off compile of the generated kernel on the first batch, which the unfused path does not pay because its kernels are already in the pipeline cache. The unfused stage is 0.14 to 0.15 s in every round.)

The wall clock only moves from 379 ms to 336 ms, and Polars and DuckDB are still ahead, because at readers = 2 this cell is read-bound. The read stage is 0.315 s of a 0.336 s wall and the GPU stage waits 0.13 s of it for a batch the two reader threads had not finished: what is being measured is two threads moving 4 GB against Polars reading the same directory with every core. One supplementary round at readers = 4 shows the operator with the read out of the way — 359 ms unfused to 264 ms fused, a 1.36x speed-up (GPU 0.147 s to 0.043 s), which brings it to within 15 % of DuckDB's 229 ms; the cost is peak RSS, 4.4 to 4.9 GB, because four part files are mapped at once.

The earlier 30 GB measurement

Kept because 30 GB with readers = 8 is the size at which the read stops being the limit and the per-batch work shows through. Same M4 Max, a 30.21 GB directory: 570,000,000 rows, 30 files, 570 batches of 1M rows.

Workload ArrowMetal (before) Polars DuckDB ArrowMetal RSS Polars RSS DuckDB RSS
sum(amount) where region < 100 527 515 418 8.9 GB 2.0 GB 1.1 GB
group by region -> sum, count (1k groups) 1,765 554 625 9.5 GB 3.1 GB 1.3 GB
group by bigkey -> sum (10M groups) 19,946 4,031 5,232 12.4 GB 13.1 GB 25.7 GB
top 100 by amount 4,060 587 544 8.4 GB 2.5 GB 0.9 GB
count_distinct(id) 532 3,825 451 8.8 GB 17.0 GB 0.9 GB
order by amount desc limit 1000 11,121 642 567 9.3 GB 2.7 GB 0.9 GB
broadcast join + sum 1,807 764 423 9.2 GB 2.3 GB 1.1 GB

The resident top-n and threshold pruning of §4.2 and §5 were measured once on that dataset before it was deleted, in a single interleaved A/B round with readers = 8, printed to a tenth of a second:

Workload before after
top 100 by amount 4.7 s 0.8 s
order by amount desc limit 1000 11.2 s 1.1 s
group by region (1k groups) 1.3 s 1.0 s
sum(amount) where region < 100 0.5 s 0.5 s
count_distinct(id) 0.5 s 0.5 s
broadcast join + sum 1.7 s 1.6 s

At that size top-k and ORDER BY ... LIMIT come within 1.4x-1.9x of Polars and DuckDB — 0.8 s against 0.587 and 0.544, 1.1 s against 0.642 and 0.567 — down from 4.7 s and 11.2 s, because 30 GB is where the per-batch work, not the read, was the limit. The 30 GB run predates the resident group table (§4.1); groupby_10m with that table is measured at 8 GB and 4 GB in the tables above.

pyarrow.dataset (measured in a separate single-reader run, so not comparable cell by cell) finished groupby_10m in 557 s and timed out at 900 s on count_distinct.

  • count_distinct_approx is 7x Polars (532 ms against 3,825 ms) at 8.8 GB of peak RSS against Polars' 17.0 GB, and its own running state is a 16 KB sketch. DuckDB's sketch is faster still, but its answer is 12.4 % off against ArrowMetal's 0.007 % — DuckDB's approx_count_distinct is tuned for a much smaller sketch. Against an exact count this is the operator where streaming on a GPU clearly pays.
  • filter_sum ties Polars at 57 GB/s — a whole-dataset filtered aggregate over 570 million rows in half a second, with the CPU doing nothing but reading bytes.

Accuracy of the two approximate operators

Row Exact ArrowMetal Error
count_distinct(id) 570,000,000 569,958,240 0.0073 % (the p = 14 bound is 0.81 % standard error)
quantile(amount, 0.5) 500.000 499.989 rank error < 0.01 %
quantile(amount, 0.99) 990.000 989.988 rank error < 0.01 %

DuckDB's approx_count_distinct on the same column: 640,524,923, an error of 12.37 %.

The overlap, measured

prefetch=0 makes the read stage run inline on the GPU thread — the same work, serialised. Same process, same page-cache state, one reader thread on both sides, best of two alternating runs each, on a 30 GB dataset:

Query prefetch=0 (serial) prefetch=3 (pipelined) Speed-up Overlap serial -> pipelined
filter + sum 3.57 s 2.83 s 1.26x 1.00x -> 1.14x
group by region 8.00 s 4.93 s 1.62x 1.00x -> 1.82x
top 100 7.17 s 3.52 s 2.04x 1.22x -> 2.54x

filter + sum gains least because its GPU stage is only 0.5 s against a 3 s read: there is barely anything to hide. top 100 gains most because all three stages are busy. Going from one reader thread to eight is a second, larger step on top of this: filter_sum went from 2.83 s to 0.53 s and count_distinct from 2.36 s to 0.53 s once the read stopped being the bottleneck.

What the memory figures mean

ArrowMetal's peak RSS at readers = 8 is around 9 GB against Polars' 2 GB, and almost all of the difference is mapped file pages: eight readers hold eight ~1 GB part files mapped at once, and ru_maxrss counts those. They are page cache, not anonymous memory — the kernel reclaims them under pressure — but the number is real and it is the price of the parallel read. With readers = 1 the same queries run at 1.2 GB of RSS (and take two to five times as long). The private state is what §4 lists: a few scalars, a 16 KB sketch, k rows, or the group table.

10. Limits

  • readers > 1 does not preserve batch order, so a row-order-preserving sink_ipc or to_reader needs readers: 1 (the default).
  • Threshold pruning is exact but not free of assumptions: it compares against the running n-th value with >=, which needs a comparison kernel for the key column's type. Types without one (utf8, binary, boolean, decimals) simply run unpruned — correct, just not faster.
  • Sort keys on utf8 / binary columns fall back to a host sort per batch. Numeric, boolean and temporal keys use the GPU radix sort.
  • count_distinct_approx is a whole-dataset aggregate, not a per-group one; a per-group HLL would need one sketch per group and is not implemented.
  • variance / stddev are not available in the dense-key GPU group-by path (they need a third accumulator array); the arbitrary-key path has them.
  • Grace join keys must be integers.
  • The IPC sink writes the stream encapsulation, not the random-access file format: the file footer's block index cannot be built incrementally without reaching into IPCWriter. Every reader that matters (pyarrow, Polars, this package) reads it.
  • Dictionary-encoded columns are decoded by the sink and by the merge helpers. One dictionary per column for a whole stream is the only form the writer emits, which an incremental sink cannot promise.
  • A Stream is single use: a terminal consumes the source. Open a new scan for a second question.
  • A group-by over ten million groups is 1.6x behind Polars (§9), down from 5.5x. The rows go straight into the resident table now, so there is no per-batch encoding left to remove; what is left is the insert itself, the batch's GroupBy and the fold, all in the merge stage, which the GPU stage then waits 0.22 s on.
  • A float64 sum cannot use the row-level atomic accumulate, because Metal has no 64-bit atomic add or compare-exchange (atomic_ulong offers only fetch_min/fetch_max) and no emulation of one rounds a binary64 addition correctly. It takes the dense-id branch of §4.1 instead, which costs a second pass over the batch's groups and two uint arrays the size of the table. Counts and integer sums skip all of that; on the same query and dataset that is 330 ms against 614 ms.
  • The row-level path's float64 sum is deterministic but not associative-order-free: a group's rows are added in a fixed order that depends on how the batches fell, so the same dataset read with a different batch_rows can differ in the last bits, exactly as the per-batch path always could. Integer sums and counts have no such dependence — they are 64-bit integer adds and commute — so they are bit-identical across paths, batch layouts and runs. There is no non-deterministic option to turn on.
  • The resident group table takes one integer key and sum / count / mean. min / max (they would need a per-slot atomic minimum), variance (a third accumulator), multi-column keys and non-integer keys all keep the host table, which is exact but folds one row per group per batch on the CPU.
  • A fused join + aggregate is an inner broadcast join only. join(...).sum(...) and join(...).groupBy(...) fuse (§8.1); a left join followed by an aggregate raises, because it would have to count unmatched probe rows and that is not implemented, and so does a grace hash join, which writes its partitions to disk and has no fused form. Both name the plan to use instead. variance / stddev / count_distinct_approx and non-numeric value columns raise too.
  • A fused aggregate takes at most seven aggregates. Metal binds 31 buffers and the probe needs nine of them, three per aggregate after that.
  • The dense-id branch costs about 270 MB at ten million groups — two uint arrays the size of the table — which is why groupby_10m's peak RSS rises by 0.62 GB.
  • Each batch is one Metal command buffer, not several. The fixed cost per batch is paid once per batch; a larger batch_rows amortises it, at the cost of memory per batch.
  • readers > 1 raises peak RSS by roughly readers x the part-file size, because that many files are mapped at once. Those pages are reclaimable page cache, not anonymous memory, but they do show up in ru_maxrss.
  • am_stream_query supports filter + project and filter + aggregate; a grouped query goes through am_stream_group_by, because the Expr compiler's group_by takes a dense integer key and the streaming group-by takes arbitrary key columns. A joined query has no s-expression form either: it goes through am_stream_join_aggregate / am_stream_join_group_by, which take the build side as an Arrow C Stream.
  • Arrays above 2³² rows are still refused (Dispatch.checkLength), which bounds one batch, not a dataset.
An open invitation

Join forces.

ArrowMetal is its own project: an Apache-2.0 compute library for the GPU every Apple silicon Mac already has, Arrow at every edge, measured on every operation. It is not a fork of anyone and not a plugin for anyone. Unified memory, though, is a door that stands open for every engine and library that runs on a Mac, and it is more useful with company. Below is what already runs against the engines and libraries we measure, and what each path costs today. If you work on one of them, or on something we have not named, the kernels and the C ABI are the same for you.

Polars

A Metal engine behind collect()

Polars hands a lazy plan to cuDF with engine="gpu", on NVIDIA. On a Mac that engine has nowhere to go. ArrowMetal's lazy engine takes the same shape of plan and runs it on Metal.

What runs today

  • Tier 1, the bridge. am.from_polars(df) in, am.to_polars(...) out, zero-copy for numeric columns, a conversion for strings, plus .arrowmetal namespaces on Series, DataFrame and LazyFrame.
  • Tier 2, expression plugin. A Rust plugin registers ArrowMetal kernels as Polars expressions, so pl.col("v").arrowmetal.sum() runs inside a normal query.
  • Tier 3, the hand-off. lf.arrowmetal.collect_gpu(q): Polars runs the plan, its streaming engine included, so projection and predicate pushdown happen first; then ArrowMetal runs one GPU pass over the collected result.
  • 53 / 38 / 72 tests against native Polars, DuckDB and pandas respectively, in the test suite; the Polars plugin tests run when it is built.

Measured, 50M rows, Apple M4 Max, against Polars 1.44.1 in its eager idiom

operationPolars, msnamespace (tier 1), msGPU resident, ms
group-by sum, 1000 keys (resident: aggregate only, group ids already built)79.320.1 (4.0x)1.9
group-by sum, 1000 keys, whole pass (matrix row)82.4—4.89 (16.9x)
string contains, literal634.1290.7 (2.2x)7.1 (89.3x)
top_k(100)60.018.2 (3.3x)10.5 (5.7x)
filter + sum, fused4.313.9 (0.3x)0.8 (5.1x)
sum(Int64)4.110.1 (0.4x)1.1 (3.8x)

Wall time. The first group-by row's resident cell times the aggregate alone, with the group ids built beforehand, and prints no ratio, because Polars' figure on that row is its whole hash group-by and the two are not like for like; its namespace cell is a whole pass and does. The row under it is the matrix's whole group-by, group-id pass included, with the column already in Metal memory, read from the current parallel CSV rather than the eager run this card's other rows come from: 4.89 ms against Polars' 82.4 ms eager, 16.9x, and against Polars' lazy engine on the same row, 81.93 ms, 16.8x. Against the fastest CPU idiom any library has for it — pyarrow Acero, 16 batches — it is 3.8x, the figure the home page quotes and the one to use. That row costs Polars 1,193 CPU-ms across 16 threads against 1.2 CPU-ms here; the CPU-ms of every row, and the tier 2 plugin column, are in docs/POLARS.md and the matrix CSV. The middle column is what a Polars user pays through the .arrowmetal namespace: every namespace or plugin call imports the 50M-row column first, 5.3 ms for an Int64 column, so on the two lightest rows Polars is ahead and the three heavier rows are where the bridge pays off. The right column is the same kernels with the data already on the GPU, which is what an engine behind collect() would get without the import. These rows are against Polars' eager idiom, the numbers docs/POLARS.md recorded; the matrix also measures Polars' lazy engine on the same answers, and the Compare tab has that column beside pyarrow's Acero, with the cores each idiom used. On several of the rows above the lazy engine is the faster Polars, so read these as the hand-off story, not as the current speedup.

The Polars engine

Tier 4 is a Polars engine: lf.collect(engine=am.MetalEngine()) translates the parts of the optimised plan that read in-memory frames (filter, projection, slice, sort, group-by, aggregate, inner, left, semi and anti joins, unique) into ArrowMetal plans through Polars' post-optimisation callback and leaves every other node to Polars, checked against Polars' own answers (docs/POLARS.md, tier 4). One fact about polars 1.44.1: Rust invokes that callback only for its four engine names, so a third-party engine runs under one of them and cannot carry its own name through explain or the callback's error message.

DuckDB

A GPU operator inside the extension

DuckDB already runs on every Mac. ArrowMetal has a loadable extension and, since 0.2.0, an optimizer extension that runs eligible aggregates of unchanged SQL on the GPU with DuckDB's exact answers.

What runs today

  • Tier 1, Python bridge. A DuckDB result becomes GPU columns without a copy when DuckDB returns a single chunk; GPU results register back as DuckDB views; a fused query runs over a DuckDB result.
  • Tier 2, loadable extension. Table functions that run ArrowMetal aggregates, group-by, top-k, sort and a whole plan from SQL and return ordinary relations, built with duckdb-extension/build.sh.
  • Tier 3, the optimizer extension. SET arrowmetal_rewrite = 'auto': integer sum, avg, min, max and count over projections and filters of a table scan, with no key or one key, replaced by a GPU aggregate with DuckDB's exact answers; everything else stays DuckDB's (docs/DUCKDB.md §4b).
  • 27 tests against DuckDB SQL plus 11 through the extension, which run when the extension is built, and the public C header compiled as C in the test suite.

Measured, 50M rows, Apple M4 Max, against DuckDB 1.5.5 in its own default execution

operationDuckDB, msCPU-msGPU resident, msGPU + crossing, ms
group-by sum, 100k keys71.41,0564.5 (16.0x)65.9 (1.1x)
count(s LIKE '%user1%')47.47195.1 (9.3x)84.7 (0.6x)
group-by sum, 1k keys7.61122.1 (3.6x)68.8 (0.1x)
filter + sum, fused1.1141.1 (1.0x)40.6 (0.0x)
sort39.257017.6 (2.2x)57.3 (0.7x)

The last column is the GPU time plus the crossing from DuckDB's vectors into Arrow and back, 40.6 to 84.7 ms on the rows above. Crossing one 50M-row BIGINT column costs 52.1 ms on its own (to_arrow_table, combine_chunks and the Metal import, which shares the buffer rather than copying it), so a one-shot query through the bridge is 1.1x on the 100k-key group-by and behind DuckDB on the other four rows; DuckDB's own SQL is ahead of the extension as well, measured separately in section 4 of the DuckDB doc. Resident, the same kernels are 1.0x to 16.0x; the crossing takes that back. Inside the engine there is no crossing. These rows are against DuckDB's own default execution, which is already multi-threaded; the Compare tab is where Polars' lazy engine and pyarrow's Acero are measured against the same kernels, with the cores each one used.

What DuckDB's C API lacks

The optimizer extension is C++ against DuckDB's internal headers, because the C extension API has no optimizer hook. With one, the same rewrite could be built and distributed like the loadable extension.

pandas

An engine option pandas already has the door for

pandas is where a great deal of analytics code on a Mac already lives, and it already accepts engine="numba" on group-by and rolling operations. The same door, opened for a GPU engine, is the whole ask.

What runs today

  • Tier 1, the .am accessor. df["price"].am.sum(), df.am.groupby("symbol").sum("size"), df.am.merge(...): explicit, one method at a time, on any frame.
  • Tier 2, accel mode. arrowmetal.pandas_accel.install() patches the hot paths so unchanged pandas code routes group-by, sort, top-k, merge and string scans to the GPU and leaves everything pandas already does at memory bandwidth alone; uninstall() restores every slot.
  • 72 tests against plain pandas across three storage flavours (numpy-backed, Arrow-backed, masked), with nulls, NaNs, strings and duplicate keys; the accessor never changes an answer, and the one deliberate difference is documented.

Measured, 50M rows, Arrow-backed frame, Apple M4 Max, against pandas 3.0.5

operationpandas, msaccel mode, msspeedup
str.contains492.618.826.2x
groupby-sum, 1k keys504.335.614.2x
merge, 100k int keys1,258.5134.89.3x
nlargest(100)264.029.09.1x
isin, 10 values337.439.38.6x
sort_values (int64)2,312.1305.37.6x
sum (int64)4.94.81.0x, left to pandas

These include the conversion the dtype forces, the map into Metal, the GPU work and the trip back into a pandas object, so they are what a pandas user gets, not kernel times. On a numpy-backed frame the speedups are smaller, 1.16x to 14.22x at 50M on the rows accel mode routes to the GPU, because every column is converted first; the full tables for both frames are in the pandas page of the docs. pandas' threaded paths, numexpr and numba, were not installed for this run, so its pandas-parallel rows say so and these are pandas in its eager idiom; the Compare tab has the threaded idioms measured for Polars and pyarrow.

Where one step on the pandas side goes a long way

Accel mode works by patching, and patching is fragile across releases. pandas already has the shape of the fix: engine="numba" on groupby.agg, rolling and apply. An engine= registry that a third party can add to, covering group-by, sort, merge and the string methods, ends the patching and gives every pandas user on a Mac a GPU with one keyword. Arrow-backed dtypes as the default, which pandas 3 has started with strings, makes the zero-copy path the common case.

numpy

The array most Python analytics starts from

A large numpy array on macOS is page aligned, so it becomes an Arrow array through pyarrow without a copy and lands in Metal memory without a copy: the GPU reads the bytes numpy wrote, and a result comes back as the same buffer. The test suite checks both at 1,000,000 elements: a write to the numpy array after the import is visible through the Metal column, and the exported buffer carries the numpy array's address.

What runs today

  • Copy-free in and out. am.array(pa.array(x)) for integer and float dtypes; the same path under a numpy-backed pandas column through the .am accessor and accel mode.
  • Measured directly. The matrix times numpy itself on every operation where pandas has no vectorised idiom of its own: 40 rows in the current CSV, shown in the pandas / numpy column of the Compare tab and marked.

Measured, largest size in the matrix, Apple M4 Max, against numpy 2.5.3

operationnumpy, msArrowMetal, msspeedupvs fastest CPU idiom
sqrt (float64)12.4 1.0c3.924 0.4 CPU-ms3.2x0.87x Polars lazy
sin (float64)301.3 1.0c150.0 0.4 CPU-ms2.0x0.16x Polars lazy
case_when (2 conditions)199.1 1.0c6.921 1.4 CPU-ms28.8x0.94x Polars lazy
if_else (bool ? int64 : int64)14.6 1.0c3.745 1.0 CPU-ms3.9x1.4x Polars lazy
argsort int644,545 1.0c39.1 0.9 CPU-ms116.1x7.7x Polars
sort float641,759 1.0c32.1 1.3 CPU-ms54.8x4.1x Polars
filter two columns + sum111.9 1.0c3.526 1.7 CPU-ms31.7x0.83x Polars lazy
compare + filter + take202.0 1.0c4.545 1.1 CPU-ms44.4x10.1x Polars

Wall time of the whole call, with the array already in memory on both sides. The small number after numpy's time is the cores that call used; ArrowMetal's column prints its processor time instead, because the work happened on the GPU and the cores figure would round to zero. numpy runs each of these on one core: its ufuncs are single-threaded, and the matrix's numpy-parallel rows say so, so the speedup column is one core against the GPU, with unified memory making the crossing free. The last column is the same row against the fastest CPU idiom any library has for it, the figure the Compare tab uses; where it is below 1x the row is listed there under to improve, and only a row that clears 3x there is set in bold here.

numpy dispatch

numpy already dispatches: NEP 18's __array_function__ lets an array type answer np.sum, np.sort or np.where on its own memory. ArrowMetal does not implement NEP 18 dispatch; the hook on numpy's side has been there since 1.17.

Also welcome

The rest of the Arrow world

Anything that speaks Arrow can reach ArrowMetal through the C Data and C Device interfaces, the C ABI or the JSON plan interface: engines, storage layers, dataframe front ends and array frameworks that share the same unified memory. nanoarrow implements ARROW_DEVICE_METAL for memory, experimentally: buffers, alignment and copies, no compute; ArrowMetal speaks the same interface with kernels behind it.

Analytics teams

Run it on your data and tell us what happened

The numbers on this site are from an Apple M4 Max over generated columns. Run the matrix on a table you use, and send the CSV back with the machine it ran on; rows from your data go into the tables here.

git clone https://github.com/singhpratech/ArrowMetal && cd ArrowMetal
swift build -c release --product ArrowMetalC
PYTHONPATH=python python Benchmarks/full_matrix.py --quick     # 1M rows, a few minutes
# the CSV lands in Benchmarks/results/; attach it to an issue

What you get

  • Copy-free export, and copy-free import for page-aligned buffers, through the Arrow C Data, C Device and C Stream interfaces; ArrowMetal implements ARROW_DEVICE_METAL.
  • A C ABI with no Swift in sight, and a JSON plan interface for a whole query.
  • 41,688 differential cases against pyarrow with each divergence named, and 1,047 Swift and 7,278 Python tests in the test suite.
  • A maintainer who will answer, and a project small enough to move.

Open an issue or a pull request on GitHub. The Integrations tab has the full documentation for each integration.

README.md

ArrowMetal

Apache Arrow Powered By Apache DataFusion Integration CI PyPI crates.io crates.io datafusion-arrowmetal Go Reference License

Apache Arrow compute on the Apple silicon GPU, inside DataFusion, Polars and DuckDB.

Listed on Apache Arrow's Powered By page and on Apache DataFusion's integrations list.

Three measured queries, each engine alone on the CPU and the same engine with ArrowMetal: Apache DataFusion full sort 22.7x, Polars distinct 11.5x, DuckDB group-by 4.9x faster, 50,000,000 rows on an Apple M4 Max

The CPU and the GPU of an Apple silicon Mac share one memory: the GPU scans an Arrow column where it already is, with no copy across a bus, and while it runs the CPU is free for the rest of your application. So every figure here gives the CPU time of a call beside its wall time.

50,000,000 rows speed-up engine, ms (CPU-ms) with ArrowMetal, ms (CPU-ms)
DataFusion: ORDER BY int64 22.7x 1,581.22 (6,104.1) 69.74 (125.8)
DataFusion: DISTINCT, 200 groups 2.31x 30.16 (422.5) 13.05 (47.7)
Polars: unique over 2 keys, keep first 11.54x 153.08 (2,063.6) 13.26 (8.7)
Polars: group-by over 1 key, 200 groups, min + max 1.52x 16.46 (245.8) 10.82 (5.9)
DuckDB: sum, count, 100,000 keys 4.89x 79.86 (1,201.4) 16.33 (99.9)
DuckDB: sum, max, avg, no GROUP BY 1.08x 4.28 (61.6) 3.98 (45.1)

Apple M4 Max. The engine's own time ÷ its time with ArrowMetal's default; for Polars 1.44.1, the faster of its in-memory and streaming engines (Polars 2.0.0 is under Polars in full). Each engine's second row is its lowest speed-up (best of the runs) among the cases its default takes. On the first run after 500 ms of idle, against the engine's own first run, DataFusion's group-bys are 1.24x to 2.17x and four of DuckDB's thirteen rewritten pairs are behind, down to 0.38x.

Apache DataFusion DataFusion. A physical optimizer rule for DataFusion 55.1; the SQL is unchanged. A Rust crate used by crates.io; it loads libArrowMetalC.dylib (install):

datafusion-arrowmetal = "0.5.0"   # beside datafusion = "=55.1.0"

Full sorts 6.9x to 28.8x faster than DataFusion alone (250,000 to 50,000,000 rows), ten group-by series 2.31x to 4.27x; 13,632 query pairs, 0 mismatches. Left to DataFusion: top-k 0.14x to 0.41x, filters 0.31x to 0.79x. rule.report() gives each node, TAKEN or LEFT, and why. DataFusion in full

Polars Polars. MetalEngine runs the subtrees measured ahead (full sorts, inner, left and anti joins, unique, group-bys judged by their estimated number of groups); Polars runs the rest. On Polars 1.44.1, 1.52x to 11.54x on all 75 of 220 case-size pairs the default took, and 15,376 generated cases, 0 different; on Polars 2.0.0, 1.35x to 7.94x on all 52 of 107 cases it took at 50,000,000 rows. Left to Polars: whole-frame aggregates 0.09x to 0.24x on 1.44.1 and 0.11x to 0.47x on 2.0.0, filters 0.14x to 0.42x and 0.16x to 0.40x. Polars in full

pip install "arrowmetal[polars]"   # macOS 14 or later on Apple silicon; pins the tested Polars range
python -m arrowmetal.bench         # your Mac, 30 s or less, every answer checked against pyarrow
import polars as pl, arrowmetal as am
lf = pl.scan_parquet("orders.parquet")
engine = am.MetalEngine()                             # the measured defaults
df = lf.sort("amount", descending=True).collect(engine=engine)  # the frame lf.collect() returns
print(engine.last_report)                             # Metal or Polars, per node, and why

DuckDB DuckDB. An optimizer extension; the SQL is unchanged. Built from a checkout with ./duckdb-extension/build_rewrite.sh (DuckDB 1.5.6), then LOADed, or opened by am.duckdb_connect(). Rewritten aggregates 1.08x to 5.32x faster than DuckDB's own operators; 33,376 generated queries, 0 different. Left to DuckDB: a single sum 0.32x, VARCHAR keys 0.22x. DuckDB in full

339 benchmark rows, 77 where the CPU is ahead, each with its cause (matrix, to improve); 307 of Arrow v25's 307 compute function names, 283 on the GPU (list).

Details

ArrowMetal runs Apache Arrow compute on the Apple silicon GPU: Arrow arrays that live in Metal shared memory, GPU kernels that keep Arrow's semantics (validity bitmaps, packed booleans, the C Data and C Device Data Interfaces), and one C ABI reachable from Swift, Python, Rust, Go, TypeScript, R and C.

Contents: The engines in full (DataFusion · Polars · DuckDB) · The one-row picture · Measure it on your Mac · Release · The pitch · What was measured · Why this exists · Benchmarks · From Python · Bindings · Quick start (Swift) · Reading Arrow files · What is implemented · Design notes · Citing · License

The engines in full

ArrowMetal runs inside three query engines. Each one plans the query as it always does; ArrowMetal takes the parts of the plan measured faster on the Apple GPU, hands back what the engine would have returned, and leaves every other node to the engine. Every figure below is from a run recorded in this repository, named beside it, on an Apple M4 Max (16 CPU cores, 64 GB); each speed-up is the engine's own time divided by the time with ArrowMetal, with the CPU time of the call next to its wall time.

DataFusion in full

A physical optimizer rule for DataFusion 55.1, the Rust crate datafusion-arrowmetal in datafusion/, on crates.io. It loads libArrowMetalC.dylib, built from the repository root by swift build -c release --product ArrowMetalC (DataFusion):

# Cargo.toml
[dependencies]
datafusion = { version = "=55.1.0", default-features = false, features = ["sql"] }
datafusion-arrowmetal = "0.5.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
use std::sync::Arc;
use datafusion::{datasource::MemTable, prelude::*};
use datafusion_arrowmetal::{session_context, ArrowMetalConfig, ArrowMetalRule};

let rule = ArrowMetalRule::new(ArrowMetalConfig::default());
let ctx = session_context(SessionConfig::new(), rule.clone());
let t = MemTable::try_new(batch.schema(), vec![vec![batch]])?;
ctx.register_table("t", Arc::new(t))?;
ctx.sql("SELECT * FROM t ORDER BY amount DESC").await?.show().await?;   // the SQL as written
println!("{}", rule.report());                                          // TAKEN / LEFT, and why

Registered on a SessionContext, the rule replaces DataFusion's full sorts and the hash aggregates of measured shapes with MetalExec, which runs them on the GPU and hands DataFusion the RecordBatches it expects. The default takes full sorts (ORDER BY without LIMIT) from 250,000 rows, over DataFusion's own Parquet reader too, and, over a MemTable of 50,000,000 rows or more, count(*) and DISTINCT over two int32 keys and MIN/MAX of an integer column over one int64 key or two integer keys, at the numbers of groups where each was measured ahead, estimated from a sample of the keys when the query runs (DataFusion).

  • Full sorts of 250,000 to 50,000,000 rows: 6.9x to 28.8x faster than DataFusion alone; at 50,000,000 rows, 96.2 to 125.8 CPU-ms against DataFusion's 6,104.1 to 8,310.5 (datafusion/results/datafusion_sort_warm_2026-09-29.csv). DataFusion's own sort of 50,000,000 rows spends 6,104.1 CPU-ms in 1,581.22 ms, about four of the sixteen cores on average, which is part of why the figure is large; Polars 1.44.1 sorts the same three columns by the same int64 key in 380.46 ms on this machine, and ArrowMetal under Polars in 53.03 ms, 7.17x (Benchmarks/results/polars_engine_bench_2026-10-02.csv); Polars 2.0.0 in 148.79 ms, and ArrowMetal under it in 52.70 ms, 2.82x (Benchmarks/results/polars_engine_bench_2026-10-08-polars2-refit.csv).
  • The ten group-by series the default runs on the GPU, in both table layouts (20 cases), at 50,000,000 rows: 2.31x to 4.27x warm; on the first run after 500 ms of idle against DataFusion's first run after the same idle, 1.24x to 2.17x, and after 5 s, 1.11x to 1.68x (datafusion/results/datafusion_groupby_refit_check_2026-10-02.csv and the current-crate rows of datafusion/results/datafusion_groupby_default_import_2026-10-01.csv).
  • 13,632 query pairs, each run with and without the rule and the answers compared: 0 mismatches (DataFusion).
Row rows DataFusion alone, ms (CPU-ms) with the rule, ms (CPU-ms) speed-up
ORDER BY an int64 key, 3 columns 50,000,000 1,581.22 (6,104.1) 69.74 (125.8) 22.7x
count(*) over two int32 keys, 1,000,000 groups 50,000,000 75.36 (1,127.5) 17.63 (61.7) 4.27x
DISTINCT over two int32 keys, 200 groups 50,000,000 30.16 (422.5) 13.05 (47.7) 2.31x

To improve, left to DataFusion by the default (DataFusion alone ÷ with the rule): top-k (ORDER BY … LIMIT 100) 0.14x to 0.41x at 250,000 to 50,000,000 rows (datafusion/results/datafusion_sort_warm_2026-09-29.csv), and filters 0.31x to 0.79x at 10,000,000 and 50,000,000 rows (datafusion/results/datafusion_filter_2026-10-01.csv); the other aggregate shapes are under To improve.

Polars in full

A MetalEngine for lf.collect(engine=…):

pip install "arrowmetal[polars]"      # polars>=1.44,<2.1, the tested range
import polars as pl, arrowmetal as am
lf = pl.scan_parquet("orders.parquet")
engine = am.MetalEngine()                                         # the measured defaults
df = lf.sort("amount", descending=True).collect(engine=engine)    # the frame lf.collect() returns
print(engine.last_report)                                         # Metal or Polars, per node, and why

Polars optimises the lazy plan as it always does. MetalEngine replaces the subtrees it translates with ArrowMetal plans that run on the GPU and return Polars DataFrames; Polars' in-memory engine runs everything else, on Polars 2.0.0 as on 1.44 (a plain collect() on 2.0.0 runs the streaming engine). The default takes the shapes measured ahead, from one crossover table per Polars major. On Polars 1.x (the table measured on 1.44.1): full sorts from 1,000,000 rows, inner, left and anti joins, unique, String sorts and unique from 5,000,000 rows, a sort over a Parquet scan, and group-bys judged by their estimated number of groups. On Polars 2.x (the table measured on 2.0.0): numeric full sorts from 10,000,000 rows, unique from 2,943,034, inner, left and anti joins, String unique from 5,000,000 rows, a sort over a Parquet scan, and group-bys judged by their estimated number of groups, where sum and mean at 200 to 10,000 groups stay with Polars except a two-key sum over 1,000 groups (from 28,976,995 rows) and a one-key mean over 10,000 groups (from 50,000,000 rows) (Polars).

  • On Polars 1.44.1, over 220 benchmarked case-size pairs (2,000,000 and 50,000,000 rows), the default took a subtree in 75, and in every one of them it is 1.52x to 11.54x faster than the faster Polars engine, in-memory or streaming (Benchmarks/results/polars_engine_bench_2026-10-02.csv).
  • On Polars 2.0.0, over 107 benchmarked cases at 50,000,000 rows, the default took a subtree in 52, and in every one of them it is 1.35x to 7.94x faster than the faster Polars 2.0.0 engine, none behind (Benchmarks/results/polars_engine_bench_2026-10-08-polars2-refit.csv).
  • Polars 2.0.0 (released 2026-10-06) runs collect() on its streaming engine by default, and runs group-bys, joins and sorts faster than 1.44.1: over the 62 cases the 1.44 default takes at 50,000,000 rows, its own time is 0.72x of 1.44.1's at the median, and 0.25x on unique over two keys (153.1 ms to 38.5 ms); ArrowMetal's times are unchanged (median ratio 1.03) (Benchmarks/results/polars_engine_bench_2026-10-02.csv and Benchmarks/results/polars_engine_bench_2026-10-07-polars2.csv).
  • MetalEngine is tested on Polars 1.44.1, 1.44.2 and 2.0.0 (TESTED_POLARS), and pip install "arrowmetal[polars]" installs polars>=1.44,<2.1. Polars 2.0 removed LazyFrame.profile; on 2.0.0 MetalEngine.profile raises NotImplementedError. On 2.0.0 the engine takes the same 1,326 of 2,523 plans of the capability grid, and Polars 2.0.0 itself rejects 63 plans that 1.44.1 accepted (docs/ENGINE_CAPABILITIES_POLARS2.md).
  • 15,376 generated cases against Polars 1.44.1: 15,096 identical, 33 within the float-summation bound, 0 different, and 247 where Polars' plan had nothing to run (Benchmarks/results/engine_conformance_2026-10-02.csv).
50,000,000 rows, Polars 1.44.1 faster Polars engine, ms (CPU-ms) MetalEngine(), ms (CPU-ms) speed-up
unique over 2 keys, keep first in-memory 153.08 (2,063.6) 13.26 (8.7) 11.54x
sort 3 columns by an int64 key in-memory 380.46 (4,709.0) 53.03 (7.1) 7.17x
inner join, 1,000,000-row build side streaming 82.91 (1,248.3) 22.33 (4.5) 3.71x
group-by over 1 key, 200 groups, min + max in-memory 16.46 (245.8) 10.82 (5.9) 1.52x
50,000,000 rows, Polars 2.0.0 faster Polars engine, ms (CPU-ms) MetalEngine(), ms (CPU-ms) speed-up
unique over 2 keys, keep first streaming 38.01 (571.8) 13.42 (8.7) 2.83x
sort 3 columns by an int64 key streaming 148.79 (2,113.2) 52.70 (7.0) 2.82x
inner join, 1,000,000-row build side streaming 57.97 (829.5) 22.63 (4.9) 2.56x
group-by over 1 key, 200 groups, min + max in-memory 17.20 (260.0) 10.54 (5.8) 1.63x
inner join, then a whole-frame sum streaming 8.24 (110.3) 6.12 (5.5) 1.35x

To improve, left to Polars by the default (Polars ÷ MetalEngine, 250,000 to 50,000,000 rows). On Polars 1.44.1 (Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv): whole-frame aggregates 0.09x to 0.24x, row-wise filters and projections 0.14x to 0.42x, a semi join against a 1,000-row table 0.21x to 0.47x, and the top 100 by a nullable Float64 key 0.75x to 1.05x, which keeps top-k with Polars. On Polars 2.0.0 (Benchmarks/results/polars_engine_crossover_2026-10-08-polars2.csv): whole-frame aggregates 0.11x to 0.47x, row-wise filters and projections 0.16x to 0.40x, a semi join against a 1,000-row table 0.42x to 0.82x, top-k 0.20x to 1.02x, a join on a String key 0.24x to 0.48x, group-by sum and mean at 200 to 10,000 groups 0.36x to 0.97x at 250,000 and 500,000 rows, and the sort with a String column, 0.89x at 50,000,000 rows (244.77 ms against Polars' 217.88 ms, Benchmarks/results/polars_engine_bench_2026-10-08-polars2-after.csv), which leaves the filter-then-sort of its class with Polars too. Every translatable shape, with its measured result, is in Polars engine capabilities (Polars 1.44.1) and docs/ENGINE_CAPABILITIES_POLARS2.md (Polars 2.0.0).

DuckDB in full

An optimizer extension; the SQL is unchanged. Built once with ./duckdb-extension/build_rewrite.sh (against DuckDB 1.5.6), and loaded into a connection opened with allow_unsigned_extensions, or by am.duckdb_connect() from Python:

LOAD 'duckdb-extension/build/arrowmetal_rewrite.duckdb_extension';
SELECT region, sum(amount), count(*), max(amount) FROM sales GROUP BY region;   -- as written
SELECT * FROM arrowmetal_rewrites();     -- each decision, with its reason
SET arrowmetal_rewrite = 'off';          -- plain DuckDB; 'auto' is the default

The extension puts ARROWMETAL_AGGREGATE where DuckDB's HASH_GROUP_BY or UNGROUPED_AGGREGATE would have been; the scan, the pushed-down filters and everything above the aggregate stay DuckDB's. The default (auto) takes sum, avg, min, max and count over integer, DATE and TIMESTAMP columns, grouped by one column or none, over a table scan or read_parquet: a fused group-by of an estimated 10,000 groups or more from 10,000,000 rows, the other measured classes from 50,000,000 (DuckDB).

Row rows DuckDB, ms (CPU-ms) rewritten, ms (CPU-ms) speed-up
100,000 INTEGER keys: sum, count 50,000,000 79.86 (1,201.4) 16.33 (99.9) 4.89x
sum, max, avg (BIGINT), no GROUP BY 50,000,000 4.28 (61.6) 3.98 (45.1) 1.08x

To improve: on the first run after 500 ms of idle, four of the thirteen rewritten query-size pairs are behind DuckDB's first run after the same idle (same file), all at 50,000,000 rows: sum, max, avg (BIGINT), 22.38 ms against 8.55 ms (0.38x); sum, count, min, max (BIGINT, 10% NULL), 29.39 against 23.72 ms (0.81x); sum, min, max, avg (INTEGER), 12.54 against 10.34 ms (0.82x); and 1,000 INTEGER keys with five aggregates, 36.68 against 32.61 ms (0.89x). Left to DuckDB by the default, each with a query behind at 50,000,000 rows in the same file: a single sum (sum(INTEGER), 0.32x), few groups with at most two aggregates (1,000 INTEGER keys, sum, 0.59x) and VARCHAR keys (1,000 long VARCHAR keys, sum, count, 0.22x).

The one-row picture

Apple silicon has one physical memory shared by the CPU and the GPU, so an Arrow buffer placed in a Metal shared buffer is at once a valid CPU Arrow buffer and a valid GPU buffer: a column is used where it already is, with no copy across a bus in either direction. While a kernel runs, the CPU is free for the rest of the application, and every table in this repository shows the CPU time of each call next to its wall time.

One row, the same in-process data in every column:

sum by int32 key (1000 groups), 50,000,000 rows wall ms CPU ms of that call
ArrowMetal 4.89 1.2
DuckDB 1.5.5 (its own table, 16 threads) 9.00 135
pyarrow Acero (Table.group_by, 16 threads) 18.45 250
Polars lazy (16 threads) 81.93 1,186
pandas 247.93 248

From Benchmarks/results/full_matrix_2026-09-07-parallel.csv, Apple M4 Max (16 CPU cores, 64 GB), best of up to five calls after one warm-up, release build; the 339-row matrix that row comes from, including the 77 rows where the CPU idiom is ahead, is in The full matrix. The DuckDB row is from Benchmarks/results/duckdb_matrix_2026-09-12.csv, measured on 2026-09-12 with the matrix's generators and protocol over a native DuckDB table, the data loaded before timing (DuckDB).

Measure it on your Mac

pip install arrowmetal          # macOS 14 or later on Apple silicon; pyarrow is the only dependency
python -m arrowmetal.bench      # 30 seconds or less
python -m arrowmetal.bench --parquet data.parquet   # read, sum, filter, group-by on your own file

The second line generates 10,000,000 rows, runs sum, filter, sort and group-by sum through pyarrow (and Polars when it is installed) and through ArrowMetal, and checks every ArrowMetal answer against pyarrow's. It prints one table for your Mac, wall and CPU milliseconds per call, with a block ready to paste into a benchmark result issue or the Discord #benchmarks channel; nothing is sent.

import pyarrow as pa, arrowmetal as am
amount = am.MetalArray.from_arrow(pa.array([10.0, 20.0, 30.0, 40.0]))
region = am.MetalArray.from_arrow(pa.array([1, 2, 1, 2], pa.int32()))
print(am.group_by([region]).sum(amount).to_arrow())     # [40, 60]: GPU group-by sum, one row per region
print(amount.filter_where(">", 15).to_arrow())          # [20, 30, 40]: GPU filter, back as a pyarrow array

Release

Listed on Apache Arrow's Powered By page and on Apache DataFusion's integrations list. Site and docs: https://arrowmetal.org · Python: pip install arrowmetal · Rust: arrowmetal = "0.5.0" · DataFusion: datafusion-arrowmetal = "0.5.0" · Release: v0.5.0

New in 0.5.0: the Polars engine runs on polars 2.0.0 as on 1.44, with one measured crossover table per Polars major (on 2.0.0 the default takes 52 of 107 benchmarked cases at 50,000,000 rows, 1.35x to 7.94x ahead of the faster Polars engine, none behind); integer means in the Polars and plan engines run in one pass from the integer column (two-key means at 200 to 1,000,000 groups 1.41x to 1.49x faster than before); the group-count estimate cache holds on polars 2.0.0; the DuckDB extensions are built and tested against DuckDB 1.5.6. Before, 0.4.0 brought datafusion-arrowmetal, the DataFusion optimizer rule on crates.io, Polars sort keys passed one to one, and correctly rounded grouped Float64 sums and means.

The pitch in one paragraph

Every array is Arrow layout in memory the GPU already shares, so there is nothing to upload. Gathers, group-by, sorts and string scans run on the GPU 3.8x to 24.2x faster than the fastest CPU idiom in the table below. That idiom is Polars' lazy engine or pyarrow's Acero, using a median of eleven of the sixteen cores and up to fifteen. While they run the CPU is free for the rest of the application: every table here reports CPU time per operation next to wall time, and the same rows cost 26 to 1,284 CPU-ms on the other side (the shift view apart, at 0.06) against 0.4 to 1.7 here. Chains of operations share one GPU round trip. The whole thing is reachable from Swift, Python, and any language with Arrow bindings through one C ABI. The full distribution over all 339 measured rows — including the 77 where the CPU idiom is ahead — is in The full matrix and Where ArrowMetal needs to improve.

What was measured

  • 339 benchmark rows over 173 operations against four CPU libraries at their most parallel idiom — Polars' lazy engine, pyarrow's Acero, pandas and numpy on an idle M4 Max, with the cores each call used recorded beside it. 145 rows at or above 3x, 102 between 1x and 3x, 77 where the CPU idiom is ahead, 15 with no CPU equivalent; each of the 77 has its cause written down. The full matrix, Where ArrowMetal needs to improve
  • 307 of Apache Arrow v25's 307 compute function names — 283 on the GPU, 17 on the host, 7 with a stated limitation. The table is generated from a registry the test suite executes against pyarrow.compute. Arrow functions, one by one
  • 41,688 differential cases against pyarrow over 46 column types, 1,046 Swift tests against a CPU oracle and 7,188 Python cases (7,159 passed, 29 skipped), all run in release. Testing
  • Seven languages on one C ABI — Swift, Python, C, Rust, Go, TypeScript and R, each binding with its own suite against that language's Arrow library. docs/README.md
  • 19 findings in other projects — pyarrow, Apple Metal, the Arrow JS, R and Go libraries, and the Swift compiler — each with the evidence behind it and where its report stands. Upstream tracker

Why this exists

Apple silicon has one physical memory shared by CPU and GPU. An Arrow buffer placed in a MTLBuffer with storageModeShared is simultaneously a valid CPU Arrow buffer and a valid GPU buffer. No upload and no download: a page-aligned producer's buffers are used in place, and a buffer that is not page aligned is copied once on the way in. No Arrow project we surveyed took advantage of that:

Project What it has What it lacks
arrow-swift Types, IPC, Flight, C Data Interface Zero compute kernels, no Metal
nanoarrow device C wrapper for Metal buffers No compute, C only
cuDF Full GPU dataframe CUDA only
MLX Fast unified-memory tensors No nulls, no columnar semantics, ML focused

ArrowMetal fills that hole: Arrow arrays whose buffers live in Metal shared memory, GPU kernels that honour Arrow semantics, and standard Arrow C interfaces in and out so it plugs into arrow-rs, pyarrow, DuckDB, Polars, arrow-swift or anything else that speaks the C Data Interface.

Benchmarks

Apple M4 Max (16 CPU cores), 50,000,000 rows (10,000,000 for the string row), best of up to five calls after a warm-up, release build. Full history and methodology in Benchmark method and Benchmarks/README.md. Every number here is from that one machine. If you have another Apple silicon Mac, the output of swift run -c release arrowmetal-bench 10000000 5, posted on Discord or in an issue, is new information.

Called from Python, same in-process data. Every CPU library is measured twice — its plain eager idiom, and the most parallel idiom it has for the same answer: polars-lazy through pl.LazyFrame on the in-memory or streaming engine, pyarrow-threaded through an Acero plan over 16 record batches (pandas' threaded paths, numexpr and numba, were not installed for this run; numpy's ufuncs are single-threaded). The baseline below is the fastest of all of them, named, with the cores that call used (its CPU-ms over its wall ms). Every row is taken from Benchmarks/results/full_matrix_2026-09-07-parallel.csv, the raw output behind The full matrix; the eager-only run of the same build is kept beside it as full_matrix_2026-09-07.csv. Wall time in milliseconds, with the CPU time each call consumed in parentheses. The last three rows are ones where the CPU idiom is ahead:

Operation Rows ArrowMetal ms (CPU-ms) fastest CPU idiom its ms (CPU-ms) its cores speedup
sum int64, 10% nulls 50,000,000 1.11 (0.4) Polars lazy 2.30 (26) 11.1 2.1x
filter int64 (30% kept) 50,000,000 2.23 (0.7) Polars lazy 2.85 (36) 12.6 1.3x
take 25M random indices 50,000,000 5.90 (0.8) pyarrow 143 (143) 1.0 24.2x
group-by sum, 1000 keys 50,000,000 4.89 (1.2) pyarrow Acero 18.45 (250) 13.6 3.8x
filter two columns + sum 50,000,000 3.53 (1.7) Polars lazy 2.92 (41) 13.9 0.83x
sort float64 50,000,000 32.12 (1.3) Polars 133 (1,284) 9.7 4.1x
string contains, literal 10,000,000 1.64 (0.4) Polars lazy 11.47 (173) 15.0 7.0x
ln (float64) 50,000,000 81.31 (0.5) Polars lazy 8.46 (120) 14.2 0.10x
temporal year 50,000,000 26.82 (0.4) pyarrow Acero 17.91 (246) 13.7 0.67x
shift (lag 1, int64) 50,000,000 3.58 (0.9) Polars lazy 0.03 (0) 2.2 0.01x

Swift, against all 16 CPU cores (tight typed loops over the same Arrow layout) and Accelerate. These are the Swift-level baselines of rounds 2, 3 and 7 in Benchmark method, measured 2026-09-06; the sort row predates the 2026-09-07 sort rewrite that the matrix above measures:

Operation Metal 16-core CPU / Accelerate
sum Int64, 10% nulls 1.40 ms 4.89
min Int64 1.34 3.73
compare Int64 > 0 to bitmap 1.07 2.12
filter Int64 (45% kept) 2.90 3.49
take 25M random indices 5.85 11.93
group-by sum, 5 keys 1.96 5.92
query: filter two columns + sum 2.33 6.36
multiply Int64 * 3 2.13 2.26
cast Int64 to Float32 3.14 1.80
sum Float32 0.90 0.84 (vDSP)
multiply Float32 * 2.5 1.72 1.63 (vDSP)
Float32 compare + filter 2.14 5.73
sort Float64, 50M rows 138.52 591.97 (chunk sort + merge tree)
string contains, 10M utf8 1.65 17.77

Takeaways, all against the fastest idiom of any CPU library. The rows at or above 3x are gathers, group-by, sorts and GPU string predicates: gathers (take, 24.2x), multi-key and high-cardinality group-by (lexsort 24.0x, sum by int32 key 3.8x at a thousand groups and 6.3x at a hundred thousand), sorts (argsort int64 7.7x, sort float64 4.1x) and GPU string predicates (contains 7.0x at 10M rows; the four GPU predicates measured at ten million rows — match_like on a pure prefix, contains, starts_with and match_substring_regex on a literal — run 1.6x to 7.0x against Polars lazy and Acero). The group-by family is 65 of 84 rows at or above 3x. A single pass that reads one column and writes one is memory bound on both sides: eleven to fifteen cores reach the same unified memory the GPU does.

Across all 339 rows the verdicts are 145 at or above 3x, 102 between 1x and 3x, 77 where the fastest CPU idiom is ahead, and 15 with no CPU equivalent. The 77 by measured cause, as Where ArrowMetal needs to improve groups them: 16 at a million rows and below, where the dispatch floor is the operation; 28 memory-bound single passes; 6 software binary64 transcendentals and calendar arithmetic; 2 regular expressions matched on the host; 4 where the CPU library returns a view and ArrowMetal materialises a column; 8 temporal extractions; 4 unique and value_counts rows against a threaded hash aggregation; 1 grouped moment; 4 string conversions with variable-length output; and 4 whole-query chains against a streaming engine. shift (lag 1) at 0.01x is the lowest ratio in the matrix outside the latency family. Every one of them is listed with its cause in Where ArrowMetal needs to improve. The crossover states the other side of the floor: from what row count the GPU path is ahead, per operation and per family, measured with a size sweep from a thousand rows to fifty million. Below the router's crossover in that document (the GPU kernel against a single-core CPU loop, not against a CPU library), seven operations (sum, min, max, compare, add/subtract/multiply, filter, group-by sum) take a byte-identical single-core CPU path on integer columns instead; ARROWMETAL_ROUTER=gpu turns that off (Design).

DuckDB on the same rows. The matrix's CPU libraries are Polars, pyarrow and pandas. DuckDB 1.5.5, measured on 2026-09-12 on the matrix's sort, group-by, filter and sum rows with the same data and protocol (DuckDB, Benchmarks/duckdb_matrix.py), is the fastest CPU engine on low-cardinality group-by and multi-key sort: against it, sum by int32 key at 1,000 groups and 50M rows is 1.8x rather than the 3.8x the matrix shows against Acero, and lexsort is 7.0x rather than 24.0x. From 100,000 groups upward, and on every sort, ArrowMetal stays 3x or more ahead of DuckDB, at 1 to 2 CPU-ms against DuckDB's 135 to 4,350. An optimizer extension also runs eligible aggregates of unchanged DuckDB SQL on the GPU with DuckDB's exact answers (DuckDB §4b). In Rust, a physical optimizer rule for DataFusion 55.1 runs the full ORDER BY sorts of unchanged SQL on the GPU with DataFusion's answers, 6.9x to 28.8x faster than DataFusion alone from 250,000 to 50M rows on an M4 Max (DataFusion).

From Python

pip install arrowmetal installs the published wheel (macOS 14 or later on Apple silicon). Two more ways, both from this checkout:

# From source (needs the Swift toolchain)
swift build -c release --product ArrowMetalC        # .build/release/libArrowMetalC.dylib
PYTHONPATH=python python -c "import arrowmetal as am; print(am.device_name())"

# From a wheel that carries the dylib (no Swift toolchain at install time)
pip install build && scripts/build_wheel.sh         # or python/build_wheel.sh, if the dylib is built
pip install python/dist/arrowmetal-0.5.0-*.whl      # macOS arm64 only, pyarrow comes with it
python -c "import arrowmetal as am; print(am.device_name())"
import pyarrow as pa, arrowmetal as am               # pyarrow is the only import dependency
col = am.array(pa.array([1, None, 3, 40]))
print(col.filter_where(">", 2).sum())              # 43, computed on the GPU
with am.batch():                                   # several kernels, one GPU round trip
    total = col.filter((col > 1) & (col < 40)).sum()

With the polars extra installed (pip install 'arrowmetal[polars]', or just pip install polars), a Polars Series crosses the same way:

import polars as pl
col = am.array(pl.Series([1, None, 3, 40]).to_arrow())
print(pl.from_arrow(col.filter_where(">", 2).to_arrow()))

A whole lazy plan can run through lf.collect(engine=am.MetalEngine()), which takes the parts it measured ahead of Polars and leaves the rest to Polars (Polars, tier 4).

from decimal import Decimal

# Group-by over arbitrary key columns — strings here, several columns if you pass several.
# A null key forms its own group, as in Arrow.
region = am.array(pa.array(["emea", "apac", "emea", None, "apac"]))
amount = am.array(pa.array([10.0, 20.0, 30.0, 40.0, 50.0]))
gb = am.group_by([region])
dict(zip(gb.keys()[0].to_pylist(), gb.sum(amount).to_arrow().to_pylist()))
# {'emea': 40.0, 'apac': 70.0, None: 40.0}

# Checked arithmetic raises exactly where the unchecked kernel wraps, naming the row.
big = am.array(pa.array([2**62, 2**62], type=pa.int64()))
(big + big).to_arrow()                             # wraps, which is Arrow's unchecked `add`
big.add_checked(big)                               # ArrowMetalError: add_checked: overflow at index 0

# Regex runs on the host behind the same API; a pattern that is really a literal
# routes back to the GPU kernel.
names = am.array(pa.array(["ArrowMetal", "polars", "pyarrow"]))
names.match_substring_regex("^p").to_arrow()       # [false, true, true]

# decimal128 arithmetic on the GPU; decimal32/64 widen into it and narrow back.
price = am.array(pa.array([Decimal("19.99"), Decimal("5.01")], type=pa.decimal128(10, 2)))
price.decimal_add(price).to_arrow()                # [39.98, 10.02]

See Python.

Bindings

Four language bindings over the same C ABI (include/arrowmetal.h) are in 0.1.0, in this repository, each exchanging columns through the Arrow C Data Interface and each with its own test suite:

Binding Where Tests Doc
Rust rust/arrowmetal, rust/arrowmetal-sys; crates.io 66 tests plus 5 no_run doc-tests Rust
Go go/arrowmetal 66 test functions and three examples, 69 runnable Go
TypeScript / JavaScript node/ (N-API addon) 84 tests TypeScript / Node.js
R r/arrowmetal 88 test_that() blocks in the sources (89 as testthat runs them: the one in test-dispatch.R runs once per attach order), 920 expectations R

C and C++ callers use the header directly. Any language with an Arrow binding can use the same header.

Quick start (Swift)

import ArrowMetal

let prices = try MetalArray<Float>([9.5, nil, 12.0, 3.25])       // nullable, lives in Metal shared memory
let mask   = try prices.compare(.gt, 5)                          // GPU, Arrow boolean bitmap out
let picked = try prices.filter(mask)                             // GPU stream compaction
print(try picked.sum(), try picked.max(), picked.nullCount)      // .float(21.5), 12.0, 0

// Multi-column batches behave like an Arrow RecordBatch.
let orders = try MetalRecordBatch(names: ["region", "amount"], columns: [.int32(region), .float32(amount)])
let hits = try orders.filter(try region.compare(.eq, 2).and(try amount.compare(.gt, 100)))
let sample = try hits.take(try MetalArray<Int32>([0, 5, 9]))
let window = try hits.slice(offset: 32, length: 1000)             // zero-copy view

Chains of operations can share one GPU round trip:

let total = try MetalContext.shared.batch {
    try amount.filter(try region.compare(.eq, 2).and(try amount.compare(.gt, 100))).sum()   // one command buffer
}

batchAsync is the same thing without the wait, so the CPU is free while the GPU scans. Return a pending array from the body (anything that reads a scalar on the CPU would sync inside it) and use the async accessors for scalars:

let ctx = MetalContext.shared
let hits = try await ctx.batchAsync {                          // records, commits, releases the thread
    try amount.filter(try region.compare(.eq, 2).and(try amount.compare(.gt, 100)))
}
print(hits.length)                                             // already resolved: no GPU round trip
let total = try await hits.sumAsync()                          // reduction, still without blocking

// Callback form, for code that is not async:
ctx.batchAsync({ try amount.filter(where: .gt, 100) }) { result in
    switch result { case .success(let hits): print(hits.length); case .failure(let e): print(e) }
}

Six end-to-end scenarios (analytics query, feature preparation, Float64 with NaN, C Data Interface interop, sliced windows, one batched command buffer) live in Sources/ArrowMetalExamples: swift run -c release arrowmetal-examples.

Interop with any Arrow implementation through the C Data Interface:

import CArrowABI

var schema = ArrowSchema(); var array = ArrowArray()
producer.export(into: &schema, &array)                            // arrow-rs, pyarrow, nanoarrow, arrow-swift ...
let imported = try importArrowArray(schema: &schema, array: &array)
// imported.zeroCopy == true when the producer's buffers were page aligned

var device = ArrowDeviceArray()
picked.exportArrowDeviceArray(into: &device)                      // device_type == ARROW_DEVICE_METAL

Requirements: macOS 14+ / iOS 17+, Swift 5.10+. Shaders compile at runtime so the Command Line Tools are enough to build and use it. Running the test suite needs Xcode (for XCTest): DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test.

Reading Arrow files

The Arrow IPC streaming and file formats are read and written directly, with no dependencies: a small FlatBuffers codec lives in Sources/ArrowMetal/IPC. Buffers land straight in Metal shared memory, so a file read is ready for the GPU with no further copy.

let batches = try ArrowIPCReader(url: url).readAll()          // .arrow or .arrows, memory mapped
try ArrowIPCWriter.write(batches, to: url)                     // file format, readable by pyarrow

let reader = try ArrowIPCReader(url: url)                      // random access via the file footer
print(reader.schema.names, reader.batchCount)
let first = try reader.batch(at: 0)

let stream = try ArrowIPCWriter.encode(batches, format: .stream)   // Data, for sockets or Flight

Int8 to UInt64, Float32/64, Bool, Utf8, LargeUtf8 and Binary are read into their MetalArray types, and dictionary-encoded columns round trip through DictionaryBatch messages. Temporal columns (date32/64, time32/64, timestamp, duration) are carried by their storage integer array while the logical type stays visible in reader.schema.

Both directions take every type this package can hold, nested children and all: decimals, float16, fixed_size_binary, the three interval units, null, list / large list / fixed-size list, struct, map, dense and sparse unions, run-end encoded columns and extension types. pyarrow reads each one back with the right type and the right values, and every file pyarrow writes for one of them reads back with pyarrow's values. The reader also decompresses LZ4_FRAME and ZSTD bodies (ZSTD through libzstd, as the Parquet reader does, with a clear error when it is not installed), and follows message order for dictionaries, so a stream may replace a dictionary part way through or extend it with a delta. The writer emits uncompressed, little-endian bodies of classic types only. The reader takes big-endian sources, the view types (materialised to the classic layouts) and the arrow.fixed_shape_tensor extension type, and refuses IPC tensor messages. Coverage summary says what each type is covered by.

What is implemented

307 of Apache Arrow v25's 307 compute function names — 283 entirely on the GPU, 17 on the host, 7 with a stated limitation, none missing. Every one of those names has a row in Arrow functions, one by one naming the Swift file behind it, the ArrowMetal call that reaches it and what it does differently, and that table is generated from a registry the test suite executes: python/tests/test_functions.py calls every runnable row and compares the answer to pyarrow.compute. Coverage summary is the same ground arranged by family, with the Arrow type matrix and the interop status.

  • MetalArrowBuffer: page-aligned shared-memory buffers, zero-copy wrap of foreign page-aligned memory.
  • MetalArray<T> for Int8/16/32/64, UInt8/16/32/64, Float16/32/64; MetalBooleanArray with packed bits; MetalStringArray (utf8, binary), MetalDecimalArray (decimal128/256), MetalSmallDecimalArray (decimal32/64), MetalTemporalArray (date32/64, time32/64, timestamp with timezone, duration), MetalIntervalArray (all three layouts), MetalFixedBinaryArray, MetalListArray, MetalStructArray, MetalMapArray, MetalUnionArray, dictionary, run-end-encoded and extension arrays.
  • MetalRecordBatch: named equal-length columns with filter, take, slice, selecting, plus a GPU hash join over int32/int64 keys (Kernels/Join.swift).
  • Element-wise: the six comparisons, wrapping and checked (overflow-raising) add/subtract/ multiply/divide/power/negate/abs/sqrt/the logarithms/the shifts, bit_wise_*, boolean and/or/not/xor/and_not and the Kleene forms, all ten Arrow round modes with ndigits, min/max element-wise, is_nan/is_finite/is_inf.
  • Transcendentals: the twelve trigonometric and hyperbolic functions, their seven _checked twins, atan2, expm1, log1p, logb, hypot — float32 through Metal's library functions, float64 through a software binary64 implementation on the GPU; measured worst case 5 ulp (tan) on float64 and 4 on float32 — the per-function table is in Coverage summary.
  • Aggregates: sum, min, max, mean, product, variance, stddev, quantile, median, mode, count_distinct, first/last, index, min_max, skew, kurtosis, tdigest.
  • Grouped aggregates: all 24 hash_* names over arbitrary key columns — integers sparse or negative, floats, booleans, temporal values, utf8, binary, dictionary and decimal, several columns folded together — mapped to dense ids on the GPU by GroupByKeys, with GroupBy's dense-integer fast path kept underneath.
  • Selection and ordering: filter (including a fused predicate form), take, slice, drop_null, scatter, inverse_permutation, single- and multi-key sort_indices / lexsort_indices, top_k / select_k_unstable (a GPU radix select for any k, with a per-threadgroup path at k ≤ 1024), partition_nth_indices, rank, dense_rank, row_number, rank_quantile, rank_normal, winsorize.
  • Strings: length, equality, prefix/suffix/containment, count_substring, find_substring, murmur3 hash, dictionary_encode, the case and title transforms (ASCII and Unicode), padding and centring, trimming (ASCII and the Unicode whitespace class), slicing, replace-slice, repeat, reverse, joining, is_in/index_in, and the full ascii_is_* / utf8_is_* predicate set. The regex family, SQL LIKE, splitting, Unicode normalisation and the float/boolean casts run on the host behind the same API, with a GPU fast path for patterns that are really literals.
  • Temporal: every extractor (year … nanosecond, iso_calendar, year_month_day, subsecond, week with all its options, us_week, us_year), ceil/floor/round_temporal, every *_between difference including the three interval-valued ones, add_interval, strftime/strptime, and the three timezone functions (assume_timezone, local_timestamp, is_dst) on the host, where the IANA database lives.
  • Structural and conditional: if_else, case_when, choose, coalesce, replace_with_mask, fill_null, fill_null_forward/_backward, is_null/is_valid/true_unless_null, indices_nonzero, make_struct, struct_field, list_value_length/list_flatten/list_element/ list_slice/list_parent_indices, map_lookup, pivot_wider, run_end_encode/run_end_decode.
  • Windows: pairwise_diff (and its checked form), the cumulative family (sum, prod, min, max, mean, and the checked sums and products), shift, and rolling sum/mean/min/max.
  • libArrowMetalC: a C ABI over everything above, and a ctypes Python package that speaks the Arrow PyCapsule protocol.
  • Float64 on the GPU even though Metal has no double: compare, min, max, filter, take and slice use an order-preserving map of the IEEE bit pattern; sum, add, subtract, multiply, divide, the segmented group-by sums and means and the whole transcendental family use a software IEEE-754 binary64 implementation on 64-bit integers. The arithmetic (+ − × ÷) is bit-exact against Swift's Double over millions of random and edge-case inputs, subnormals and NaN included; sum and the grouped sums use the same adder but reassociate over threadgroups, so they are checked to a tolerance. sqrt is correctly rounded; exp, ln, log10, log2 and power measure a worst case of 1 ulp against libm and are asserted within 2; the trigonometric and hyperbolic families are within 5.
  • NaN: min/max skip NaN and return null if only NaN remains; sum propagates NaN; comparisons follow IEEE.
  • C Data Interface import/export for every type above and for struct (+s) record batches, C Stream Interface import, C Device Data Interface import/export, MTLBuffer recovery from our own exports.
  • ArrowIPCReader / ArrowIPCWriter: the Arrow IPC streaming and file formats, including a minimal FlatBuffers reader and builder, with no dependencies. Cross-checked against pyarrow in both directions.
  • Parquet decoded on the GPU, structs, maps and lists nested to any depth included, with the stored Arrow schema applied and page-index and bloom-filter skipping (Parquet on the GPU).
  • Delta Lake and Apache Iceberg tables (am.read_delta, am.read_iceberg): metadata on the CPU, data files through the GPU Parquet reader (Delta Lake and Iceberg).
  • CSVReader / am.read_csv: CSV parsed and typed on the GPU with pyarrow's rules, tested against pyarrow.csv.read_csv (CSV on the GPU).
  • JSONReader / am.read_json: newline-delimited JSON parsed on the GPU with pyarrow.json.read_json's types, field order and error texts (JSON on the GPU).
  • A CPU reference implementation behind the kernels, used as the oracle in tests.

Design notes

  • Runtime shader compilation. Kernels are MSL strings generated per element type and cached. No .metal files, no metal toolchain, no Xcode required to build.
  • No atomics in reductions. Each threadgroup writes a partial; the host finalises a few thousand values. Deterministic results, works for 64-bit integers where Metal atomics are limited.
  • Bitmaps as 32-bit words. Compare writes one word per thread; filter uses popcount + simdgroup prefix sums. Buffers are always padded so whole-word reads are in bounds.
  • Page-aligned allocation. Every buffer ArrowMetal hands out can be re-wrapped with makeBuffer(bytesNoCopy:) by another Metal consumer.
  • Lifetime discipline. Raw pointers are only valid while their owning object lives. Prefer the closure accessors (withValues, withTyped).

See ROADMAP.md for the project's scope and where its measured record is kept, and CONTRIBUTING.md to get involved.

Questions, and timings from Macs we have not measured: Discord or a GitHub issue, whichever you prefer.

Citing

If you use this project in research or commercial work:

ArrowMetal: Apache Arrow compute on Apple silicon GPUs via Metal.
2026. https://github.com/singhpratech/ArrowMetal

License

Apache License 2.0. Apache Arrow and Apache DataFusion are trademarks of the Apache Software Foundation; Polars and DuckDB and their logos are trademarks of their respective owners. This project is independent and not affiliated with or endorsed by the ASF, Polars, DuckDB or Apple.

docs/DESIGN.md

Design

How ArrowMetal is put together and what limits it today.

Layers

Python / Rust / Go / C# / R / C++  ──(Arrow C Data Interface)──►  libArrowMetalC (C ABI, handles)
                                                                          │
Swift apps ────────────────────────────────────────────────────►  ArrowMetal (Swift API)
                                                                          │
                                       MetalContext ── pipeline cache ── BufferPool
                                                                          │
                                   MSL kernels generated per element type, runtime compiled
                                                                          │
                                       Metal shared-memory buffers (page aligned, unified memory)
  • Buffers are the Arrow columnar layout verbatim: validity bitmap + values (+ offsets for variable-length types). The same bytes serve CPU consumers (C Data Interface) and GPU kernels (C Device Interface): copy-free out always, and copy-free in when the producer's buffers are page aligned — one copy otherwise, which the importer reports (ImportResult.zeroCopy).
  • Copy-free import wraps a producer buffer with makeBuffer(bytesNoCopy:) after checking that every page of it is mapped and readable. The check asks the VM map for the regions that cover the buffer (mach_vm_region, one call per region), so its cost does not grow with the page count; see Chunked columns and the import for what it replaced.
  • Kernels never assume a length multiple of anything: every buffer is padded to a page so trailing 32-bit bitmap words are readable; kernels bounds-check the last word/element.
  • Nulls ride along as bitmaps. Element-wise ops share the input's validity buffer (zero-copy); binary ops AND the two bitmaps on the GPU; compaction repacks.

Where the time goes today (M4 Max, 50M rows)

Source: swift run -c release arrowmetal-bench, recorded 2026-09-06 on the Apple M4 Max (16 cores, 64 GB unified memory, Darwin 25.6.0) that every table on this page was measured on; the dated rounds are in Benchmark method.

Kernel Passes over data Achieved Bound
sum / min / max 1 read 290-375 GB/s memory
compare 1 read, 1/64 write 375 GB/s memory
multiply / cast (vectorised) 1 read, 1 write 375 / 190 GB/s memory
filter (count, scan, scatter, pack) 2 reads, 1 write 138 GB/s passes
take (random gather) random reads 85 GB/s latency
group-by, privatised (K <= 1024) 1 read + TG atomics see benchmarks atomics
grouped min/max (any width) 2 reads + 32-bit atomics see below memory
counting sort by group id 2 reads, 1 write see below memory

Device peak on M4 Max is ~546 GB/s. Single-pass kernels sit at 55-70% of peak; the rest is command buffer setup and the CPU wait.

Group-by over string keys: a hash table instead of a sort

GroupByKeys turns a key column into dense ids 0 ..< K before any aggregate runs. For utf8 and binary columns that used to mean the GPU string dictionary: hash every row to 64 bits, argsort the hashes, mark run boundaries by comparing the bytes of adjacent sorted strings. Correct, but the argsort is eight radix passes over 50 million 64-bit keys — ~185 ms — and it costs the same whether the column holds a thousand distinct keys or ten million. Real group-by keys are low cardinality, so the sort was paying row-count prices for distinct-count work.

Kernels/StringHashTable.swift replaces it with an open-addressing table sized to the cardinality:

  1. Hash every non-null row to 64 bits in one pass over the bytes (sht_hash64).
  2. Estimate the distinct count by building a throwaway table over a 1/64 slice of the hash space — a row takes part only when (h >> 40) & 63 == 0. Each distinct string makes that decision once, so the occupied-slot count times 64 estimates the cardinality no matter how skewed the row frequencies are (a row sample would not: it sees the frequent keys and misses the rest). The estimate only sizes the real table, and being wrong costs a retry, never a wrong answer.
  3. Build the table with three slots per estimated distinct value: linear probing, slots[s] = row + 1 so every atomic stays 32-bit (Metal has no 64-bit atomic add; it has 64-bit atomic min and max only, see UPSTREAM.md), and the key of an occupied slot is the string of row slots[s] - 1. Equality is decided by comparing the bytes of the candidate against that representative, the same rule is_in uses, so a 64-bit hash collision costs one extra probe and can never merge two different strings. Each row also records the slot it landed in and folds itself into that slot's lowest row index with an atomic_fetch_min guarded by a plain load.
  4. Rank the occupied slots with the same GPU scan unique() uses, compact one representative row per slot, and relabel into first-seen order with two argsorts over the distinct count.

Two design choices are worth stating plainly. Nothing is cached beside the slot — not the hash, not a key prefix — because MSL only guarantees memory_order_relaxed: a companion array could be read before its writer had published it, and a probe that then walked past its own bucket would give one string two group ids. Reading hashes[slots[s] - 1] cannot go stale, because the hashes are written by an earlier kernel and never change. And because byte comparison decides equality, this path needs no re-hash retry and no host fallback, unlike the sort path it replaces. The only retry is a table that turned out too small; the last attempt gets two slots per row and an unbounded probe budget, so it cannot fail.

The byte comparison was measured against deciding equality on the 64-bit hash alone: 17.5 vs 13.7 ms at a thousand distinct keys, 22.9 vs 17.0 ms at 100k, and no difference at all at 10 million (where the random slot access dominates). A 20-30% saving is not worth an answer that is only correct to the birthday bound, so there is no unverified mode.

The result is identical to the sort path — same group ids, same group keys, same first-seen dictionary order, same null semantics (a null key still forms its own group, id K) — which is what Tests/ArrowMetalTests/StringHashTableTests.swift asserts, case by case, against both the host hash map and the old path.

Measured (M4 Max, 50M rows, 12-byte utf8 keys, best of 5), 2026-09-06

Group-by sum over a utf8 key column, key mapping included. Metal from Swift; Polars 1.44 (16 threads), pyarrow 25 and the 16-core Swift hash group-by on the same buffers.

distinct keys Metal before (sort) Metal after (hash table) pyarrow Polars 16-core CPU
1,000 203.3 ms 20.4 58.0 208.1 117.7
100,000 231.5 27.0 194.2 221.4 303.8
10,000,000 322.7 154.1 3387.6 751.7 2336.1

dictionary_encode on the same column, which is the mapping plus the first-seen relabel and the gather of the distinct strings:

distinct keys sort path (old) hash table pyarrow dictionary_encode
1,000 275.8 ms 20.9 422.3
100,000 329.0 26.1 603.0
10,000,000 438.9 268.1 4500.4

At a thousand keys the whole table is 4096 slots — 16 KB — so every probe is a cache hit and the pass is memory-bound on the key bytes. At ten million the table is 32 M slots (128 MB) and the cost becomes the random slot access, which is why the margin narrows from 10x to 2x. The 1000- and 100k-key cases are now 2.8x and 7.2x faster than pyarrow, where the sort path had been 3.5x and 1.2x behind it, and they cost the CPU about 4 ms against pyarrow's 700-2000.

Integer, boolean, temporal and dictionary key columns still take the range path (~22 ms at 50M rows); they never enter the hash table when they are group-by keys.

The same table for the distinct-value functions

unique, value_counts, count_distinct, mode and dictionary_encode over a primitive column had the same problem for the same reason: all five began with an argsort of every row, so all five cost the same whatever the cardinality, and all five were behind the CPU libraries that use a hash table (0.11x to 0.71x of pyarrow/Polars/pandas at 10M rows before this change). Kernels/HashTable.swift is the string table with the byte comparison removed — the key is the 64-bit value, so equality on it is exact — and the estimate, the growth retry, the rank scan and the id pass are literally the same code.

Turning a column into that key is one kernel: integers widen (sign-extended, so the map is injective), and floats are normalised first — every NaN to one bit pattern, -0.0 to 0.0 — so bit equality means Arrow value equality, which is what unique's sort path achieves by normalising before the sort. The order does not change either: the distinct values come back ascending, because the last stage argsorts the K representative values. Sorting K values instead of n rows is the whole trick. count_distinct skips even that — it is the occupied-slot count, so it never gathers or orders anything.

Because the table keeps the lowest row per slot, first-appearance order is available for one argsort of K int32s (HashGroups.firstSlotOrder, exposed as HashDistinct.firstRows) rather than another pass over the rows, which is what order="first_appearance" uses.

The threshold is 1 << 16 rows: below it the sort is a handful of small passes and the table's extra round trips do not pay for themselves. ARROWMETAL_NO_HASH=1 forces the sort path in a release build, which is how the before column below was measured.

M4 Max, 50M int64 rows, Swift, best of 3, 2026-09-06 (the "before" column with ARROWMETAL_NO_HASH=1):

function distinct before (sort) after (hash table)
unique 1,000 / 100k / 10M 142.7 / 147.2 / 303.9 ms 12.3 / 14.5 / 89.7
value_counts 1,000 / 100k / 10M 141.2 / 235.5 / 443.3 14.2 / 17.7 / 110.6
count_distinct 1,000 / 100k / 10M 142.3 / 282.3 / 265.0 9.6 / 10.1 / 42.5
mode 1,000 / 100k / 10M 142.2 / 199.7 / 278.0 13.9 / 17.9 / 121.6
dictionary_encode 1,000 / 100k / 10M 162.3 / 295.5 / 277.2 12.8 / 16.5 / 108.2

Against the CPU libraries on the same buffers, called from Python through Benchmarks/python_gpu_bench.py (M4 Max, 50M rows, wall ms), from Benchmarks/results/python_gpu_bench_50000000_2026-09-07.txt:

function distinct ArrowMetal Polars pyarrow pandas vs the best of them
unique 1,000 15.97 111.03 85.34 75.20 4.7x
100,000 19.00 123.60 77.98 82.21 4.1x
10,000,000 100.55 165.92 658.25 620.09 1.7x
value_counts 1,000 17.10 116.33 139.61 140.09 6.8x
100,000 20.49 320.30 192.25 194.27 9.4x
10,000,000 109.78 1532.52 1446.67 1496.30 13.2x
count_distinct 1,000 9.59 116.06 81.68 85.50 8.5x
100,000 9.83 119.60 71.23 76.89 7.2x
10,000,000 41.29 150.05 714.21 657.65 3.6x
mode 1,000 26.52 58.67 21.49 — 0.8x
100,000 30.36 70.99 549.52 — 2.3x
10,000,000 166.28 383.45 988.16 — 2.3x

Two rows need a note. unique at ten million distinct is 1.7x Polars because ArrowMetal orders its output and Polars does not: part of its 100.55 ms is the argsort of the ten million distinct values, which a hash-order unique would not pay. And mode is measured through a Python binding that computes it twice (_mode in python/arrowmetal/__init__.py calls the reduction once for the value and again for the count), so its row carries two reductions. At 1,000 distinct pyarrow's mode is ahead because the values span a narrow range and it counts into a direct-indexed table rather than a hash map.

Strings in two layouts: offsets + bytes, and views

A string column is held in one of two Arrow layouts (MetalStringArray):

  • offsets + bytes (utf8 / binary): int32 offsets, one more than the rows, into one data buffer.
  • views (utf8_view / binary_view, Sources/ArrowMetal/StringView.swift): 16 bytes per row and any number of data buffers. The first four bytes of a view are the length. A string of 12 bytes or fewer sits in the other twelve; a longer one keeps a 4-byte prefix there, then the index of a data buffer and its byte offset inside it. This is Polars' own layout for String and Binary columns, with data buffers that grow to 16 MB each.

Import. The C Data import takes vu / vz as they are: the views and every data buffer are wrapped in Metal shared buffers without a copy. A view buffer or data buffer does not have to start on a page: MetalArrowBuffer.wrapCovering wraps the pages that hold it and binds the buffer at its offset into the first one (the kernels read it byte-wise or 16 bytes at a time, never in page-sized units), so Polars' buffers, whose starts are not on a 16 KiB page, import without a copy. The validity bitmap keeps the page-aligned rule, because bitmap kernels read it in 32-bit words; it is copied when it is not page aligned or carries a bit offset. The import also totals the bytes of the non-null rows in one GPU pass over the views; a column over 2 GB is refused, the limit the large_utf8 import has. Export hands the same buffers back as vu / vz, so a Polars column crosses in both directions without a copy. An array with more than 64 data buffers (an arrow-rs or pyarrow concatenation of view arrays keeps every input's buffers) has its data buffers copied back to back into merged buffers of under 2 GB each and its views rewritten to point into them, since binding thousands of buffers for every kernel costs more than one parallel copy.

How a kernel reads either layout. Every kernel that reads strings reads them through one accessor, row(i, len), which returns a pointer to row i's first byte and its length (StringLayoutSource): StrOff over offsets + bytes, StrView over views. The kernel body is a template over the accessor, and StringLayoutSource.variants instantiates it once per layout (name and name_v; name_ov, name_vo and name_vv for a kernel over two string columns). A view kernel binds the views where the offsets kernel binds its offsets, and a small table where it binds its data: {0, count} followed by one {GPU address, byte size} per data buffer (MTLBuffer.gpuAddress), so a view that points into any buffer is one indexed load away. The encoder is told about the data buffers with useResources, since nothing binds them directly. The accessor checks every out-of-line view against its buffer's size and reads a view that points outside it as the empty string, so a malformed view, or the unspecified view under a null slot, cannot make a kernel read out of bounds (StringViewTests.testMalformedViewsReadAsEmpty).

What reads views. Lengths, hash32, the four pattern predicates, str_eq against a scalar and against another column, is_in / index_in (either side a view), the gather behind filter / take (the result is offsets + bytes), slice (the result stays a view), the sort keys, the string hash table (group-by keys, dictionary_encode, unique, value_counts), the Unicode and ASCII case transforms, the trims, replace, repeat, slice_codeunits, the pads, str_reverse, count_substring / find_substring, the ascii_is_* / utf8_is_* predicates, utf8_center / utf8_replace_slice, the fused expression kernels (am.query, the engines' filters and string predicates), concatenation of view columns (join keys, unions: the views and data buffer lists are appended and the buffer indices of the later parts moved, no string byte is copied) and every host-side row pass (regex, normalisation).

What converts. Anything else reads offsets / data, and the first such read converts the column on the GPU in a command buffer of its own (lengths, scan, byte copy) and keeps the result, so a column is converted at most once. That is str_concat, the splits, match_like, strptime, the byte-counting pads and utf8_zero_fill, binary_slice / binary_reverse / ascii_reverse, casts from strings and the IPC, Parquet, CSV and JSON writers. MetalArray.string_layout reports "view (converted)" for a converted column, am.string_view_conversions() counts conversions process-wide, and ARROWMETAL_TRACE_VIEW_CONVERSION=1 prints the call stack of each one.

Group-by

Dense group ids 0 ..< K come out of GroupByKeys; everything below aggregates over them. There are four shapes, and the point of the current design is that none of them sorts the key column.

Atomic accumulation (Kernels/GroupBy.swift, GroupBySource.swift) is one linear pass. For K <= 1024 each threadgroup keeps a private table in threadgroup memory and merges it into the device table once; above that the updates go straight to device memory. 64-bit sums are a pair of 32-bit atomic adds with an explicit carry, because Metal has no 64-bit atomic add (only min and max, UPSTREAM.md).

Correctly rounded Float64 sums (Kernels/GroupSumExact.swift, GroupSumExactSource.swift) give sum and mean over Float64 values without any group order. A correctly rounded sum does not depend on the order of its terms, so the rows are added where they lie, in two passes of 32-bit atomics. Pass one takes each group's largest exponent (an atomic max of the 11-bit field), counts its values and notes NaN, ±inf and +0.0 in a flags word. Pass two writes each value as an integer in a fixed-point window whose unit is 2^-G of the group's largest exponent's last bit and adds it to a 128-bit two's-complement accumulator, one 32-bit word at a time with the carry (or borrow) taken from each word's own read-modify-write, so the words hold the exact sum modulo 2^128 however the rows interleave. G = 74 - bitlength(rows): 53 significand bits, G guard bits and bitlength(rows) of headroom stay below the sign bit, so no sum can overflow it. A value whose last bits fall below the window is truncated and counted. The finalize, a thread per group, rounds once to nearest-even: with nothing truncated the accumulator is the exact sum, and the mean is the exact sum divided by the count in a long division, rounded once. With d values truncated the exact sum lies strictly between W - d and W + d units; the result is taken when both ends round to the same double, and the rare group whose rounding the window cannot settle (or that holds a NaN other than the canonical one) is summed exactly on the host, from its rows alone. Both come out of one call, which the GroupBy keeps, so a sum and a mean of the same column cost one pair of passes. Up to 1,024 groups each threadgroup accumulates in threadgroup memory and merges once, as the atomic accumulation does.

Sort-free extremes (Kernels/GroupByExtrema.swift) is how min, max and hash_min_max avoid the missing 64-bit atomic. Every element type maps order-preservingly into a 64-bit unsigned key. Pass one takes the extremes of that key's high word with 32-bit atomics; pass two takes the extremes of the low word among only the rows whose high word already equals the winner. Some row attains the winning high word, and among those the smallest low word is the overall minimum, so the answer is exact. Types four bytes wide or narrower skip pass one — their whole key is the low word. A group's four words sit together in one 16-byte entry, a row reads them before it writes and issues an atomic only when it improves on what it read, and no count is kept: a group has a value exactly when its minimum key is at or below its maximum key, which the empty identities never are. Both extremes come out of one call, which the GroupBy keeps, so a min and a max of the same column cost one pair of passes. first / last ride the same kernel over a masked row index.

Counting sort by group id (Kernels/GroupOrder.swift, GroupOrderSource.swift) replaces the argsort for the aggregates that need each group's rows together — list, distinct, product, the central moments, the order statistics. Histogram, scan, scatter: one pass over the keys rather than four over keys and payload. Two scatters share the scan:

  • chunked, when a per-block histogram fits the budget. A "block" is one simdgroup, so a row is ranked against 32 lanes rather than 256, and each block owns a reserved slice of every group's run and fills it in row order. Stability is exact and needs no atomics.
  • atomic, when the group count is large. One atomic bump per row places the rows, then cs_fix (a thread per group) or cs_fix_tg (a threadgroup, bitonic) sorts each run back into row order. Cheap exactly while runs are short, which is the case that forced this path; a longer run falls through to the argsort, so no input is worse off than before.

The ordering is cached on the GroupBy, so N aggregates over one grouping pay for it once.

Segmented reductions come in two shapes. _wide gives a threadgroup to a group and tree-reduces in threadgroup memory; _narrow gives a single thread to a group. Ten million groups of five rows want the second one — the first would launch a quarter of a billion threads to do fifty million adds.

Grouped variance, stddev, skew, kurtosis (Kernels/GroupMoments.swift) run the two-pass shifted algorithm over that ordering in true binary64: an exact d_add sum for the per-group mean, then the sums of d = x - mean and d^2 (and d^3, d^4) about it, finished as m2 = (sum d^2 - (sum d)^2 / n) / (n - ddof). Shifting first is what makes it accurate — sum x^2 - (sum x)^2 / n cancels catastrophically on data far from zero — and the (sum d)^2 / n term removes the error in the mean itself. On 200k float64 values offset to 1e9 in 997 groups, the largest relative error against an exact rational reference is 1.9e-16; pyarrow's own answer on the same input is 2.5e-10 off. The Float32 deviations this replaced cost about 1e-5.

Finalizers write the output column and its validity on the GPU. A Swift loop over the group count is invisible at a thousand groups and is the whole cost at ten million.

Before and after (Apple M4 Max, best of five, 10M rows, one group_by per call), 2026-09-06

grouped aggregate groups before after change
min (int64 values) 1,000 18.7 ms 2.7 ms 6.9x
min 100,000 35.8 ms 3.5 ms 10.2x
min 10,000,000 1189 ms 17.0 ms 70x
max 1,000 18.8 ms 2.7 ms 7.0x
max 100,000 36.0 ms 3.5 ms 10.2x
max 10,000,000 1228 ms 17.8 ms 69x
mean 10,000,000 338 ms 30.3 ms 11x
variance (float64, ddof=1) 1,000 512 ms 6.5 ms 79x
variance 100,000 500 ms 11.1 ms 45x
variance 10,000,000 823 ms 34.8 ms 24x
first 100,000 10.7 ms 4.4 ms 2.4x
first 10,000,000 480 ms 18.1 ms 27x
list 1,000 20.3 ms 6.7 ms 3.0x
list 100,000 20.8 ms 11.6 ms 1.8x
sum any unchanged (already the atomic path)
count any unchanged
count_distinct any GPU hash set over (group, value) (Where ArrowMetal needs to improve)

At 50M rows, against the fastest idiom of Polars / pyarrow / pandas (Benchmarks/results/full_matrix_2026-09-07-parallel.csv): min 3.32x / 4.93x / 4.57x at 1k / 100k / 10M groups, where it was 0.25x / 0.56x / 0.21x; variance 0.96x / 2.52x / 4.06x, where it was 0.05x at 100k; first 7.95x / 5.81x / 5.12x; list 10.85x / 9.72x / 5.70x. Two caveats on those ratios. At a thousand groups every numeric grouped aggregate lands near 3.3x to 4.1x, sum and count included, because a fresh group_by([...]) spends about 7 ms of the 10 ms rebuilding the dense key mapping — reuse the object and the aggregate itself is 3 ms. And count_distinct by key was the remaining shortfall and is now 3.4x to 7.2x the fastest CPU idiom (Where ArrowMetal needs to improve) — the sort of the key column is gone from every aggregate.

Latency (small inputs)

Measured floor on M4 Max: an empty kernel with encode + commit + wait costs about 60-70 µs measured (The persistent-kernel result), 110-160 µs per call for sum and filter at 1,000 rows in the matrix's latency family; ten kernels in one command buffer cost ~100 µs in total. So the fixed cost is the round trip, not the kernel, and the only lever is fewer round trips.

Unbatched, every public call is one command buffer. filter already fuses its three kernels into one command buffer with a GPU scan.

Batched execution (MetalContext.batch { }, am_batch_begin/end in C, with am.batch(): in Python): kernels append to one serial compute encoder; results are created immediately with their buffers, but a result whose length the GPU decides (filter) or whose null count needs a bitmap read is marked pending. Any CPU-side read (a reduction, length, nullCount, subscripts, export) triggers a sync point: the open command buffer is committed and waited once, deferred fix-ups run (lengths, null counts, take bounds errors), and a fresh batch is opened so the caller keeps batching. While any batch is open the pool parks returned buffers instead of recycling them, so pending GPU work can never observe a reused buffer. Per-thread batches; nested batch calls join the outer one.

Lengths flow on the GPU. Every kernel takes its element count as device const uint* nPtr. A pending filter result binds the GPU-written total as that pointer and is dispatched at its maximum possible size, so the next kernel (compare, arithmetic, cast, bitmap ops, another filter, a reduction's partial pass) never needs the CPU to know the length. A reduction still syncs once to read its partials.

Float64 without hardware doubles. Kernels/DoubleMath.swift carries a software IEEE-754 binary64 (d_add, d_sub, d_mul, d_div, d_sqrt) over ulong bit patterns, correctly rounded. Sum accumulates with d_add; arithmetic kernels run one element per thread. d_div is a Newton reciprocal seeded by one hardware float division, with the exact 128-bit remainder N - q·D settling the last bit; d_sqrt refines a hardware rsqrt seed with Newton steps in fixed point and settles the last bit with the exact remainder. Both are correctly rounded rather than close, and DoubleMathTests holds them to Swift's own Double bit for bit. add and multiply run at this ~390 GB/s these single-pass rows reach at 50M rows and divide within 15% of it (331 GB/s), so the software arithmetic is all but invisible in a bandwidth-bound query. cast is the one float64 path that still runs on the host, and modulo is the one binary operator with no float64 kernel — both say so in Coverage summary.

The transcendentals are a different story, and worth being explicit about. Kernels/DoubleTranscendental.swift (expm1, log1p, logb, hypot, the ten RoundModes) and Kernels/DoublePower.swift (exp, ln, log2, log10, pow) evaluate in binary64 from end to end, over that same software arithmetic — not by narrowing the column to float, calling Metal's own library and widening back, which is what these kernels used to do for about seven correct significant decimal digits out of sixteen. pow is the one that sets the bar: a 1-ulp result needs the product y·log2 x, which reaches 1024 in magnitude, accurate to 2⁻⁶¹ absolutely — more than a double holds — so log2 x is carried as an unevaluated hi/lo pair whose high part keeps 21 significant bits, and y is split the same way so that y₁·t₁ is an exact double. That is fdlibm's layout, and its Remez coefficients and hi/lo constants are reused verbatim; the three logarithms fall out of the same reduction, more accurately than a direct series would give them.

Accuracy is measured, not derived. DoubleTranscendentalTests compares each function with Foundation over 10⁶ random inputs drawn across its whole domain and prints the ulp histogram; these are those numbers, measured 2026-09-06. The measured column is what the run reports; the test asserts a 2-ulp bound on everything but sqrt, which it holds to bit equality, so a regression of one ulp shows up as a changed number here before it fails the suite. The trigonometric family is a separate kernel and a separate budget: measured 2-5 ulp against a 6-ulp assertion (TrigTests, Coverage summary).

function domain sampled max ulp
sqrt 10⁶ random positive bit patterns drawn uniformly over the exponent range as well as the significand — subnormals, 1e-320 and 1e308 included — plus the perfect squares, their neighbours, every binade and the subnormal range in testSqrtIsCorrectlyRoundedOnAdversarialInputs 0 (bit-identical)
exp -745.2 to 709.78, plus the subnormal-result and near-overflow edges 1
ln 5e-324 to 1.8e308, plus near 1 and the subnormals 1
log2 same 1
log10 same 1
power 10⁶ random pairs, 5·10⁵ negative bases with integer exponents 1

The C99 edge table — x^0, 0^y, 1^y, (-1)^int, infinity and NaN propagation — matches libm bit for bit, and the _checked twins are the unchecked kernel plus a read-only check pass, so they inherit every value and raise at exactly the same boundaries.

The price is throughput, and it is the cost of the accuracy. At 50M rows on an M4 Max, the old float-detour ln ran at 250 GB/s because it was memory bound; the binary64 one runs at 10 GB/s because it is compute bound on forty-odd software operations per element. That is a 25x drop in throughput for nine more correct digits, and it is the right trade for a library whose whole claim is that a Float64 column means Float64. The float32 kernels are untouched and still take the hardware path.

Async

MetalContext.batchAsync is batch { } without the wait. It records body's kernels into one command buffer exactly as batch does, then commits and hangs an MTLCommandBuffer.addCompletedHandler off it instead of spinning. The deferred fix-ups — afterFlush closures (pending lengths, null counts, take bounds errors), pool.releaseParked(), the openBatches decrement — run on the GPU's completion thread, so the calling thread is released the moment the work is committed. Two forms:

let kept = try await ctx.batchAsync { try amount.filter(where: .gt, 100) }   // suspends, no thread held
ctx.batchAsync({ try amount.filter(where: .gt, 100) }) { result in ... }     // returns immediately

flush is split into detachBatch() (end encoding, unhook from the thread) and finishBatch(_:) (the post-completion fix-ups, which touch no thread-local state); the synchronous path calls both back to back around its wait, the async path puts the completion handler in between. Batches stay per-thread: body runs synchronously, before the first suspension, so the batch is opened, filled and detached without a thread hop. Nested calls join the enclosing batch, as with batch.

Sync points still exist inside body. A reduction, length or nullCount of a pending filter result, a subscript or an export commits the open batch and blocks right there, exactly as in batch { }. So body should return something that does not force a sync — a pending array from filter, compare, take, cast or arithmetic. Its length, null count and contents are resolved by the time the await returns. For a scalar there is an async accessor: MetalArray.sumAsync() (and meanAsync()) records the reduction kernel in an async batch and combines the per-threadgroup partials on the completion path, so nothing blocks. Deferred errors are thrown from the await (or delivered as .failure), not from the recording.

Not available: async accessors for min/max/group-by, and cancellation (a committed command buffer runs to completion).

CPU/GPU router

Every public call without a batch pays one command-buffer round trip (above), and on small inputs that floor is the whole cost. The router is the answer the engine gives to that: for seven operations it has a second implementation, a tight single-threaded CPU loop, and it runs that loop when its crossover table says the CPU is faster for an input of this size (the table and what its CPU side measured are below). The output is the same Arrow array either way, byte for byte.

What is routed. sum, min, max (and mean, which is sum over the valid count), compare (scalar and array), add / subtract / multiply (scalar and array), filter by a mask and the fused filter(where:), and GroupBy.sum over integer values (and GroupBy.sumUnsigned, uint64 kept unsigned, which the C ABI and Python use for uint64) with at most Router.groupBySumMaxKeys (1,024) keys, which is also what hash_sum reaches through the dense-key mapping. Every other operation is unchanged and always runs its GPU kernel.

Where the decision is made. Once, at the top of each routed operation's public entry point (Kernels/Reductions.swift, Compare.swift, Arithmetic.swift, Filter.swift, GroupBy.swift), before anything is encoded: if Router.decide(.sum, self).isCPU { return RouterCPU.sum(self) }. Not in MetalContext.run, which sees an encode closure and neither the operation nor the row count. The code is in Sources/ArrowMetal/Router/.

The decision, in this order (Router.decide):

  1. No CPU path, no choice. If the operation has no CPU loop for this input the GPU runs it, as before the router, and the decision records why ("no alternative: ..."). That is Float32 min / max, Float32 arithmetic and the fused Float32 filter(where:) predicate, which compute in hardware float on the GPU, where subnormals are flushed (Evaluation method, finding 1), so a CPU loop would not give the same bits; divide, which is not routed; and a group-by sum over more than 1,024 keys.
  2. Pending input. An input whose length the open batch is still deciding stays on the GPU.
  3. Mode gpu runs the GPU.
  4. Inside a batch, the GPU. MetalContext.batch shares one command buffer's floor across every kernel in it, and queued kernels may be producing this very input; a CPU read would have to commit and wait for them, which is what batching exists to avoid. So any open batch keeps the routed operations on the GPU, including under mode cpu.
  5. Mode cpu runs the CPU loop.
  6. Mode auto (the default) looks the operation up in the crossover table: below the crossover the CPU loop runs, at or above it the GPU. The table was measured on int64 columns; integer columns of every width use those rows, and floating-point columns, which have no measured crossover, stay on the GPU. The arithmetic row was measured on add, and subtract uses it. multiply, whose 64-bit integer form costs more per element on the CPU than an add, has its own row (Router.crossoverRows(arithmetic: .mul)), fitted from the shipped multiply loop (below).

The router does not adjust for residency: every routed call starts from Metal-shared buffers (an imported column is mapped before the operation is called), which is the state the table was measured in. It keeps no state across calls beyond the table in force, the modes and the last decision, and it never times or estimates anything at call time: Router.route, the rule every routed call goes through, is a pure function of the operation, the value type, the row count, the mode, the batch state and the table row.

The table is data generated from the measurement, not typed: Benchmarks/router_table.py --from-check Benchmarks/results/router_check_2026-09-24.csv fits it from a Benchmarks/router_check.py run, which times every routed operation with the router pinned to gpu and to cpu, so the CPU side is the shipped RouterCPU loops, and writes Sources/ArrowMetal/Router/RouterTable.swift, a Swift literal, so there is no resource bundle to load. The step alone would send every size between the last measured size where the CPU was ahead and the first where the GPU was ahead to the CPU, so the script fits the crossover once, offline: inside that bracket both paths are taken as straight lines between the two measured points and the crossover is where they meet. It never leaves the measured bracket. multiply has its own row, fitted the same way from the multiply(int64, 3) rows, because a 64-bit integer multiply costs the CPU more per element than an add. router_table.py --check fails when the committed table no longer matches the results file it names, and python/tests/test_router.py runs it. The values are in the generated file, in Router.crossoverRows(_:) and Router.crossoverRows(arithmetic:) from Swift, am_router_crossover and am_router_multiply_crossover from C and am.router_crossovers() from Python.

Benchmarks/results/router_check_2026-09-24_after_refit.csv is the same check run against the refitted table: auto took the faster path in 71 of 72 cases. The one miss is compare(int64 > int64) at 3M rows, by a margin of 1.10, because the compare row is fitted on the scalar compare. Both runs compare the GPU with a single-core CPU loop; a background process holding one of the sixteen cores does not enter either side.

The table can also be generated from Benchmarks/results/router_2026-09-17.json, the size sweep behind The crossover (router_table.py --json), whose CPU side is the bench's own single-core loops in Sources/ArrowMetalBench/main.swift rather than RouterCPU; the generated file's header says which source it came from, and --check regenerates from that source.

A table per machine. python -m arrowmetal.router calibrate [--quick] runs the same sweep on the machine it is run on, fits it with the same code (python/arrowmetal/_router_fit.py, which router_table.py loads too) and writes ~/.arrowmetal/router/<chip>.json. At first use the router takes ARROWMETAL_ROUTER_TABLE (a path, or shipped), else that file for this chip, else the shipped table; python -m arrowmetal.router explain <op> <rows> prints a decision with the table row it came from. The crossover describes the pipeline and the JSON format.

The CPU side (Router/RouterCPU.swift) is one generic loop per shape, over the Arrow layout: reduce (a branch-free fold where a null slot feeds the operation's identity), map (arithmetic, every slot including those under nulls, as the GPU computes them), map-to-bitmap (compare, 32 bits per word, bits past the length zero), compact (filter, by 64-bit selection words), and a dictionary loop for the group-by sum (a keyCount-slot table of wrapping 64-bit sums and counts). They are single-threaded; CROSSOVER.md also records the bench's all-core loop for reference. CPUReference stays the tests' oracle; its closure-per-element walk is one to two orders slower than these loops (CROSSOVER.md, cpu-ref).

Byte-identical output. For every routed operation the CPU loop returns what the GPU kernel returns: the same values, the same validity buffer (the scalar forms share the input's bitmap exactly as the GPU does; the array forms AND the two bitmaps), the same null count and the same buffer sizes. Integer arithmetic wraps as the GPU does. Float64 arithmetic is the hardware add, subtract or multiply, which is correctly rounded like the software d_add / d_sub / d_mul, with the software kernels' NaN rule applied when the result is a NaN (the first NaN operand, quieted; otherwise the positive quiet NaN). Float64 min / max compare the GPU's order-preserving keys, so NaN is skipped and -0 and +0 are one key. Float sums reproduce the GPU's summation order exactly rather than adding in row order: thread t of the groups x 256 grid adds its 4-element blocks t, t + grid, ... and then tail element t, each threadgroup folds its 256 accumulators as the kernel's tree does, and the host adds the group partials in order, as finaliseSum does. The sum is therefore the GPU's to the bit, NaN payloads included, with no Accelerate and no tolerance. RouterTests (Swift) and test_router.py (Python) hold both paths to this on every type, on sizes across the word and threadgroup boundaries, with 0%, 10% and 90% nulls, on slices at an offset, with signed zeros, infinities, quiet and signaling NaNs with payloads and subnormals, and on float sums past the point where every GPU thread takes more than one block.

Overrides and visibility.

Row Process One call or one scope Last decision
Environment ARROWMETAL_ROUTER=auto|gpu|cpu, read at load
Swift Router.mode Router.withMode(.cpu) { try col.sum() } (per thread) Router.lastDecision (per thread)
C am_router_set_mode, am_router_get_mode am_router_set_thread_mode (-1 clears) am_router_last, am_router_last_reason
Python am.set_router("cpu"), am.get_router() with am.router("cpu"): am.last_route() -> RouteDecision(op, path, reason, rows)

The per-thread override wins over the process mode. Bindings other than Python reach the router through the C functions above; no operation signature changed.

What the router does not do. It does not remove the floor, and it does not touch the operations whose cost is not the floor (memory-bound single passes, software binary64 transcendentals, host regex, views, temporal extraction: Where ArrowMetal needs to improve). It chooses between ArrowMetal's own two paths, never a CPU library. Strings are not routed: the string rows are behind a 12-core Acero, and a single-threaded loop would not change that.

Top-k and order statistics: GPU radix select

top_k, quantile and approximate_median all ask the same question — which rows sit at which rank — and all of them used to answer it by sorting the whole column. Sorting 50M Int64 is eight LSD radix passes and a gather, about 130 ms; the answer needs a few hundred bytes of it. Radix select replaces the sort with two passes over the column, whatever k is.

The algorithm

Everything runs on the order-preserving key: the same map argsort uses (TopKSource.keyMap), so nulls, NaN, -0.0 and the ascending/descending flag behave identically and the answer is exactly argsort(descending:)[..<k], index for index.

  1. Histogram (rs_histogram, Kernels/RadixSelectSource.swift). One pass counts the top 8 bits of every non-null row's key. The counts are kept per simdgroup-sized sub-block — one 256-bin private histogram per simdgroup in threadgroup memory, so the only atomic contention is between 32 lanes — and written digit-major as counts[digit * subBlocks + sub]. Summed over sub-blocks that is the global histogram; left per sub-block it is exactly the offset table step 3 needs. That double duty is what keeps the fast path at two passes instead of three.
  2. Pick the bin. The host walks the 256 totals to the first digit whose running total reaches k. Rows with a smaller digit are guaranteed winners — there are fewer than k of them — and the rest of the answer lives in that one bin, about n/256 rows for well-spread keys.
  3. Compact (rs_scatter). A second pass writes the winners and then the bin's rows, both in ascending row order, into one array of (key, row) pairs. Every winner's key is smaller than every bin key, so a stable sort by key alone turns that array into (key, row) order — the total order — with no second sort key and no 96-bit comparisons. Because each sub-block owns a contiguous slice of the output, the scatter needs no threadgroup barriers: a simd_prefix_exclusive_sum per 32 rows is the whole ranking.
  4. Refine. The bin is still ~n/256 rows, far more than the answer, and it would dominate the final sort. So steps 1-3 run again on the compacted array and the next digit — microseconds of work that shrinks the bin by another factor of 256. One refinement round is enough; the loop stops when the bin is under TopK.refineCap (4096) rows.
  5. Order. Up to TopK.bitonicCap (2048) survivors are sorted by a bitonic network in a single threadgroup (rs_sort_small, one dispatch, the slot index as the tie-break), which covers every k up to about 1024. Above that the stable LSD radix sort orders them and the first k rows are the answer.

Ties. Heavily tied data can put nearly every row in the chosen bin, which would make step 3 copy the whole column. Step 2 therefore repeats on the next digit without compacting while the bin is over the candidate budget (1M rows). When the key runs out of digits every remaining row is an exact tie, and ties are settled by row order, so the scatter writes only the first k - winners of them: the output is exactly k pairs however many rows share the key.

The same in-place narrowing covers the other skewed case: a float64 key's top byte is the sign plus seven exponent bits, so a column of uniform doubles concentrates in a handful of bins rather than spreading over 256, and a rank in the middle of one of them costs one extra pass before the bin fits the budget.

Nulls. The histogram and the scatter both skip invalid rows, so the selection is over the non-null rows only. When fewer than k rows are non-null the answer has to reach into the null rows, which only the sort places — that case, and k above half the non-null rows (where the compaction would copy most of the column anyway), fall back to argsort.

Order statistics: selecting a value, not rows

A quantile wants the value at a rank, not the rows, so Kernels/RadixSelectValue.swift runs the same narrowing with the winners counted but never written (wantLt = 0). Once the bin is under 4096 keys it is read back and finished on the host, which ends the round trips immediately instead of after another six digits. kthElement(k, largest:) is that search plus the inverse key map; quantile(q) asks for the one or two adjacent ranks that bracket the interpolated position, and because they are adjacent one search usually settles both. approximateMedian() is quantile(0.5).

The reconstructed value is exact for every integer type. For floats it differs from indexing a sorted copy on exactly the two values the key deliberately merges: -0.0 comes back as +0.0 and a NaN loses its payload. Both are values Arrow's total order calls equal, and quantile returns a Double, so its answers are unchanged.

tdigest still sorts: its centroids are defined by the whole sorted sequence, not by one rank, so selection cannot produce the same digest.

Which path runs

condition path
k > 1024, or n >= 2^19 radix select
k <= 1024 and n < 2^19 per-threadgroup selection (Kernels/TopK.swift) — one dispatch, no mid-flight readback, so it wins where latency rather than bandwidth decides
fewer than k non-null rows, or k > (non-null)/2, or a type with no key map full argsort

The crossover at 2^19 rows is where radix select's two passes plus one host readback start beating a single selection dispatch. Below it the per-threadgroup kernel is one command buffer; above it, it is bandwidth that matters and radix select reads the column at close to peak.

Numbers (M4 Max, 50M rows)

Called from Python on the same in-process data (PYTHONPATH=python python Benchmarks/python_gpu_bench.py 50000000 5), against pyarrow 25, Polars 1.44 (16 threads) and numpy 2.5. Wall ms, from Benchmarks/results/python_gpu_bench_50000000_2026-09-07.txt. Before radix select, top_k(10 000), top_k(100 000) and both quantiles ran a full argsort or sort of the column.

Operation (50M rows) ArrowMetal pyarrow Polars numpy
top_k(100), Int64 2.65 24.59 select_k_unstable 67.35 183.58 argpartition
top_k(10 000), Int64 3.02 34.78 62.06 182.37
top_k(100 000), Int64 3.16 154.78 61.29 183.50
quantile(0.5), Int64 2.56 275.87 70.09 median 280.94 median
quantile(0.5), Float64 2.27 353.04 126.01 median 362.31 median

That is 9.3x / 11.5x / 49.0x pyarrow's select_k_unstable at the three k, and 108x / 156x pyarrow's quantile. The target was 3x. top_k is flat in k, because k only changes how many of the compacted candidates the final ordering has to sort, not how much of the column is read — while pyarrow's heap-based select_k_unstable goes from 24.6 ms to 154.8 ms over the same range.

The column is read twice, so the useful bandwidth figure is 2 x 400 MB over the wall time: 302 / 265 / 253 GB/s for the three top-k, against a device peak of ~546 GB/s. The gap is the mid-flight readback — the host reads 256 counts to pick the bin, so the two passes cannot share a command buffer — plus the final ordering, which grows with k.

The CPU stays free throughout: 1.4-1.8 CPU-ms for the top-k calls and 0.9-1.2 for the quantiles, against 24.6-362.3 CPU-ms for the CPU libraries on the same questions (pyarrow, Polars and numpy; Benchmarks/results/python_gpu_bench_50000000_2026-09-07.txt, rows 259-278).

The radix sort's block size is also now adaptive below ~256k rows: a fixed 4096 elements per block left a 20k-element sort — the size top-k's final ordering lands on — running on five threadgroups. Inputs above ~256k rows are unaffected, so the 50M argsort is unchanged.

Index arrays: UInt32 row numbers

Every call that returns row numbers into its input returns them as UInt32 (Arrow uint32, format I): argsort (numeric, boolean, temporal, utf8 and binary), topK, partitionNthIndices, the lexsort, the hash join's index pairs (hashJoin, JoinExtra.indices), the ranks (rowNumber, rank, denseRank, maxRank, rank(tiebreaker:), and the engine's row_number / rank / dense_rank window columns), GroupByKeys.representativeRows() and GroupByKeys.rowIndices(_:_:). UInt32 is the width the Int32 arrays had, so the kernels, the bytes they move and the values they write are the same; it is the type arrow-rs sort_to_indices and Polars' IdxSize use. pyarrow returns uint64 for the same functions, with the same values. Before, a row number of 2^31 or more came back negative in an Int32 array.

Arrays hold at most 2^32 - 1 elements, so every row number fits. A call whose row numbers would pass 2^32 - 1 is refused rather than wrapped, with the error "<op>: row N does not fit the UInt32 index type (row numbers go up to 4294967295)" (Dispatch.checkIndexRows). take accepts Int32, Int64 and UInt32 indices, as Arrow's does; an Int64 index the 32-bit gathers of nested, fixed-size-binary and interval columns would have to narrow is checked against the range first, so it is an error and never another row.

The integer outputs that are not row numbers into the input keep their types: dictionary codes and index_in positions are int32 (Arrow's defaults), group ids are int32 (a group-by holds at most 2^30 groups), inverse_permutation is Arrow's signed int32 and refuses an index column of more than 2^31 rows, list_parent_indices() is int32 and refuses a list column of more than 2^31 rows (listParentIndices64() is int64), and indices_nonzero is uint64, as in Arrow. The hash join takes at most 2^31 - 1 rows per side and returns at most 2^31 - 1 pairs.

Timed against the Int32 build, alternating in one process (Benchmarks/index_type_bench.py, Benchmarks/results/index_type_2026-10-01.csv): an untimed warm-up of at least 100 ms per call, 8 rounds of up to 30 calls, and the three calls whose median moved by more than 1% timed again alone, 20 rounds of 100 calls (marked *). No call is more than 1% slower in both best and median time:

Call 10M best (ms) 10M median (ms) 50M best (ms) 50M median (ms)
argsort int64 7.410 → 7.412 7.555 → 7.563 37.399 → 37.395 38.297 → 38.251
argsort float64, 5% nulls 7.860 → 7.858 8.054 → 8.021 40.840 → 40.753 45.007 → 44.756
argsort utf8 20.402 → 20.339 20.928 → 20.773 120.588 → 121.152 122.698 → 122.940
top_k 10 int64 * 3.898 → 3.811 4.878 → 5.243 6.106 → 6.190 6.915 → 6.960
top_k 5000 float64 * 3.546 → 3.572 4.709 → 4.645 4.491 → 4.494 4.839 → 4.766
partition_nth_indices int64 * 5.592 → 5.554 8.012 → 8.235 11.828 → 11.800 13.081 → 13.070
lexsort (int32, float64) 11.991 → 11.898 12.257 → 12.229 70.462 → 70.462 71.681 → 71.602
rank int32 6.491 → 6.507 6.705 → 6.694 44.901 → 44.798 46.054 → 45.976
group_by keys, 1000 groups 2.660 → 2.687 4.990 → 4.334 4.653 → 4.653 4.798 → 4.772
take int64 through its argsort 1.646 → 1.641 1.704 → 1.702 10.207 → 10.222 10.307 → 10.318
join indices int64, n × n/4 5.103 → 5.098 5.268 → 5.257 44.425 → 44.229 50.299 → 49.478

A median of a call of a few milliseconds moves by several percent from one block to the next (top_k 10 at 10M: 5.453 → 5.012 in the first block, 4.878 → 5.243 alone); a loss shows in both columns.

Chunked columns and the import

A column often arrives as many Arrow arrays: a pyarrow ChunkedArray, a multi-chunk Polars Series, one column of a stream of arrow-rs RecordBatches. am_import_chunks (Sources/ArrowMetal/ChunkedImport.swift) takes all of them in one call and writes each chunk straight into the final page-aligned Metal buffers, with no concatenated copy in between. The work is split by output position, not by chunk, into pieces that the copy threads take one after another, so 6,104 chunks of 8,192 rows and 16 large chunks spread alike, a thread on a slower core takes fewer pieces, and no two threads write the same byte:

  • fixed-width values: one memcpy per chunk piece, from the chunk's Arrow offset;
  • bitmaps (validity, boolean values): assembled 64 output bits at a time from each chunk's bits at its own bit offset, since chunk boundaries are generally not byte aligned; a chunk without a bitmap, or with a null count of 0, contributes set bits, and the null count is counted from the merged bitmap;
  • utf8 / binary and their large variants: offsets rebased by a prefix sum of the chunks' byte lengths and narrowed to int32 (a total of 2 GB or more is refused, as for one large_utf8 array), bytes copied;
  • utf8_view / binary_view: views copied with each out-of-line view's buffer index and offset rewritten. Data buffers shared by several chunks (the slices of one array) are taken once; up to 64 distinct buffers are wrapped without a copy, more are copied back to back into merged buffers. A single utf8_view array with more than 64 data buffers takes the same path.

Dictionary, nested, run-end encoded and extension types are not taken: the call returns 3 without reading or moving anything, and the bindings concatenate those columns first, as before.

The view pass. The views are rewritten by am_rewrite_views (Sources/CArrowMetalCopy), C with NEON intrinsics: vld4q_u32 loads four views and splits them into lengths, prefixes, buffer indices and offsets; every lane is rewritten with vector selects (an inline view is kept as it is, an out-of-line one gets its merged buffer index and its offset plus its buffer's base) and checked in the same instructions: length and offset below 2^31, buffer index below the chunk's buffer count, offset plus length within its buffer. vst4q_u32 writes the four back, and the lengths of the valid rows are summed for the column's byte total. Nothing branches per view. A chunk with one data buffer uses its target as a constant; a chunk with several loads each lane's target by its buffer index. A block of 2,048 rows with a view that fails the check is rewritten again view by view, which gives that view an index no buffer has, so it reads as the empty string, as after the single-array import. On one thread (ARROWMETAL_IMPORT_THREADS=1) three 50M-row utf8_view columns in 16 chunks import in 85.5 ms, against 339.3 ms with the view-by-view loop it replaces (6,104 chunks: 134.5 against 381.6 ms; best of three alternating rounds, Benchmarks/results/chunked_import_2026-09-30.csv).

Copy threads. The number of threads is a measured policy (ImportThreads). 1, 2, 3, 4, 6, 8, 12 and 16 threads were timed with Benchmarks/chunked_import_bench.py --threads at 100,000 to 50,000,000 rows (Benchmarks/results/import_threads_2026-09-30.csv), and Benchmarks/import_threads_policy.py turns the sweep into two tables with one rule per import: a thread count is allowed when its CPU time is at most 32 ms or at most twice the one-thread import's, whichever is larger, and the policy takes the fewest threads that come within 5% of the fastest allowed count. Copies have no CPU limit from the size where the limit costs wall time: the smallest measured size from which, at every larger size too, some type and chunk layout is more than 5% slower under the limited count than at its own fastest count. In the sweep that is 381 MiB per column (50M int64 rows in 16 chunks: 4 threads within the limit, 7.0 ms, against 6.5 ms on 16), and the sizes below it, up to 305 MiB, lose nothing to the limit; from there a copy takes the fastest count. Sizes are grouped by the bytes an import writes (both chunk layouts together), and between two measured sizes the boundary is their geometric mean:

copies (values, string bytes, offsets, bitmaps, view data buffers): bytes written threads
under 1.1 MiB 2
1.1 to 2.7 MiB 1
2.7 to 5.4 MiB 2
5.4 to 108 MiB 8
108 to 341 MiB 12
341 MiB and more (no CPU limit) 16
the utf8_view / binary_view view pass: bytes of views threads
under 2.4 MiB 4
2.4 to 5.4 MiB 8
5.4 to 10.8 MiB 12
10.8 to 21.6 MiB 8
21.6 to 216 MiB 12
216 MiB and more 8

Below about 2 MiB the copies are split into pieces of at least 1 MiB, so the first row's two threads run one piece; 250,000 int64 rows in 16 chunks take 0.10 ms on 1 to 16 threads. A memory copy of the largest columns stops gaining wall time at four threads until all sixteen cores run it, and those sixteen take 5.6-6.0x the one-thread CPU time in 16 chunks (4.2-4.5x in 6,104 chunks); the view pass, bound by its arithmetic, divides its wall time by the thread count at almost no CPU cost up to six. Wall ms / CPU-ms, three columns, in 16 chunks and (last column) one utf8_view array with 6,104 data buffers, best of 5 runs (Benchmarks/results/import_threads_2026-09-30.csv; the last column is its single_import columns):

threads int64 10M int64 50M utf8 50M utf8_view 50M utf8_view 50M, one array
1 3.63 / 3.6 16.5 / 16.5 35.4 / 35.4 85.5 / 85.5 139.3 / 139.3
2 2.52 / 5.0 9.2 / 18.4 19.4 / 38.5 43.3 / 86.4 72.3 / 142.3
4 2.27 / 8.9 7.0 / 27.6 14.3 / 56.2 22.2 / 87.3 41.2 / 158.8
6 2.26 / 13.2 7.0 / 41.1 14.1 / 83.3 15.1 / 88.7 32.1 / 182.2
8 1.46 / 10.7 6.9 / 54.3 14.1 / 110.1 14.3 / 110.7 27.4 / 204.3
12 1.43 / 15.8 6.9 / 80.9 13.9 / 162.9 14.2 / 163.9 27.0 / 298.4
16 1.36 / 19.2 6.5 / 98.3 13.3 / 198.7 13.9 / 204.7 26.3 / 374.2

am_set_import_threads(n) (Python am.set_import_threads, Rust arrowmetal::set_import_threads, Swift ImportThreads.limit) or the environment variable ARROWMETAL_IMPORT_THREADS caps every step at n threads; 1 keeps the import on the calling thread, 0 is the policy.

The page probe. A copy-free import has to know that every page it wraps is mapped. The probe used to be mincore, which reports on every page. mach_vm_region answers per VM region, and a large allocation is one region. Three page-aligned pyarrow int64 columns of 50M rows (400 MB each) imported in 15.71 ms and 15.7 CPU-ms with mincore and import in 0.05 ms with mach_vm_region; at 10M rows 3.10 against 0.02 ms, at 1M rows 0.32 against 0.01 ms (best of two rounds, the single_import columns of the int64 rows in 16 chunks of Benchmarks/results/chunked_import_2026-09-28.csv, where old is the build with mincore). A buffer that is not page aligned is still copied.

In practice pyarrow's buffers of 1M rows and more (pa.array from numpy, compute results, combine_chunks) start on a 16 KiB page and are wrapped; so are arrow-rs concat results of fixed-width and utf8 columns at 1M rows and more.

Measured (M4 Max, Benchmarks/chunked_import_bench.py, three columns per case, the previous build (c4e2f6f, which copied on all sixteen cores) and this one alternating; Benchmarks/results/chunked_import_2026-09-30.csv). At 10M rows: three rounds, best of 5 runs per round, best of the rounds. At 50M rows: each case re-timed alone, eight rounds of 100 runs, best (the 1-minute load was 2.8 when the block started). "combine + import" is combine_chunks() and the import of the combined column (one round); "chunked" is am.array(chunked_array):

3 columns chunks combine + import chunked, previous chunked CPU-ms previous / now
int64 10M 16 3.3 ms 1.61 ms 1.54 ms 20 / 11
int64 10M 1,221 4.2 ms 2.80 ms 2.76 ms 20 / 12
utf8 10M 16 6.9 ms 3.37 ms 3.14 ms 40 / 35
utf8 10M 1,221 9.3 ms 4.62 ms 4.42 ms 44 / 37
utf8_view 10M 16 76.3 ms 6.57 ms 3.24 ms 79 / 32
utf8_view 10M 1,221 75.6 ms 11.12 ms 7.89 ms 116 / 65
int64 50M 16 16.5 ms 6.52 ms 6.42 ms 91 / 99
int64 50M 6,104 21.4 ms 14.38 ms 13.88 ms 96 / 106
float64 50M 16 16.5 ms 6.51 ms 6.43 ms 90 / 98
float64 50M 6,104 21.2 ms 14.11 ms 13.74 ms 98 / 108
utf8 50M 16 35.1 ms 13.41 ms 13.21 ms 182 / 198
utf8 50M 6,104 47.6 ms 22.15 ms 21.27 ms 209 / 227
utf8_view 50M 16 372.6 ms 31.19 ms 14.26 ms 392 / 111
utf8_view 50M 6,104 379.3 ms 54.26 ms 38.96 ms 564 / 299

Every case in the table is as fast as on the previous build or faster on the best run, and on the median run every case but utf8 at 50M in 16 chunks, whose median run is 14.77-14.82 ms in each of the eight rounds against 14.21-14.45 ms (Benchmarks/results/chunked_import_2026-09-30.csv, as are the figures below). CPU time: the 50M copies, where the policy has no CPU limit, take 1.09-1.11x the previous build's (and 4.8-6.0x the single-threaded combine_chunks and import, for 0.38-0.65x its wall time); the 10M copies take 0.54-0.88x; the utf8_view pass takes 0.28x at 50M in 16 chunks and 0.41-0.56x elsewhere in the table. At 20M and 30M rows (three rounds) every copy case is within 1.3% of the previous build's best time or faster, at 0.81-1.22x its CPU time (utf8 at 30M in 16 chunks: 8.67 against 8.56 ms, 128 against 104 CPU-ms); at 1M rows within 0.01 ms or faster (utf8_view 0.52 against 0.95 ms in 16 chunks). utf8_view imports 1.08x the time of utf8 in 16 chunks (14.26 against 13.21 ms) and 1.83x in 6,104 chunks (38.96 against 21.27 ms): there its 6,104 data buffers are copied into one merged buffer, so the column moves 16 bytes of view per row plus the string bytes, against 4 bytes of offset per row plus the string bytes for utf8.

One utf8_view array with 6,104 data buffers (combine_chunks of the 6,104-chunk column above) imports in 26.5 ms and 274 CPU-ms (previous build: 44.8 ms and 548 CPU-ms; the single_import columns of Benchmarks/results/chunked_import_2026-09-30.csv; the build before the merged copy path: 150.5 ms and 143 CPU-ms, Benchmarks/results/chunked_import_2026-09-28.csv); at 10M rows in 5.6 ms and 59 CPU-ms against 9.2 ms and 112 CPU-ms (chunked_import_2026-09-30.csv).

Through the engines, with the previous build alternating: Benchmarks/polars_engine_bench.py --sizes 2000000,50000000 --cases a,c,i,t1,w1,x2, MetalEngine with the import cache cleared, takes 0.98-1.01x of the previous build's best time at 50M rows (three rounds) and 0.73-1.06x at 2M rows (the 1.06x, the 200-group sum under the default engine, takes 0.91x of its median); the two 2M cases that differed more with every shape taken, the 100,000-group sum and the join, re-timed alone over eight rounds of 100 runs, take 0.92x and 0.98x of its best time and 1.02x and 1.00x of its median (Benchmarks/results/chunked_import_polars_2026-09-30.csv). The DataFusion rule's sorts (datafusion/examples/bench.rs --families sort, rule on, two rounds, datafusion/results/datafusion_sort_import_2026-09-30.csv) take 0.93-0.99x of the previous build's time at 50M rows in both table layouts, at 0.91-1.04x its CPU time (int64 key in 8,192-row batches: 68.84 against 70.63 ms), and 0.95-1.05x at 10M rows with 0.54-0.88x its CPU time.

  • Not a query planner or SQL engine. It is the compute layer that DuckDB, DataFusion, Polars plugins or an app can call.
  • Not a tensor library; MLX is that.
docs/EXPR.md

Fused expression queries

One Metal kernel for a whole Arrow compute expression, instead of one kernel per operator.

(a * 2 + b) / (c + 1) - d is five Arrow kernels: five reads of a column, five writes of a temporary, five command buffers (or five dispatches inside one). At 50M rows on an M4 Max that costs 24 ms even when every kernel runs at memory bandwidth, because the data is moved five times.

The expression compiler lowers the whole tree — arithmetic, comparisons, null logic, casts, string predicates — into one runtime-generated MSL kernel, so the inputs are read once and the outputs are written once. Same expression, 16 ms, and the CPU does 0.7 ms of work instead of 2.8 ms.

import ArrowMetal

let batch = try MetalRecordBatch(names: ["region", "amount"], columns: [.int32(region), .float32(amount)])
let total = try batch.query(
    query().filter((col("region") == 2) && (col("amount") > 100)).sum(col("amount"))
).onlyScalar                                   // .double(...)
import arrowmetal as am
am.query(table, am.filter((am.col("region") == 2) & (am.col("amount") > 100)).sum(am.col("amount")))
am.query(table, ((am.col("a") * 2 + am.col("b")) / (am.col("c") + 1) - am.col("d")).alias("r").project())
am.query(table, am.group_by(am.col("k"), 1000).sum(am.col("v") * 3 + 1, "s"))

What it is

File What lives there
Sources/ArrowMetal/Expr/Expr.swift The Expr tree, the operators, the Swift DSL, the canonical text
Sources/ArrowMetal/Expr/ExprPlan.swift ExprQuery (filter + group-by + terminal), the builder, MetalRecordBatch.query(_:)
Sources/ArrowMetal/Expr/ExprSource.swift The lowering: type checking, promotion, CSE, MSL emission, the shared MSL helpers
Sources/ArrowMetal/Expr/ExprCompiler.swift Kernel assembly, the compiled-kernel cache, buffer binding
Sources/ArrowMetal/Expr/ExprKernels.swift project / filter+project / aggregate terminals
Sources/ArrowMetal/Expr/ExprGroupBy.swift The group-by terminal (privatised and device-wide atomic tables)
Sources/ArrowMetal/Expr/ExprText.swift The serialised grammar, parsed and printed
Sources/ArrowMetalC/ArrowMetalC_Expr.swift am_query and the result handle
the block at the end of python/arrowmetal/__init__.py am.col, am.filter, am.group_by, am.query

How the lowering works

The emitter walks the tree once, bottom up, and appends straight-line MSL to one inline void am_row(...) function. Every node becomes two registers: the value and a bool validity.

  • Common subexpression elimination is keyed on the node's canonical text, so a subtree that occurs four times is evaluated once. ExprTests.testDeepMixedTreeWithSharedSubtrees builds a 20+ node tree with a shared subtree used four times and checks it against a Swift oracle.
  • Validity is compiled in, not carried in buffers. A node that cannot be null carries the literal true instead of a register, and the Metal front end folds the whole null chain away. A node that can be null gets one bool; the input bitmaps are read one 32-bit word per 32 rows into a register (am_vword) and the output bitmap is built in a register and stored once per 32 rows.
  • Vectorised loads. The kernels are word shaped: one thread owns 32 consecutive rows (the same shape the existing compare and filter kernels use). When every leaf is a plain numeric column the thread loads its 32 rows as eight four-wide vector loads per column; a boolean or utf8 leaf, whose access is not a contiguous vector, falls back to the scalar path.
  • Float64 has no hardware support on Apple GPUs, so it travels as a raw 64-bit pattern in a ulong and goes through the correctly rounded software binary64 of Kernels/DoubleMath.swift (d_add/d_sub/d_mul/d_div) and the transcendentals of Kernels/DoubleTranscendental.swift. d_add/d_sub/d_mul/d_div and d_sqrt are correctly rounded, so those results are bit identical to Swift's Double — ExprTests.testFloat64ArithmeticIsBitExact asserts add, sub, mul, div, negate, abs and round on bit patterns, and test_expr.py asserts against pyarrow. The transcendentals are bounded to 1-2 ulp, not correctly rounded.
  • Pipeline caching. A compiled query is cached on the canonical query text plus the referenced columns' types and nullability, and the pipeline itself on a hash of the generated source (the same process-wide cache every other kernel uses). Running the same shape again costs one dictionary lookup: ExprTests.testPipelineCacheHit asserts the compile counter does not move.
  • Batching. A query joins an open MetalContext.batch { } like any other kernel, and a filtered project inside a batch produces pending arrays whose length the GPU decides, so a second query can consume them without a CPU round trip (ExprTests.testChainedQueriesInOneBatch).

Kernel shapes

Terminal Dispatches Passes over the input
project, no filter 1 1 read, 1 write per output
project + filter 3 (count, scan, scatter) 2 reads, 1 write — the standard compaction pipeline
aggregate (± filter) 1 1 read; threadgroup partials, finished on the CPU
group_by (± filter) 2 (accumulate, merge) 1 read; privatised 32-bit atomic tables

The filtered project reads its inputs twice on purpose: the output positions are only known after the scan, and re-evaluating the expression for the selected rows is cheaper than materialising every intermediate. The predicate is compiled into the counting pass, so no boolean array is ever built.

Group-by privatises a table of keyCount × aggregates slots in threadgroup memory while that fits in 2048 slots (24 KB of the 32 KB budget); above that it uses device-wide atomics, as Kernels/GroupBy.swift does. Sums are 64-bit through a lo/hi carry pair, because MSL has no 64-bit atomic add.

The grammar

Every query serialises to an s-expression. It is what Expr.description prints, what ExprQuery(text:) parses, what am_query takes over the C ABI, and what Python's .sexpr() produces. include/arrowmetal.h carries the same grammar for C consumers.

query   := "(query" filter? group_by? terminal ")"
filter  := "(filter" expr ")"
group_by:= "(group_by" INT "\"name\"" expr ")"
terminal:= "(project" ("(as \"name\"" expr ")")+ ")"
         | "(aggregate" agg+ ")"
agg     := "(" ("sum"|"min"|"max"|"mean"|"count") "\"name\"" expr? ")"

expr    := "(col \"name\")"
         | "(int" INT ")" | "(float" NUM ")"              -- untyped: adapts to the other operand
         | "(i8"|"i16"|"i32"|"i64"|"u8"|"u16"|"u32"|"u64" INT ")"
         | "(f32"|"f64" NUM ")" | "(bool" true|false ")" | "(str \"...\")" | "(null" TYPE ")"
         | "(" BINOP expr expr ")" | "(" UNOP expr ")"
         | "(cast" expr TYPE ")"
         | "(if_else" expr expr expr ")" | "(coalesce" expr+ ")" | "(fill_null" expr expr ")"
         | "(is_null" expr ")" | "(is_valid" expr ")" | "(is_in" expr literal+ ")"
         | "(str_eq"|"starts_with"|"contains" expr "\"pattern\"" ")"
BINOP   := add sub mul div | eq ne lt le gt ge | and or and_kleene or_kleene
         | bit_and bit_or bit_xor shl shr
UNOP    := negate abs sqrt exp ln round not bit_not signbit is_nan
TYPE    := i8 i16 i32 i64 u8 u16 u32 u64 f32 f64 bool str

Example:

(query (filter (and (eq (col "region") (int 2)) (gt (col "amount") (int 100))))
       (aggregate (sum "total" (col "amount"))))

Types and promotion

Checked against pyarrow.compute (test_expr.py), and applied to both operands of every binary operator:

  1. float64 with anything numeric → float64.
  2. float32 with any integer → float32 (Arrow's rule; int64 + float32 is float32, not float64).
  3. Two integers of the same signedness → the wider one.
  4. Mixed signedness → a signed type of max(signed width, unsigned width × 2), capped at int64 (so uint8 + int8 → int16, uint32 + int32 → int64, uint64 + int64 → int64).
  5. boolean and utf8 have no numeric common type: mixing them is an error naming both types.

An untyped literal (col("x") > 100, (int 100)) takes the type of the other operand when that is numeric, so comparing a float32 column with 100 stays in float32. Pin a literal with Expr.typedInt(100, .int32) / am.lit(100, "int32") when you want a specific width.

It takes the other operand's type only when it fits there; a literal that does not fit widens the pair instead of being truncated to it, because truncating would silently answer a different question. An integer literal outside the column's range promotes as if it carried its own smallest type (int8_column > 200 compares in int16, not against (char)200 == -56; uint8_column == -1 compares in int16 and is false everywhere), and a floating literal against an integer column promotes the pair to float64 under rule 1 (int64_column >= 2.5 really does compare against 2.5). The same rule applies inside if_else, fill_null, coalesce and is_in.

sqrt, exp and ln on an integer column promote to float64, as Arrow's do. round puts halves away from zero (Arrow's half_towards_infinity), matching ArrowMetal's existing round.

Sign bit and NaN

(signbit x) and (is_nan x) are boolean, null where x is null, and read the value's bits as stored:

x signbit is_nan
float: -0.0, a negative value, -inf, a NaN with the sign bit set (any payload) true false, except the NaN: true
float: +0.0, a positive value, +inf, a NaN with the sign bit clear (any payload) false false, except the NaN: true
signed integer x < 0 false
unsigned integer false false
boolean, utf8 error error

is_nan is Arrow's is_nan; signbit is NumPy's signbit (Arrow's sign gives 0 for both zeros and NaN for NaN, so it cannot tell -0.0 or a negative NaN apart). Swift: col("x").signBit, col("x").isNaN; Python: am.col("x").signbit(), am.col("x").is_nan(). A float literal keeps its sign in the text form ((f64 -nan), (f64 -0.0)); a NaN literal's payload is not kept.

totalOrder comparisons

The comparison operators are IEEE: a NaN compares false (and ne true), and -0.0 equals +0.0. Arrow's Rust implementation compares floats by IEEE 754 totalOrder instead: -NaN < -inf < … < -0.0 < +0.0 < … < +inf < +NaN. DataFusion 55.1 uses those kernels after rewriting -0.0 to +0.0 on both sides (normalize_float_zero), so for DataFusion the two zeros are equal and the "finite non-zero" row below is the one to use for a zero literal; the -0.0 and +0.0 rows are arrow-rs's order. With signbit and is_nan, a column against a literal c in totalOrder is one expression (x the column, s = (signbit x), n = (is_nan x)):

c x == c x < c x <= c x > c x >= c
finite non-zero, ±inf (eq x c) (or (lt x c) (and n s)) (or (le x c) (and n s)) (or (gt x c) (and n (not s))) (or (ge x c) (and n (not s)))
+0.0 (and (eq x c) (not s)) s (or s (eq x c)) (not (or s (eq x c))) (not s)
-0.0 (and (eq x c) s) (and s (ne x c)) s (not s) (not (and s (ne x c)))

x != c is the not of the x == c column (for a non-zero c that is (ne x c)). Every entry is null where x is null, so a filter drops those rows as DataFusion does. A NaN literal is not in the table: totalOrder tells NaN payloads apart, and the grammar has no bit-pattern equality. ExprSignBitTests checks every cell against the totalOrder keys of Float32 and Float64 columns holding ±0.0, ±inf, subnormals, NaN of both signs with four payloads each, and nulls; python/tests/test_expr.py checks the same against NumPy.

How nulls are compiled

Node Result validity
arithmetic, comparison, bit ops, casts, abs/negate/sqrt/exp/ln/round, signbit, is_nan valid iff every input is valid
and / or valid iff both inputs are valid
and_kleene false as soon as either side is a valid false; otherwise null if either side is null
or_kleene true as soon as either side is a valid true; otherwise null if either side is null
not validity of the operand
if_else(c, a, b) null when c is null, otherwise the validity of the chosen branch
coalesce, fill_null valid when any argument is valid
is_null, is_valid, is_in never null (a null input is_in anything is false, as pyarrow does)
string predicates validity of the string column
filter(pred) a row is kept when pred is true and not null (Arrow's null_selection_behavior="drop")
sum/min/max/mean skip nulls; null when nothing contributed. min/max also skip NaN
count(expr) non-null values of expr; count() counts rows that pass the filter
group-by rows whose key is null or outside [0, keyCount) are skipped; a group with no value is null

Integer division by zero yields 0, which is what ArrowMetal's other integer kernels do (pyarrow raises). A shift amount outside [0, width) yields 0.

Numbers

M4 Max, 50M rows, best of 5, in-process against Polars 1.44 (16 threads), pyarrow 25, pandas 3, numpy 2.5. Benchmarks/expr_bench.py. "op-by-op" is the same expression built from ArrowMetal's per-operator kernels, all batched into one command buffer — today's best without fusion.

case implementation wall ms GB/s CPU ms
(a) sum(amount) where region == 2 and amount > 100 ArrowMetal fused (1 kernel) 1.69 237 0.4
ArrowMetal op-by-op (batched, 6 kernels) 2.06 195 0.5
polars lazy 16.72 24 29.6
pyarrow.compute 63.73 6.3 63.7
pandas (arrow-backed) 42.01 9.5 42.0
numpy masked sum 119.22 3.4 119.2
(b) (a*2 + b) / (c + 1) - d, float64, 5% nulls ArrowMetal fused (1 kernel) 15.94 126 0.7
ArrowMetal op-by-op (batched, 5 kernels) 23.82 84 2.8
polars lazy 123.55 16 123.5
pyarrow.compute 126.97 16 127.0
numpy (no nulls) 41.26 49 41.3
(c) filter(sel < 5) then project 3 columns ArrowMetal fused 10.27 146 1.3
ArrowMetal op-by-op (batched) 10.80 139 1.2
polars lazy 42.11 36 77.6
pyarrow.compute 209.89 7.1 209.8
(d) group-by sum(v * 3 + 1) by 1000 keys ArrowMetal fused (1 kernel + merge) 1.99 201 0.5
ArrowMetal op-by-op (batched) 3.66 109 0.5
polars lazy 124.35 3.2 1203.2
pyarrow group_by 58.41 6.8 260.8

At 100 000 rows the fixed cost dominates and the GPU is the wrong tool:

case implementation wall ms
(e) sum(amount) where region == 2 and amount > 100 ArrowMetal fused 0.25
ArrowMetal op-by-op (batched) 0.29
polars lazy 0.12
pyarrow.compute 0.14
(e) (a*2 + b) / (c + 1) - d, float64 ArrowMetal fused 0.86
ArrowMetal op-by-op (batched) 0.62
polars lazy 0.12

Reading the small-input row: the command-buffer round trip is about 60-70 µs measured (The persistent-kernel result), 110-160 µs per call for sum and filter at 1,000 rows in the matrix's latency family, and it is paid once per query however many operators the expression has — that is what fusion buys at the low end. What it cannot buy back is the round trip itself, nor the cost of allocating and first-touching a fresh output buffer (the reason the float64 project costs more than the aggregate, which only writes threadgroup partials). Crossover against Polars for these shapes is around 1M rows.

Where fusion gains most is where the operator count is highest relative to the bytes moved: (b) is 1.5× the batched op-by-op chain, (d) is 1.8×, and (a) is 1.2× — while (c), which is compaction bound rather than operator bound, is a wash. Against the CPU engines in these tables — Polars, pyarrow and pandas — the margin is 4× to 62×, at a sixtieth to a twenty-four-hundredth of the CPU time; against numpy it is 2.6× to 71×.

Limits

  • No string outputs. String columns can be read by str_eq / starts_with / contains against a literal pattern; the compiler will not materialise a utf8 output column, and rejects a projection that asks for one by name.
  • Only these column types are read: int8–int64, uint8–uint64, float32, float64, boolean, utf8 (offsets + bytes, or utf8_view views read in place: the kernel reads each row through the view accessor of Design). Temporal, decimal, dictionary, list, struct, map and union columns are rejected with an error naming the column and its Arrow format. Extension columns are read through their storage.
  • Group-by needs a dense integer key in [0, keyCount), exactly like GroupBy. Hash the keys with GroupByKeys first if they are not dense.
  • Group-by aggregate types are limited to the aggregates 32-bit atomics support (MSL has no 64-bit atomic add): sum/mean accept any integer or float32 (not float64 — cast first); min/max accept a 32-bit or narrower integer or float32. Both errors name the aggregate and say what to cast.
  • is_in takes a small literal set and expands to a chain of comparisons; it is not a hash lookup, so keep the set to a few dozen values. A null input is false.
  • No window functions, no joins, no sorts, no aggregates over strings inside a query.
  • A count(expr) of a boolean expression is allowed; sum of a boolean is not (cast it).
  • pyarrow.compute.Expression is not accepted. Converting one means walking str(expr), whose rendering is ambiguous for string literals and field names with spaces, or going through Substrait, which is a larger dependency than this feature warrants. Build the expression with am.col instead — the two read almost identically.
  • Arrays above 2^32 elements are not supported, as elsewhere in the package.

Correctness

  • Tests/ArrowMetalTests/ExprTests.swift — 20 test cases against Swift oracles at 0 / 1 / 33 / 4097 / 1 000 003 rows with nulls: every operator alone, deep mixed trees with shared subtrees, bit-exact float64 arithmetic and comparisons (including ±0, subnormals, infinities and NaN), Kleene predicates, all four terminals, batched vs unbatched, the pipeline-cache hit, the error cases, and the canonical text round trip.
  • python/tests/test_expr.py — 40 cases against pyarrow.compute compositions and Polars lazy for the same expressions, including a bit-identical check of the six-operator float64 expression.
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test --filter ExprTests
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test -c release --filter ExprTests
swift build -c release --product ArrowMetalC
PYTHONPATH=python python -m pytest python/tests/test_expr.py -q
PYTHONPATH=python python Benchmarks/expr_bench.py
docs/ENGINE.md

The query engine

A lazy, optimizing query engine that runs on the GPU: filter, select, group_by, sort, join, join_asof, window functions, unique, explode and concat, planned as a whole and executed in as few Metal command buffers as the sync points below allow — usually one.

import arrowmetal as am

q = (am.scan(table)
       .filter((am.col("amount") > 0) & (am.col("qty") > 5))
       .group_by("region").agg(am.agg.sum("amount", "revenue"), am.agg.count("orders"))
       .sort("revenue", descending=True)
       .limit(10))
q.explain()      # the optimized plan, the way Polars prints one
q.collect()      # -> pyarrow.Table
let df = LazyFrame(PlanSource(name: "sales", batch: batch))
    .filter(col("amount") > 0)
    .groupBy(["region"], [ExprAggregate(.sum, col("amount"), name: "revenue")])
    .sort([SortKey("revenue", descending: true)])
    .limit(10)
let out = try df.collect()

Why this layer exists

docs/EXPR.md describes the fused expression compiler: it turns a whole element-wise tree plus one terminal (project, filter+project, aggregate, group-by over dense keys) into one runtime-generated Metal kernel. That is the fast half of a query engine, and it was already here.

What was missing is everything that moves rows around — sorting, joining, grouping by keys that are not already dense, windows, limits — and, more importantly, the layer that decides: which parts of a query become one kernel, which columns are read at all, where a predicate should run. Without it, a caller writing a real query gets one Arrow kernel per operator and a CPU round trip between each.

So the engine is a plan, three layers of decision over it, and one of execution:

File What lives there
Sources/ArrowMetal/Engine/LogicalPlan.swift the plan, its schema inference and type checking, describe()
Sources/ArrowMetal/Engine/Optimizer.swift the rewrite rules and the cardinality estimates
Sources/ArrowMetal/Engine/PhysicalPlan.swift fusion planning: which regions become one kernel
Sources/ArrowMetal/Engine/Executor.swift running it, and LazyFrame
Sources/ArrowMetal/Engine/WindowOps.swift window functions over partitions
Sources/ArrowMetal/Engine/Concat.swift column and batch concatenation
Sources/ArrowMetal/Engine/PlanJSON.swift the serialised plan grammar
Sources/ArrowMetal/Kernels/JoinExtra.swift the rest of the join matrix and the as-of join
Sources/ArrowMetalC/ArrowMetalC_Plan.swift am_plan_* over the C ABI
python/arrowmetal/lazy.py am.scan(...), am.LazyFrame, am.agg

Operators

Operator Swift Python Runs as
scan .scan(source, columns:) am.scan(table) no kernel: the columns by reference
filter .filter(_:) .filter(expr) one fused kernel (predicate + compaction)
select / project .select(_:) .select(...) one fused kernel; bare column references cost nothing
with_columns .withColumns(_:) .with_columns(...) as select, keeping the other columns
aggregate .aggregate(_:) .agg(...) one fused kernel, threadgroup partials
group-by aggregate .groupBy(_:_:) .group_by(...).agg(...) GroupByKeys for the ids, then one fused group-by kernel (or GroupBy's per-aggregate kernels)
sort .sort(_:) .sort(by, descending, null_placement=, float_order=) lexsort (one stable radix sort per key) + take
limit / head / slice .limit(_:offset:) .limit(n), .head(n), .slice(o, n) a slice, or the top-k selection kernel when it follows a single-key sort (with that key's options)
unique .unique(subset:) .unique(subset=) GroupByKeys + the lowest row index per group
join .join(_:leftOn:rightOn:how:) .join(other, on=, how=) GPU hash join; see the matrix below
join_asof .joinAsof(_:_:) .join_asof(other, on=, by=, strategy=, tolerance=) GPU binary search per probe row
concat .concat(_:) am.concat([...]) memcpy over unified memory
window .window(_:) .with_row_number(...) and friends sort once, work in sorted order, scatter back
explode .explode(_:) .explode(columns) list offsets to a gather index, then take

Aggregates are sum, min, max, mean and count, over any expression, whole-table or per group. Window functions are row_number, rank, dense_rank (uint32 columns), lag, lead, cum_sum, rolling_sum / rolling_mean / rolling_min / rolling_max, and any aggregate broadcast over its partition.

The optimizer

Every rule preserves the result's rows and nulls exactly; join_reorder alone permutes row order, and only fires where that order is unobservable. explain() lists the ones that fired.

Rule What it does
constant_folding evaluates literal-only subtrees, collapses and(true, x), x * 1, not(not x), dead coalesce arms. Only the Kleene forms absorb a literal (and_kleene(false, x) → false): plain and/or propagate nulls, so and(false, null) is null and does not fold. if_else(c, a, a) does not fold either — Arrow's if_else is null wherever c is
filter_fusion filter(filter(x, a), b) → filter(x, and(a, b)): one compaction pipeline instead of two
predicate_pushdown moves each conjunct as far down as it can go: through projections (substituting the projected expression), through with_columns, below sorts, below a group-by when it only touches key columns, into each side of a join where the join kind allows, into every branch of a concat, and below an explode
projection_pruning works out what each node's parent needs and narrows the scan to those columns; drops projection and window outputs nothing reads
expression_cse drops duplicate with_columns outputs with the same canonical text (the fused kernel's own CSE only sees one query at a time). select is left alone: it names its outputs positionally, so a repeated one really is a repeated column
join_reorder an inner join is commutative and hashJoin builds its table from the right side, so the smaller estimated input is put there; a projection on top restores the caller's column order. The swap permutes the rows, so it only fires below a sort, a whole-input reduction or a group-by — never where "Row order" below is still observable

Two rules are deliberately absent. A filter is not pushed below a limit, because that changes which rows survive. A filter is not pushed below a window, because a window function's value depends on the rows in its partition.

Cardinality comes from PlanStats: row counts flow up from the scans, a filter is assumed to keep a quarter of its rows, a group-by to fold by 16 per key, an inner join to produce min(left, right). Setting stats.useGPUDistinctCounts replaces the per-column guess with a real GroupByKeys pass; it costs a pass over the column, so it is off unless the plan will run for far longer than the estimate.

explain()

LOGICAL PLAN
  LIMIT 10
    SORT BY [total DESC]
      GROUP_BY [region] AGG [(sum "total" (col "amount")), (count "n")]
        FILTER (gt (col "amount") (int 100))
          SCAN sales [region, amount] 2/5 columns, 1000 rows

PHYSICAL PLAN
  HEAD 10
    TOP-K 10 BY total DESC
      HASH-AGGREGATE [region] AGG [(sum "total" (col "amount")), (count "n")] (per-aggregate kernels)
        FUSED-FILTER-PROJECT [region, amount] FILTER (gt (col "amount") (int 100))
          SOURCE sales [region, amount] 2/5 columns, 1000 rows

RULES APPLIED: projection_pruning

Three of the five source columns are never read. The filter and the projection are one kernel. The sort + limit became a top-k selection, which touches each row once instead of running eight radix passes over all of them.

A join, with the predicate pushed into the side it belongs to:

LOGICAL PLAN
  LIMIT 5
    SORT BY [w DESC]
      SELECT [region_name, (mul (col "amount") (col "weight")) AS "w"]
        JOIN INNER ON [region]
          FILTER (gt (col "qty") (int 5))
            SCAN sales [region, amount, qty] 3/5 columns, 1000 rows
          FILTER (gt (col "weight") (float 1.0))
            SCAN dim [region, region_name, weight] 3/3 columns, 200 rows

PHYSICAL PLAN
  HEAD 5
    TOP-K 5 BY w DESC
      FUSED-PROJECT [(mul (col "amount") (col "weight")) AS "w", region_name]
        HASH-JOIN INNER ON [region]
          FUSED-FILTER-PROJECT [region, amount, qty] FILTER (gt (col "qty") (int 5))
            SOURCE sales [region, amount, qty] 3/5 columns, 1000 rows
          MASK-FILTER (gt (col "weight") (float 1.0))
            SOURCE dim [region, region_name, weight] 3/3 columns, 200 rows

RULES APPLIED: predicate_pushdown, projection_pruning

As written, that query was a join of two full tables followed by one and of two predicates. The dim side falls back to MASK-FILTER — one fused kernel for the predicate, then the ordinary compaction kernels — because it has to carry a utf8 column through, and the expression compiler will not materialise a string output.

Fusion planning

The physical planner decides what becomes one kernel. The rule is short, because the expression compiler already defines what it can do:

  • An element-wise region — arithmetic, comparisons, null logic, casts, string predicates — is one kernel (FUSED-PROJECT).
  • A filter immediately under a select or an aggregate joins that kernel, so the predicate is evaluated inside the same pass that computes the outputs and no boolean column is ever written (FUSED-FILTER-PROJECT, FUSED-AGGREGATE).
  • A select output that is a bare column reference is not a kernel at all: the column is carried by reference (CARRY).
  • A region stops being fusable the moment an output would be utf8, or a column it must read is temporal, decimal, list, struct, map, union or dictionary encoded. Then the predicate becomes a mask (MASK-FILTER) and the ordinary per-column compaction kernels run — which is what the package did before fusion existed, so nothing is lost, only the extra pass.
  • Everything that moves rows is its own node.

Execution

The whole tree runs inside one MetalContext.batch { }, so every kernel goes into one command buffer and the round trip — about 60-70 µs measured (The persistent-kernel result), 110-160 µs per call for sum and filter at 1,000 rows in the matrix's latency family — is paid once for the query rather than once per operator. Where an operator's length is decided by the GPU — a filter — the result is a pending array whose length lives in a device buffer and the next kernel binds that buffer instead of a CPU-known count (docs/DESIGN.md, "Lengths flow on the GPU"), so a filter feeding a projection feeding another filter never returns to the CPU.

Five operators are unavoidable sync points, because the CPU has to know a count before it can size the next dispatch: GroupByKeys (the number of groups), the hash join (the number of pairs), the top-k selection, and explode and slice (which read offsets). flush(reopen: true) commits, waits and reopens the batch at each, so batching resumes immediately afterwards. Intermediate buffers come from and go back to MetalContext.pool, which parks rather than recycles while a batch is open.

Getting the data in

An engine that runs a query in 10 ms can spend longer than that getting to it, and this one did. am.scan(table) handed the plan its columns as pyarrow arrays and collect() imported every one of them into Metal memory, on every run. At 20M rows that was 6 ms of makeBuffer(bytesNoCopy:) and page residency per call — and worse, the allocation and teardown of a fresh 240 MB import each time churned the buffer pool, so a query whose kernels take 10 ms took 25.

So the Python side imports once. am.scan() imports nothing at all; the first collect() imports the columns the plan reads and caches them on the object it scanned, keyed by identity and held by a weak reference, so the cache dies with the table and a second scan of the same table costs nothing. An imported MetalArray owns the Arrow C Data Interface release callback of the array it came from, so it keeps the producer's buffers alive by itself; caching it is safe even after the pyarrow.Array is gone. A chunked column is imported on first use rather than on the way into every query, chunk by chunk through am_import_chunks (each chunk copied straight into the final Metal buffers, with no combine_chunks copy first) for every type that path takes; dictionary and nested columns are combined first.

Which columns get imported is worked out from the plan text before the plan is sent, so a 200-column table scanned for two of them imports two. It is the same question projection_pruning answers in the optimizer, asked one step earlier — the optimizer can only prune columns that are already on the GPU. The Python answer is deliberately conservative: a superset is always safe, and anything it does not recognise (a renaming join, an escaped column name, a hand-built plan node) means "every column". am.scan(table, columns=[...]) names them by hand for the cases it gives up on.

LazyFrame.columns and LazyFrame.warmup() register a prefix of each source, taken before the import, so neither touches the whole table. explain() is the exception: it registers every column and every row, because the n/m columns it prints and the cardinalities that pick a join order are statements about the real table.

A source can also be columns that were never anywhere but the GPU. A _Source takes MetalArrays as well as Arrow arrays, and a MetalArray is used as it is, with no import. That is how the Polars engine runs a Parquet scan (Polars, "Parquet scans"): it reads the columns Polars' projection names with am.read_parquet through the open-file cache (Parquet on the GPU, "The open-file cache"), the decoder writes them straight into Metal shared memory, and the plan scans those arrays. The row groups and pages the reader skipped by statistics never reach the plan; the plan's filter still runs over every row the reader returned, so the answer does not depend on what was skipped.

That leaves the one cost that cannot be cached away: the first collect() in a process generates and compiles MSL for the kernels its plan lowers to; every later run finds them in MetalContext's pipeline cache. q.warmup() runs the plan over 4096 rows per source to move that cost off the first real query.

With the import cached, a warm collect() over a pyarrow.Table does the same work as the same query over columns already resident in Metal memory, plus the Python side around am_plan_run: am.scan, building the operators, plan_json() and materialising the result. python/tests/test_lazy.py holds the pyarrow.Table case within 3 ms of the resident one; per-stage timings are not recorded.

The join matrix

Kernels/Join.swift does inner and left over one int32 or int64 key, returning uint32 index pairs (at most 2^31 - 1 rows per side and 2^31 - 1 pairs). Kernels/JoinExtra.swift does the rest.

Row single int32/int64 key multi-column keys utf8 keys mixed types
inner direct hash join ✓ ✓ ✓
left direct hash join ✓ ✓ ✓
right probes the right side ✓ ✓ ✓
full outer left join + the unmatched right rows ✓ ✓ ✓
semi match flags + filter ✓ ✓ ✓
anti match flags + filter ✓ ✓ ✓
as-of (backward / forward / nearest, with by and tolerance) int and temporal keys partition columns of any type partition columns of any type ✓

Making arbitrary keys joinable. A join needs an equality that is exact, not probabilistic. Rather than hashing a wide or string key into 64 bits and adding a verification pass, the key columns of both sides are concatenated and handed to GroupByKeys — the machine that already turns arbitrary key columns into dense int32 ids with an injective mapping. Two rows get the same id exactly when their keys are equal, so the int32 hash join over those ids is the original join, with no collisions to resolve. It costs one densification pass over left + right rows. The common case that needs none — a single int32 or int64 key on both sides — goes straight to hashJoin.

Nulls never match, in either direction and for every join kind, which is Arrow's, Polars' and SQL's rule. GroupByKeys gives null keys a group of their own, so the ids alone would match them up; the per-side "every key column is valid" bitmap is attached to the id array instead, and hashJoin skips a null key on both the build and the probe side.

Row order. Inner and left keep probe (left) order, and a left row's matches are contiguous. Right keeps right order. Full is left order followed by the unmatched right rows. Semi and anti keep left order and emit each row once. The order of several matches within one probe row is unspecified.

Semi, anti and the right tail of a full outer join all need the same primitive: which rows of one side the join touched. That is one jx_scatter_flag dispatch over the index pairs plus a filter of the row indices — no second hash table.

As-of. The build side is sorted by (partition, key) on the GPU with its null keys dropped, and every probe row does two binary searches — one for its partition's range in the sorted array, one for the key inside it. That is log2(n) dependent loads per probe row and no table at all, which is the whole reason an as-of join does not need a hash. strategy is backward (last key at or before), forward (first at or after) or nearest (ties to backward, as Polars does); tolerance bounds |probe − match|. An as-of join is a left join: every probe row comes out, in its original order, with nulls where nothing matched.

Window functions

Always the same shape, and it is the shape a GPU likes: sort once, work in sorted order, scatter back.

  1. lexsort by (partition keys…, order keys…). Every partition is now one contiguous run and inside it the rows are in the window's order — one stable radix sort per key column.
  2. Compute the answer as a function of a row's position in that order, its partition's first position, and its tie group's first position. All three are a GroupBy minimum over the row positions plus a take.
  3. take the result through the inverse permutation (which is argsort of the permutation), so the output is aligned to the caller's rows.

Ranking, lag and lead fall out of step 2 with no new kernel: the index a row wants is if_else(position - k >= partition_start && position - k <= partition_end, position - k, null), which is one fused expression, and take of a null index gives a null. RANK is the tie group's first position minus the partition's first position, plus one; DENSE_RANK is a running sum of tie-group marks minus its value at the partition's first row.

cum_sum and the four rolling_* functions are the exception: their existing kernels (Kernels/Cumulative.swift, Kernels/Window.swift) are already exactly right per partition, so each partition's contiguous slice is handed to them and the results concatenated. That is one dispatch per partition — the right trade below a few thousand partitions, and capped at 8192 with an error above.

Nulls follow SQL ORDER BY x NULLS LAST, which is what lexsort does in both directions: a null key sorts after every value, and all nulls of one key form one tie group.

Sort key options

Each key of a sort carries two options besides its direction. Both default to the order the engine has always used, so a key that names neither sorts exactly as before.

option values meaning
nulls "last" (default), "first" the key's null rows before or after every value, in either direction (SQL NULLS FIRST / NULLS LAST)
float_order "ieee" (default), "total", "nan_largest" how a Float32 / Float64 key orders; integer, string and temporal keys ignore it
  • "ieee" is Arrow C++'s (and pyarrow's) order: IEEE comparison, -0.0 and +0.0 equal (a tie, kept in input order), and every NaN one value placed next to the nulls in both directions — after the values when the nulls are last, between the nulls and the values when they are first.
  • "total" is IEEE 754 totalOrder, the order arrow-rs (total_cmp), DataFusion and Rust use: -NaN < -inf < … < -0.0 < +0.0 < … < +inf < +NaN, NaNs ordered by payload, only identical bits tie, and a descending key is the exact mirror, so +NaN comes first.
  • "nan_largest" is Polars' (and NumPy's) order: IEEE comparison with -0.0 and +0.0 equal, and every NaN one value greater than every number, +inf included, in both directions — last ascending, first among the values descending. The null placement is independent of it.

No option adds work to the sort. A null placement is where the stable partition that takes the null rows out of the radix sort puts them; a float order is the key map in front of the radix passes — "total" is the raw bit pattern with the sign-flip transform, a pure function of the value, and "nan_largest" is "ieee"'s map with the descending key the plain complement. A single-key sort + limit stays a top-k selection with any option.

The keys of a window's order_by take the same forms and the same options (a window spec's own nulls and float_order are the defaults of its keys), and so do the keys of the streaming external sort (ExternalSortOperator.Key, am_stream_sort_ex, Stream.sort(null_placement=, float_order=)), whose merge compares by the same rules.

The plan grammar

Expressions already have a text form — the s-expression grammar of docs/EXPR.md, which is the cache key the fused compiler uses — so a plan reuses it verbatim and only spells the operators. JSON is the wrapper, because a plan is a tree of records with optional fields.

{"op": "sort", "by": [["total", true]], "input":
  {"op": "group_by", "keys": [["region", "(col \"region\")"]],
   "aggs": [["sum", "total", "(col \"amount\")"]], "input":
    {"op": "filter", "predicate": "(gt (col \"amount\") (int 100))",
     "input": {"op": "scan", "source": "sales"}}}}
op fields
scan source, columns?
filter input, predicate
select / with_columns input, exprs: [[name, sexpr], …]
aggregate input, aggs: [[op, name, sexpr?], …]
group_by input, keys, aggs
sort input, by: [key, …], nulls?, float_order?
limit input, count, offset?
unique input, subset?
join left, right, left_on, right_on, how, suffix?
join_asof left, right, left_on, right_on, by?, by_right?, strategy?, tolerance?, suffix?
concat inputs
window input, specs: [{name, fn, column?, n?, partition_by?, order_by?, nulls?, float_order?}, …]; order_by keys as sort's
explode input, columns

A sort key is [column, descending] as before, [column, descending, {"nulls": …, "float_order": …}], or {"column": …, "descending": …, "nulls": …, "float_order": …}, every field but column optional. The sort-level nulls and float_order are the defaults for keys that do not name their own:

{"op": "limit", "count": 100, "input":
  {"op": "sort", "by": [["price", true, {"nulls": "first", "float_order": "total"}]],
   "input": {"op": "scan", "source": "t"}}}

Over the C ABI the tables are registered once (am_plan_source_create, which takes the am_array handles the caller already holds) and the plan text carries no data, so the same text can be re-run against new sources and am_plan_explain can print a plan without touching the GPU. include/arrowmetal.h carries the same grammar for C consumers.

Polars' MetalEngine (Polars, "Tier 4") writes this grammar from Polars' optimised plans. Which plans it can write, per plan shape, input dtype and null pattern, with the reason for each one it leaves to Polars, is Polars engine capabilities, generated from runs of the engine by python/tests/engine_capabilities.py and checked against a fresh run by test_polars_engine.py; which Polars entry points reach it is POLARS.md, "Collect paths".

Numbers

M4 Max, 50 M rows, best of 5, in process, against Polars 1.44.1 lazy (16 threads) and DuckDB 1.5.5 (16 threads) on the same Arrow buffers. Benchmarks/engine_bench.py; recorded in Benchmarks/results/engine_bench_50000000_2026-09-07.txt. "CPU ms" is process CPU time over the same run: it is what the query cost the machine, next to what it cost the caller in latency.

case implementation wall ms CPU ms vs ArrowMetal
(a) sum(amount), count(*) where region < 20 and qty > 10 ArrowMetal lazy 2.19 0.6 —
polars lazy 15.60 24.9 7.1x
duckdb 171.28 174.5 78x
(b) group-by 200 keys, sum + count, order by total desc limit 10 ArrowMetal lazy 11.80 3.5 —
polars lazy 28.56 171.3 2.4x
duckdb 602.71 850.0 51x
(c) group-by (region, sub), 10 000 groups, mean + max ArrowMetal lazy 18.58 3.5 —
polars lazy 201.17 2305.4 11x
duckdb 469.87 940.9 25x
(d) (amount*2 + qty) / (region + 1) - qty, then filter ArrowMetal lazy (1 fused kernel) 4.72 0.6 —
polars lazy 198.97 199.0 42x
duckdb 307.42 310.4 65x
(e) inner join 10M x 1M on int64, then sum ArrowMetal lazy 6.27 1.0 —
polars lazy 22.40 194.2 3.6x
duckdb 127.34 241.5 20x
(f) semi join 10M against a 1 000-row key set ArrowMetal lazy 3.50 1.3 —
polars lazy 3.06 33.6 0.9x
duckdb 5.59 7.1 1.6x
(g) row_number() over (partition by g order by v), 10M rows, 1 000 partitions ArrowMetal lazy 17.24 5.2 —
polars lazy 101.34 644.4 5.9x
duckdb 440.32 1727.4 26x
(h) as-of join 50M trades against 1M quotes ArrowMetal lazy 13.27 2.9 —
polars 152.76 152.9 12x
duckdb not run (off by default; set AM_BENCH_DUCKDB_ASOF=1)

Reading it: the widest margins are where the operator count is high relative to the bytes moved — (d) is six operators over three columns and 42x, (a) is a filter plus a reduction and 7.1x — and where the work is a scan the GPU does in one pass, like (h)'s binary search. The narrowest is (f), where Polars is ahead — 3.06 ms against ArrowMetal's 3.50 — because the case is bound by writing 10M output rows rather than by compute; (b) at 2.4x is the next narrowest.

The CPU column is the other half of the story: ArrowMetal's queries cost the machine 0.6 to 5.2 ms of CPU where Polars spends 24.9 to 2305.4 ms and DuckDB 7.1 to 1727.4 ms across 16 threads. Case (c) at 3.5 CPU-ms instead of Polars' 2.3 CPU-seconds leaves the cores free for whatever else the process is doing.

Below about 1M rows the fixed cost dominates and the GPU is the wrong tool; at 2M rows on the same machine (Benchmarks/results/engine_bench_2000000_2026-09-07.txt) (a) is 0.57 ms against Polars' 0.63 ms and (f) is 2.52 ms against Polars' 1.23 ms; at that size (a), (c), (d), (e), (g) and (h) are ahead of Polars and (b) and (f) are behind, and (f) is behind at 50M as well.

Polars' lf.collect(engine=am.MetalEngine()) runs this engine under Polars: it translates subtrees of Polars' optimised plan into the grammar above and, by default, runs a subtree here only when its input rows are at or above the measured crossover of every shape class in it (sort, each join kind, unique, group-by and whole-frame aggregates per aggregate family, row-wise), per dtype class and input, fitted from Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv against Polars' own engines. A group-by is judged at the bucket of its number of groups (200, 1,000, 10,000, 100,000, 1,000,000, or at least a quarter of the rows, a bucket never taken), which a fixed-seed sample of its key columns estimates at plan time (bias-corrected Chao1, 512 sampled rows and up until the decision is settled). A shape counts as ahead at a size when its time, raised by 15% (35% for a shape with a String column), is at most the faster Polars engine's; the default takes it from 1.5 times that fitted crossover, and a shape with a String column from 5,000,000 rows at the earliest. In Benchmarks/results/polars_engine_bench_2026-10-02.csv (2,000,000 and 50,000,000 rows, best of 5) the default takes 75 of 220 case-size pairs, 55 of them group-bys, all ahead of the faster Polars engine, 1.52x to 11.54x, with every result equal to Polars' (Polars, "Which translatable subtrees it runs: the defaults").

Limits

  • Strings are read, not written. The fused kernels will not materialise a utf8 output column, so a projection that computes one is rejected; a string column can be carried through a plan, filtered, sorted by (through GroupByKeys), joined on and grouped by, but not transformed inside a query. Use the per-operator string kernels for that.
  • Temporal, decimal, list, struct, map, union and dictionary columns can be carried, filtered, joined on, grouped by and sorted, but not read by a fused expression. A predicate over one of them is an error naming the column and its Arrow format.
  • min and max in a whole-table aggregate come back at the input's width but through a 64-bit scalar, so a uint64 maximum above Int64.max is not representable.
  • Group-by aggregate types. The one-kernel group-by path is limited to the aggregates 32-bit atomics support (MSL has no 64-bit atomic add): sum/mean over any integer or float32, min/max over a 32-bit-or-narrower integer or float32. Anything else — a float64 sum, a 64-bit min — silently takes GroupBy's own per-aggregate kernels instead, which explain() says. There a Float64 sum is correctly rounded (the binary64 value nearest the exact sum of the group's non-null values, ties to even) and a Float64 mean is the exact sum divided by the count, rounded once; both come from two passes over the rows with no group order (Design). A sum and a mean, or a min and a max, of the same column in one group_by share one pair of passes.
  • count(expr) is the number of non-null values of expr for a column of any type, on both paths and in a whole-table aggregate. A count never decides the path: a count of an expression the fused kernel does not read as a number (utf8, boolean, temporal, decimal, nested, dictionary, ...) is taken from the column's validity bitmap next to the kernel.
  • Group order is not Polars'. GroupByKeys emits groups ascending by key for numeric, boolean, temporal and decimal keys and in first-seen order for utf8; pyarrow and Polars use first-seen for everything. Sort both sides before comparing.
  • join_asof keys must be integer or temporal. Float and string as-of keys are not supported.
  • explode takes one list column at a time, and builds its gather index on the CPU, so it is a sync point.
  • cum_sum and the rolling window functions are capped at 8192 partitions, because they run one dispatch per partition.
  • Concatenation is a memcpy, not a kernel: the buffers are unified memory, so appending is a memcpy at ~100 GB/s with no transfer, and concatenation is always a materialising boundary anyway.
  • No SQL front end, no LIMIT push into a join, no spilling. Every input must fit in the working set; there is no partitioned or out-of-core execution.
  • Arrays above 2^31 rows are not supported by the join, and 2^32 elsewhere, as in the rest of the package.

Correctness

  • Tests/ArrowMetalTests/EngineTests.swift — 34 tests: schema inference and type checking, every optimizer rule asserted on explain(), every operator against a Swift oracle at 0 / 1 / 33 / 4097 / 1 000 003 rows with nulls, all six join kinds against a Swift oracle over an integer key at four sizes with null keys on both sides, multi-column and utf8 keys, the as-of join in all three strategies with partitions and a tolerance and against an oracle at 4097 × 1301 rows, the window functions, concat, explode, batched execution, and optimized-vs-unoptimized equivalence.
  • python/tests/test_lazy.py — 56 queries against Polars lazy, pyarrow and DuckDB on the same data, including TPC-H shapes (filter + group-by + sort + limit; join + aggregate; window over partition; as-of join on timestamps), plus what the Python side costs: that a column is imported once however often it is scanned, that a query importing two of five columns imports two and one that can read any imports all, that a schema check and a warm-up import none, that rescanning a changed dict or a mutated Polars frame sees the change, and that a warm collect() over a pyarrow.Table stays within 3 ms of the same query over GPU-resident arrays.
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test --filter EngineTests
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test -c release --filter EngineTests
swift build -c release --product ArrowMetalC
PYTHONPATH=python python -m pytest python/tests/test_lazy.py -q
PYTHONPATH=python python Benchmarks/engine_bench.py
docs/PARQUET.md

Parquet on the GPU

ArrowMetal reads Apache Parquet with Metal compute kernels: from file bytes to Arrow arrays in shared memory. The CPU reads column data only to decompress it: the ZSTD, GZIP and BROTLI pages, and the Snappy and LZ4 pages it decodes faster than the GPU (token-dense pages; the GPU takes the literal ones), all straight into the shared buffer the GPU decoders read, while the GPU decompresses its own share. Definition levels, dictionary indices, every value encoding, the delta encodings and BYTE_STREAM_SPLIT are all kernels. The host parses the Thrift footer and the per-page Thrift headers — metadata, not data — and everything after decompression runs on the GPU.

cuDF does this for NVIDIA.

The pipeline

For one leaf column, across every selected row group at once:

  page headers (host, Thrift only, in parallel across the read's column chunks)
    -> decompression       SNAPPY / LZ4 / LZ4_RAW split page by page between the host and the GPU,
                           both at once; host for ZSTD / GZIP / BROTLI; nothing for UNCOMPRESSED
    -> pq_page_layout      finds each page's level and value sections *inside* the page
    -> pq_decode_levels    definition levels -> one byte per row, plus each row's rank
    -> pq_page_scan        per-page offsets into the chunk's dense value section
    -> one kernel per encoding, each writing dense values
    -> pq_scatter          dense values -> row positions (skipped when the column has no nulls)
    -> pq_levels_to_bitmap definition levels -> an Arrow validity bitmap

The file's bytes are the GPU's bytes

ParquetFile mmaps the whole file, read-only and shared (PROT_READ, MAP_SHARED). mmap always returns a page-aligned address, so any range of the mapping can be wrapped with makeBuffer(bytesNoCopy:) — the same trick MetalArrowBuffer.wrapOrCopy uses for zero-copy Arrow import. A column chunk's pages are then addressable by a kernel as byte offsets into that buffer. An uncompressed file needs no staging buffer at all: the mapped file is the page buffer the decoders read, so the values go from page cache to Arrow array without the host ever loading them. Every kernel binds the page bytes device const; nothing writes to the mapping.

Creating the wrap costs next to nothing (under 0.1 ms for 2 GB). The first kernel that reads a wrapped range makes all of it resident for the GPU, and that costs time in proportion to the range: 7-10 ms per gigabyte for this read-only shared mapping on an M4 Max, against 67-78 ms per gigabyte for a private writable one, which the reader keeps only as the fallback for a device that will not wrap read-only memory (and on virtualised GPUs). So what a read wraps matters:

  • A read whose columns fill most of the bytes they span (at least four fifths) wraps that span once, up front. Column chunks are interleaved by row group, so in a file with fifty row groups one column's chunks already span nearly the whole file, and wrapping per column would map the same pages once per column.
  • A sparse projection — one column of a wide file — maps its column's chunks side by side into one reserved range of address space (MAP_FIXED over each chunk, MappedView in ParquetFile.swift) and wraps only them, so the GPU makes resident the bytes it reads and not the other columns' chunks in between.

Wraps and views are cached on the handle, and so are the parsed page headers, so holding the ParquetFile across queries still saves time; see the benchmarks. Closing a file unmaps it on a background queue.

Pages are addressed with 32-bit offsets from their binding point, so file size is not a limit: a column whose chunks span more than 4 GiB of a larger file reads through a view. A column whose chunks in one read total 4 GiB or more raises ParquetError.unsupported.

ARROWMETAL_PARQUET_PROFILE=1 prints, for every open and read, the wall time and minor page faults of each phase to stderr (waiting for the GPU at each phase boundary, so a phase's kernels are charged to it).

Page layout is computed on the GPU, not the host

A v1 data page stores <4-byte length><repetition levels><4-byte length><definition levels><values>. Those two lengths live inside the page, which for a compressed column means they only exist after decompression. pq_page_layout therefore runs as a kernel: one thread per page, filling in the byte offsets of the three sections and, for a dictionary page, the index bit width stored in its first byte. A v2 page carries the two lengths in its Thrift header, so the host fills those in and the kernel only does the arithmetic.

The parallel RLE strategy

Definition levels, repetition levels and dictionary indices all use Parquet's RLE / bit-packing hybrid: a sequence of runs, each introduced by a varint header whose low bit picks the run kind (0 = a repeated value, 1 = a group of 8·n bit-packed values). Run boundaries are only known after the previous header has been read, so the headers cannot be parsed in parallel. The values inside them can, and that is where all the work is.

One threadgroup (256 threads) owns one page and walks it in batches of up to 2048 values:

  1. Thread 0 scans forward over run headers only. It never touches the packed value bytes — for a bit-packed run it computes the run's byte length from the width and skips it — until it has covered 2048 values or filled its table of 256 run records. A record is (kind, value or byte offset of the packed data, values of this run already emitted, where this run starts inside the batch). A run that spills past the batch is left half-consumed and resumed by the next batch, so runs of any length work and a page of any size works.
  2. A threadgroup barrier publishes the table.
  3. Every thread takes a contiguous slice of 8 batch positions and binary-searches the run table (256 entries, threadgroup memory, a few cycles) for the run each position falls in, then decodes its level: a constant for an RLE run, an LSB-first bit-field read for a bit-packed one. All of the bit unpacking happens here, 256-wide.
  4. The same pass scans "is this level the max definition level?" across the batch — 8 values serially per thread, then simd_prefix_exclusive_sum across the 32 lanes of each SIMD group, then a 256-wide combine. That gives every row its rank: the index of its value in the page's dense, null-free value section.

Ranks are the load-bearing idea. Because every row knows where its value sits in the dense section, every value decoder can be a pure gather, no decoder has to think about nulls, and a column with no nulls skips the scatter entirely because dense positions and row positions coincide.

A page therefore costs one serial header walk of a few instructions per run, and everything else runs 256-wide while thousands of pages run at once across the GPU.

Dense values, then one scatter

Every value decoder writes its page's values densely: value j of a page lands at nonNullOffset + j. One pq_scatter at the end moves the dense values into their row positions using the ranks. Keeping the two apart means each encoding needs exactly one kernel, and a chunk whose pages mix encodings — which is what a writer produces when a dictionary outgrows its budget and the remaining pages fall back to PLAIN — just runs two kernels over two slices of the page list into the same output buffers.

Variable-length values never get copied twice. Each encoding only fills, for every dense value slot, where its bytes are and how many, always as offsets into the page buffer (or, for DELTA_BYTE_ARRAY, into a dense staging buffer built by its own kernel). One prefix sum over per-row lengths produces the Arrow offsets buffer, and one gather moves every byte of the column in parallel.

Several row groups, one dictionary

A dictionary column is decoded across every selected row group in a single pass even though each row group has its own dictionary page: the dictionaries are concatenated, and pq_decode_rle_values adds each page's dictionary base to its codes as it writes them. The result is one Arrow dictionary array over a merged (not deduplicated) dictionary, which is exactly what Arrow allows.

Decompression

Snappy and LZ4 are byte-oriented LZ77: a stream of tokens, each either "copy N literal bytes from the input" or "copy N bytes from N' bytes back in the output". A single block cannot be parsed in parallel — but a Parquet file has thousands of pages, each an independent block, and that is the parallelism.

However a page is decoded, one worker walks its token stream from start to end. The GPU has many slow workers for that and the host a few fast ones, so a read splits its Snappy and LZ4 pages between the two, page by page, and runs both at once.

What a page costs on each side. The columns of the 50 M-row benchmark files, 2,525 pages of about 160 KB of output each (int64: id sequential, qty below 1,000; ts timestamps; price random doubles), host decoders on one core and on all 16, GPU kernels over the column's pages in one dispatch:

column codec ratio host, 1 core, per page host, 16 cores, 2,525 pages GPU, a SIMD group per page, 2,525 pages GPU, a thread per page, 2,525 pages
id Snappy 2.00 60 µs 12.7 ms 68.4 ms 47.1 ms
qty Snappy 2.72 81 µs 15.7 ms 57.7 ms 50.5 ms
ts Snappy 1.32 58 µs 11.8 ms 74.5 ms 41.0 ms
price Snappy 1.00 2.2 µs 2.5 ms 4.1 ms 44.5 ms
id LZ4 2.00 41 µs 8.7 ms 66.9 ms 31.4 ms
qty LZ4 2.53 45 µs 9.0 ms 51.5 ms 45.0 ms
ts LZ4 1.31 41 µs 8.3 ms 56.3 ms 36.8 ms
price LZ4 1.00 2.4 µs 2.6 ms 3.9 ms 39.5 ms

A token-dense page (id, qty, ts) is tens of thousands of tokens of a few bytes. On the GPU its cost is the time one worker takes to walk it: 12-15 ms on a SIMD group and 37-60 ms on a thread, the same for 32 pages in a dispatch as for 512, and a dispatch lasts as long as its slowest page. The 12,625 pages of a file that compress 1.25x or more (these three columns and two of long runs) take the SIMD- group kernel 159-190 ms and the page-per-thread kernel 51-65 ms, and 16 host threads 44 ms (Snappy) and 33 ms (LZ4) reading a freshly opened file. A literal page (price) is the other way round: it is one long copy, 1.2 GB of such pages take the GPU 10 ms, and the host 19 ms from a fresh mapping (the first touch of each 16 KB page of a mapping is a minor fault; 5.6 ms once touched). A column of literal pages split between the two read in 19.6 ms, against 12.1 ms all on the GPU and 13.7 ms all on the host: both sides copy at memory speed.

The router (DecodeRouter in Kernels/DecodeSplit.swift) sees every Snappy and LZ4 page of a read — all the columns staged together — before any is decoded, with only its page header: compressed and uncompressed sizes, hence its ratio. A page at or below 1.0 is literal; above 1.05 it is token-dense (an LCG-generated int64 column compresses only 1.05x with Snappy yet is short tokens throughout, 32-35 ms on the GPU against 8-17 ms on the host for 160 MB); far above 4 it is a few long runs again. Each class has a cost per output byte on each side (DecodeCost), from the measurements above:

Row literal page token-dense page
host, one core (fresh mapping) 1.0 ns Snappy 0.42 ns, LZ4 0.37 ns
GPU, SIMD group: the page alone 4 ns 130 ns
GPU, SIMD group: added to a full dispatch 0.008 ns 0.12 ns
GPU, page per thread: the page alone — Snappy 370 ns, LZ4 250 ns

The pages go in order, densest first; the host takes a prefix of them and the GPU the rest, and the router picks the prefix whose later finishing time is earliest: the host's pages spread over its cores (12 performance cores and a quarter of each efficiency core, less a tenth; 16 threads decoded token- dense pages 12.4 times as fast as one), never less than its largest page, against the GPU's slowest page plus what every other page adds (with the page-per-thread kernel for the token-dense part when that is predicted faster). ZSTD, GZIP and BROTLI pages are host-only and count against the host's side; literal pages cost the host more than the GPU and stay on the GPU. On the 50 M-row files the host takes 6,924 of the 20,250 Snappy pages and 7,102 of the LZ4 ones — the pages of id, qty and ts but for 470-650 — and the GPU the rest; on the 10 M-row, 7-column file (pyarrow's defaults) the host takes its 1,338 token-dense pages, 790 KB dictionary pages among them (97 ms each on a SIMD group), and the GPU its 2,274 literal ones.

Both at once. The GPU's pages go out as one dispatch per codec and kernel, slowest pages first, and the command buffer is committed before the host starts; the host then decodes its pages on every core (DispatchQueue.concurrentPerform, most expensive first) while the GPU runs, and the read waits once for both before any value kernel reads a page. The two sides never write the same memory: a column's staging layout puts the GPU's pages first and the host's from the next page boundary, so no page of memory holds bytes of both. A header that does not parse or a page that fails to decompress, on either side, drops the batch, and each column then stages its own pages (routed the same way, column by column), so errors are raised exactly as a column-by-column read raises them.

The result, whole-file reads through a fresh open, best of 5, phases from ARROWMETAL_PARQUET_PROFILE (the previous build decompressed every Snappy and LZ4 page on the GPU but for dictionary pages and chunks of a few pages):

file decompression, GPU only decompression, split of which the host whole read, GPU only whole read, split
50 M rows x 8 columns, Snappy 83.6 ms 43.0 ms 42.4 ms, 6,924 pages 106.6 ms 67.2 ms
50 M rows x 8 columns, LZ4 71.7 ms 32.9 ms 32.4 ms, 7,102 pages 94.4 ms 56.5 ms
10 M rows x 7 columns, Snappy, pyarrow defaults 56.0 ms 11.5 ms 11.3 ms, 1,338 pages 67.5 ms 25.4 ms

In all three the GPU finishes its share before the host finishes its own. One column of token-dense pages read on its own with the handle kept went from 49.7 to 15.2 ms (id, Snappy), 53.5 to 18.1 (qty), 44.0 to 15.3 (ts), and 34.3 to 11.2, 48.0 to 12.2 and 39.4 to 10.9 with LZ4; literal columns read as before (price 7.0 and 6.8 ms). The host's share costs CPU time: a whole-file read takes 674 (Snappy) and 508 (LZ4) CPU-ms, against 80 and 78 for the GPU-only build and against Polars' 1,283 and 1,007 and pyarrow's 1,164 and 987 in the same run (Benchmarks, below).

The host decoders. SnappyHost and LZ4Host (Decompress.swift) check every read against the page's input and every write against its output slot, so a damaged page is an error, never an access outside either; LZ4Host makes the GPU kernels' checks in their order, with their outcomes, and recognises the Hadoop framing the same way. Back-references move 8 bytes at a time (a pattern shorter than 8 bytes first repeats to its first multiple of 8) and short literals 16 at a time, only where the whole step lies inside the page and the slot. ZSTD, GZIP and BROTLI are decompressed on the host too, straight into the shared-memory buffer the GPU decoders read. ZSTD pages go out in runs of up to eight, each decoded with one ZSTD_DCtx: ZSTD_decompress allocates and frees a context (about 160 KB) on every call, which for a column of thousands of small pages cost more than the decompression. GZIP and BROTLI go through Foundation's Compression framework (COMPRESSION_ZLIB is raw DEFLATE, so the gzip container is stripped first). The macOS SDK has no COMPRESSION_ZSTD, so libzstd is looked up with dlopen at first use — $ARROWMETAL_ZSTD, then /opt/homebrew/lib, /usr/local/lib, /usr/lib. When it is not installed a ZSTD column raises ParquetError.unsupported naming the missing library rather than returning wrong data; that is the documented host fallback.

The GPU decoders. One SIMD group (32 lanes) owns one page. Lane 0 walks the token stream and broadcasts each parsed token with simd_broadcast; all 32 lanes then move that token's bytes. A 256-thread threadgroup per page was tried and is worse — a threadgroup barrier per token costs more than the extra lanes are worth, because real pages have tens of thousands of small tokens rather than a few large ones. Two details do help:

  • The token stream is staged in threadgroup memory. Every tag byte lane 0 reads is a dependent device-memory load, and one of those costs hundreds of cycles. All 32 lanes cooperatively stage the next 8 KB of the compressed stream into threadgroup memory and the parse reads from there; payload bytes are still read straight from device memory, where the reads are wide and coalesced and off the dependency chain.
  • Overlapping back-references are still copied in parallel. When offset < length (which is how both formats encode runs) the copy repeats a pattern of offset bytes, so output byte i is out[dst - offset + (i % offset)] and every one of those source bytes was written before this token began. simdgroup_barrier(mem_flags::mem_device) before a back-reference orders the stores; a literal token reads only the input and skips the barrier.

snappy_decompress_lane and lz4_decompress_lane give each thread its own page instead: it reads the token stream 16 aligned bytes at a time into registers and keeps the last 256 bytes it wrote in its own slice of threadgroup memory, so the back-references of token-dense pages (a value or two back) are served from there rather than from device memory. They check the same things in the same order and return the same status codes.

Per-block status codes are written back so corrupt input becomes an error instead of silently wrong data. A page that decompresses to nonsense is a separate question: the value decoders clamp what they read to the page they were given (a BYTE_ARRAY length field, for instance), so damaged bytes produce wrong values but never an out-of-bounds access and never an unbounded loop.

ARROWMETAL_PARQUET_DECODE=host, gpu or lane sends every Snappy and LZ4 page of a process to one decoder; the tests read every fixture, and 240 damaged files, on each (Correctness, below).

Supported matrix

Encodings

Encoding Types Where Notes
PLAIN all physical types GPU fixed width is a page-sized copy; BYTE_ARRAY is a per-page length walk plus a parallel gather
RLE definition and repetition levels, BOOLEAN values GPU the batched run-table decoder above
PLAIN_DICTIONARY, RLE_DICTIONARY all GPU returns an Arrow dictionary array, or materialised values with dictionaryEncoded: false
DELTA_BINARY_PACKED INT32, INT64 GPU thread 0 parses block headers, all threads unpack their miniblock deltas, a log-step threadgroup scan turns deltas into values
DELTA_LENGTH_BYTE_ARRAY BYTE_ARRAY GPU delta-packed lengths, then one prefix sum and one gather
DELTA_BYTE_ARRAY BYTE_ARRAY GPU prefix and suffix lengths delta-packed; the prefix chain is serial, so one threadgroup walks its page's values in order while all 256 threads move each value's bytes
BYTE_STREAM_SPLIT FLOAT, DOUBLE, FIXED_LEN_BYTE_ARRAY GPU byte k of value j is at plane k, slot j
BIT_PACKED (deprecated level encoding) levels — rejected with ParquetError.unsupported; no writer has emitted it since 2015

Codecs

Codec Where Notes
UNCOMPRESSED GPU (no work) the mapped file is the page buffer
SNAPPY GPU and host, page by page literal pages on the GPU (one SIMD group per page), token-dense pages on the host, both at once ("Decompression")
LZ4 GPU and host, page by page as SNAPPY; the Hadoop framing (big-endian sizes) is recognised by both decoders
LZ4_RAW GPU and host, page by page as LZ4
ZSTD host dlopen of libzstd; a clear error when it is missing; one context per run of pages
GZIP host Compression framework, gzip container stripped
BROTLI host COMPRESSION_BROTLI
LZO — ParquetError.unsupported

Types

Parquet Arrow Notes
BOOLEAN bool bit-packed PLAIN or RLE
INT32 int32
INT32 + UNKNOWN (Arrow's null type) null
INT32 + INT(8|16|32, signed) int8 / int16 / int32 narrowed with the existing cast kernels
INT32 + INT(8|16|32, unsigned) uint8 / uint16 / uint32
INT32 + DATE date32
INT32 + TIME(MILLIS) time32[ms]
INT32 + DECIMAL(p,s) decimal128(p,s) widened on the GPU
INT64 int64
INT64 + INT(64, unsigned) uint64
INT64 + TIMESTAMP(unit) timestamp[unit], UTC when isAdjustedToUTC the stored zone instead of UTC when ARROW:schema has one, below
INT64 + TIME(MICROS|NANOS) time64[us|ns]
INT64 + DECIMAL(p,s) decimal128(p,s)
INT96 timestamp[ns] Julian day + nanoseconds, converted on the GPU
FLOAT / DOUBLE float32 / float64
BYTE_ARRAY binary
BYTE_ARRAY + STRING / JSON / ENUM utf8
FIXED_LEN_BYTE_ARRAY fixed_size_binary(n)
FIXED_LEN_BYTE_ARRAY + DECIMAL(p,s) decimal128(p,s) big-endian, sign-extended on the GPU
FIXED_LEN_BYTE_ARRAY + FLOAT16 float16
list<T> (3-level and 2-level) list<T> Dremel assembly on the GPU, below; T may itself be nested
struct (a group without a LIST / MAP annotation) struct<...> reassembled from its leaves; its leaves also read on their own by dotted path
map (MAP / MAP_KEY_VALUE) map<K, V> a list of key/value entries; V may be nested

Nesting

Structs, maps and lists nest to any depth — list<list<T>>, list<struct<...>>, struct<list<...>>, map<K, list<V>>, list<map<K, V>> — with nulls and empty lists at every level, in the three-level (group (LIST) { repeated group list { element } }) and two-level (group (LIST) { repeated element }) list shapes. Every Arrow-level field carries three numbers from the schema: the definition level at which it is present, the largest repetition level that starts a new element of it, and the definition level of its nearest repeated ancestor. Over the level entries of any one leaf beneath the field, an entry is one slot of the field when its repetition level is at most the first and its definition level reaches the third, and the slot is non-null when the definition level reaches the second. So:

  • a leaf is its decoded array compacted to its own slots with the package's existing filter;
  • a struct is its children plus a validity bitmap, read off one leaf's entries at the struct's slots;
  • a list is its child plus offsets: at each of the list's slots, the number of child slots before it, which is a prefix sum of the child's slot flags read at the list's own slots;
  • a map is a list whose child is the key/value entries struct.

Each of those is one flag kernel over the entries (pq_nest_flags), one prefix sum and one scatter (pq_nest_scatter), and every leaf is decoded once however many fields read its levels (ParquetNested.swift). A one-level list<primitive> keeps the dedicated kernel pair it had before, which reads the same levels the same way. A struct's leaves still read on their own by dotted path: am.read_parquet(path, columns=["addr.city"]) returns the city leaf as a flat column, null wherever addr or city is null.

The stored Arrow schema

Parquet has no time zone (only isAdjustedToUTC), no duration, no decimal32 / decimal64, no fixed-size list, no dictionary type, no extension types and no per-field metadata. Arrow writers — pyarrow, Arrow C++, Polars, arrow-rs — therefore store the original Arrow schema in the file's key/value metadata under ARROW:schema (base64 of an IPC Schema message), and the reader decodes it with the IPC FlatBuffers views in IPC/FlatBuffers.swift and puts back what the Parquet schema lost (ParquetArrowSchema.swift):

Stored Arrow type Parquet stores Comes back as
timestamp[unit, tz=Z] TIMESTAMP(isAdjustedToUTC) timestamp[unit, tz=Z], on the stored unit (a seconds timestamp is stored, and read, as milliseconds)
duration[unit] plain INT64 duration[unit]
decimal32(p,s) / decimal64(p,s) INT32 / INT64 DECIMAL decimal32(p,s) / decimal64(p,s)
fixed_size_list<T>[n] a list fixed_size_list<T>[n]; a null row gets n null child slots
dictionary<K, T> over strings or binaries (a pandas categorical, a Polars Categorical or Enum) the values dictionary<int32, T>, unordered, dictionary encoded whatever the dictionary switch says (pyarrow keeps the stored index type K and the ordered flag; see Limits); a dictionary over any other value type (integers, timestamps, dates) reads as that value type, as pyarrow reads it
an extension type its storage the storage wrapped in MetalExtensionArray, so a consumer that knows the type rebuilds it
field custom_metadata — ParquetFile.arrowFieldMetadata(column:), am_parquet_field_metadata, f.field_metadata(column), and on every field of read_parquet_table's Table

Time zones and durations are restored inside structs, lists and maps too. The file's other key/value metadata is the schema metadata (arrowSchemaMetadata, am_parquet_schema_metadata, f.schema_metadata), and a Parquet field_id shows up as PARQUET:field_id, as it does in pyarrow. The null type needs no stored schema: Parquet annotates a null column UNKNOWN, which reads as null, at the top level and inside lists, maps and structs (list<null>, map<string, null>, list<struct<a: null, ...>>). The stored fields are matched to the Parquet columns by position, as Arrow's reader matches them; a stored schema with a different number of top-level fields is ignored, and its ARROW:schema key stays in the schema metadata, as pyarrow keeps it. A stored type the column cannot take (a dictionary claim over a struct, say) is ignored for that column. A file without ARROW:schema reads exactly as its Parquet schema describes it. A file whose ARROW:schema is not base64, or does not decode as a Schema message, reads the same way, with the undecodable value left in the schema metadata; pyarrow refuses to open such a file (Invalid base64 input, Corrupted metadata length), and test_parquet_nested.py checks both sides. A stored field nested more than 64 levels deep is read down to 64 levels and taken as a type no column matches, so that column reads as its Parquet schema says and the other stored fields still apply, as in pyarrow (test_a_stored_field_nested_past_64_levels_is_ignored_like_pyarrow). A leaf read on its own by dotted path always reads as the Parquet schema describes it.

Projection and predicate pushdown

let f = try ParquetFile(path: "trades.parquet")
let batch = try f.read(ParquetReadOptions(
    columns: ["price", "qty"],
    rowGroups: [0, 1],
    filters: [ParquetFilter(column: "price", op: .gt, value: .int(100))]))
cols = am.read_parquet("trades.parquet", columns=["price", "qty"],
                       filters=[("price", ">", 100)])
# already on the GPU, dictionary-encoded or not
total = cols["price"].sum()

# Across several queries, keep the handle: mapping the file is a per-open cost.
f = am.ParquetFile("trades.parquet")
for day in days:
    px = f.read(columns=["price"], filters=[("day", "==", day)])["price"]

# Or let the open-file cache hold it (below).
px = am.read_parquet("trades.parquet", columns=["price"], cache=True)["price"]

Only the requested column chunks are ever touched — the other columns' pages are never even faulted in. filters is evaluated against the footer's min_value / max_value statistics per row group; a row group whose range cannot contain a match is skipped without reading a page. When the file also has a column index and an offset index, the same filters then skip pages inside the row groups that remain (next section). Either way the result is a superset of the matching rows: follow it with filter (or a fused am.query) to get exactly the rows. A filter value is a string, a boolean, an integer or a float; a date or timestamp column is filtered by its stored integer (days since the epoch, or ticks in the column's unit), and Python raises on any other value (a datetime.date, a Decimal). A literal of another kind than the column's (a string against a number, in the C and Swift filter text) never rules a row group or page out. A string value may hold any character: Python quotes it and escapes " and \, so a ;, a quote or an operator inside it is part of the value; a column name cannot hold =, !, <, > or ;, and Python raises on one that does. An integer above the int64 range stays exact and is compared as unsigned against a uint64 column's statistics, which are read as unsigned (test_uint64_literals_past_the_signed_range, test_string_literals_with_quotes_semicolons_and_backslashes). String and binary statistics are compared the way Parquet orders them, by unsigned bytes: the min / max stay raw bytes and a string literal is compared as its UTF-8 bytes, so a composed and a decomposed accent, Z before a before é, and an emoji above every BMP character are all ordered as pyarrow orders them, row groups and page index alike (test_string_statistics_are_ordered_by_bytes). An integer statistic and a float literal are compared exactly, never through a double: 2^53 + 1 is above the literal 2^53 (test_int64_statistics_against_a_double_literal_near_2_pow_53). A decimal column's statistics hold its unscaled integer, so they never rule a row group or page out (test_decimal_statistics_stored_as_integers_are_not_compared_unscaled). On a float or double column != never rules a row group or page out: writers leave NaN out of min / max, so a range of one value equal to the literal can still hold a NaN, which != keeps. pyarrow's read_table(filters=...) rules out a row group whose min and max both equal the literal, and so leaves out that group's NaN rows; ArrowMetal returns them (test_not_equal_keeps_a_nan_row_group_where_pyarrow_drops_it). ParquetFile.selectedRowGroups(_:) / ParquetFile.selected_row_groups(...) report what the row-group statistics keep without reading anything, and ParquetFile.column_null_count(column) (am_parquet_column_null_count) a top-level column's null count: 0 for a required column, else the sum of the row groups' null_count statistics, or None when a row group does not record it.

Page-level skipping

A writer may store, after the row groups, a column index (each data page's min, max and whether it holds only nulls) and an offset index (where each data page starts and its first row). pyarrow writes them with write_page_index=True; Polars writes them by default. With them a filter narrows each row group to candidate row ranges (ParquetPageIndex.swift):

  1. For each filter on a flat column, a page whose [min, max] cannot satisfy it, or that holds only nulls, rules out its rows. The ranges the filters leave are intersected; a row group left with none is not read at all, even when its row-group statistics let it through. A page counts as all-null only when the index's null count for it covers every row: Polars (1.44) flags each page that holds a NaN as a null page with a null count of 0, and such a page is kept whole. A NaN min or max rules nothing out, as the format asks. Polars also leaves the flagged pages out of the row group's min / max, so when a chunk's column index shows such a page, that column's row-group statistics do not drop the row group; pyarrow.parquet.read_table(filters=...) trusts them and returns no rows for a filter outside the unflagged pages' range, where ArrowMetal returns every match (test_polars_row_group_statistics_leave_nan_pages_out).
  2. Every flat column with an offset index decodes only the data pages that overlap a candidate range. The page list comes from the offset index, so a skipped page is never decompressed, never decoded, and its header is never read.
  3. Pages have different boundaries in different columns, so every column — nested ones and those without an offset index included, which decode their row groups whole — is trimmed to exactly the candidate rows with one filter. All columns of the result cover the same rows.

The rows that match a filter are the same with and without the index; the index only makes the superset smaller. ParquetFile.usePageIndex (f.use_page_index in Python, am_parquet_set_page_index in C) turns it off, and lastReadStatistics (f.last_read_stats, am_parquet_last_read_stats) reports what the last read did: row groups read and skipped by statistics, by the page index and by bloom filters (below), data pages decoded and skipped, rows returned. test_parquet_nested.py compares the exact matches with and without the index against pyarrow over fifteen filter sets on five files (pyarrow in three page layouts and once without an index, and Polars), and six filter sets on a float column with a NaN every 97th row, written by Polars and by pyarrow, and != on pages of 64 rows where a NaN hides in a page whose min and max both equal the literal; ParquetPageIndexTests also checks that the skipped and decoded pages of flat columns add up to the pages of the row groups read. A filter on a column inside a list does not narrow pages; the row-group statistics still apply to it.

Bloom filters

A writer may also store a split-block bloom filter per column chunk (pyarrow with bloom_filter_options, DuckDB for its dictionary-encoded columns). An == filter hashes its literal the way the writer hashed the column's values — xxHash64 of the PLAIN encoding — and a row group whose filter does not have all 8 bits of that hash set is dropped before any page is read, even when its min/max statistics let the value through (ParquetBloomFilter.swift). A set of bits is only a maybe, so the row group is then read as usual; a bloom filter never drops a row group that holds the value. Only literals with one exact encoding are looked up — integers stored as INT32 / INT64, strings and binaries, and floating-point values exactly representable in the column's type other than zero and NaN (which have two encodings each) — and anything else keeps the row group. ParquetFile.useBloomFilters (f.use_bloom_filters, am_parquet_set_bloom_filters) turns it off, and the read statistics count the row groups it dropped. ParquetBloomFilterTests checks xxHash64 against its published vectors and every value of the fixture against its row group's filter; test_parquet_nested.py looks up values in seven columns of a pyarrow file and one of a DuckDB file and requires pyarrow's exact matches with the filters on and off.

The open-file cache

Opening a file maps it, and the first read of a column hands the mapped pages it covers to Metal; a ParquetFile keeps both for as long as it lives (above, "The file's bytes are the GPU's bytes"). A caller that cannot hold the handle itself — one read_parquet call per query, or the Polars engine, which sees a file path in every plan — reads through a process-wide cache of open files instead:

cols = am.read_parquet("trades.parquet", columns=["price"], cache=True)   # opens and maps
cols = am.read_parquet("trades.parquet", columns=["price"], cache=True)   # the same open file

am.parquet_cache_info()     # {"entries", "bytes", "max_entries", "max_bytes", "hits", "misses",
                            #  "invalidations", "files"}
am.parquet_cache_limit(max_entries=4, max_bytes=8 << 30)
am.clear_parquet_cache()
  • Keyed by the file's identity and state: its real path, device, inode, modification time in nanoseconds and size. A file rewritten in place (new size or new modification time) or replaced by another under the same name (new inode) is opened afresh on its next read, and its stale entry is dropped then (invalidations counts those).
  • Bounded: least recently used entries are evicted above max_entries (16 by default) or above max_bytes, the sum of the cached files' sizes (a quarter of physical memory by default). A file larger than max_bytes is read without being cached; max_entries=0 turns the cache off.
  • Shared: reads of one cached file are serialised by a lock per file, so last_read_stats belongs to the read that took it. Evicting or clearing an entry closes the file once the last read holding it returns.
  • read_parquet without cache=True opens and closes the file on every call, as before.

python/tests/test_polars_engine.py checks reuse, invalidation on a new size, a new modification time with the same size, a touched file and a replaced file, LRU order, both bounds and clear.

Cold is the first read in the process (the cache cleared before each run; the file itself stays in the OS page cache), warm is the next read of the same file through the cache. 50,000,000 rows x 8 columns, the files of Benchmarks/parquet_bench.py, best of 5, Benchmarks/results/parquet_cache_2026-09-26-split.csv (run conditions in Benchmarks/results/bench_conditions_2026-09-26-split.txt):

codec file measure cold ms warm ms
snappy 1.65 GB open only 0.2 0.01
snappy read price (400 MB of values) 12.2 7.0
snappy read price + sum 13.9 8.5
snappy read all 8 columns 66.7 53.9
lz4 1.67 GB open only 0.2 0.01
lz4 read price 12.3 7.0
lz4 read price + sum 13.5 7.9
lz4 read all 8 columns 54.5 42.9
none 2.23 GB open only 0.3 0.01
none read price 7.2 2.7
none read price + sum 7.9 4.4
none read all 8 columns 38.3 16.0

Opening the file (the footer and the mapping) is 0.2-0.3 ms. A cold one-column read is 7.2-12.3 ms against 2.7-7.0 ms warm: the cold read also parses the column's page headers, a minor fault each on a fresh mapping, and makes the column's own chunks resident for the GPU (above, "The file's bytes are the GPU's bytes"). A cold read of all 8 columns is 38.3-66.7 ms, 16.0-53.9 ms warm.

PYTHONPATH=python python Benchmarks/parquet_bench.py --rows 50000000 --codecs snappy,lz4,none \
    --cache --skip-main --keep --repeat 5 --cache-out parquet_cache.csv

Benchmarks

Measured on an Apple M4 Max (Mac16,6, 64 GB), macOS 26.x, release build. 50,000,000 rows x 8 columns (int64, int64, int32, float64, float64, dictionary-encoded string, timestamp[us], bool), 1 MB data pages. Best of 5 in-process runs, caches warm. The tables below are from Benchmarks/results/parquet_bench_2026-09-26-split.txt (run conditions in Benchmarks/results/bench_conditions_2026-09-26-split.txt).

wall ms is elapsed time for the whole read; CPU ms is process CPU time over the same interval, so GPU work does not appear in it and ArrowMetal's host share of the decompression does; ttfc is time to first compute — read one float64 column and sum it, which is the smallest query anyone actually runs.

Whole-table read, 50 M rows x 8 columns

codec reader wall ms CPU ms MB/s ttfc ms
snappy arrowmetal 65 674 25373 14
snappy pyarrow.parquet 160 1164 10314 85
snappy polars 95 1283 17455 20
snappy pandas 239 1346 6915 89
lz4 arrowmetal 54 508 31062 14
lz4 pyarrow.parquet 159 987 10516 83
lz4 polars 73 1007 22854 19
lz4 pandas 226 1164 7396 90
none arrowmetal 38 83 58724 9
none pyarrow.parquet 162 746 13721 80
none polars 63 790 35232 16
none pandas 237 910 9412 88

On wall time ArrowMetal is ahead of pyarrow on all three files (2.5x Snappy, 2.9x LZ4, 4.3x uncompressed) and ahead of Polars on all three (1.5x Snappy: 65 ms against 95; 1.4x LZ4: 54 against 73; 1.7x uncompressed: 38 against 63). It takes 1.7-2.0x less CPU time than pyarrow and Polars on the two compressed files (674 and 508 ms against 987-1,283), where the host decodes the token-dense pages, and 9.0-9.5x less on the uncompressed one (83 ms against 746-790), where the decode is work the host never does. Time to first compute, which opens the file afresh for every query, is 9-14 ms against Polars' 16-20 ms and pyarrow's 80-85 ms. With the handle kept, which is what a query engine does:

One float64 column (400 MB of values), file handle kept open

codec reader read ms sum ms
snappy arrowmetal 7 2
snappy pyarrow.ParquetFile 48 6
lz4 arrowmetal 7 2
lz4 pyarrow.ParquetFile 48 6
none arrowmetal 3 2
none pyarrow.ParquetFile 42 6

That is the shape a query actually has — open once, project a column, compute — and ArrowMetal is 6.9-14.0x ahead on the read (6.9x Snappy, 6.9x LZ4, 14.0x uncompressed). The reduction takes 2 ms against 6 on all three files, because the values are already in GPU memory when it starts. 400 MB decoded in 2.7 ms (the warm price read of the open-file cache table above) is 150 GB/s.

Decompression on its own

One int64 column of 20 M rows, deliberately structured data (an LCG sequence, so Snappy finds many short matches and emits many tokens — the hard case for a GPU), 1 MB pages:

codec file MB am ms am MB/s decode alone
none 160 3.2 50032 —
snappy 152 8.1 19670 32.4 GB/s
lz4 161 4.2 38027 (pyarrow wrote it barely compressed)
zstd (host, libzstd) 120 10.6 15079 21.6 GB/s
gzip (host) 111 29.7 5382 6.0 GB/s

Page size, the same column, MB/s of decoded output:

page size none snappy lz4 zstd (host) gzip (host)
1 MB 50032 19670 38027 15079 5382
256 KB 55896 19016 33799 16009 5347
64 KB 59473 20219 32228 15921 5447

These Snappy pages compress 1.05x and are short tokens throughout, so they decode on the host; the LZ4 pages (under 1.0x at 1 MB and 256 KB, one long literal each) decode on the GPU, and at 64 KB (1.12x) on the host.

Reproduce with:

PYTHONPATH=python python Benchmarks/parquet_bench.py --rows 50000000 --codecs snappy,lz4,none --codec-scan --repeat 5

Nested reads — a struct, a list, a list of lists, a map and a list of structs, at 1 M and 10 M rows, against pyarrow, Polars and DuckDB — have their own script. Its numbers here are from Benchmarks/results/parquet_nested_2026-09-24.csv. The run conditions at its start are recorded in Benchmarks/results/bench_conditions_2026-09-24.txt; this run started at a load average of 6.10, busier than the other benchmarks of that day. Wall time in that file, with the fastest CPU reader of each row:

shape ArrowMetal, 1 M fastest CPU, 1 M ArrowMetal, 10 M fastest CPU, 10 M
struct 10.35 ms 11.44 ms (pyarrow) 19.99 ms 27.14 ms (Polars)
list 6.1 ms 4.96 ms (pyarrow) 17.54 ms 14.46 ms (Polars)
list of list 7.39 ms 9.34 ms (pyarrow) 25.56 ms 26.16 ms (Polars)
map 9.69 ms 17.65 ms (pyarrow) 36.0 ms 39.21 ms (Polars)
list of struct 10.76 ms 6.09 ms (pyarrow) 24.14 ms 25.17 ms (pyarrow)
all five 42.81 ms 19.8 ms (pyarrow) 115.24 ms 91.38 ms (pyarrow)

ArrowMetal is ahead on the struct, list of list and map reads at both sizes and on the list of struct read at 10 M rows. To improve: the list read (6.1 ms against pyarrow's 4.96 at 1 M rows, 17.54 ms against Polars' 14.46 at 10 M), the list of struct read at 1 M rows (10.76 ms against 6.09), and the read of all five columns together (42.81 ms against pyarrow's 19.8 at 1 M rows, 115.24 ms against 91.38 at 10 M). The host CPU time is lower on every row: all five columns at 10 M rows cost 34.99 ms of CPU against pyarrow's 593.14 and Polars' 1269.84 (cpu_ms). Every ArrowMetal row matches pyarrow's table (match). An earlier smoke run, recorded while other work shared the GPU, is kept as history in Benchmarks/results/parquet_nested_2026-09-23_provisional.csv.

PYTHONPATH=python python Benchmarks/parquet_nested_bench.py --rows 1000000,10000000 --repeat 3 --out results.csv

What the numbers say

  • Both processors decode, and the CPU still does less. A whole-file read of the compressed tables spends 508-674 ms of CPU, the host's share of the decompression, against 987-1,346 ms for the CPU readers; the uncompressed one 83 ms against 746-910. Everything but the decompression of token-dense pages is compute the process never does.
  • The arrays land in GPU memory already. Summing the column ArrowMetal just decoded takes 2 ms; the CPU reader pays 6 ms and had to materialise the array first. There is no import step, because the decode wrote into Metal shared memory in the first place.
  • Opening the file costs little; the first read of a column costs a little more. A fresh open is 0.2-0.3 ms, and a cold one-column read 7.2-12.3 ms against 2.7-7.0 ms with the handle kept: the cold read parses the column's page headers and makes its chunks resident for the GPU. Holding the ParquetFile across queries, which is what a query engine does, keeps both.
  • Snappy and LZ4 decode on whichever side is faster for the page. An LZ77 token stream is serial, so a page is one worker's job and its cost scales with the number of tokens, not with bytes. Incompressible pages are one huge literal and the GPU moves them at memory speed — the price column, 400 MB of random doubles, comes back in 7 ms with the handle kept. Token-dense pages go to the host's cores, which walk a token stream about a thousand times as fast as a GPU thread, while the GPU decodes the rest (Decompression, above): the whole Snappy table in 65 ms against Polars' 95, the LZ4 one in 54 against 73.
  • Where the GPU is unambiguously ahead is the uncompressed and dictionary paths, which is also where a GPU-resident analytics stack wants to be: 50-59 GB/s for a plain int64 column, the whole uncompressed table 1.7x Polars, and a dictionary column that comes back as an Arrow dictionary array without materialising a single string.

The writer

ParquetWriter (and am.write_parquet, am_parquet_write) is deliberately small: it exists so ArrowMetal can round-trip its own output, not to compete with pyarrow's writer. It runs on the host — writing is not the part of Parquet that needed a GPU.

  • PLAIN for fixed-width columns, PLAIN or RLE_DICTIONARY for byte arrays.
  • RLE definition levels; every column is written as optional, so nulls round-trip.
  • Uncompressed or Snappy (a compact hash-table Snappy encoder in ParquetWriter.swift).
  • Data page v1, one data page per column chunk, rowGroupSize rows per row group.
  • Types: all integers, float32, float64, bool, utf8, binary, fixed_size_binary, date32, time32, time64, timestamp. Dictionary columns are materialised first. Decimals, lists, structs and the other nested types are not written.

Files it writes read back with identical values, nulls and types under ArrowMetal and pyarrow — over int64, float64, string, bool and timestamp columns, uncompressed and Snappy; ParquetWriterTests and python/tests/test_parquet.py::test_writer_round_trip check both directions.

Correctness

  • Tests/Fixtures/generate_parquet.py writes 44 small fixtures (1.8 MB total, committed): every logical dataset once per encoding/codec/page-version variant, plus long strings (up to 64 KB), lists of int64 and of string, several row groups, decimals stored both as FIXED_LEN_BYTE_ARRAY and as INT32 / INT64, a struct column, all-null columns, an empty file, a single-row file and INT96 timestamps.
  • Tests/ArrowMetalTests/ParquetTests.swift reads every variant of a dataset and compares them element for element, which checks the encodings against each other, plus known-value, type, list, statistics and projection tests.
  • python/tests/test_parquet.py reads every fixture twice — once on the GPU, once with pyarrow.parquet — and asserts the values, nulls and types are identical: 88 checks over the fixture set, plus projection, row-group selection, statistics pushdown, dictionary output, struct leaves and a 50 M-row round trip behind ARROWMETAL_PARQUET_BIG=1.
  • Every Snappy and LZ4 decoder reads the same bytes. ParquetTests.testEveryDecoderAgrees reads every Snappy and LZ4 fixture, whole and one row group at a time, with every page sent to the host, to the SIMD-group kernel and to the page-per-thread kernel, against the routed split; test_every_fixture_reads_like_pyarrow_on_every_decoder reads every fixture and a Snappy, an LZ4 and a ZSTD file of small pages on each decoder against pyarrow; testDamagedPagesOnEveryDecoderRaiseOrReturn reads 240 damaged Snappy and LZ4 files on each decoder and on the split, requiring every read to raise or return. DecompressSplitTests checks the host decoders against byte-at-a-time reference decoders (overlapping copies at every offset from 1 to 16, runs ending exactly at the end of the slot, the Hadoop framing) and on damaged blocks placed between inaccessible pages, so a read or write outside a block would end the process; it also checks the router's decisions and that the staging layout never puts a host page and a GPU page in the same page of memory.
  • python/tests/test_parquet_robustness.py damages a file two hundred ways — truncation, a broken magic, a footer length larger than the file, single-byte damage in the footer and in the pages, a hand-built footer nesting Thrift structs 60,000 deep, a 2^64 length, a num_children past the schema — and requires every one of them to raise rather than crash, hang or read outside the mapping.
  • ParquetWriterTests and the Python writer test close the round trip.
  • Tests/Fixtures/generate_parquet_nested.py writes the nested fixtures under Tests/Fixtures/nested (committed): structs three levels deep, structs of strings and binaries, map<string, int64>, map<int32, string>, maps of structs and of lists, list<list<T>>, list<list<list<int32>>>, list<struct<...>>, struct<list<...>> and list<map<...>>, with nulls and empty lists at every level — written by pyarrow in four encoding / codec / page-version variants with 512-byte pages (so levels cross hundreds of page boundaries), by DuckDB (COPY ... TO ... (FORMAT parquet)) and by Polars wherever it can express the shape (it has no map type).
  • Tests/ArrowMetalTests/ParquetNestedTests.swift checks known values against the generator's formulas and every writer's and variant's file against every other's, row by row.
  • python/tests/test_parquet_nested.py reads every nested fixture with ArrowMetal and with pyarrow.parquet.read_table and requires the same values and the same types, apart from the three differences listed under Limits (32-bit offsets and no view layouts, nullable struct members, the index type and ordered flag of a restored dictionary), each of which has its own test showing the difference is exactly that; it also damages nested files 200 ways and requires every read to raise or return rather than crash.
  • The same generator writes the ARROW:schema fixtures: time zones on every unit (named zones, a fixed offset, UTC, naive), the null type (also below lists, maps and structs), durations, decimal32 / decimal64, a fixed-size list, categoricals over strings, binaries, integers, timestamps and dates (and a pandas categorical, a Polars Categorical and a Polars Enum), field metadata and a field_id, a registered (arrow.uuid) and two unregistered extension types, zones and durations inside a struct, a list and a map, the view and 64-bit-offset layouts, and the same columns with no ARROW:schema (from pyarrow and DuckDB). DuckDB's KV_METADATA writes the crafted ones: an ARROW:schema that is not base64, one that is a truncated message, stored schemas with fewer and more fields than the file and one whose first field is renamed, and a dictionary claim over a struct. ParquetArrowSchemaTests and the ARROW:schema half of test_parquet_nested.py check types, values, field metadata and schema metadata against pyarrow.
  • It also writes the page-index fixtures (pyarrow with write_page_index=True in three page layouts and once without, and Polars; plus a float column with NaN from Polars and pyarrow, a NaN inside a page whose other values all equal the filter literal, and uint64 values past the int64 range) and the bloom-filter fixtures (pyarrow with bloom_filter_options, and DuckDB), which ParquetPageIndexTests, ParquetFilterEdgeTests, ParquetBloomFilterTests and the second half of test_parquet_nested.py read; that file also damages the page indexes 180 ways and reads them with filters, requiring every read to raise or return, and reads 40,000 rows of repeated columns three times over.
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test --filter "Parquet"
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test -c release --filter "Parquet"
swift build -c release --product ArrowMetalC
PYTHONPATH=python python -m pytest python/tests/test_parquet.py python/tests/test_parquet_nested.py python/tests/test_parquet_robustness.py -q
python Tests/Fixtures/generate_parquet_nested.py      # regenerate the nested, schema, page-index and bloom fixtures
ARROWMETAL_PARQUET_BIG=1 PYTHONPATH=python python -m pytest python/tests/test_parquet.py -q -k fifty

Limits

  • Duplicate column names are kept, not merged. read hands back a ColumnSet — a dict that is positional underneath — so read_parquet(path, columns=["a", "a"]) returns both columns and a file with two columns of the same name reads both of them. Indexing by name gives the first; names, columns, items() and iteration walk all of them in order.
  • BIT_PACKED (the deprecated level encoding) and LZO are rejected.
  • Encrypted files are not supported.
  • A column whose chunks in one read total 4 GiB or more is rejected (the file itself has no size limit).
  • Statistics pushdown returns a superset of the matching rows: row-group granular, or page granular when the file has a page index. The deprecated min/max fields are only used when min_value/max_value are absent (they use a signed byte order that is wrong for strings, which is why Parquet deprecated them).
  • ZSTD needs libzstd installed; see above.
  • The members of a struct and the element of a list are exported as nullable. A writer that declares a struct member required gets not null on that member from pyarrow.parquet.read_table; the values are the same, and the member reads as nullable here.
  • A restored dictionary type has int32 indices and no ordered flag. The engine's dictionary arrays carry int32 codes only, so a stored dictionary<int8 | uint8 | uint32, T> (pandas stores a categorical with int8 indices, Polars a Categorical with uint32 and an Enum with uint8, ordered) reads here as dictionary<int32, T>, unordered, where pyarrow.parquet.read_table keeps the stored index type and flag. The values are the same (test_restored_dictionaries_have_int32_indices_and_no_ordered_flag).
  • Custom metadata on a nested field is not carried. A top-level column's field metadata comes back (above); metadata on a struct member, a list element or a map value does not, because the engine's nested arrays have no per-field metadata. The types still compare equal.
  • large_string, large_binary and large_list come back 32-bit, and the view types as their non-view twins. ArrowMetal narrows 64-bit offsets everywhere and has no view layouts, so a column whose stored Arrow type is large_string or string_view reads here as string, large_list or list_view as list, with the same values. Polars records large_string / large_list for every string and list column it writes.
  • decimal256 (precision above 38) is rejected, as is any Arrow type ArrowMetal does not carry.
  • A dictionary-encoded column decodes itself where the kernels cannot use the codes. read_parquet returns dictionary-encoded columns encoded (dictionary=False, which read_parquet_table passes, materialises them instead), and the entry points that need the values rather than the codes decode once on the way in: the reductions and statistical aggregates, arithmetic, comparison, cast, the sorts and top_k, and the element-wise maths kernels. The decode is a GPU take of the values by the codes, so it costs one pass; dictionary=False at read time is still the cheaper way to run many operations over the same column. The one thing that does not decode for you is the fused expression compiler behind am.query / am.scan(...), which reads flat columns only: hand it a column read with dictionary=False.
  • PLAIN BYTE_ARRAY pages are walked by one thread each to find the value boundaries — the format gives no other option — so a byte-array column with a handful of very large pages has less parallelism than a wide one.
  • Arrays are capped at 2^32 elements, as everywhere else in ArrowMetal.
docs/CSV.md

CSV on the GPU

ArrowMetal reads CSV with Metal compute kernels: the file's bytes go into one shared-memory buffer, a quote-aware scan finds every field and record boundary on the GPU, and each column is typed with pyarrow.csv.read_csv's inference rules and converted by a kernel straight into Arrow arrays. The host parses bytes only for a byte order mark, skip_rows and skip_rows_after_names, the header names, error messages and the rare float the GPU parser hands back (below).

The oracle is pyarrow.csv.read_csv. For every file and option set in python/tests/test_csv.py the two readers return the same table — names, types, validity and values, floating point bit for bit — or fail with the same message.

Using it

var o = CSVReadOptions()
o.includeColumns = ["price", "qty"]
o.columnTypes = ["qty": .int32]
let batch = try CSVReader(path: "trades.csv", options: o).read()     // MetalRecordBatch
import arrowmetal as am
import pyarrow.csv as pc

cols = am.read_csv("trades.csv", include_columns=["price", "qty"])   # ColumnSet of MetalArrays
total = cols["price"].sum()                                          # already on the GPU

# pyarrow's own option objects work too, and read_csv_table returns a pyarrow.Table (zero copy)
t = am.read_csv_table("trades.csv", convert_options=pc.ConvertOptions(column_types={"qty": "int32"}))
am_csv_options o;
am_csv_options_init(&o);
const char* keep[] = {"price", "qty"};
o.include_columns = keep; o.n_include_columns = 2;
am_csv_reader* r; am_csv_batch* b;
am_csv_open("trades.csv", &o, &r);
am_csv_read(r, &b);          /* am_csv_batch_rows / _columns / _column_name / _column, as for Parquet */

The pipeline

  the file -> one Metal buffer          parallel pread into a pooled, page-aligned shared buffer
  csv_summarize                         each thread runs its block of bytes from every parser state
  csv_scan_local, csv_scan_groups       the blocks' transfer functions, prefix-composed
  csv_emit                              every field and record boundary, as a byte position
  csv_check_fields                      every record has the header's width; complex fields counted
  csv_classify on a sample              Arrow's inference over the first 8192 rows
  csv_convert_rows                      every column converted in one row-major pass, each row checked
                                        against the sampled type
  (strings) offsets scan, csv_str_copy  the utf8 / binary columns' bytes

Getting the bytes to the GPU

By default the file is read with pread — up to eight ranges in parallel — into a page-aligned buffer from the context's pool, which is already a Metal shared buffer. fileAccess = .map (file_access="map") maps the file instead and wraps the mapping with makeBuffer(bytesNoCopy:), as the Parquet reader does. No copy is made, but on a warm page cache the mapped read measured slower than the copy overall (105.39 ms against 51.8 ms at 10 M rows in Benchmarks/results/csv_bench_2026-09-24.csv); Benchmarks/csv_bench.py records both (arrowmetal and arrowmetal_map), and ARROWMETAL_CSV_TRACE=1 shows where the time goes phase by phase.

Finding every field: the parser as a table, scanned

RFC 4180 as Arrow reads it is a five-state machine over four byte classes:

state other delimiter quote \n or \r
0 line start 2 1, field end 3 0 (an empty line, skipped)
1 field start 2 1, field end 3 0, record end
2 unquoted field 2 1, field end 2 (a literal quote) 0, record end
3 inside quotes 3 3 4 3
4 quote inside quotes 2 1, field end 3 (a doubled quote) 0, record end

A quote opens a quoted section only at the start of a field. Anywhere else it is an ordinary byte, and after a closing quote the rest of the field is literal: ab"c reads as ab"c and "ab"cd as abcd, which is what pyarrow does. A quote-parity scan cannot express that, so the scan runs the exact generalisation of it. Each thread takes a block of bytes (1024 by default, scanBlockBytes) and runs it from all five start states at once, recording for each the end state and the number of boundaries it passed — the block's transfer function. In practice the five runs collapse onto two states (inside quotes or not) within the first line, and the thread continues with just those two. Transfer functions compose associatively, so a prefix scan over them (256-wide in threadgroup memory, then once across the threadgroup totals) gives every block its true start state and the index of its first boundary, and a second pass over the bytes writes each boundary's position. A boundary is one uint32: the byte position, with bit 31 set when it ends a record.

The table is data passed to the kernels, so the delimiter, the quote character, quoting off and double_quote are all the same code. \r\n, a lone \r and \n all end a record: the second byte of a \r\n pair lands in state 0 as an empty line, and empty lines are skipped, as pyarrow's ignore_empty_lines=True does. A last record with no newline after it is closed by one boundary the host appends at the end of the data.

csv_check_fields then checks the shape in one pass over the boundaries: every record has the header's width exactly when the record ends fall at k = w - 1, 2w - 1, ..., so the first boundary that breaks the pattern names the offending row. The same pass counts each column's complex fields — a doubled quote, or bytes after a closing quote — whose value is not one contiguous span of the file.

Spans without materialising them

A field's value is found from two neighbouring boundaries: the bytes between them, or between the quotes of a quoted field. Column kernels derive it on the fly, so a column costs no per-row span storage. A column that has complex fields (usually none) materialises its spans, and its complex values are unescaped into a side buffer by csv_side_len, a prefix sum and csv_unescape.

Type inference: a sample, then a checked guess

Arrow infers a column's type as the first kind in a fixed order that every value converts as (null values convert as anything, except strings when strings_can_be_null is off). csv_classify computes, for each row, the set of kinds it converts as and ANDs them over a column (simd_and, one atomic per SIMD group). It runs over the first 8192 rows only. Every kind ahead of the sampled answer already fails on some sampled row, so the sampled type is Arrow's answer for the whole column exactly when every remaining row also converts as it — and that is checked by the conversion itself, which flags any row that does not convert. A column whose guess fails (a float after 8192 integers, say) is inferred over every row (csv_kind_range, then csv_classify when rows disagree) and converted again. The file's bytes are therefore read once per column set in the common case, not twice. test_type_decided_after_the_sample places the deciding value past the sample for thirteen type transitions.

Conversion: one pass over the rows

csv_convert_rows converts every column that has no complex fields in a single kernel: one thread per row walks the row's fields left to right and writes each column's value, with validity (and booleans) packed 32 rows at a time by simd_ballot. One kernel per column would read every cache line of the file and of the boundaries once per column; this reads them once. Each column's values are a sub-range of one shared buffer that stays alive as long as any of them does. Columns with complex fields use one kernel per column over their materialised spans.

Integers follow Arrow's CSV integer grammar, which is not the grammar of Arrow's cast: spaces and tabs around the value are trimmed, 0x / 0X with one to twice-the-byte-width hex digits is accepted (the digits are the value's bit pattern, so 0xFF is -1 as int8), and + is rejected. The package's string → integer cast (MetalStringArray.parse(Int64.self), str_parse_int) implements the cast grammar — + accepted, no trimming, no hex — so the reader has its own kernel rather than reusing that one; CSVReaderTests.testIntegerGrammarIsTheCSVOneNotTheCasts pins the difference. The kernel accumulates up to 19 significant digits without a range check and checks only the final value, so no 64-bit division runs on the GPU.

Floats parse on the GPU with no floating-point arithmetic (Metal has no double): see below.

Dates, times and timestamps follow Arrow's ParseYYYY_MM_DD, its time-of-day parser and ParseTimestampISO8601, transcribed into the kernel: YYYY-MM-DD validated as a calendar date, hh:mm / hh:mm:ss / hh:mm:ss.fraction, the T or space separator, and a zone of Z, ±HH, ±HHMM or ±HH:MM that is applied to the value. The package's GPU strptime takes one format per call and ISO-8601 as Arrow accepts it is several (a date alone, hh, hh:mm, hh:mm:ss, a fraction, four zone spellings), so it is parsed by its own grammar rather than by strptime.

Strings are a length pass, the GPU offsets scan, and a copy of each row's bytes (from the file, or from the side buffer for an unescaped value).

Float parsing on the GPU

MetalStringArray.parse(Double.self) and parse(Float.self) were Swift's Double(_:) / Float(_:) on the CPU. They now run on the GPU and return the same bits for every row, and the CSV reader uses the same core (CSVFloatSource).

The digits go into a 64-bit integer w (the first 19 significant digits) and a power of ten q; the Eisel-Lemire algorithm (the one in fast_float, which Arrow's CSV reader uses) multiplies w by a 128-bit approximation of 5^q from a table of 651 entries, using mulhi on ulong for the high half, and rounds to nearest-even from the bits below the mantissa. For up to 19 significant digits that is always correctly rounded, the same value a correct strtod returns. Anything it cannot settle exactly goes back to the CPU for that row: more than 19 significant digits where the 19-digit prefix and the prefix plus one round differently; the one product shape the original algorithm leaves to a fallback; and, for the parse API, every spelling outside the plain decimal form and inf / infinity / nan (hex floats, nan(payload), snan, embedded NUL, a malformed exponent). The CPU parses those with the initialiser the API always used. ARROWMETAL_FLOAT_PARSE_HOST=1 keeps parse on the CPU path.

CSVFloatParseTests compares the GPU path with the CPU path bit for bit, validity included, for float64 and float32 over: a hand-written adversarial list (exact halfway points such as 9007199254740993, the subnormal boundary 2.4703282292062327e-324 / ...328e-324, the overflow boundary around 1.7976931348623158e308, an 808-digit mantissa, 1e-400, 1e99999999999999999999, the float32 boundaries, every inf / nan spelling, hex floats); 60,000 random strings over the float alphabet; 120,000 random decimals of 1 to 30 digits with exponents from -360 to 359; and 100,000 random doubles printed shortest, as %.17g and as %.25e, plus odd integers above 2^53 (exact halfway cases) and random float32 values. It also checks that the GPU decided most rows itself, and that ordinary numbers of up to 19 significant digits never need the CPU. In the CSV reader the same kernel is compared with pyarrow's parse, bit for bit, by every float case in test_csv.py.

Matching pyarrow

pyarrow's rules were established by probing pyarrow 25.0.1 (the probe below), and every row of this table is a case in python/tests/test_csv.py; the cases pass unchanged on pyarrow 26.0.0 (run on 2026-10-15).

Rule pyarrow 25.0.1, matched
Inference order null → int64 → bool → date32 → time32[s] → timestamp[s] → timestamp[ns] → timestamp[s, tz=UTC] → timestamp[ns, tz=UTC] → float64 → string → binary (the first kind every value converts as)
null_values default "", #N/A, #N/A N/A, #NA, -1.#IND, -1.#QNAN, -NaN, -nan, 1.#IND, 1.#QNAN, N/A, NA, NULL, NaN, n/a, nan, null — matched exactly, no trimming (" NA" is not null)
true_values / false_values default 1 True TRUE true / 0 False FALSE false, exact (tRue and " true" are strings); a column of 0 and 1 is int64 because int64 comes first, but 1 with true is bool
Strings and nulls strings_can_be_null=False: a string column keeps NA and "" as strings; an all-null column is null
Quoted values converted like unquoted ones ("1" is int64, "2020-01-01" is date32); with quoted_strings_can_be_null=True a quoted "" or "NA" is null
Integers -?digits in range, or 0x + 1..16 hex digits; spaces and tabs trimmed; +3 makes the column float64; out of int64 range makes it float64
Floats one optional + or -; digits[.digits] / .digits with an optional exponent; inf, infinity, nan, nan(chars) in any case; spaces and tabs trimmed; no hex; nan is null by default because it is in null_values, while NAN is a NaN; NaN is the quiet NaN with the sign given
Dates and times YYYY-MM-DD validated (2021-02-29 is a string); hh:mm and hh:mm:ss are time32[s], a fraction makes the column a string; spaces and tabs trimmed
Timestamps not trimmed; YYYY-MM-DD alone counts; seconds 60 and hour 24 are rejected; a fraction (up to 9 digits) makes it timestamp[ns], provided the value fits in int64 nanoseconds as Arrow computes it (whole seconds scaled first, then the fraction added: 1677-09-21 00:12:44 through 2262-04-11 23:47:16.854775807, so 1677-09-21 00:12:43.145224192 is outside), and a fractional value outside that range makes the column a string; without a fraction the column is timestamp[s] for any year; a forced timestamp[ns] column raises invalid value for a value outside the range; an offset or Z makes it UTC with the offset applied; naive and zoned values together are a string column
Structure ragged rows raise CSV parse error: Row #N: Expected w columns, got m: <row>, N counting skipped lines and records, not empty lines, and <row> the row's text when it is at most 100 bytes, otherwise its first 96 bytes and ... (a row that runs to the end of the file inside an open quote is quoted without its last line terminator, \n, \r or \r\n); a header with no newline after it raises Empty CSV file or block: cannot infer number of columns; an empty file raises Empty CSV file; a BOM is skipped
skip_rows whole lines by their terminators, quotes ignored, empty lines counted; a line with no terminator cannot be skipped
skip_rows_after_names rows after the header, skipped without a width check (a,b then 1,2,3 is skipped, not an error); a quoted newline stays inside its row; an empty line counts as one of the skipped rows; row numbers in later errors count the skipped rows
delimiter equal to quote_char accepted; the delimiter wins and no field is quoted, the same table as quote_char=False
Errors In CSV column #c: Row #N: CSV conversion error to <type>: invalid value '<v>', and the two zone-offset messages for timestamp columns

The probe that established the table, abridged (probe(text, **options) prints pyarrow's types and values or its error):

import io, pyarrow as pa, pyarrow.csv as pc
def probe(text, **kw):
    try:
        t = pc.read_csv(io.BytesIO(text.encode()), **kw)
        print([(f.name, str(f.type), t.column(i).to_pylist()) for i, f in enumerate(t.schema)])
    except Exception as e:
        print(type(e).__name__, e)
print(pc.ConvertOptions().null_values, pc.ConvertOptions().true_values, pc.ConvertOptions().false_values)
probe("a\n1\n+3\n")                            # double: '+' is not an integer
probe("a\n0x1F\n")                             # int64 31
probe("a\n 1\n2 \n")                           # int64: trimmed
probe("a\n true\n")                            # string: booleans are not trimmed
probe("a\n1\ntrue\n")                          # bool
probe("a\n12:34:56.123\n")                     # string
probe("a\n2020-01-01 12:34:56 \n")             # string: timestamps are not trimmed
probe("a\n 2020-01-01\n")                      # date32: dates are
probe("a\n2020-01-01 12:34:56+01:00\n")        # timestamp[s, tz=UTC], 11:34:56
probe("a\n2020-01-01 12:34:56Z\n2020-01-01 12:34:56\n")   # string
probe("a\nNAN\n-NAN\nnan(123)\n")              # double NaN (nan alone is a null value)
probe("a,b,c\n\n1,2,3\n4,5\n", read_options=pc.ReadOptions(use_threads=False))   # Row #3
probe('"x\ny"\na,b\n1,2\n', read_options=pc.ReadOptions(skip_rows=1))           # skip_rows ignores quotes
probe("a,b\n1,2,3\n4,5\n", read_options=pc.ReadOptions(skip_rows_after_names=1))  # a=[4] b=[5]: not width-checked
probe("a\n1000-01-01 00:00:00.5\n")             # string: outside int64 nanoseconds
probe("a\n1000-01-01 00:00:00\n")               # timestamp[s]
probe('a"b\n1"2\n', parse_options=pc.ParseOptions(delimiter='"'))                  # a=[1] b=[2]
probe("a\n1\n" + "x" * 300 + ",1\n", read_options=pc.ReadOptions(use_threads=False))  # row text cut: 96 bytes + " ..."

Options

pyarrow Swift CSVReadOptions C am_csv_options
ReadOptions.skip_rows skipRows skip_rows
ReadOptions.skip_rows_after_names skipRowsAfterNames skip_rows_after_names
ReadOptions.column_names columnNames column_names, n_column_names
ReadOptions.autogenerate_column_names autogenerateColumnNames autogenerate_column_names
ParseOptions.delimiter delimiter delimiter
ParseOptions.quote_char (or False) quoteChar (or nil) quote_char (or -1)
ParseOptions.double_quote doubleQuote double_quote
ConvertOptions.include_columns includeColumns include_columns, n_include_columns
ConvertOptions.include_missing_columns includeMissingColumns include_missing_columns
ConvertOptions.column_types columnTypes (CSVColumnType) column_type_names + column_type_formats (Arrow format strings)
ConvertOptions.null_values nullValues null_values, n_null_values
ConvertOptions.true_values / false_values trueValues / falseValues true_values / false_values
ConvertOptions.strings_can_be_null stringsCanBeNull strings_can_be_null
ConvertOptions.quoted_strings_can_be_null quotedStringsCanBeNull quoted_strings_can_be_null
ConvertOptions.check_utf8 checkUTF8 check_utf8
ConvertOptions.decimal_point decimalPoint decimal_point
— scanBlockBytes scan_block_bytes
— fileAccess (.read, .map) file_access (0, 1)

column_types takes null, bool, the eight integer types, float32, float64, utf8, binary, date32, time32[s|ms], time64[us|ns] and timestamp[s|ms|us|ns] with or without a timezone. In Python the keywords have pyarrow's names and win over the option objects.

Column names and error messages can hold NUL bytes (a header field a\0, an error quoting the value 1\0). The C ABI hands both out as NUL-terminated strings and also gives their byte length, am_csv_batch_column_name_length and am_csv_last_error(&length); Python reads them by length, so they arrive whole (test_nul_bytes_in_names_and_messages). Option strings go in the same way: each string array in am_csv_options has a *_lengths array beside it (NULL means NUL-terminated strings), and Python fills them, so include_columns=["a\0b"], a column_types key, column_names, null_values, true_values and false_values holding a NUL byte match as they do in pyarrow (test_nul_bytes_in_option_strings, test_c_option_lengths). A NUL delimiter, quote_char or decimal_point is refused, as pyarrow refuses it (test_nul_option_characters_are_refused).

Differences from pyarrow

  • Quoted newlines are always parsed, which is pyarrow's newlines_in_values=True; the tests run pyarrow that way. pyarrow's default (False) reads a quoted newline correctly while it sits inside one of its blocks and raises when one straddles a block boundary; test_quoted_newline_across_pyarrow_blocks shows both.
  • The ragged-row and conversion errors always carry Row #N, which is the message pyarrow's serial reader (use_threads=False) gives; its threaded reader leaves the row number out.
  • skip_rows_after_names with no complete row after the header: when nothing after the header ends with a line terminator outside quotes (a,b\n, or a,b\n1,2 with no final newline), pyarrow raises straddling object straddles two block boundaries (try to increase block size?); this reader skips what is there and returns an empty table (test_skip_rows_after_names_with_no_complete_row).
  • A header name that is not valid UTF-8 has each invalid sequence replaced by U+FFFD (\xef\xbba becomes \ufffda); pyarrow keeps the name's bytes as they are, and its schema.names then fails to decode in Python (test_header_name_not_utf8).
  • The table comes back with one chunk per column, where pyarrow's has one chunk per block it read; the tests compare values, not chunking.
  • Not supported yet, each raising NotImplementedError from Python rather than reading differently: escape_char, ignore_empty_lines=False, timestamp_parsers, auto_dict_encode, an encoding other than UTF-8, invalid_row_handler, and every column_types type outside the list above, among them decimal, dictionary, date64, duration, float16, large_string and large_binary (pyarrow reads all of these except float16, for which it raises its own ArrowNotImplementedError). The input is a file path given as str or os.PathLike (a bytes path is refused, as pyarrow refuses it: test_bytes_path_is_refused); pyarrow also takes file objects, and decompresses a path ending in .gz, .bz2, .lz4, .zst or .br, which am.read_csv refuses (test_compressed_extensions_are_refused).

Tests

  • Tests/ArrowMetalTests/CSVReaderTests.swift — the structure scan against an independent byte-at-a-time CPU parser over 24 random files full of structural hazards (quoted delimiters and newlines, \r\n, \r, doubled quotes, text after a closing quote, mid-field quotes, empty lines, BOM, missing final newline) at scan block sizes 1, 2, 3, 5, 16, 64, 257 and 1024 bytes, in both file-access modes; inference, forced types, the error messages, projection, skip options, and a float column against Swift's parse.
  • Tests/ArrowMetalTests/CSVFloatParseTests.swift — the GPU float parse against the CPU path, above.
  • python/tests/test_csv.py — the differential against pyarrow.csv.read_csv: one case per rule in the table above, every option, 60 random files with a fixed seed (every inferred type, nulls, quoting, embedded delimiters, quotes and newlines, mixed line endings, BOM, block sizes 1 to 100), 3000-row files dense with quotes, doubled quotes and \r\n read at scan block sizes 1, 2, 7, 64 and 1024 (so they land across block boundaries throughout), a 300-column file, the default null and boolean spellings read from pyarrow itself, a quoted newline across one of pyarrow's blocks, a 20,000-row file, the type decided after the inference sample, and files written by Polars and DuckDB.
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test -c release --filter "CSVReaderTests|CSVFloatParseTests"
swift build -c release --product ArrowMetalC
PYTHONPATH=python python -m pytest python/tests/test_csv.py -q
PYTHONPATH=python python Benchmarks/csv_bench.py --rows 1000000,10000000

Benchmarks

Benchmarks/csv_bench.py reads a mixed-type file (int64, float64, string, bool, date, timestamp and a quoted text column) at 1M and 10M rows with ArrowMetal, pyarrow.csv.read_csv, polars.read_csv, pandas.read_csv (pyarrow engine and default engine) and DuckDB's read_csv, and writes the median, min and max of each. The numbers here are from a quiet run, Benchmarks/results/csv_bench_2026-09-24.csv; the run conditions at its start are recorded in Benchmarks/results/bench_conditions_2026-09-24.txt. In that file, median wall time:

reader 1 M rows 10 M rows
arrowmetal (am.read_csv, pread) 8.46 ms 51.8 ms
arrowmetal_table (am.read_csv_table) 7.79 ms 50.84 ms
arrowmetal_map (file_access="map") 14.56 ms 105.39 ms
polars 10.37 ms 98.75 ms
pyarrow 16.74 ms 158.81 ms
pandas_pyarrow 21.91 ms 196.04 ms
duckdb 56.06 ms 261.12 ms
pandas_c 403.25 ms 4174.89 ms

The default read and read_csv_table are ahead of every CPU reader at both sizes; at 10 M rows arrowmetal reads 16580.9 MB/s against Polars' 8697.9. The mapped read is to improve: at 10 M rows it takes 105.39 ms against Polars' 98.75 ms, and at 1 M rows 14.56 ms against 10.37 ms.

An earlier run, taken while other work shared the GPU, is kept as history in Benchmarks/results/csv_bench_2026-09-23_provisional.csv (named for the day it was started; the run itself went past midnight, so its date column reads 2026-09-24).

ARROWMETAL_CSV_TRACE=1 prints the wall time of each phase of a read to stderr, and =2 runs every kernel in its own command buffer and prints its GPU time.

Limits

  • Files of 2 GiB and more are rejected (boundaries are 31-bit positions), and so is a single field of 1 GiB or more.
  • Per-field work is one thread per field: a quoted field of many megabytes is walked and copied by a single GPU thread.
  • Arrays are capped at 2^32 elements, and a utf8 column at the 2 GB its 32-bit offsets address, as everywhere else in ArrowMetal.
  • The options listed under Differences are not implemented.
docs/JSON.md

JSON on the GPU

ArrowMetal reads newline-delimited JSON with Metal compute kernels and returns Arrow columns with the semantics of pyarrow.json.read_json: the same inferred types, the same field order, the same handling of missing keys and nulls, the same explicit-schema options and the same error texts. Finding the records, validating the JSON grammar, matching keys to fields, unescaping strings and parsing ISO-8601 timestamps all run on the GPU; number text is gathered on the GPU and handed to MetalStringArray.parse.

import arrowmetal as am
cols = am.read_json("events.jsonl")               # ColumnSet of MetalArrays, already on the GPU
cols["latency_ms"].mean()
table = am.read_json_table("events.jsonl")        # the same columns as a pyarrow.Table (zero copy)

import pyarrow as pa, pyarrow.json as pj
am.read_json_table("events.jsonl", parse_options=pj.ParseOptions(
    explicit_schema=pa.schema([("id", pa.int32())]), unexpected_field_behavior="ignore"))
am.read_json_table("events.jsonl", explicit_schema=pa.schema([("id", pa.int32())]))  # same, as keywords
let table = try JSONReader.read(path: "events.jsonl")            // JSONTable: names, columns, rowCount
let typed = try JSONReader(path: "events.jsonl").read(JSONReadOptions(
    explicitSchema: [JSONField("id", .int32)], unexpectedFieldBehavior: .ignore))

The C ABI is am_json_open / am_json_open_buffer / am_json_read and the am_json_batch_* accessors in include/arrowmetal.h, shaped like am_parquet_batch; the explicit schema crosses as a struct-typed ArrowSchema. am_json_batch_column_names hands out a column's name and its nested struct field names with their lengths, so a key holding \u0000 arrives whole where a C string and the C Data Interface's field names stop at the NUL; am.read_json_table restores such nested names on the pyarrow table. am.read_json keeps the full top-level names, but a struct column's to_arrow() goes through the C Data Interface and its nested names stop at the NUL (test_key_holding_nul_keeps_its_full_name). The same holds going in and for errors: am_json_read_named takes the explicit schema's field names with their lengths, which am.read_json / am.read_json_table always pass, so a schema field named "a\x00b" matches the key "a\u0000b" (the nul_in_*schema* explicit cases); am_json_last_error hands out the last error with its length, so a message quoting a key or value that holds a NUL arrives whole (test_messages_holding_a_nul_arrive_whole).

The pipeline

  file bytes (parallel pread into a pooled Metal buffer)
    -> structure    one thread per 256-byte block: escapes, strings, depth, record boundaries
    -> walk         one thread per record: full grammar check, one entry per field (key span,
                    value span, kind), counted then written at scanned offsets
    -> keys         positional byte compare against the first record's keys; the rest through the
                    GPU dictionary encoder
    -> slot matrix  entry index per (field, row), -1 for a missing key; kinds OR-reduced per field
    -> columns      one validity pass, one text gather and one parse per scalar type; nested values
                    walk again one level down

Records

A backslash escapes the byte after it, and whether a block of the file starts inside an escape depends only on the parity of the backslash run that ends the last block before it that is not all backslashes. jb_escape writes that as a key per block and a max-scan gives every block its escape carry. With the carry known, a block's unescaped quotes are counted, and a sum-scan of their parity gives the in-string state at every block start; brackets outside strings are counted the same way, and a sum-scan gives the nesting depth at every block start.

Inside a block the work is bit masks over 64-byte chunks: SWAR compares classify eight bytes at a time into backslashes, quotes and brackets ('[' and '{' differ in one bit, as do ']' and '}'), the escaped bytes come from the carry (with a byte loop only for chunks that hold a backslash), and the bytes inside strings are the prefix XOR of the unescaped-quote mask. The record pass then visits only the brackets, in order, and reads one at a time only the bytes that sit between them at depth 0 — normally a newline. A { at depth 0 opens a record and the bracket that brings the depth back to 0 closes it; records need not be one per line ({"a":1}{"a":2} is two records, and an object may span lines), which is what pyarrow reads too. A null at depth 0 is a record whose fields are all null, as pyarrow reads it after the first object. Anything else at depth 0 that is not whitespace is an error, reported with the text a sequential parser gives (Column() changed from object to array, The document is empty., a string's own error when the string is malformed). RapidJSON reads a NUL byte as the end of its input, so a NUL where a record would start is The document is empty. too.

The walk

Each record is walked by one thread with an explicit container stack (1024 levels). The walk checks the whole grammar as RapidJSON — the parser inside pyarrow — checks it: literals, the number grammar including NaN, Inf and Infinity (which end the number: a . or e after one is the missing-comma error, as in RapidJSON; the special_* probes), RapidJSON's "Number too big to be stored in double" rule for positive exponents, string escapes, \u surrogate pairs, control characters in strings, and every structural position. Errors carry RapidJSON's texts. For every immediate child of the record it emits an entry: the key span, the value span and a kind (null, false, true, integer that fits int64, other number, string, object, array) with flags for escaped keys and strings and for NaN/Inf. The walk runs once to count and once to write at the scanned offsets.

When a record holds a syntax error, the walk still reports the children it reached — including a key whose value never came and the kind of a container it had opened — so the columns see what a sequential parser had seen before the error. That is how a type conflict or a repeated key earlier in the same record is reported ahead of the syntax error, as pyarrow reports it.

A column whose classes conflict is not built, but its objects or arrays are still walked one level down as the class the column started with (values of other classes read as null there), so a conflict or a repeated key inside them that comes earlier in the file than the column's own conflict is found and reported first (nested_*_before_outer probes).

Keys and fields

Field order is first appearance. Entry k of every record is compared, byte for byte, with the key of entry k of the first record that has fields; when every entry matches, the field ids are the positions and nothing else is needed. Keys that do not match are unescaped, prefixed by the reference keys, and dictionary-encoded on the GPU (dictionaryEncode, first-seen order), which gives new fields their ids in order of first appearance. The entries are then scattered into a slot matrix — one column of row slots per field, -1 where a record lacks the key — with an atomic minimum, so a key named twice in one object leaves the later entry without its slot: that is the "was specified twice" check. For groups of up to 64 fields the same scatter OR-reduces each field's kinds through threadgroup atomics; wider groups take a separate pass. Matrices above 512 MB are built a group of fields at a time.

Columns

Every field's kinds decide its type (the table below) or, against an explicit type, whether the column conflicts. Columns that come out as the same scalar type are built together — one validity pass, one gather and one parse for all of them — so a file with hundreds of fields costs a few dispatches per type rather than per column.

  • Numbers: the value text is gathered into one utf8 array and parsed by MetalStringArray.parse, integers and floats both on the GPU. Floats use Eisel-Lemire in integer arithmetic, bit-identical to Swift's Double / Float initialiser; the rare values the GPU cannot decide exactly (more than 19 significant digits on a rounding boundary, for instance) are parsed on the CPU with that initialiser (testFloatColumnsParseOnTheGPU checks that a JSON read builds the GPU float-parse kernel). NaN, Inf and Infinity are set by the reader rather than left to the text parser.
  • Strings: a length pass and a write pass; strings without escapes are copied, escaped ones decoded (\uXXXX and surrogate pairs to UTF-8) on the GPU.
  • Timestamps: a string column whose every value is an ISO-8601 timestamp becomes timestamp[s], as in pyarrow. The kernel (jt_parse) follows the rules of Arrow's ParseTimestampISO8601: the date alone or with an hour, hour and minute, or hour, minute and second after a space or a T; then Z or one of three offset forms, converted to UTC; fractions only where the unit holds them. It is its own kernel rather than a call to the strptime kernel in TemporalFormat.swift because one strptime format describes one layout, and inference has to accept all of them, with their zones, in one pass.
  • Booleans, validity: bitmaps written 32 rows per thread.
  • Nested values: a struct or list column gathers the spans of its objects or arrays and walks them one level down with the same kernel; a struct's fields go through the same key matching and column building, and a list's elements become its child column with the walk's offsets as the list offsets.

Type inference

JSON values in a column Arrow type Probe in python/tests/test_json.py
only null (or the key never present) null null_only
true / false bool bools
integers that fit int64 int64 basic, int64_max, int64_min
any number with ., e, NaN/Inf, or outside int64 double (integers promoted) int_then_double, int_overflow_is_double, nan_inf
strings, every one an ISO-8601 timestamp timestamp[s] ts_*
other strings string ts_then_string, strings_and_nulls
objects struct, fields in first-appearance order, missing fields null struct, struct_missing_in_row
arrays list<item>, the item type inferred over all elements list, list_of_structs
only empty arrays list<item: null> list_only_empty
two classes (number, string, boolean, object, array) error conflict_*

The ISO-8601 forms that infer as timestamps, all probed against pyarrow: YYYY-MM-DD, YYYY-MM-DD[ T]hh, ...hh:mm, ...hh:mm:ss, each followed optionally by Z, +hh, +hhmm or +hh:mm (not after a bare date). Fractions (.123), a lower-case t, hour 24, second 60, an invalid calendar day, a five-digit or negative year and YYYY-MM-DDZ keep the column a string.

pyarrow's behaviour, as probed

Every behaviour below is an input in PROBES or EXPLICIT in python/tests/test_json.py, read by both readers and compared: the schema, the values and the nulls, or the error text.

Input pyarrow 25.0.1 and ArrowMetal
{"a":1} then {"a":"x"} JSON parse error: Column(/a) changed from number to string in row 1
{"a":1,"a":2} JSON parse error: Column(/a) was specified twice in row 0
{"s":{"x":1}} then {"s":{"x":"a"}} ... Column(/s/x) changed from number to string in row 1
{"l":[1,"a"]} ... Column(/l/[]) changed from number to string in row 0
a conflict, then a syntax error in the same record the conflict
a syntax error, then a conflict in a later record the syntax error
{"a":1 (truncated) ... Missing a comma or '}' after an object member. in row 0
{"a":1} x ... Invalid value. in row 1
{"a":1}} JSON parse error: The document is empty.
[1,2] at the top level ... Column() changed from object to array in row 0
an empty file Empty JSON file
only whitespace or newlines zero rows, zero columns
{} rows rows with no columns (read_json_table keeps the row count)
a UTF-8 BOM at the start skipped
part of one at the start (EF BB, EF BF, BB BF, EF, BB or BF) skipped as pyarrow skips it: each byte of EF BB BF present, in order; EF alone reads as zero rows (the bom_* probes)
a BOM after whitespace, two BOMs, EF BB BB, EF EF JSON parse error: Invalid value. in row 0
\r\n, \r, tabs, spaces between records whitespace
{"a":1e309} ... Number too big to be stored in double. in row 0
{"a":1.7976931348623157e309}, {"a":10e308} inf
invalid UTF-8 inside a string value passed through unchanged (neither reader validates); in a key, see the differences
{"a":[1]}, {"a":["x"]}, {"a":1} ... Column(/a/[]) changed from number to string in row 1 (the nested conflict comes first)
{"a":1} NUL {"a":2}, a NUL line, a leading NUL JSON parse error: The document is empty.
NUL inside a record the error for what was expected there (Invalid value., Missing a closing quotation mark in string., ...)
-NaN a NaN with the sign bit set (test_negative_nan_keeps_its_sign)

With an explicit schema, the schema's fields come first in schema order and the others follow in order of first appearance (infer), are dropped (ignore) or fail with JSON parse error: unexpected field (error); ignore and error have no effect without a schema, and a name given twice keeps its first type. A value of the wrong class is a conflict against the schema's class (Column(/b) changed from string to number in row 0); a value of the right class that does not convert fails with Failed to convert JSON to int8, couldn't parse:300. Nested struct and list types apply the same rules one level down.

Explicit-schema types

bool, int8 to int64, uint8 to uint64, float, double, string, timestamp in any unit and timezone (fractions up to the unit's precision, zone offsets converted to UTC), null, list and struct of those. Any other type is rejected with an error naming the field (date32, decimal128, binary, large_string, float16, dictionary; test_explicit_types_outside_the_supported_set_are_rejected).

timestamp[ns] holds 1677-09-21T00:12:43.145224192 to 2262-04-11T23:47:16.854775807; a value outside fails with Failed to convert JSON to timestamp[ns], couldn't parse:..., as in pyarrow. As in Arrow, the whole seconds (after the zone offset) must fit before the fraction is added, so 1677-09-21T00:12:43.145224192 fails although its nanosecond count would fit (timestamp_ns_* explicit cases). The coarser units hold every four-digit year.

null follows pyarrow, which infers the column and then converts it: values that are all null (or absent) read as a null column, including as a list item or struct field; values of two classes are the inferred column's conflict; any other value fails with Failed to convert JSON to null from <type>, the type as pyarrow holds it before conversion (bool; numbers and strings as dictionary<values=string, indices=int32, ordered=0>; struct<...> and list<item: ...> of those; the null_* explicit cases, as top-level, list-item and struct-field types).

Differences from pyarrow

Established against pyarrow 25.0.1 and unchanged on 26.0.0 (the suite ran on it on 2026-10-15). Each has a test of its own in python/tests/test_json.py that checks the difference is exactly this.

  • Row numbers in error messages count from the start of the file. pyarrow parses in 1 MiB blocks and counts rows from the start of the block; the two agree for files within one block and whenever pyarrow is given a block as large as the file (test_error_row_counts_from_the_start_of_the_file).
  • Field order is always first appearance. pyarrow's threaded read of a file larger than one block can order fields by a later block; with use_threads=False its order is first appearance, which is ArrowMetal's (test_field_order_is_first_appearance).
  • An object may span a line anywhere in the file. With newlines_in_values=False pyarrow splits blocks at newlines and fails on an object that crosses a block boundary; ArrowMetal has no blocks and reads it, as pyarrow does when the file fits in one block (test_multiline_object_across_a_block_boundary). newlines_in_values, block_size and use_threads are accepted and do not change the result.
  • Leading nulls in a list whose item type is not yet known are kept. pyarrow 25.0.1 drops them and shifts the values that follow ([null,1] then [2] reads as [1,2] then [0]); with a struct item its process can end. ArrowMetal returns the nulls, which is what pyarrow returns once the type is given explicitly (test_pyarrow_list_leading_null_defect).
  • A file may start with a null record. It reads as a row of nulls, as pyarrow reads a null after the first object; pyarrow's process ends on a leading one (test_leading_null_record).
  • Nesting deeper than 1024 levels is an error. pyarrow's process does not survive 2000 levels (test_nesting_limit). Columns nested deeper than 64 levels read on the GPU but cannot cross into pyarrow, whose C Data importer stops at 64 (test_nesting_deeper_than_pyarrows_import_limit).
  • Explicit decimal128, binary and large_string are rejected, where pyarrow converts to them (test_pyarrow_converts_to_types_this_reader_rejects).
  • When several explicit-schema values fail to convert, the one earliest in the file is named. pyarrow names one of them, depending on its conversion order (test_conversion_error_names_the_first_failing_value).
  • Invalid UTF-8 in a key becomes U+FFFD. Field names are Swift strings, so {"\xff":1} reads as a field named "\ufffd"; pyarrow keeps the raw byte, which its Schema.names then cannot decode (test_invalid_utf8_in_a_key_is_replaced). Values are passed through unchanged.
  • Errors are ArrowMetalError, with pyarrow's text; pyarrow raises ArrowInvalid.

Benchmarks

Benchmarks/json_bench.py reads the same generated event-log file with am.read_json, am.read_json_table, pyarrow.json.read_json, polars.read_ndjson, pandas.read_json(lines=True) and DuckDB's read_json (each producing an in-memory table), at 1 M and 10 M rows, in a flat shape and a nested one that adds a struct and a list per record, after checking ArrowMetal's table against pyarrow's. The numbers here are from a quiet run, Benchmarks/results/json_bench_2026-09-24.csv; the run conditions at its start are recorded in Benchmarks/results/bench_conditions_2026-09-24.txt. In that file, median wall time:

reader flat, 1 M flat, 10 M nested, 1 M nested, 10 M
arrowmetal (am.read_json) 21.12 ms 156.76 ms 40.36 ms 287.12 ms
arrowmetal-table (am.read_json_table) 24.75 ms 156.71 ms 40.77 ms 290.51 ms
polars 26.15 ms 252.12 ms 84.53 ms 861.20 ms
pyarrow 64.15 ms 517.29 ms 79.17 ms 775.28 ms
duckdb 78.04 ms 220.15 ms 109.21 ms 320.60 ms
pandas 1082.20 ms 11159.75 ms 1707.81 ms 17557.51 ms

ArrowMetal is ahead of every CPU reader in all four cases. The closest is DuckDB on the nested file at 10 M rows (320.60 ms against 287.12 ms). The host CPU time differs more than the wall time: at 10 M flat rows am.read_json used 113.28 ms of CPU, Polars 2906.46 ms and pyarrow 8024.27 ms (cpu_ms). The first results, a smoke run taken while other work shared the GPU and the cores, are kept as history in Benchmarks/results/json_bench_2026-09-23_provisional.csv.

PYTHONPATH=python python Benchmarks/json_bench.py --rows 1000000,10000000 --shapes flat,nested
PYTHONPATH=python python Benchmarks/json_bench.py --rows 1000000 --readers arrowmetal,pyarrow,polars

Tests

  • python/tests/test_json.py — every probe and explicit-schema case against pyarrow; 60 seeded random files mixing every type, nulls, missing keys, shuffled key order, unicode and escapes; every prefix of eight small files (truncation at each byte); 400 files with one or two bytes replaced; escapes and backslash runs straddling structure blocks at 43 alignments; wide files, a one-record 5000-field file, a file where every record has a new key, long escaped strings and a file past pyarrow's block size; the API; and one test per documented difference.
  • Tests/ArrowMetalTests/JSONReaderTests.swift — the max-scan against a CPU loop; record boundaries of random documents (strings holding brackets, escaped quotes, backslash runs across blocks, multi-line objects, null records) against a sequential CPU scanner; top-level error positions and codes; the walk's key spans, value spans, kinds and flags; RapidJSON's error texts; conflicts and repeated keys; timestamps against a day-counting reference and the edges of timestamp[ns]; explicit schemas, explicit null; nested structs and lists; nested conflicts ahead of a column's own; NUL bytes; the sign of -NaN; NaN / Inf / Infinity ending the number; partial byte order marks at the start; keys holding \u0000; random flat files against Foundation's JSONSerialization; a 400-field file; files on disk and bytes in memory.
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test -c release --filter JSONReaderTests
swift build -c release --product ArrowMetalC
PYTHONPATH=python python -m pytest python/tests/test_json.py -q

Limits and what is to improve

  • Inputs of 4 GiB or more are rejected: byte positions are 32-bit on the GPU.
  • The whole input is read into memory and parsed in one pass; there is no streaming or chunked read yet.
  • One thread walks each record, so a single very large record — one line of many megabytes, or one long string — is walked serially, which is to improve.
  • Each nested level costs its own set of dispatches, so deeply nested documents take time proportional to their depth.
  • Explicit-schema types are the set above.
  • Arrays are capped at 2^32 elements, as everywhere else in ArrowMetal, and a column set's text at 2 GiB (32-bit utf8 offsets).
docs/LAKEHOUSE.md

Delta Lake and Apache Iceberg tables

ArrowMetal reads Delta Lake and Apache Iceberg tables from the local filesystem. The table metadata is resolved on the CPU, the data files are pruned by partition values and column statistics, and the Parquet files that remain are decoded by the GPU Parquet reader (Parquet on the GPU) with a projection. The result is the same thing a Parquet read returns: GPU-resident columns (a ColumnSet in Python, a MetalRecordBatch in Swift), or a pyarrow.Table exported from them without a copy.

Source: Sources/ArrowMetal/Lakehouse/ (DeltaTable.swift, IcebergTable.swift, Avro.swift, LakehouseCommon.swift, LakehouseParquet.swift), the C ABI in Sources/ArrowMetalC/ArrowMetalC_Lakehouse.swift and the last section of include/arrowmetal.h, the Python functions in the "Lakehouse tables" section of python/arrowmetal/__init__.py.

Using it

import datetime as dt
import arrowmetal as am

cols = am.read_delta("events", version=3, columns=["user", "amount"],
                     filters=[("day", ">=", dt.date(2024, 1, 1)), ("amount", ">", 100)])
total = cols["amount"].sum()                                # on the GPU, no import step

t = am.read_iceberg_table("warehouse/db/orders", snapshot_id=5593686759590374981)   # a pyarrow.Table
cols, stats = am.read_iceberg("warehouse/db/orders/metadata/00004-....metadata.json",
                              filters=[("region", "==", "west")], with_stats=True)
stats["files_pruned_by_partition"], stats["manifests_pruned"]
am.delta_latest_version("events"), am.iceberg_current_snapshot("warehouse/db/orders")
Python Returns
read_delta(path, version=None, columns=None, filters=None, with_stats=False) ColumnSet, or (ColumnSet, stats)
read_delta_table(path, version=None, columns=None, filters=None) pyarrow.Table
read_iceberg(metadata_path_or_table_dir, snapshot_id=None, columns=None, filters=None, with_stats=False) ColumnSet, or (ColumnSet, stats)
read_iceberg_table(metadata_path_or_table_dir, snapshot_id=None, columns=None, filters=None) pyarrow.Table
delta_latest_version(path), iceberg_current_snapshot(path) the newest Delta version; the current Iceberg snapshot id or None

Swift: DeltaTable(path:) with read(version:columns:filters:), scan(...) (rows plus LakehouseScanStats), snapshot(version:) and latestVersion(); IcebergTable(path:) with read(snapshotId:columns:filters:), scan(...), plan(snapshotId:filters:), schema(forSnapshot:) and the parsed metadata (schemas, specs, snapshots). Filters are ParquetFilter values, the type the Parquet reader takes. C: am_delta_read, am_iceberg_read, am_delta_latest_version, am_iceberg_current_snapshot and the am_lakehouse_batch_* accessors.

Filters

filters is a list of (column, op, value) triples with op one of == != < <= > >=; all of them must hold. The column is named as the table's schema names it (the snapshot's schema when time-travelling in Iceberg). They are used three times: to skip files (partition values; Delta's per-file minValues / maxValues / nullCount; Iceberg's manifest partition summaries, partition tuples and column bounds), to skip row groups (Parquet footer statistics), and then on the rows. The result holds exactly the matching rows, as deltalake's to_pyarrow_table(filters=...) and pyiceberg's scan(row_filter=...) return them. This differs from read_parquet, whose filters only skip row groups.

A null never matches, including under != (SQL and Arrow semantics); NaN is unequal to everything, so it matches != and nothing else, as in pyarrow. Literals are read in the column's type and compared as exact values, as pyarrow compares them:

  • an integer column compared with 2.5 behaves as >= 3 for > 2.5; a literal outside the column's range, including doubles beyond the Int64 range such as 9.25e18, matches everything or nothing as the comparison says (testFilterLiteralsAtTypeEdges, test_filter_literals_at_type_edges);
  • a float32 column compared with a double literal is the exact comparison: 0.1 as a float32 is 0.10000000149..., so it is > 0.1 and not == 0.1 (testFloat32ComparesExactly, test_float32_filters_compare_exactly);
  • a date literal is a datetime.date or "YYYY-MM-DD" (ASCII digits, a year from 0 to 5,000,000, a value that fits date32); a timestamp literal is a datetime.datetime (converted to UTC when it carries a zone, taken as UTC when it does not) or an ISO 8601 string with two-digit hour, minute and second fields that fits the column's unit;
  • a decimal literal is an integer, a decimal.Decimal or a string of ASCII digits that is exact at the column's scale and has at most 38 significant digits;
  • a string literal is written into the filter text double-quoted, with " and \ escaped as \" and \\ (the grammar of the Parquet filter text, include/arrowmetal.h), so it may hold ;, ", \ and operator characters (("s", "<", "a==b")); a bytes literal (for a binary column) is passed the same way as its UTF-8 text, so it must be valid UTF-8. A NUL byte ends the C string and is refused in both, and so are bytes that are not UTF-8, each with an error saying so. Data columns, partition columns and binary columns filter as deltalake and pyiceberg filter them (test_delta_string_literals_holding_quotes_and_semicolons, test_iceberg_string_literals_holding_quotes_and_semicolons). A column name cannot hold =, !, <, >, ; or ", since the filter text's name ends at the first operator character; Python refuses one that does (test_operators_in_a_literal_are_kept_and_in_a_column_name_refused).

A literal that does not fit its column is an error naming both, never a crash (test_filter_literals, testRowFilterSemantics, testDeltaUnknownColumnAndBadLiteral, testFilterLiteralsAtTypeEdges). Integer, float, temporal and decimal columns compare on the GPU; string, binary and boolean columns compare on the CPU over the unified-memory buffers, strings and binary byte-wise, and a comparison whose answer is known without the data (a literal outside the column's range, a NaN literal) is a constant mask built on the CPU. Row groups are pruned by the same order: the readers decide string and binary row-group pruning themselves, byte-wise on the footer's min_value / max_value, so a decomposed "é" (which byte-wise sorts below "f") is kept for < "f" (testStringRowGroupPruningIsByteWise, test_delta_decomposed_strings_survive_row_group_pruning). Numeric, date, timestamp and boolean filters use the Parquet reader's row-group filter, only where its comparison is exact (an integer column against a double literal of any magnitude, compared without rounding the integer; a timestamp stored in the table's unit; floats never under !=).

Malformed metadata is an error naming the file, never a crash or a hang: Avro blocks whose sizes or counts run past the data, records that contain themselves with nothing optional in between, a Delta partitionValues that is not an object of strings and nulls, a Delta reader protocol 3 whose readerFeatures is missing or not a list of strings, an Iceberg snapshot with neither a manifest-list nor manifests, and a data file that holds none of the table's columns (a Delta table without column mapping, or an Iceberg file without field ids in a table without a name mapping) instead of rows of nulls (testAvroMalformedContainersAreErrors, test_malformed_manifest_list_is_an_error, testDeltaMalformedPartitionValues, testDeltaReaderFeaturesMustBeAListOfStrings, testIcebergSnapshotWithoutManifestsIsAnError, testDataFilesWithoutTheTableColumnsAreErrors). version is 0 or more (None reads the latest; in C, -1).

Delta Lake

Feature Support Test
JSON commit replay (add, remove, metaData, protocol) Yes testReadsMatchReferenceReaders (every version of every fixture)
Single-file checkpoints, read with the GPU Parquet reader (the map and list columns of the checkpoint included) Yes testCheckpointAgreesWithLogReplay
Multi-part checkpoints (N.checkpoint.P.T.parquet) Yes the multipart fixture in expected.json
Time travel to any version reconstructable from the log Yes; a version whose commits were cleaned up and that no checkpoint covers is an error saying so testLogCleanup
Partition values of every primitive type, null partitions Yes; an empty partition value is null for every type, strings included, as the protocol specifies and deltalake reads it partitioned (null partition), by_day (date partition), testDeltaEmptyStringPartitionIsNull, test_delta_empty_string_partition_is_null
Schema evolution: columns added later read as null from older files Yes evolution
Column mapping none and name (renamed columns, physical names in files and partition values) Yes column_mapping, testDeltaColumnMappingUsesPhysicalNames
Partition pruning and per-file statistics pruning Yes; the counters are in stats. A NaN float partition value is kept for != only testDeltaPruning, test_generated_delta_pruning_counters, testDeltaNaNPartitionMatchesNotEqual, test_delta_nan_partition_matches_not_equal
Reader protocol versions 1 to 3; reader features columnMapping, timestampNtz, vacuumProtocolCheck Yes testDeltaReaderFeaturesItImplements
Deletion vectors, column mapping mode id, any other reader feature (type widening, v2 checkpoints, variant, unknown names) Rejected with an error naming the feature testDeltaRejectsUnsupportedFeaturesByName, test_delta_errors_name_the_feature
Nested columns (struct, array, map) Rejected when projected, with an error naming the column; project the other columns testNestedColumnsAndRemotePaths
Files on remote storage (s3:// and other schemes) Rejected with an error naming the scheme; local paths and file: URIs are read testNestedColumnsAndRemotePaths
Change data feed, _last_checkpoint hints, log compaction files Not used; the log directory listing is the source of truth

File pruning by the per-file statistics uses integer, float, date and string columns. Timestamp statistics are written at millisecond precision and string statistics may be truncated, so timestamp columns and strings of 32 characters or more are not used; boolean, decimal and binary statistics are not used either. The row filter still applies to every column.

Apache Iceberg

Feature Support Test
Format versions 1 and 2 Yes v1_plain, v2_partitioned
The table named by its metadata file, its directory (newest NNNNN-*.metadata.json or vN.metadata.json), or version-hint.text Yes testIcebergMetadataLocation
Current snapshot and any listed snapshot id; a table with no snapshot reads as empty Yes every snapshot of every fixture in expected.json, testIcebergSnapshotsAndSchemas, testNestedColumnsAndRemotePaths
Manifest lists and manifests (Avro, null / deflate / snappy codecs), v1 snapshots that list manifests directly Yes testAvroManifestCodecs, testAvroSnappyContainer, test_iceberg_v1_manifests_listed_in_the_snapshot
Columns resolved by field id: renamed columns, added columns (null in older files), int -> long promotion Yes v2_partitioned (rename, added column, promotion)
Files without field ids, through schema.name-mapping.default Yes transforms, by_day (files registered with add_files)
Manifest pruning by partition summaries, file pruning by partition tuples and column bounds identity, year, month, day, hour, truncate[W] partitions; bucket[N] and void partitions are read and not pruned testIcebergPruning, testIcebergTransformProjection, test_generated_iceberg_pruning_counters
Relocated tables: paths under the recorded location are re-rooted where the metadata was found Yes the committed fixtures record relative locations and are read from any working directory
Paths as written: pyiceberg writes the directory of partition value x=y as grp=x%3Dy and records that path; it is opened as written, and the percent-decoded path is tried only when that does not exist Yes testIcebergPathsAreNotPercentDecodedFirst, test_iceberg_partition_values_that_need_escaping
Position and equality delete files (v2) Rejected with an error naming the kind and a delete file testIcebergRejectsDeleteFiles
gzip-compressed metadata files Rejected with an error testNestedColumnsAndRemotePaths
Nested columns Rejected when projected, as for Delta testNestedColumnsAndRemotePaths

Where the results differ from the reference readers

Checked by python/tests/test_lakehouse.py:

  • Types match deltalake column for column. pyiceberg returns large_string and large_binary for some string and binary columns; ArrowMetal returns string and binary (test_iceberg_fixtures_match_pyiceberg, test_iceberg_partition_values_that_need_escaping, which has a binary column).
  • A projection comes back in the order the columns were asked for; pyiceberg's selected_fields returns them in schema order (test_iceberg_partition_values_that_need_escaping).
  • Float comparisons follow pyarrow. deltalake 1.6.5 agrees on every number but also returns the NaN row for some ordering comparisons on a float32 column (> 0.1, < 1e300); pyiceberg 0.12.0 rounds a double literal to float32 before comparing, so its f > 0.1 leaves out the rows holding 0.1 as a float32, which ArrowMetal and pyarrow return (test_float32_filters_compare_exactly, test_float32_literal_rounding_differs_from_pyiceberg).
  • pyiceberg binds a time-travel scan's row filter against the current schema, so a column renamed after the snapshot is not found under its old name; ArrowMetal resolves filters in the snapshot's schema, like its projection (test_time_travel_filters_use_the_snapshot_schema). pyiceberg also refuses a float literal against an integer column; ArrowMetal compares exactly, as pyarrow does (test_generated_iceberg_filters).
  • pyiceberg 0.12.0 returns every row, nulls included, for a comparison it can decide without the data: != on a column added after some files were written returns those files' null-filled rows, and a literal beyond the column's range (i32 < 2**63 - 1, f32 < 1e308) returns the null and NaN rows. ArrowMetal keeps its rule (a null never matches; NaN matches only !=) and returns the rows pyarrow's filter returns (test_null_rows_under_always_true_filters_differ_from_pyiceberg).
  • deltalake 1.6.5 and polars 1.44.1 return nulls for every mapped column of the column-mapping fixture (Parquet files with physical names and field ids, partition values keyed by physical name, as the Delta protocol specifies). DuckDB 1.5.5's delta_scan reads the mapped data columns (id, total) exactly as ArrowMetal does, which checks the file-level mapping, and returns null for the mapped partition column region, which ArrowMetal fills from partitionValues keyed by the physical name. No independent reader confirms that partition keying; it follows the protocol text, and the reference for that column is the pyarrow data the table was written from (test_column_mapping_reference_readers_return_nulls, test_column_mapping_duckdb_reads_data_columns_not_the_partition, test_column_mapping_reads_the_source_data). The fixture is written by hand: pyarrow data files and JSON commits.
  • Row order is not defined by either format; every comparison sorts.

Tests and fixtures

Tests/Fixtures/lakehouse/generate_lakehouse.py writes the fixture tables with deltalake 1.6.5 and pyiceberg 0.12.0 (the SQL catalog on SQLite, pyarrow 25.0.1): several commits, appends, deletes, predicate overwrites, a checkpoint, a multi-part checkpoint, partitions of several types, schema evolution, a hand-written column-mapping table, three tables with reader features the reader refuses, an Iceberg table whose last snapshot adds a position delete file, and parquet/nfd_strings.parquet (decomposed strings in three row groups, for the byte-wise row-group pruning test). It also writes expected.json: the rows the reference readers return for 62 reads, which LakehouseTests.swift replays. python/tests/test_lakehouse.py replays the same file, compares the committed tables with the reference readers live, and generates larger tables (thousands of rows, many files, a checkpoint, deletes, schema evolution) to compare every filter comparison over every column type. See Testing.

Performance

Benchmarks/lakehouse_bench.py times full, projected and filtered reads of one Delta and one Iceberg table against deltalake, pyiceberg, polars (scan_delta, scan_iceberg) and DuckDB's delta_scan / iceberg_scan (used only when the extensions load with auto-install switched off); the first run used deltalake 1.6.5, pyiceberg 0.12.0, polars 1.44.1 and DuckDB 1.5.5 with its extensions from the local extension cache. The numbers here are from a quiet run over a 2,000,000-row table, Benchmarks/results/lakehouse_2026-09-24.csv; the run conditions at its start are recorded in Benchmarks/results/bench_conditions_2026-09-24.txt. Median wall time in that file:

read arrowmetal deltalake / pyiceberg polars duckdb
Delta, full 1788.18 ms 18.15 ms 7.16 ms 66.82 ms
Delta, projected 740.68 ms 16.17 ms 4.17 ms 21.64 ms
Delta, filtered 185.79 ms 9.93 ms 3.31 ms 7.59 ms
Iceberg, full 155.20 ms 27.66 ms 12.87 ms 91.18 ms
Iceberg, projected 46.29 ms 22.19 ms 10.81 ms 36.04 ms
Iceberg, filtered 32.93 ms 9.95 ms 7.72 ms 7.39 ms

Every CPU reader is ahead of ArrowMetal on every read, so table reads are to improve. The gap is largest on Delta: a full read takes 1788.18 ms against Polars' 7.16 ms. On Iceberg it is narrower: a projected read takes 46.29 ms against 36.04 ms for DuckDB and 10.81 ms for Polars. The file also times arrowmetal_table, the same read handed to pyarrow. An earlier run over a 1,000,000-row table, taken while other work shared the machine and the GPU, is kept as history in Benchmarks/results/lakehouse_2026-09-23_provisional.csv. The benchmark times whole reads; data files are decoded several at a time by the GPU Parquet reader (Parquet on the GPU), each thread with its own command buffer.

docs/STREAMING.md

Out-of-core streaming execution

Datasets larger than memory, answered on the Apple GPU. The rows flow from disk through the GPU one record batch at a time; the CPU touches value columns only where §5 and §10 say it does: a utf8 sort key and the external sort's k-way merge. Only the answer grows with the input, so RAM tracks the answer rather than the scan: measured to 30 GB on a 64 GB machine, where the private running state ranges from a few scalars to a 940 MB group table (§4.1) and peak RSS is dominated by mapped file pages (§9).

let rows = try StreamQuery(ipc: "/data/events")
    .filter(col("amount") > 100)
    .groupBy(["region"], [StreamAggregate(.sum, "amount")])
import arrowmetal as am
am.scan_ipc("/data/events").filter(am.col("amount") > 100) \
  .group_by("region").agg([("sum", "amount", "total")])

Source files: Sources/ArrowMetal/Stream/, C ABI in Sources/ArrowMetalC/ArrowMetalC_Stream.swift and the block at the end of include/arrowmetal.h, Python in python/arrowmetal/stream.py.


1. The pipeline

Three stages run at the same time on three threads, connected by bounded queues:

   ┌──────────────┐   batch i+1   ┌─────────────┐   result i   ┌──────────────┐
   │  stage 1     │ ────────────► │  stage 2    │ ───────────► │  stage 3     │
   │  read        │   queue(3)    │  GPU        │   queue(2)   │  merge       │
   │              │               │             │              │              │
   │ mmap + import│               │ one batch   │              │ fold a small │
   │ next batch   │               │ of kernels  │              │ result into  │
   │ (own thread) │               │ (this one)  │              │ running state│
   └──────────────┘               └─────────────┘              └──────────────┘
        SSD                        Metal                        host memory
  • Stage 1 is PrefetchingSource. It runs the wrapped source on its own thread and keeps up to depth batches ready (3 by default). Backpressure is two-sided: the reader blocks when depth batches are queued or when the queued batches exceed budgetBytes (2 GB by default), whichever comes first, so a stream of very wide batches cannot pin more memory than the budget.
  • Stage 2 is the caller's thread inside StreamingExecutor.run. It pulls one batch, records the operator's kernels into a single Metal command buffer via MetalContext.batch { }, and hands the small result on. It never waits for the merge unless the merge queue is full.
  • Stage 3 is the merge thread. It folds each batch's result into the running state, one at a time, in batch order.

Every stage records its own busy time. StreamStats.overlap is (read + gpu + merge) / wall: 1.0 means the stages ran one after another, and it approaches 3.0 when all three are saturated at once. readStallNanos is how long stage 2 waited for a batch the reader had not finished; mergeStallNanos is how long it waited for the merge to drain.

Why the merge stage is cheap

The GPU stage always produces a small result — a handful of scalars, one row per group, k rows for a top-k, a 16 KB sketch. That is the whole reason streaming works: the merge's cost is a function of the answer's size, not the dataset's. The one exception is documented below (the external sort's k-way merge, which is O(rows) on the host by construction).

Buffer budget

What Where Size
Batches in flight MetalContext.pool (page-aligned shared memory) prefetchDepth + 1 batches, capped by budgetBytes (2 GB default)
Per-batch kernel scratch MetalContext.pool recycled by exact length, so a steady stream of equal-shaped batches allocates once
Running state host or GPU, per operator see the operator table
Sink buffer one batch the IPC sink writes each message straight through

MetalContext.pool recycles page-aligned MTLBuffers by exact byte length. Batches of the same shape hit that cache on every read after the first, so an out-of-core scan does not pay mmap and page-fault costs per batch. Its cap defaults to a quarter of the device's recommended working set, at most 8 GB (MetalContext(poolLimitBytes:)).


2. Sources

Sources/ArrowMetal/Stream/BatchSource.swift.

Source Reads Notes
IPCFileSource one Arrow IPC file Memory mapped. Body buffers are borrowed without a copy where the file's layout allows; Arrow only guarantees 8-byte body alignment, so a buffer that does not start on a page boundary is copied into shared memory instead. Readahead with fcntl(F_RDADVISE) on a second descriptor plus F_RDAHEAD, which warms the unified buffer cache ahead of the read cursor.
IPCDirectorySource a directory of them Sorted by filename, opened lazily one at a time, so a directory larger than memory never has more than one file mapped.
CStreamSource any ArrowArrayStream A pyarrow RecordBatchReader, a pyarrow.dataset scanner (so Parquet, CSV and partitioned datasets work today through pyarrow's readers), a Polars LazyFrame, DuckDB — anything that speaks the Arrow C Stream ABI. Buffers are imported zero-copy when page aligned.
ParallelIPCSource a directory, several files at once readers threads, each with its own IPCFileSource and its own files, feeding one bounded queue. Batch order is not preserved — see below.
ChunkedTableSource batches already in Metal memory For tests and in-memory tables.
PrefetchingSource wraps any of the above Stage one of the pipeline, one thread.

One reader thread is not enough

PrefetchingSource hides the read behind the GPU, but it is still one thread doing the mapping and the import. One core copies at roughly 10 GB/s; Polars and DuckDB scan the same directory at roughly 50-60 GB/s because they read it with every core. ParallelIPCSource closes that gap: it hands each of readers threads its own files, so the read stage scales with cores.

The cost is batch order. Batches arrive interleaved across files, in whatever order the threads finish. Every operator here is order independent in its answer — aggregates, the HLL sketch, group-by, top-k, the external sort's run generation, both joins — though a float64 sum's last bits depend on batch order (§10). So the only thing that needs readers: 1 is a filter -> sink_ipc (or to_reader) that must preserve the source's row order.

am.scan_ipc("/data/events", readers=8).group_by("region").agg([("sum", "amount", "t")])
try StreamQuery(ipc: "/data/events", readers: 8)

Parquet files reach the streaming executor through scan_arrow(pyarrow.dataset(...)); am.read_parquet is the GPU reader for in-memory reads (Parquet on the GPU).

3. Sinks

Sources/ArrowMetal/Stream/StreamingSink.swift.

  • IPCStreamSink writes the Arrow IPC stream encapsulation incrementally: a schema message, one message per record batch, then the 8-byte end-of-stream marker. That encapsulation is a pure concatenation, which is what makes it appendable — the bytes for each message come from ArrowIPCWriter.encode, with the schema prologue (measured once by encoding the schema with no batches) and the end-of-stream marker trimmed off each call. pyarrow.ipc.open_stream, polars.scan_ipc_stream and this package's own ArrowIPCReader all read the result. Dictionary-encoded columns are decoded before writing, because an incremental sink cannot promise one dictionary per column across batches.
  • CallbackSink hands each batch to a closure.
  • CollectingSink keeps the batches (only for results known to be small).
  • ArrowStreamExporter exports a query as an ArrowArrayStream. get_next pulls exactly one source batch through the whole pipeline, so pyarrow or Polars can consume an out-of-core ArrowMetal query lazily and never materialise more than one batch — Stream.to_reader() in Python.

4. Operators

Operator Exact or approximate GPU work Host work Running state
filter / project → sink exact the predicate and every numeric projection compile into one fused kernel per batch (docs/EXPR.md); a string, temporal or decimal output takes the two-step path below writing the sink none
sum, count, min, max exact one fused aggregate kernel per batch one scalar combine per batch a few scalars
mean exact decomposed to sum + count on the GPU combine, divide once at the end 2 scalars
variance, stddev exact decomposed to sum(x), sum(x·x) in float64 and count, all in one fused kernel combine 3 scalars
count_distinct_approx approximate — relative standard error 1.04 / sqrt(2^p): 0.81 % at the default p = 14, 0.41 % at p = 16 a new HyperLogLog kernel: one atomic_fetch_max per row into a 2^p-register table element-wise max of two register arrays 2^p bytes (16 KB at p = 14), whatever the dataset's size
group_by (dense integer key) exact GroupBy per batch, then element-wise add / min / max folds the batch result into the GPU-resident global table none K accumulators per aggregate on the GPU; spills to the host table above gpuStateBudgetBytes (512 MB default)
group_by (one integer key, sum / count / mean) exact a dense key encodes per batch and folds into the GPU-resident hash table; a sparse one skips the encoding entirely — one thread per row probes and inserts into that table directly (§4.1) none one slot per distinct key on the GPU
group_by (any other key or aggregate) exact GroupByKeys turns the key columns into dense ids on the GPU, then the aggregates fold one row per group into a host table sharded by key hash across mergeShards threads one entry per distinct group, not per row
top_k exact one comparison kernel rejects the batch against the running k-th value (§4.2); survivors go through the GPU top-k and are folded into the GPU-resident k rows none k rows on the GPU
quantile approximate — see §6 one radix argsort plus one gather per batch; only compression values reach the host merge two weighted centroid lists and re-compress ≤ compression centroids (1000 default)
sort with limit n exact top-n, not a sort: the same threshold prune, then the GPU top-k selection over the survivors (§5) none n rows on the GPU; nothing spills
sort (external, no limit) exact one radix argsort per batch; each output chunk is assembled with take the k-way merge is on the CPU (see §5) one batch per run + one output batch
join (broadcast) exact the existing GPU hash join per probe batch writing the sink the build side, in memory
join (broadcast) + sum / count / min / max / mean exact one kernel probes, gathers the build-side value and accumulates (§8.1); the build table is built once for the scan none the build table plus two 64-bit words per aggregate, on the GPU
join (broadcast) + group_by exact the hash join, then a gather of the key and value columns of the matched pairs only, into the resident group table (§4.1) as the group-by's own path the build table plus the group table
join (grace hash) exact partition on the GPU, then the GPU hash join per partition writing the partition files one partition pair at a time

Null handling follows Arrow throughout: a null key forms its own group, count(expr) counts non-null values, sum/min/max skip nulls and return null for an all-null input, and a filter drops rows where the predicate is null.

Filter and project: two paths

The fused Expr compiler evaluates every expression into a fixed-width register, so it can read a utf8 column but cannot write one. streamFilterProject picks between:

  • Fused — every output is numeric or boolean: predicate and projections compile into one kernel, the batch is read once, nothing intermediate is materialised.
  • Two-step — some output is a plain utf8 / binary / temporal / decimal column. The predicate still compiles into one fused kernel, producing a boolean mask; that mask drives the GPU filter, which carries any column type, and only the computed outputs go back through the compiler.

Streaming group-by: three global tables

  • Dense integer key (denseKeyCount given, one key column already inside [0, K)): the global table lives on the GPU as one accumulator array per aggregate, K entries wide. Each batch runs GroupBy over the batch's keys and the per-batch result is folded into the global arrays with element-wise add / min / max kernels — no host round trip at all. When K * aggregates * 16 bytes exceeds gpuStateBudgetBytes the state spills to the host table, and partials recorded before the spill are folded into it, so a 10-million-key group-by still runs inside a fixed GPU budget.
  • One integer key with sum / count / mean: the resident hash table of §4.1, on the GPU for the whole scan. A dense key encodes per batch and folds one row per group into it; a sparse one skips the encoding and puts the rows in directly.
  • Anything else (several key columns, a string or float key, min / max / variance): GroupByKeys (which turns any key columns into dense ids on the GPU) plus the aggregates give one row per group — typically thousands of rows for a batch of millions. The merge folds those rows into a host dictionary keyed by the key value itself. Exact, order independent, and its size is the number of distinct groups.

Every path returns the groups in ascending key order, so the result is deterministic across batch layouts and runs. (This differs from pyarrow, which returns first-seen order; sort both sides before comparing.)

4.1 The resident group table

Sources/ArrowMetal/Stream/StreamGroupTable.swift. For one integer key with sum / count / mean aggregates, the global table is an open-addressing hash table in device memory that lives for the whole scan: one slot per distinct key, and per aggregate a 64-bit sum and a 64-bit count. Ten million groups with one aggregate is about 940 MB at a load factor of one third. Nothing crosses to the host until finish().

Publishing a 64-bit key with 32-bit atomics

Metal has no 64-bit atomic add or compare-exchange — checked on this M4 Max: atomic_ulong admits only fetch_min and fetch_max on device memory — and only guarantees memory_order_relaxed on the 32-bit ones. So the usual "claim the slot, then publish the key beside it" handshake is not safe: nothing orders the key's store against the claim, and a reader that sees a claimed slot may read a key that is not there yet. Kernels/HashTable.swift avoids that by storing row + 1 in the atomic and reading the key out of an array nothing writes, which a persistent table cannot do.

This table publishes the key through the atomics instead. A slot is three 32-bit words holding the key's 64 bits split 22 / 21 / 21, each stored as field + 1, so 0 means "not written" and every one of the 2^64 keys maps to three non-zero words. There is no reserved sentinel value, and so no key a caller may not use — 0, -1, Int64.min and Int64.max are ordinary keys, and the tests say so. Each word is written with compare_exchange(0 -> field + 1), which gives three properties:

  • a word only ever goes from 0 to its final value, and never changes again;
  • a thread accepts a slot only when all three words equal its own fields, and because those words are immutable once set, that decision is permanent and every thread agrees with it;
  • a thread that mismatches any word walks on, which is what linear probing wants it to do.

The case a naive scheme gets wrong — two threads with the same key racing on one empty slot — resolves without a spin (which can deadlock a divergent SIMD group) and without a duplicate slot: the loser's compare-exchange fails with the winner's value, which is its own field, so it reads the failure as a match and stops on the same slot. Two threads with different keys resolve the same way, because the loser sees a value that is not its field and walks on. A thread that wins word 0 and then loses word 1 to another key leaves a slot some other thread completes — the thread that won a word is still inside its own probe step and goes on to write the rest — so no slot is left half written when a dispatch ends.

Nothing in that argument needs an ordering Metal does not give, and nothing needs the keys in a dispatch to be distinct. So one thread per row is safe, which is what removes the per-batch dense encoding from a sparse, high-cardinality scan.

Three ways in

Path When Per batch
distinct keys the key column is dense enough for GroupByKeys' range scan (a thousand regions, a date column, dictionary codes) GroupByKeys gives one row per group, one insert dispatch places them, one accumulate folds them
rows, atomic sparse key, and every aggregate is a count or an integer sum / mean one thread per row inserts, one thread per row folds itself into its slot
rows, dense ids sparse key with a float64 or float32 sum in it the insert also stamps a batch-local id onto every slot it touched; the batch's aggregates then run on the ordinary GroupBy and fold into distinct slots

The chooser is one min/max over the key column per batch: a span small enough for GroupByKeys.rangeIsWorthIt keeps the per-batch encoding, because a thousand groups fit in threadgroup memory and beat a million atomic adds landing on a thousand slots; anything sparser goes row-level. Both give the same answer, so it is only ever a choice of plan.

The atomic accumulate builds a 64-bit add out of two 32-bit ones: add the low word, take the carry out of it into the high word. That is exactly two's-complement 64-bit addition, so it wraps like Int64 does, and being plain integer addition it is independent of the order the rows reach a slot in. Counts and integer sums are therefore bit-identical to the per-batch path and to the host table, run after run, whatever the batch layout — which the tests assert at cardinalities 1, 1k, 100k and 2M.

A float64 sum cannot take that path. There is no 64-bit atomic to compare-exchange a double into, and no emulation of one rounds a binary64 addition correctly or reproducibly. It takes the dense-id branch instead, which still never sorts the key column — the resident table is the dictionary — and keeps the correctly-rounded software adder d_add. The batch-local id is assigned by whichever thread first swaps this batch's stamp into a slot, so exactly one thread per slot per batch assigns one, and the ids are read back in a later dispatch, after the barrier that makes them visible. The cost is two uint arrays the size of the table (about 270 MB at ten million groups), allocated only when that branch is used.

The per-group float64 sum is one thread per group, not the one threadgroup per group the segmented reduction gives it: at a million groups of one row that was 256 threads per row, and three quarters of the batch. Its answer has to match the old one bit for bit, because a binary64 sum depends on the order it was added in and the host table is checked against it — and it does, because the shape of that reduction is fixed. Every butterfly step whose stride is at or above the run length does nothing, so a run of L rows only uses the first P lanes (P the power of two at or above L); and the butterfly over P lanes is the balanced tree whose leaves, left to right, are the lanes in bit-reversed order. Walking the lanes in that order and merging equal-rank partial results on a stack of at most nine rebuilds the same tree, including the rule that an empty accumulator is replaced rather than added to. O(L) per group, same additions, same order. Runs long enough that one thread each is the wrong shape keep the threadgroup version; the mean run length decides, and since both give the same bits it is only a choice of dispatch.

Growth

Growth doubles the table and rehashes on the GPU under the same claim rule, and the row path checks the load factor after its insert, when the number of new keys is known: the pre-emptive growth is sized by what the last batch added, and a batch that outruns it grows the table and re-runs the insert, which finds the keys that did land already present and claims the rest. Being wrong about the estimate costs one extra pass, never an answer.

min / max / variance, multi-column keys and non-integer keys keep the host table, and the tests check every resident path against it bit for bit — a null-key group, all-null groups, duplicate keys per batch, empty and ragged batches, growth across the rehash threshold, keys at both ends of the int64 range, and three integer key widths.

Both fold the same per-batch partials in the same order, one with Swift's + and one with d_add, so the float64 sums agree to the last bit.

4.2 Threshold pruning

Once a top-k (or an ORDER BY ... LIMIT n) has k rows resident, the k-th value is a lower bound on anything that can still enter the answer. Every later batch therefore starts with one comparison kernel over the key column; the rows that pass are gathered — usually none, or a handful — and only they reach the selection and the fold. The expected number of survivors in batch i of a scan for the top k is k / i, so the work per batch collapses to a single pass over one column.

Two details make it exact rather than merely close:

  • The comparison is >=, not >. A row that ties the running k-th value can still be the row the total order picks (topK breaks ties by row index), and for a multi-key sort it can still win on the second key. Keeping ties is what makes the pruned answer the answer.
  • A threshold is only taken when the k-th row's key is non-null and not NaN. Nulls sort last, so once k non-null rows are resident no null row can displace one; while fewer than k are, nothing is pruned. With a sort key's nulls first (§5), a batch that holds nulls is not pruned, and where the key's float order puts NaN ahead of the values the NaN rows pass the prune as well.

The fold runs on the GPU thread rather than the merge thread, which leaves the merge stage idle and means the threshold the next batch prunes with is always the newest one.


5. External sort

Each batch is sorted on the GPU (the existing radix argsort) and written to its own Arrow IPC stream file — one run per batch. finish() merges the runs.

The merge is on the CPU, deliberately. A k-way merge is a branch-per-row loop with no parallelism to give a GPU: at 20 runs the whole merge is one comparison of 20 heap entries per output row. What is on the GPU is the gather. For each output chunk the merge decides the order, then:

  1. the rows needed from each run are gathered with one take per run, giving rows grouped by run;
  2. one permutation take puts them back in merged order.

So no column data is ever rebuilt value by value on the host, and memory during the merge is one batch per run plus one output batch.

ORDER BY ... LIMIT n never gets here. With a limit the answer is n rows, so nothing has to spill: the running n rows stay Metal-resident and each batch folds into them on the GPU thread, under the threshold prune of §4.2. That is the same argument the bounded fan-in below already used — the global first n are inside the union of each part's first n — applied one batch earlier, where it is worth a thousand times more: a run used to be a whole million-row batch written to disk and read back, and now there are no runs at all. A single-key head uses the GPU top-k selection, which returns exactly the first n indices argsort would (same order-preserving key, same tie-break by row index), so it is a cheaper way to compute the same prefix and not a different answer.

Runs are merged with a bounded fan-in. 570 batches means 570 runs, and opening them all at once would want 570 file descriptors and 570 resident batches; instead the merge runs in passes of at most mergeFanIn (32 by default), writing intermediate runs and deleting their inputs as it goes, so both stay constant however many runs there are. A limit is applied to every pass — the global first n rows are always inside the union of each group's first n — which is what makes ORDER BY ... LIMIT cheap over hundreds of runs.

Each sort key carries the options of a plan sort key (Query engine, "Sort key options"): its direction, its null placement (nullsFirst, last by default) and, for a float key, its float order (ieee by default, total, nan_largest). The per-batch GPU sort and the CPU merge order by the same rules, so the runs and the merged output agree, and every NaN is placed as the order says (ieee: next to the nulls). ExternalSortOperator.Key(_:descending:nullsFirst:floatOrder:) in Swift, am_stream_sort_ex in the C ABI and Stream.sort(..., null_placement=, float_order=) in Python.

Sort keys use the GPU radix sort for numeric, boolean and temporal columns. utf8 and binary columns have no order-preserving GPU key in the streaming sort, so they fall back to a host sort of the string values (correct, but the one place a sort touches every row on the CPU).

6. Streaming quantiles

An exact quantile needs the whole column ordered, which an out-of-core query cannot afford. Instead a mergeable digest keeps a bounded set of weighted centroids: each says "about w values sit near v". Two digests merge by concatenating their centroids, ordering them and compressing back to the budget, which is associative and order independent.

Per batch the GPU does the work: sort the non-null values with the radix argsort, then gather compression sample points with one take. The sample positions are not uniform in rank — they follow the inverse of the t-digest k1 scale function k(q) = (C / 2π) · asin(2q − 1), which packs samples into the tails, where a uniform sample is least accurate. The merge re-compresses under the same scale function, so a centroid near q = 0.99 keeps far less weight than one near q = 0.5.

A centroid covering rank interval [r0, r1) answers any quantile inside it with rank error at most (r1 − r0) / 2: about n / C at the median and n·π / (2C²) near the extremes, so p99 is roughly C times more accurate than the median. The tests assert a rank error under 1 % at C = 1000 across p1 … p99.

7. HyperLogLog on the GPU

Sources/ArrowMetal/Stream/HyperLogLog.swift. Every row is hashed to 64 bits (splitmix64 for fixed-width values, FNV-1a then splitmix64 for bytes); the top p bits pick a register and the position of the first 1 in the rest updates that register with a max. One thread per row, atomic_fetch_max_explicit on a 2^p-entry uint array in device memory (64 KB at p = 14). Contention is low: the hash spreads rows across 16,384 registers and a max only ever moves a register upward, so most atomics are no-ops the hardware resolves without a retry.

Two sketches merge by element-wise max, which makes the result independent of batch boundaries and batch order — the tests assert bit-identical registers for a whole-array sketch and the same data split into 17 chunks. The estimator is the standard one with linear counting below 2.5·m.

-0.0 and 0.0 hash the same; nulls are skipped, as Arrow's count_distinct does.

8. Streaming joins

  • Broadcast — the build side is read once into one Metal-resident batch and every probe batch runs the existing GPU hash join against it, straight to the sink. Memory is the build side plus one batch. The right plan whenever one side fits, however large the other one is.
  • Grace hash — both sides are streamed once and partitioned by ((key · 0x9E3779B97F4A7C15) >>> (63 − log₂P)) & (P − 1) into P Arrow IPC files per side. The hash takes the high bits of the product so an id column that is a multiple of P does not land entirely in one partition. Equal keys always land in the same partition, so the join of the whole is the union of the per-partition joins, and each partition is small enough for the in-memory GPU hash join. Two passes over each input plus one write and one read of each; memory is one partition pair. Row order differs from an in-memory join, as it does for any hash join; the test compares the result as a set of pairs.

Join keys are int32 or int64 on both sides, which is what the GPU hash join takes. Null keys never match.

8.1 A join followed by an aggregate is one kernel

Sources/ArrowMetal/Stream/StreamJoinAggregate.swift.

A join -> sum used to be two operators with a whole joined table between them: every probe batch ran the hash join, gathered every column of both sides into a new record batch, wrote it to a sink, and the caller summed the rows it got back. The joined rows are the largest thing in that pipeline and none of them is the answer.

try StreamQuery(ipc: path).filter(col("region") < 50)
    .join(dim, on: "region", buildKey: "region")
    .sum("amount")
am.scan_ipc(path).filter(am.col("region") < 50).join(dim, on="region") \
  .agg([("sum", "amount", "total"), ("sum", "weight", "w")])

The terminal picks the plan. .sink / .collect still stream the joined rows out; .sum, .aggregate and .groupBy fuse:

  1. The build table is built once. MetalRecordBatch.join builds its hash table per call, which for a streamed join is per batch. The build side does not change across a scan, so BroadcastBuildTable builds it at construction with the same hj_clear / hj_build kernels and every probe batch reads it.
  2. The filter projects down to what the kernel reads — the join key plus the probe-side value columns. A filtered join used to gather every column of the surviving rows, including a utf8 one.
  3. jfa_probe probes, gathers and accumulates in one kernel. One thread per probe row walks its bucket chain and, for each match, reads the aggregate's value — from the probe row it is standing on, or from the build row the chain points at — and folds it into thread registers. A threadgroup tree reduction turns 256 of those into one partial per accumulator, and jfa_fold folds the partials into a device-resident accumulator pair that lives for the whole scan. Nothing proportional to the number of matches is allocated, nothing is gathered, and nothing crosses to the host until finish() reads sixteen bytes per aggregate. The merge stage does nothing.

Every accumulator is a raw ulong — a signed sum, an unsigned sum, or an IEEE-754 binary64 bit pattern folded by the correctly-rounded software adder d_add, since Metal has neither a double type nor a 64-bit atomic add. The kernel is generated per (key type, aggregate list) and unrolled, so there is no per-aggregate branch in the probe loop. A slot's count doubles as its "is empty" flag, which is what makes min / max free of a per-type sentinel and an all-null input come out as null rather than as an infinity.

sum, count, min, max and mean fuse; up to seven of them at once (Metal binds 31 buffers and the probe itself needs nine). A column name is resolved against the probe side first and then the build side, so sum("weight") reaches a build-side column; a build column shadowed by a probe column of the same name is reachable as name_right, matching the materialising join's output names.

The grouped form (join(...).groupBy(keys, aggs)) keeps the join's index pairs, because a per-group accumulator needs a table probed once per matched pair, and the resident group table's race-free insert relies on every key in a dispatch being distinct (§4.1), which rows are not. It gathers only the key columns and the aggregated values of the matched pairs — two or three columns, never the joined batch's eight — and folds those into the ordinary streaming group-by, whose global table is already resident. A key may come from either side: a probe-side key is gathered with the left index array, a build-side key with the right one.

Semantics are the inner join's and match Polars and DuckDB: duplicate build keys multiply rows, a null key on either side never matches, and a probe row with no match contributes nothing. A left join followed by an aggregate raises, naming what to use instead, rather than returning a number that counts unmatched rows wrongly; so do variance, stddev, count_distinct_approx, a grace hash join, and a non-numeric value column.

Tests/ArrowMetalTests/StreamJoinFusionTests.swift checks the fused answer against the in-memory join followed by the in-memory aggregate — bit for bit for integer aggregates, within one ulp per element for float64 sums — over duplicate build keys, null keys on both sides, empty batches, a batch with no matches at all, a build side of one row and of a million, a group-by key from each side, and int32 and int64 keys.


9. Numbers

Measured on an M4 Max (16 cores, 64 GB unified memory, internal SSD), Swift release build, over a 30.21 GB Arrow IPC directory: 570,000,000 rows, 30 files, 570 record batches of 1M rows, 8 columns (id int64 unique, region int32 with 1000 values, bigkey int64 with 10M values, label utf8 with 50 values, amount float64, qty int32, ts int64, flag int8). Generated and run by Benchmarks/streaming_bench.py, which runs every (workload, engine) cell in its own subprocess so peak RSS is that engine's alone and an out-of-memory kill costs one cell rather than the run.

The machine has 64 GB of RAM and the dataset is 30 GB, so after the first pass it is in the page cache: the GB/s figures are memory bandwidth as much as SSD bandwidth, and they are the same for every engine.

The row-level group-by: 4 GB, before and after

Measured on a 4.03 GB run of the same generator (--size-gb 4: 76,000,000 rows, 4 files, 76 batches of 1M rows) with readers = 2, best of three interleaved A/B rounds — before, after, then the reference engines, three times round, one cell process at a time, same dataset and same page cache, and only the loaded library changes between "before" and "after". "before" is the per-batch dense encoding of the previous section; "after" is the row-level path of §4.1.

Workload before after Polars DuckDB RSS before RSS after Polars RSS DuckDB RSS
group by bigkey -> sum (10M groups) 2,156 614 393 638 4.12 GB 4.74 GB 2.90 GB 4.63 GB
group by region -> sum, count (1k groups) 199 200 106 240 2.30 GB 2.32 GB 2.32 GB 0.86 GB

groupby_10m is 3.5x faster and is now ahead of DuckDB; it was 5.5x behind Polars and is 1.6x behind. groupby_1k is unchanged to the millisecond, which is the point of the chooser: a thousand contiguous values is exactly the shape the range encoding is for, the row path is never taken, and nothing about that workload moved.

Where the batch's time went, per stage, at 76 batches:

Stage before after
read (two reader threads) 0.40 s 0.43 s
GPU stage 2.05 s 0.27 s
merge stage 0.23 s 0.50 s
merge stall (GPU stage waiting on the merge queue) 0.00 s 0.22 s
overlap 1.26x 2.07x

The work moved rather than only shrank: the resident table can only be touched from the merge stage, so the insert, the aggregates and the fold all run there now and the GPU stage is left with the read and the filter. Total GPU-side work went from 2.28 s to 0.77 s. What remains in the merge is the insert of a million rows into a 33-million-slot table, the batch's GroupBy, and the fold.

Two intermediate points, measured the same way on the same dataset, showing what each half bought:

group by bigkey wall merge stage
per-batch encoding (before) 2,156 ms 0.23 s
row-level, dense ids, segmented sum 1,523 ms 1.47 s
row-level, dense ids, one thread per group 614 ms 0.50 s
the same query as count(*) (atomic path) 456 ms 0.31 s
the same query as sum(qty), int32 (atomic path) 330 ms 0.22 s

The last two lines are the point of the atomic path: with an integer aggregate the batch never builds dense ids, never sorts anything and never runs a second kernel over its groups, and the whole query is read-bound. A float64 sum pays for the dense ids and the per-group reduction because Metal has no 64-bit atomic to fold a double with.

Peak RSS rises by 0.62 GB on groupby_10m, and that is the price of the dense-id branch: two uint arrays the size of the table (2 x 134 MB at 33.5 M slots) plus the batch's segment arrays. The atomic path allocates neither. Nothing else in the table moved.

The earlier measurement: 8 GB, before and after the resident-state work

The 30 GB table this section used to carry is kept below, because it is the size at which the read stops being the limit. The current numbers are from an 8.06 GB run of the same generator (--size-gb 8: 152,000,000 rows, 8 files, 152 batches of 1M rows) with readers = 2, best of three interleaved A/B rounds in which only the loaded library changes — same benchmark script, same dataset, same page cache, one cell process at a time. "before" is main; "after" is §4.1, §4.2 and §5. Wall-clock milliseconds, then peak RSS.

Workload before after Polars DuckDB RSS before RSS after Polars RSS DuckDB RSS
sum(amount) where region < 100 325 324 158 230 2.30 GB 2.30 GB 1.86 GB 0.97 GB
group by region -> sum, count (1k groups) 509 513 164 268 2.52 GB 2.52 GB 2.26 GB 1.08 GB
group by bigkey -> sum (10M groups) 7,629 4,110 743 864 5.14 GB 4.14 GB 5.31 GB 8.04 GB
top 100 by amount 527 386 176 240 2.51 GB 2.51 GB 1.79 GB 0.81 GB
count_distinct(id) 329 331 694 223 2.30 GB 2.30 GB 4.78 GB 0.79 GB
order by amount desc limit 1000 2,297 524 177 241 3.85 GB 2.52 GB 2.91 GB 0.83 GB
broadcast join + sum 564 562 281 244 2.47 GB 2.48 GB 2.15 GB 0.95 GB

sort_limit is 4.4x faster and spills no runs at all (peak RSS falls by 1.3 GB with them), groupby_10m is 1.86x faster on a gigabyte less memory, topk is 1.4x faster. filter_sum and count_distinct are unchanged within noise, which is the point: they were already right.

The four cross-checked workloads (filter_sum, groupby_1k, topk, sort_limit) are compared against Polars' answer with a 1e-9 relative tolerance on float sums, and all four pass.

Where the pipeline's time goes now

overlap is 1.0 for a serial pipeline, and above that by however much the stages ran at the same time. With readers = 2 the read column is the sum across both reader threads, so it can exceed wall on its own. The stall columns are the GPU stage waiting: for a batch the readers had not finished, and for the merge queue to drain.

Workload Overlap Read (s) GPU (s) Merge (s) before Merge (s) after Read stall (s) Merge stall (s) Wall (s)
filter_sum 2.19x 0.57 0.09 0.00 0.00 0.21 0.00 0.32
groupby_1k 2.58x 0.56 0.48 0.30 0.22 0.02 0.00 0.51
groupby_10m 1.24x 0.72 4.01 2.35 0.34 0.01 0.00 4.11
topk 2.60x 0.60 0.34 0.48 0.00 0.03 0.00 0.39
count_distinct 2.19x 0.57 0.09 0.00 0.00 0.21 0.00 0.33
sort_limit 2.13x 0.57 0.49 1.19 0.00 0.01 0.00 0.52
broadcast_join 2.42x 0.62 0.29 0.00 0.00 0.02 0.00 0.56

The merge stage is zero on every operator that had one, which was the point: nothing folds a batch's answer on the host any more except the host group-by table (§4), and that table is only reached by the keys and aggregates the resident one does not take. The merge stall is 0.00 s everywhere, so the bounded queue between the GPU stage and the merge never fills, and peak RSS stays flat at 2.3 to 2.5 GB over 8 GB of data.

Where the CPU engines are ahead, and why.

  • filter_sum and count_distinct are read-bound at readers = 2. Their GPU stage is 0.09 s of a 0.32 s wall and the read stall is 0.21 s of it, so what is being measured there is two reader threads moving 8 GB against Polars reading the same directory with every core at 51 GB/s. The 30 GB numbers below, taken with readers = 8, are what the same operators look like once the read is not the limit.
  • groupby_10m was still 5.5x behind Polars at this point, and the reason was precisely located: the merge was 0.34 s, but the GPU stage was 4.0 s, and essentially all of it was the per-batch GroupByKeys that turns a million int64 keys into dense ids. bigkey spans ten million values over a million-row batch, too sparse for the range-encoding path, so every batch paid a full radix sort of its key column plus an atomic group-by min to pick each group's representative row — with a host loop over that batch's ~950,000 groups inside it. That is what the row-level path of §4.1 removed; the 4 GB table above is the result.
  • groupby_1k, topk, sort_limit and the join are within 2x to 3.2x of Polars and DuckDB, and are read- or fixed-cost-bound rather than dominated by any one stage.
  • The broadcast join is unchanged because the benchmark streams its joined rows back to Python and sums them there. Fusing the aggregate into the probe, so the join's output never leaves the GPU, is measured separately below.

The fused join + aggregate, measured

A separate 4.03 GB run (--size-gb 4 --ipc-format file: 76,000,000 rows, 4 files, 76 batches of 1M rows) with readers = 2, best of three interleaved rounds, one cell process at a time. Both sides are the same binary over the same dataset in the same run: broadcast_join_unfused is the old plan (gather every matched pair into a record batch, collect it, sum it in Python) and broadcast_join is the fused one (§8.1). Both compute sum(amount) from the probe side and sum(weight) from the build side after region < 50, and both agree with Polars to the last two digits of a double (the benchmark's own cross-check passes on every round).

Cell Wall Peak RSS GPU stage Merge stage Read stage Read stall
broadcast_join unfused 379 ms 2,462 MB 0.14 s 0.00 s 0.325 s 0.03 s
broadcast_join fused 336 ms 2,283 MB 0.04 s 0.00 s 0.315 s 0.13 s
Polars 233 ms 1,836 MB
DuckDB 229 ms 862 MB

The GPU stage is 3.5x smaller — 0.14 s to 0.04 s — which is what the fusion did: the gather of every column of every matched pair is gone, and so is the joined batch. Peak RSS falls by 180 MB and host CPU by 170 ms (698 to 529 ms), because nothing is collected and nothing is summed in Python. (The fused GPU stage measures between 0.04 and 0.09 s across rounds; the spread is the one-off compile of the generated kernel on the first batch, which the unfused path does not pay because its kernels are already in the pipeline cache. The unfused stage is 0.14 to 0.15 s in every round.)

The wall clock only moves from 379 ms to 336 ms, and Polars and DuckDB are still ahead, because at readers = 2 this cell is read-bound. The read stage is 0.315 s of a 0.336 s wall and the GPU stage waits 0.13 s of it for a batch the two reader threads had not finished: what is being measured is two threads moving 4 GB against Polars reading the same directory with every core. One supplementary round at readers = 4 shows the operator with the read out of the way — 359 ms unfused to 264 ms fused, a 1.36x speed-up (GPU 0.147 s to 0.043 s), which brings it to within 15 % of DuckDB's 229 ms; the cost is peak RSS, 4.4 to 4.9 GB, because four part files are mapped at once.

The earlier 30 GB measurement

Kept because 30 GB with readers = 8 is the size at which the read stops being the limit and the per-batch work shows through. Same M4 Max, a 30.21 GB directory: 570,000,000 rows, 30 files, 570 batches of 1M rows.

Workload ArrowMetal (before) Polars DuckDB ArrowMetal RSS Polars RSS DuckDB RSS
sum(amount) where region < 100 527 515 418 8.9 GB 2.0 GB 1.1 GB
group by region -> sum, count (1k groups) 1,765 554 625 9.5 GB 3.1 GB 1.3 GB
group by bigkey -> sum (10M groups) 19,946 4,031 5,232 12.4 GB 13.1 GB 25.7 GB
top 100 by amount 4,060 587 544 8.4 GB 2.5 GB 0.9 GB
count_distinct(id) 532 3,825 451 8.8 GB 17.0 GB 0.9 GB
order by amount desc limit 1000 11,121 642 567 9.3 GB 2.7 GB 0.9 GB
broadcast join + sum 1,807 764 423 9.2 GB 2.3 GB 1.1 GB

The resident top-n and threshold pruning of §4.2 and §5 were measured once on that dataset before it was deleted, in a single interleaved A/B round with readers = 8, printed to a tenth of a second:

Workload before after
top 100 by amount 4.7 s 0.8 s
order by amount desc limit 1000 11.2 s 1.1 s
group by region (1k groups) 1.3 s 1.0 s
sum(amount) where region < 100 0.5 s 0.5 s
count_distinct(id) 0.5 s 0.5 s
broadcast join + sum 1.7 s 1.6 s

At that size top-k and ORDER BY ... LIMIT come within 1.4x-1.9x of Polars and DuckDB — 0.8 s against 0.587 and 0.544, 1.1 s against 0.642 and 0.567 — down from 4.7 s and 11.2 s, because 30 GB is where the per-batch work, not the read, was the limit. The 30 GB run predates the resident group table (§4.1); groupby_10m with that table is measured at 8 GB and 4 GB in the tables above.

pyarrow.dataset (measured in a separate single-reader run, so not comparable cell by cell) finished groupby_10m in 557 s and timed out at 900 s on count_distinct.

  • count_distinct_approx is 7x Polars (532 ms against 3,825 ms) at 8.8 GB of peak RSS against Polars' 17.0 GB, and its own running state is a 16 KB sketch. DuckDB's sketch is faster still, but its answer is 12.4 % off against ArrowMetal's 0.007 % — DuckDB's approx_count_distinct is tuned for a much smaller sketch. Against an exact count this is the operator where streaming on a GPU clearly pays.
  • filter_sum ties Polars at 57 GB/s — a whole-dataset filtered aggregate over 570 million rows in half a second, with the CPU doing nothing but reading bytes.

Accuracy of the two approximate operators

Row Exact ArrowMetal Error
count_distinct(id) 570,000,000 569,958,240 0.0073 % (the p = 14 bound is 0.81 % standard error)
quantile(amount, 0.5) 500.000 499.989 rank error < 0.01 %
quantile(amount, 0.99) 990.000 989.988 rank error < 0.01 %

DuckDB's approx_count_distinct on the same column: 640,524,923, an error of 12.37 %.

The overlap, measured

prefetch=0 makes the read stage run inline on the GPU thread — the same work, serialised. Same process, same page-cache state, one reader thread on both sides, best of two alternating runs each, on a 30 GB dataset:

Query prefetch=0 (serial) prefetch=3 (pipelined) Speed-up Overlap serial -> pipelined
filter + sum 3.57 s 2.83 s 1.26x 1.00x -> 1.14x
group by region 8.00 s 4.93 s 1.62x 1.00x -> 1.82x
top 100 7.17 s 3.52 s 2.04x 1.22x -> 2.54x

filter + sum gains least because its GPU stage is only 0.5 s against a 3 s read: there is barely anything to hide. top 100 gains most because all three stages are busy. Going from one reader thread to eight is a second, larger step on top of this: filter_sum went from 2.83 s to 0.53 s and count_distinct from 2.36 s to 0.53 s once the read stopped being the bottleneck.

What the memory figures mean

ArrowMetal's peak RSS at readers = 8 is around 9 GB against Polars' 2 GB, and almost all of the difference is mapped file pages: eight readers hold eight ~1 GB part files mapped at once, and ru_maxrss counts those. They are page cache, not anonymous memory — the kernel reclaims them under pressure — but the number is real and it is the price of the parallel read. With readers = 1 the same queries run at 1.2 GB of RSS (and take two to five times as long). The private state is what §4 lists: a few scalars, a 16 KB sketch, k rows, or the group table.

10. Limits

  • readers > 1 does not preserve batch order, so a row-order-preserving sink_ipc or to_reader needs readers: 1 (the default).
  • Threshold pruning is exact but not free of assumptions: it compares against the running n-th value with >=, which needs a comparison kernel for the key column's type. Types without one (utf8, binary, boolean, decimals) simply run unpruned — correct, just not faster.
  • Sort keys on utf8 / binary columns fall back to a host sort per batch. Numeric, boolean and temporal keys use the GPU radix sort.
  • count_distinct_approx is a whole-dataset aggregate, not a per-group one; a per-group HLL would need one sketch per group and is not implemented.
  • variance / stddev are not available in the dense-key GPU group-by path (they need a third accumulator array); the arbitrary-key path has them.
  • Grace join keys must be integers.
  • The IPC sink writes the stream encapsulation, not the random-access file format: the file footer's block index cannot be built incrementally without reaching into IPCWriter. Every reader that matters (pyarrow, Polars, this package) reads it.
  • Dictionary-encoded columns are decoded by the sink and by the merge helpers. One dictionary per column for a whole stream is the only form the writer emits, which an incremental sink cannot promise.
  • A Stream is single use: a terminal consumes the source. Open a new scan for a second question.
  • A group-by over ten million groups is 1.6x behind Polars (§9), down from 5.5x. The rows go straight into the resident table now, so there is no per-batch encoding left to remove; what is left is the insert itself, the batch's GroupBy and the fold, all in the merge stage, which the GPU stage then waits 0.22 s on.
  • A float64 sum cannot use the row-level atomic accumulate, because Metal has no 64-bit atomic add or compare-exchange (atomic_ulong offers only fetch_min/fetch_max) and no emulation of one rounds a binary64 addition correctly. It takes the dense-id branch of §4.1 instead, which costs a second pass over the batch's groups and two uint arrays the size of the table. Counts and integer sums skip all of that; on the same query and dataset that is 330 ms against 614 ms.
  • The row-level path's float64 sum is deterministic but not associative-order-free: a group's rows are added in a fixed order that depends on how the batches fell, so the same dataset read with a different batch_rows can differ in the last bits, exactly as the per-batch path always could. Integer sums and counts have no such dependence — they are 64-bit integer adds and commute — so they are bit-identical across paths, batch layouts and runs. There is no non-deterministic option to turn on.
  • The resident group table takes one integer key and sum / count / mean. min / max (they would need a per-slot atomic minimum), variance (a third accumulator), multi-column keys and non-integer keys all keep the host table, which is exact but folds one row per group per batch on the CPU.
  • A fused join + aggregate is an inner broadcast join only. join(...).sum(...) and join(...).groupBy(...) fuse (§8.1); a left join followed by an aggregate raises, because it would have to count unmatched probe rows and that is not implemented, and so does a grace hash join, which writes its partitions to disk and has no fused form. Both name the plan to use instead. variance / stddev / count_distinct_approx and non-numeric value columns raise too.
  • A fused aggregate takes at most seven aggregates. Metal binds 31 buffers and the probe needs nine of them, three per aggregate after that.
  • The dense-id branch costs about 270 MB at ten million groups — two uint arrays the size of the table — which is why groupby_10m's peak RSS rises by 0.62 GB.
  • Each batch is one Metal command buffer, not several. The fixed cost per batch is paid once per batch; a larger batch_rows amortises it, at the cost of memory per batch.
  • readers > 1 raises peak RSS by roughly readers x the part-file size, because that many files are mapped at once. Those pages are reclaimable page cache, not anonymous memory, but they do show up in ru_maxrss.
  • am_stream_query supports filter + project and filter + aggregate; a grouped query goes through am_stream_group_by, because the Expr compiler's group_by takes a dense integer key and the streaming group-by takes arbitrary key columns. A joined query has no s-expression form either: it goes through am_stream_join_aggregate / am_stream_join_group_by, which take the build side as an Arrow C Stream.
  • Arrays above 2³² rows are still refused (Dispatch.checkLength), which bounds one batch, not a dataset.
docs/POLARS.md

ArrowMetal for Polars users

Polars is the reason most people on an Apple silicon Mac have Arrow-shaped data in memory at all. This document is how you point that data at the GPU.

There are four tiers, the first three in 0.1.0 and the engine (tier 4) in 0.2.0, and they differ in where the GPU sits relative to the Polars plan:

Tier Where the GPU runs What you write Needs
1. Bridge and namespaces Around Polars: you hand a collected frame over df.arrowmetal.group_by("k").sum("v") Python only
2. Expression plugin Inside a Polars lazy plan pl.col("v").arrowmetal.sum() Python only (the wheel carries the plugin)
3. Streaming hand-off Polars runs the plan, ArrowMetal finishes it lf.arrowmetal.collect_gpu(q) Python only
4. MetalEngine In place of whole subtrees of the optimised Polars plan lf.collect(engine=am.MetalEngine()) Python only

All four move data over the Arrow C Data Interface. For a single-chunk numeric Polars column that is no copy at all -- the GPU reads the buffer Polars already owns. A String column crosses the same way in tiers 1, 3 and 4: in Polars' own Utf8View layout, which the string kernels read directly (tier 2's plugin still asks Polars for large_string). Categoricals and multi-chunk Series each cost one conversion pass -- see Limits. The evidence is below.


Install

From the wheel

scripts/build_wheel.sh                                   # swift build, cargo build, then the wheel
pip install python/dist/arrowmetal-*.whl polars

A wheel built from this tree carries both native libraries in arrowmetal/_lib/: libArrowMetalC.dylib (the GPU library) and libarrowmetal_polars.dylib (the tier-2 expression plugin, built by cargo during the wheel build, against that same libArrowMetalC.dylib). All four tiers work from that install, with no Xcode, no cargo and no DYLD_LIBRARY_PATH. The packaged plugin has one rpath, @loader_path, so its @rpath/libArrowMetalC.dylib resolves to the copy beside it, the same file Python loads. The 0.2.0 wheel on PyPI carries libArrowMetalC.dylib only; with it, tier 2 needs the cargo build below.

scripts/check_wheel.sh checks a built wheel: it installs the wheel with its polars extra into a fresh virtualenv outside the repository, with a scrubbed environment and no cargo on PATH, runs one expression or plan per tier, checks that the process loaded exactly one libArrowMetalC.dylib, the packaged one, and runs python -m arrowmetal.bench with and without --parquet. On an M4 Max (macOS 26.6.2, Homebrew Python 3.13.9) it passed with polars 1.44.2 and pyarrow 25.0.1 as pip resolved them, NumPy not installed: the packaged plugin, built against polars 0.55 crates, loads in py-polars 1.44.2. The plugin adds 5.0 MB to the compressed wheel (3.2 MB before, 8.2 MB after) and is 21 MB on disk after strip -x.

Which Polars

pip install 'arrowmetal[polars]' installs polars>=1.44,<2.1: polars 1.44.1, 1.44.2 and 2.0.0 are the releases the engine suites passed on. Per tier:

Tier Polars
1. Bridge and namespaces any polars>=1.0 (pure Python over the Arrow C Data Interface)
3. Streaming hand-off any polars>=1.0 (pure Python over the Arrow C Data Interface)
2. Expression plugin 1.44.x and 2.0.0: the plugin is built on the polars 0.55 crates; Polars checks a plugin's ABI (polars_ffi's major/minor) when it loads it, and the 1.44-built plugin loads on 2.0.0 (its 151 tests pass there) (Version pinning, below)
4. MetalEngine tested on 1.44.1, 1.44.2 and 2.0.0 (TESTED_POLARS). On 1.44.1 and 1.44.2 the full engine suite passes and the capability table is the same on both (1.44.2 also by scripts/check_wheel.sh); on 2.0.0 test_polars_engine.py, test_polars.py and test_polars_sort_order.py pass, with the profile tests and the capability-table comparison skipped (Polars engine capabilities is the 1.44.1 table). It walks Polars' unstable IR, checked against TESTED_IR_VERSION: (14, 7) for 1.44, (15, 2) for 2.0.0. python -m arrowmetal.polars_engine check reports the installed Polars against these

A Polars outside 1.44 installed without the extra keeps tiers 1 and 3, and on 2.0.0 tier 4.

From source

# 1. The GPU library (every tier needs it)
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
    swift build -c release --product ArrowMetalC

# 2. The Python package
pip install polars pyarrow
export PYTHONPATH=python          # or: pip install ./python

# 3. Tier 2 only: the Polars expression plugin
cd polars-plugin && cargo build --release && cd ..

That is the whole build, and both halves are checked: on an M4 Max the Swift product and the cargo release build both go through from an empty target/, with no other flags and no DYLD_LIBRARY_PATH. cargo build --release is enough for the plugin -- it is a plain cdylib that Polars dlopens, not a Python extension module, so maturin is optional, and arrowmetal.polars_plugin.plugin_path() finds polars-plugin/target/release/ on its own.

plugin_path() looks, first hit wins, at $ARROWMETAL_POLARS_PLUGIN (the full path to a plugin dylib); the packaged arrowmetal/_lib/libarrowmetal_polars.dylib when Python loaded the packaged libArrowMetalC.dylib beside it, which is what a wheel install has; polars-plugin/target/release/ and target/debug/ in a source checkout; the packaged copy in any other case; and a maturin install on sys.path. When $ARROWMETAL_LIB pins a development build, a cargo build linked against it therefore wins over a packaged plugin left in _lib/ by a wheel build in the same checkout.

maturin develop --release is the untested path: polars-plugin/ has no pyproject.toml, and plugin_path() looks for a maturin install at <sys.path>/arrowmetal_polars/libarrowmetal_polars.dylib, which is not the layout maturin produces for a cdylib crate. Use cargo build --release, or set ARROWMETAL_POLARS_PLUGIN to whatever you did build.

The plugin's build.rs links libArrowMetalC.dylib by rpath. The dylib's install name is @rpath/libArrowMetalC.dylib, so polars-plugin/arrowmetal-sys/build.rs finds the directory holding it -- $ARROWMETAL_LIB_DIR, $ARROWMETAL_LIB, <repo>/.build/release, <repo>/.build/debug, /usr/local/lib, /opt/homebrew/lib, first hit wins -- and bakes it in as an LC_RPATH entry. No DYLD_LIBRARY_PATH is needed at run time. It republishes that directory to the plugin crate through its links = "ArrowMetalC" key (DEP_ARROWMETALC_LIB_DIR), because Cargo passes a build script's link arguments only to the crate that owns it.

Version pinning

polars-plugin/Cargo.toml pins polars 0.55.1 and pyo3-polars 0.28.0. Those are the Rust crates py-polars 1.44.x is built from: the polars workspace at tag py-1.44.1 carries version = "0.55.1", and pyo3-polars 0.28 is the release that depends on polars ^0.55.1. The requirement is a caret, so Cargo.lock currently resolves polars 0.55.2 and polars-ffi 0.55.2; the ABI check is on polars_ffi's major/minor, so that patch bump loads against py-polars 1.44.1 without complaint.

Polars checks the plugin ABI when it loads the library (_polars_plugin_get_version, which returns polars_ffi's major/minor packed into a u32) and refuses a mismatched pair with this Polars engine doesn't support plugin version: .... Moving to a different Polars means re-pinning both crates to whatever that release's workspace version is -- read it from https://raw.githubusercontent.com/pola-rs/polars/refs/tags/py-<version>/Cargo.toml, do not guess. Tiers 1 and 3 have no such constraint: they are pure Python over the C Data Interface.


Tier 1 -- the zero-copy bridge and the .arrowmetal namespaces

import polars as pl
import arrowmetal as am

Importing arrowmetal does not import Polars -- the package's only hard dependency is pyarrow. The Polars half arrives on first use. Any of these arms it:

import arrowmetal.polars_bridge          # explicit
am.from_polars(df)                       # first touch of a bridge function
import polars as pl; import arrowmetal   # Polars already loaded -> registered at import

Two functions

am.from_polars(series)      # -> MetalArray
am.from_polars(dataframe)   # -> dict[str, MetalArray]
am.to_polars(metal_array)   # -> pl.Series
am.to_polars({"a": ...})    # -> pl.DataFrame

Three namespaces

# Series
s.arrowmetal.sum() / .min() / .max() / .mean() / .count()
s.arrowmetal.top_k(100) / .bottom_k(100) / .sort(descending=True) / .arg_sort()
s.arrowmetal.filter(mask) / .unique() / .cum_sum() / .hash64()
s.arrowmetal.contains("x") / .starts_with("x") / .ends_with("x") / .upper() / .lower()
s.arrowmetal.to_metal()                     # keep the column on the GPU

# DataFrame
df.arrowmetal.group_by("k").sum("v")
df.arrowmetal.group_by("k", "region").agg(total=("v", "sum"), avg=("f", "mean"), n=(None, "len"))
df.arrowmetal.query(am.filter(am.col("k") == 2).sum(am.col("v")))
df.arrowmetal.sort(["k", "v"], descending=[False, True])
df.arrowmetal.top_k(10, by="v")
df.arrowmetal.filter(df["k"] > 8)
df.arrowmetal.join(other, on="k", how="inner")
df.arrowmetal.to_metal() / .device()

# LazyFrame
lf.arrowmetal.collect_gpu(query_or_callable)

Results come back as Polars objects. Scalar reductions come back as Python scalars, as pl.Series.sum() does -- with one difference, in both tier 1 and tier 2: the sum of an empty or all-null column is None, where pl.Series.sum() answers 0. am_reduce has nothing to add up and says so; min, max and mean are None on both sides.

Grouped aggregates available through .agg: sum, mean, min, max, count, len, n_unique, first, last, median, std, var, product, any, all, plus gb.quantile(column, q). One am_group_by_keys pass builds the dense group ids and every aggregate after that reuses it, so .agg(...) with six outputs costs one group-by.

What each tier-1 method runs

Method ArrowMetal entry point Note
sum / min / max / mean am_reduce integers widen to 64 bits, Arrow's rule
top_k / bottom_k am_top_k + am_take GPU radix sort
sort / arg_sort am_sort / am_argsort stable, nulls last, NaN after +inf
filter am_filter GPU stream compaction
unique am_unique first-seen order, nulls kept; Polars' plain unique() promises no order
hash64 am_hash64 (strings: am_str_unary kind 2) Arrow-equal values hash equal
contains / starts_with / ends_with am_str_match literal, not regex
upper / lower am_str_transform 2/3 simple 1:1 case mapping, see limits
cum_sum am_cumulative two-level GPU scan
df.group_by(...) am_group_by_keys + am_group_agg_ex any key type; several keys fold
df.sort(...) am_lexsort + am_take one argsort, one take per column
df.query(...) am_query the whole expression DAG as one generated kernel
df.join(...) am_index_in + am_take / am_filter see the join section

The join

The bridge does not call the C ABI's am_join hash join; df.arrowmetal.join is built out of two other kernels. am_index_in finds, for every left key, the row of the right key column it matches; am_take and am_filter then gather both sides. That is a complete inner or left join whenever the right key is unique -- the usual dimension-table shape -- and the whole thing, uniqueness check included (one am_group_by_keys: as many groups as rows means every key is distinct), runs on the GPU with no row-by-row work on the host.

Everything else falls back to pl.DataFrame.join: a duplicated right key (which changes the row count in a way index_in cannot express), a multi-column key, or an outer/semi/anti join. Pass allow_cpu_fallback=False to get an error instead, so a benchmark can be sure what it measured. Null keys never match, which is Polars' default join_nulls=False.

Column pruning in df.arrowmetal.query

query imports only the columns the query names -- it reads them out of the serialised query's (col "name") nodes. This matters more than it sounds: on the benchmark frame, importing all four columns (one of them 50M strings) cost more than the entire query did.


Tier 2 -- the expression plugin

import polars as pl
import arrowmetal.polars_plugin        # registers the namespace

lf.select(pl.col("amount").arrowmetal.sum())
lf.with_columns(pl.col("name").arrowmetal.upper())
lf.select(pl.col("amount").arrowmetal.filter_sum(pl.col("region") == 2))
lf.group_by("k").agg(pl.col("v").arrowmetal.sum())

These are real Polars expressions: they compose with select, with_columns, filter, group_by, over, and they take part in projection and predicate pushdown. That is the whole point of the tier -- tier 1 needs a materialised frame, tier 2 does not.

Expression Output Registered as
.sum() Int64 / UInt64 / Float64 returns_scalar
.min() / .max() the column's own dtype returns_scalar
.mean() Float64 returns_scalar
.filter_sum(predicate) as .sum() returns_scalar
.top_k(k) / .bottom_k(k) the column's own dtype changes_length
.hash64() UInt64 is_elementwise
.contains(p) / .starts_with(p) / .ends_with(p) Boolean is_elementwise
.upper() / .lower() String is_elementwise
.add(x) / .sub(x) / .mul(x) / .truediv(x) the column's own dtype is_elementwise
.group_by_sum(values) Struct{key, sum}, one row per group changes_length
.device() String, one row returns_scalar

.filter_sum is the shape that pays for itself: one GPU compaction plus one reduction, with the filtered column never crossing back into Polars.

What tier 2 accepts

Narrower than the bridge, and not the same list as the "Types" paragraph under Limits -- that one is about what round-trips, which is a tier-1 and tier-3 question.

Expression Dtypes
.sum() / .min() / .max() / .mean() / .filter_sum() / .top_k() / .add … / .group_by_sum() Int8/16/32/64, UInt8/16/32/64, Float32/64 -- nothing else
.hash64() those, plus Boolean, Date, Datetime, Duration, Time and String
.contains / .starts_with / .ends_with / .upper / .lower String

Everything else -- Boolean, the temporal types, Binary, Categorical, Enum, Decimal, List, Struct, Null -- raises a Polars ComputeError whose message starts arrowmetal:. Nothing panics through pyo3. There are two places tier 2 is behind tier 1. Categorical and Enum: the Python bridge recodes Polars' dictionary<uint32> index buffer to int32 for the GPU, and polars-plugin/src/bridge.rs does not, so a Categorical column reaches the kernel as-is and is refused with "dictionary indices must be int32 or int64". And Decimal: am_decimal_op backs s.arrowmetal.sum() in tier 1, and the plugin does not reach for it.

The scalar in .add / .sub / .mul / .truediv

"Integers wrap" is about the arithmetic: 127 + 1 is -128 on an Int8 column, and integer division by zero is 0. It is not about the operand. A scalar the column's type cannot hold exactly is an error, the same call the tier-1 bridge makes (it packs the scalar with struct.pack at the column's own width, and struct.pack raises):

pl.col("i8").arrowmetal.add(1000)      # raises: 1000 is out of range for an Int8 column
pl.col("u8").arrowmetal.add(-1)        # raises: -1 is out of range for a UInt8 column
pl.col("i64").arrowmetal.add(1.5)      # raises: an integer column takes an integer scalar
pl.col("i64").arrowmetal.add(2**60+1)  # exact -- the scalar does not go through an f64

A float column takes either an integer or a float, and an operand too large for Float32 becomes an infinity, which is what struct.pack("f", 1e300) gives tier 1.

group_by_sum, and what the plugin API cannot do

pl.col("k").arrowmetal.group_by_sum(pl.col("v")) returns a struct column of n_groups rows:

df.select(pl.col("k").arrowmetal.group_by_sum(pl.col("v")).alias("g")).unnest("g")

It has to be a struct because a plugin function answers with a single Series. And it runs as a projection over the whole frame, not inside df.group_by(...).agg(...): Polars' expression plugin API (polars.plugins.register_plugin_function) registers expressions, and has no hook for contributing a hash aggregate to the group-by engine itself. The flags it accepts are is_elementwise, changes_length, returns_scalar, cast_to_supertype, input_wildcard_expansion and pass_name_to_apply -- none of them says "I am an aggregation the group-by engine should call per group". df.arrowmetal.group_by(...) (tier 1) is the ergonomic spelling; pl.col("v").arrowmetal.sum() inside .agg(...) also works and is called once per group, which is the wrong granularity for a GPU on small groups.

How the plugin gets the data across

polars-plugin/src/bridge.rs, in both directions:

Series --rechunk--> polars_arrow::ffi::ArrowArray --am_import--> am_array (Metal-resident)
am_array --am_export--> ArrowArray --import_array_from_c--> Series

polars_arrow::ffi::ArrowArray and arrowmetal_sys::ArrowArray are both #[repr(C)] transcriptions of the same C struct, so the hand-off is a pointer cast. Ownership follows the C Data Interface's consumer-releases rule: am_import takes over the exported array (so the Rust side mem::forgets its copy, exactly as the Python binding does after _export_to_c), and import_array_from_c takes over the exported one coming back.

Strings go through CompatLevel::oldest() -- Arrow LargeUtf8, not the Utf8View layout Polars uses natively. That conversion is the plugin's only copy, and it is why the plugin's string row below is 2.3x where tier 1's, which takes the column as views, is 17.6x. Tiers 1, 3 and 4 hand String columns over as Utf8View, which the kernels read directly (the Strings paragraphs under Numbers and Limits).

arrowmetal-sys is a hand-written transcription of the header, not bindgen output: the surface is small, the header is stable, and a checked-in file needs no libclang on the build machine. cargo test in polars-plugin/arrowmetal-sys runs 10 tests against the real dylib (import, export, reductions, compare + filter, scalar arithmetic, top-k + take, hash64, group-by, and the error path).


Tier 3 -- the streaming hand-off, and Polars' engine hook

lf = pl.scan_parquet("trades/*.parquet").filter(pl.col("day") == "2026-09-01")

lf.arrowmetal.collect_gpu(am.filter(am.col("region") == 2).sum(am.col("amount")))
lf.arrowmetal.collect_gpu(lambda df: df.arrowmetal.group_by("k").sum("v"))
lf.arrowmetal.collect_gpu()                     # just lf.collect()

collect_gpu collects the Polars plan and then runs one ArrowMetal pass over the result. Its engine= and any other keyword go straight to LazyFrame.collect, so the Polars half can still use the streaming engine: projection pushdown, predicate pushdown and slice all happen before a single byte reaches the GPU. It is an explicit hand-off. Polars' own engine= hook is the other way in, and tier 4 uses it; this section is what the hook is, read from the installed package.

The engine= hook, as it stands in polars 1.44.1

Read from the installed package, not from memory:

  • polars/_typing.py:443 -- EngineTypeName: TypeAlias = Literal["auto", "in-memory", "streaming", "gpu"], and EngineType: TypeAlias = Union[EngineTypeName, "Engine"]. So collect(engine=...) does accept an Engine object, not only a name.
  • polars/lazyframe/engine.py:77 -- class Engine(ABC), documented as "Subclass this to plug a new backend into Polars", with an abstract name property, a plan_engine property, and collect / execute / collect_async / collect_batches / the sink_* family.
  • polars/lazyframe/engine.py:330 -- class _LocalEngine(Engine), "Base for in-process engines backed by PyLazyFrame". Its collect ends in wrap_df(ldf.collect(self.name, callback)).
  • The callback comes from _LocalEngine._post_opt_callback(*, background, eager), typed PostOptCallback | None where PostOptCallback: TypeAlias = Callable[[Any, int | None], None] (polars/_typing.py:450). The base returns None.
  • polars/lazyframe/engine.py:884 -- class GPUEngine(_LocalEngine) with _name = "gpu". Its _post_opt_callback imports cudf_polars and returns partial(cudf_polars.execute_with_cudf, config=self). It refuses background collection and opts out in eager mode.
  • polars/lazyframe/engine_config.py:28 -- SUPPORTED_ENGINE_NAMES = ("auto", "in-memory", "streaming", "gpu"), and _engine_from_name maps the string "gpu" to GPUEngine().
  • The callback's first argument is a NodeTraverser (polars/_plr.pyi:2595), whose surface is: get_exprs(), get_inputs(), version(), get_schema(), get_dtype(expr_node), set_node(node), get_node(), set_udf(function, is_pure=False), view_current_node(), view_expression(node), add_expressions(expressions), set_expr_mapping(mapping), unset_expr_mapping().

So a Metal backend is not blocked on Polars adding an API -- the API is there, and tier 4 below is built on exactly this surface. One fact about names: the engine's name is passed to Rust as ldf.collect(self.name, callback), and Rust only knows the four in SUPPORTED_ENGINE_NAMES (a fifth string raises ValueError). When a callback is supplied, Rust invokes it for any known name, "in-memory" and "streaming" included (checked on 1.44.1), and an Engine object passed to collect(engine=...) bypasses the Python-side name check. So a third-party engine runs by passing "in-memory" to Rust and reporting itself through plan_engine; what it cannot do is carry its own name through Rust, so explain and the callback's error message ('cuda' conversion failed) name the wrong engine.

collect_gpu stays the explicit form of the same idea: Polars owns the plan, ArrowMetal owns one pass over the result.


Tier 4 -- MetalEngine, a Polars engine

import polars as pl
import arrowmetal as am

engine = am.MetalEngine()
df = lf.collect(engine=engine)      # the same frame lf.collect() returns
print(engine.last_report)           # which nodes ran on Metal, and why the rest did not

MetalEngine is python/arrowmetal/polars_engine.py. Polars optimises the plan as it always does and hands the optimised IR to the engine's post-optimisation callback. The callback walks every node and expression, translates the subtrees it can into an ArrowMetal plan (the grammar in Query engine), and replaces each one with a function that runs that plan on the GPU and returns a Polars DataFrame. Everything it does not take stays with Polars' in-memory engine (on polars 2.0.0 too, where a plain collect() runs the streaming engine), which also runs whatever sits above a replaced subtree. Every plan collects; the most that can happen is that nothing moves, and then the answer is plain Polars'.

import arrowmetal still does not import Polars: am.MetalEngine loads the module on first touch, like the other three tiers.

How it plugs into Polars 1.44.1 and 2.0.0

Read from the installed package (polars 1.44.1, and where it says so 2.0.0) and checked by python/tests/test_polars_engine.py, which passes on both:

  • lf.collect(engine=<an Engine object>) passes the object through unchanged, so no Polars change is needed. The name the engine gives Rust is "in-memory": Rust accepts only its four engine names, and with a callback supplied it runs the callback for any of them; the in-memory engine is also what runs every node the callback leaves. engine.name is "in-memory", engine.plan_engine and repr(engine) say metal.
  • The callback receives the NodeTraverser and, on polars 1.44.1, a second argument that is None under collect and an integer under profile (the time since the query started, which the engine uses to place its rows in the profile). polars 2.0.0 passes the NodeTraverser alone.
  • On polars 2.0.0 collect() without engine= runs Polars' streaming engine (on 1.44.1 the in-memory engine). MetalEngine gives Rust the name "in-memory" on both, so the nodes it leaves run on the in-memory engine.
  • set_udf turns the current node into a PythonScan whose function Polars calls as f(with_columns, predicate, n_rows, should_time). That function takes no input, so a replaced subtree is a leaf: the only subtrees that can move are ones whose leaves are in-memory frames (DataFrameScan) or Parquet files the engine reads itself (Scan, below). Any other scan (PythonScan, a CSV or IPC Scan) stays with Polars, and so does everything above it.
  • view_current_node raises NotImplementedError: ipc scan for a scan_ipc node. The engine leaves such a node, and everything above it, to Polars and names it in the report.
  • Polars does not check the replacement's output. The engine does: a frame whose schema is not the one get_schema() promised raises ArrowMetalError inside the query.
  • An exception from the callback reaches the user as ComputeError: 'cuda' conversion failed: <Type>: <message>; the 'cuda' is hardcoded in Polars. The engine's own messages start with ArrowMetal MetalEngine: so they read correctly inside it, name the node (Sort#3) and the reason, and end with the way to run the plan on Polars instead (FORCE_POLARS: collect without engine=, or set ARROWMETAL_METAL_ENGINE=off).
  • LazyFrame.profile(engine=...) passes the callback only for a GPUEngine, so lf.profile(engine=MetalEngine()) profiles plain Polars. engine.profile(lf) passes the callback through profile's own keyword, and each replaced subtree appears as a metal:<Node>#<id> row. Polars 2.0 removed LazyFrame.profile; on polars 2.0.0 engine.profile(lf) raises NotImplementedError.
  • Only collect and the paths built on it run the callback; the next section lists every path and what the engine does on it.
  • The IR versions the engine was written against, TESTED_IR_VERSION = ((14, 7), (15, 2)) (polars 1.44.1 and 1.44.2 report (14, 7), polars 2.0.0 reports (15, 2)), are pinned by a test, as is every Polars surface it touches (_LocalEngine, _post_opt_callback, the NodeTraverser methods, the node classes), so a Polars upgrade that moves one fails a named test instead of changing an answer. An IR major that neither tested version has makes the engine leave the whole plan to Polars; a newer minor of a tested major runs and warns once, naming the tested version of that major. A node kind outside KNOWN_NODE_KINDS (the 20 node classes of polars 1.44.1; polars 2.0.0 has 19, the same less ExtContext, since 2.0 removed LazyFrame.with_context) or a node Polars fails to show to the engine also keeps the whole plan with Polars: the report line is The plan holds an unknown node <kind> in polars <version>, so the whole plan stays with Polars., and the query is not an error.
  • ARROWMETAL_METAL_ENGINE=off (or 0, false, no, polars) makes every MetalEngine leave every plan to Polars, raise_on_fail=True included; the report says so.

Collect paths

What each Polars 1.44.1 entry point does with a MetalEngine (eng below), read from the installed package and checked by test_every_collect_path_is_explicit and test_polars_side_entry_points_that_never_call_the_engine. last_report.path names the path; a path on which the whole plan runs on Polars says why in last_report.fallbacks and issues a MetalEngineFallbackWarning (a UserWarning) once per process. On polars 2.0.0 the same tests pass apart from the two profile rows, which they skip: Polars 2.0 removed LazyFrame.profile, and eng.profile(lf) raises NotImplementedError there.

Entry point Runs on last_report.path Warning
lf.collect(engine=eng) Metal, the subtrees the engine takes collect --
lf.head(n).collect(engine=eng), and lf.fetch(n, engine=eng) (deprecated; it is head(n).collect) Metal collect --
lf.collect() or df.lazy().collect() under pl.Config(engine_affinity=eng) or pl.Config.set_engine_affinity(eng) Metal collect --
lf.collect(engine=eng) inside any other pl.Config context (tbl_rows, engine_affinity="streaming", ...) Metal collect --
pl.collect_all(lfs, engine=eng) Metal, frame by frame: each frame is optimised and collected on its own, where Polars' collect_all optimises the frames together collect_all; last_reports holds one report per frame --
eng.profile(lf) Metal, with a metal:<Node>#<id> row per replaced subtree profile -- (Polars' own DeprecationWarning for profile)
eng.explain(lf) nothing runs: Polars' optimised plan followed by the report of what would run on Metal (each subtree it would take is still checked over a 64-row prefix) explain --
lf.collect_async(engine=eng) Polars' in-memory engine: Polars passes no engine callback on this path collect_async once
pl.collect_all_async(lfs, engine=eng) Polars' in-memory engine, for the same reason collect_all_async once
lf.collect_batches(engine=eng) Polars, for the same reason collect_batches once
lf.collect(engine=eng, background=True) Polars' in-memory engine (background collection is not supported, as for GPUEngine) background once
lf.sink_parquet, sink_ipc, sink_csv, sink_ndjson, sink_batches with engine=eng, and a lazy=True sink collected through eng Polars: its streaming sink panics on a replaced subtree ("entered unreachable code"), so a plan with a Sink node stays whole sink once
lf.explain(engine=eng) nothing runs: Polars' plan, the same text as lf.explain(); Polars reads only eng.plan_engine not written none: Polars does not call the engine
lf.profile(engine=eng) Polars: polars 1.44.1 passes the callback only to a GPUEngine not written none: Polars does not call the engine
Eager DataFrame methods (df.sort, df.group_by(...).agg, ...) under an engine affinity of eng Polars' in-memory engine, which Polars uses for every eager operation not written none: Polars does not call the engine
lf.collect(engine="in-memory") (or "streaming") under an engine affinity of eng Polars: an explicit engine name wins over the affinity not written none: Polars does not call the engine

collect(background=False) with Polars' eager optimisation flag set asks for no callback either; the report says eager and there is no warning, as for GPUEngine. For code that cannot change its collect calls, ARROWMETAL_METAL_ENGINE=off sends every path to Polars.

What it translates

Polars engine capabilities is the same boundary measured on polars 1.44.1, and ENGINE_CAPABILITIES_POLARS2.md the same generator run on polars 2.0.0 (the engine takes the same 1,326 of 2,523 plans; 63 plans that 1.44.1 accepted are rejected by 2.0.0 itself and show as n/a): 29 plan shapes (filters, projections, with_columns, slice, sorts, top-k, group-by with x as the key and as the value of each aggregate, whole-frame aggregates, the four joins, unique, and Parquet scans) over 29 input dtypes and three null patterns, each run through the engine and marked Metal or Polars with the engine's reason. It is generated by python/tests/engine_capabilities.py, and test_the_capability_table_is_current fails when the committed file differs from a fresh run.

Polars node ArrowMetal Taken when
DataFrameScan scan every column it reads (after Polars' projection pushdown) is an integer, float, Boolean, String, Date, Datetime, Duration or Time
Scan of Parquet the file read on the GPU (am.read_parquet, through the open-file cache), then scan over the columns it returned, with the scan's predicate as a filter one local file (a list or a glob that resolves to one file counts); every column it reads (Polars' projection is the reader's column list) is one of the DataFrameScan types above and a top-level column of the file; no hive partitions, row_index_name, n_rows (a head, tail or slice Polars pushed into the scan), include_file_paths, schema=, deletion files or column mapping; something above it does GPU work. See "Parquet scans" below
Filter filter the predicate translates; Polars' dynamic_pred hints (which sort().head() inserts) are dropped
Select, HStack kept virtual: each output is an s-expression over the physical columns, computed where it is used or in one select at the top every output translates and is numeric or Boolean (a bare column of any carried type is carried)
Select whose every output is an aggregate aggregate each output is one of the aggregates in the last row of the expression table
SimpleProjection no operator: a column list always
Slice limit offset >= 0 (tail counts from the end and stays with Polars)
Sort sort, with limit for a pushed-in slice (sort().head(), top_k, bottom_k) keys are columns of any carried dtype, each one plan key with Polars' null placement and float order as its options; no maintain_order=True together with a slice
GroupBy group_by keys are non-float columns; maintain_order=False; not rolling or dynamic
Join join inner, left, semi or anti; key columns of equal, non-float dtypes (String, multi-column and temporal keys included); nulls_equal=False (null keys never match, on both engines); maintain_order="none"; no pushed-in slice; the output names are the ones ArrowMetal's join gives (left columns, then right columns without a same-named key, the suffix on a collision), which covers Polars' coalescing defaults and left_on/right_on with different names
Distinct (unique) unique keep="first" or "any" (ArrowMetal keeps each group's first row, a valid "any"); maintain_order=False; no float column in the subset
everything else (Union, HConcat, Cache, MapFunction, MergeSorted, ExtContext, Sink, PythonScan, a CSV, IPC or NDJSON Scan, and right, full, cross and as-of joins) -- stays with Polars, named in the report
Expression ArrowMetal
column, alias, typed literal (a Null literal takes the type it meets) (col ...), the literal at Polars' own dtype
+ - *, true division add sub mul div, each operand cast to the result dtype Polars' get_dtype gives; a literal divisor as mul by its reciprocal, which is how Polars divides by a scalar (below); a float multiply by a scalar -1 as negate, as Polars does (below); Float32 goes through binary64 (below)
== != < <= > >= eq ne lt le gt ge; floats in Polars' total order; String against a literal by str_eq (==, != only)
&, |, ^, ~ and_kleene, or_kleene, ne of the two as integers for Boolean xor, bit_and/bit_or/bit_xor on integers, not/bit_not
when/then/otherwise if_else, a null condition taking the otherwise branch
cast only casts that cannot fail or lose a value (integer widening, unsigned to a wider signed type, integers to Float64, 8/16-bit integers to Float32, Float32 to Float64, Boolean to numbers)
is_null, is_not_null, fill_null is_null, is_valid, fill_null
is_in a literal list of 1 to 64 values is_in (numbers) or str_eq terms (strings), null for a null input; a NaN in the list matches NaN rows, as in Polars' total order ((ne x x))
str.starts_with, str.contains(literal=True) or a pattern without regex characters starts_with, contains
sum min max mean count len the plan's aggregates, with the fix-ups below (min/max of a Boolean stay with Polars; a per-group count of a Float64 or Boolean column is a sum of validity bits)

Everything else -- %, //, eq_missing, str.ends_with, regex, windows (over), rank, median, an expression over an aggregate, a String-valued output, a narrowing or fallible cast, true division by a scalar that is not a plain literal, a column name holding a NUL byte (Polars' own Arrow export panics on one) -- falls back, and the report says which one. Categorical, Enum, Decimal, List, Struct, Null, Binary and Object columns in a subtree's input keep the whole subtree on Polars.

Where the answers would differ, and what the engine emits instead

Each line is a differential case in test_polars_engine.py or test_engine_conformance.py, run against Polars itself.

  • Float comparisons. Polars compares floats in a total order: NaN equals NaN and is greater than every number, and -0.0 equals 0.0. The engine adds the NaN terms ((ne x x) is "x is NaN") so the fused comparison gives Polars' answer, nulls included. is_in matches the same way: a NaN in the value list becomes an (ne x x) term, since ArrowMetal's is_in compares with IEEE equality.
  • Float32 arithmetic. The GPU's float adds, multiplies and divides flush subnormals to zero, which Polars does not. The engine computes Float32 + - * / in ArrowMetal's correctly rounded software binary64 and rounds once back to Float32, which is the correctly rounded Float32 result for these four operations, subnormals included. Float64 + - * / is correctly rounded in both.
  • Division by a scalar. Polars divides a column by a scalar as x * (1 / c), with the reciprocal rounded in the result type; that differs from the correctly rounded x / c by at most one ulp, in a share of rows that depends on the divisor (about a third of Float64 rows for / 3.0, none for a power of two). The engine emits the same multiply, so the bits match Polars' (test_true_division_by_a_literal_is_polars_reciprocal_multiply, which also checks zero, infinite, NaN, subnormal and null divisors). A column divisor is a true division in both. Over a column of one row Polars divides element-wise instead (the scalar and the column have the same length), so there the answer is the correctly rounded x / c. The engine emits whichever of the two Polars computes for the row count of the node's input: from the in-memory frame when nothing between it and the division changes the row count by an unknown amount, and otherwise by counting that input when the plan runs, both forms in the plan and the count choosing between them (test_scalar_division_and_minus_one_follow_polars_at_every_length).
  • Multiplying by -1. Polars multiplies a float column by a scalar -1 (on either side, and divides by -1) as a negation, which flips a NaN's sign bit where a multiply keeps the input NaN. The engine emits ArrowMetal's negate there, so NaN rows carry Polars' bits too (test_multiply_by_minus_one_is_a_negation_like_polars, which compares the raw bits). Over a column of one row Polars multiplies, and the NaN keeps its sign; the engine chooses by the row count as for a division.
  • The streaming engine. The two forms above are those of Polars' in-memory engine, the one MetalEngine leaves the rest of a plan to, in polars 1.44.1 and 2.0.0 alike. Polars' streaming engine, the default of collect() on 2.0.0, evaluated frames of 2 and 9 rows element-wise on both versions (the correctly rounded x / c, and a NaN that keeps its sign under * -1), and frames of 100 rows and more as the in-memory engine does.
  • Aggregates. A sum over no values is 0 in Polars (ArrowMetal: null) and gets a fill_null; a min/max over only NaN is NaN in Polars (ArrowMetal: null over a whole frame, an infinity per group), so the engine counts the non-null and non-NaN values and decides from the two; a mean of an Int64/UInt64 column is planned over the values cast to Float64, because ArrowMetal's integer mean sums in 64-bit integers and wraps on extreme values where Polars does not (the executor runs it from the integer column in one pass when a min/max pass shows every value below 2^53 and the sum below 2^63, and over the cast values otherwise; the two give the same bits); every result is cast to Polars' dtype (UInt32 counts, the Int32 sum of an Int32 column, Float32 of a Float32, UInt32 for the sum of a Boolean). A per-group count of a Float64 or Boolean column is the sum of its validity bits, because ArrowMetal's group-by will not read those values even to count them, and min/max of a Float64 column per group stays with Polars for the same reason. Polars' min and max order -0.0 below 0.0, so a min over both zeros is -0.0 and a max 0.0; ArrowMetal treats the two as equal and returns whichever it met first over a whole frame, and 0.0 per group. The engine counts the zeros of the sign Polars prefers and takes the sign from that count (test_min_and_max_over_both_zeros_are_polars_signed_zeros).
  • Float sums and means. The one place the answers differ, and the engine leaves it: a Float32 or Float64 sum, and a Float64 mean, add the same values in the GPU's order where Polars adds in its own, so the two can differ in the last bits. Both are sums of the same values, so they differ by at most twice the rounding bound of a sum, 2(n - 1) u sum(|x|) for a sum of n values and 2 u sum(|x|) for a mean (u the unit roundoff of the type the sum accumulates in: 2^-24 for a Float32 sum, 2^-53 otherwise), and the engine conformance grid holds each such case to that bound. In the run recorded in Benchmarks/results/engine_conformance_2026-10-02_report.txt (totals in engine_conformance_2026-10-02.csv) the largest difference was 0.199 u sum(|x|) for a Float32 sum (3.55e-5 of the answer), 0.372 u sum(|x|) for a Float64 sum (2.00e-14 of the answer) and 0.0033 u sum(|x|) for a Float64 mean (7.05e-14 of the answer). A mean of an integer or Float32 column is computed in Float64 by both (the engine plans the cast as Polars does, and runs an integer mean from the integer column where that gives the same bits); in that run it matched bit for bit in every case but one int64 group mean over the integer extremes (1,000 rows), where the engine returns the correctly rounded mean of the cast values and the two differ by 8.76e-8 u sum(|x|), as much as the answer itself. Every other output of the grid is compared bit for bit. test_polars_engine.py compares these aggregates to a relative tolerance.
  • Sort order. Polars (1.44.1 and 2.0.0 alike, pinned by test_polars_sort_order.py) places the nulls per key, first unless nulls_last, in either direction; orders a float key with every NaN (of either sign) one value above every number, +inf included, in both directions, and -0.0 equal to 0.0; keeps tied rows in input order in both directions under maintain_order=True, a tie falling through to the next key; orders String keys byte-wise and Boolean keys False first. top_k is a descending sort with nulls_last=True and a slice, bottom_k the ascending one. The engine writes each Polars key as one ArrowMetal key whose options give that order: "nulls": "first" where the nulls go first, and "float_order": "nan_largest" on a float key (docs/ENGINE.md, "Sort key options"). No key column is added, and a single-key sort with a slice stays a GPU top-k.
  • Integer overflow wraps in both (checked on Int8 and Int64 extremes), and an integer true division by zero is IEEE in both.

ArrowMetal behaviours this suite found, now fixed in the engine

Each was first worked around here and pinned by a strict xfail; each is now fixed in ArrowMetal, its test in test_polars_engine.py passes, and the workaround is gone (test_engine_takes_the_plans_it_once_worked_around runs the plans the engine once changed or declined):

  1. A null String slot with bytes under it. Polars exports a null String value with the bytes the slot held (valid Arrow), and ArrowMetal's String gather copied those bytes over the next kept value (test_core_string_filter_null_slot). The engine now hands such a column over as exported.
  2. A Boolean column through the plan's sort came back with the right validity bitmap and a null count of 0 (test_core_bool_sort_null_count). Result columns are now used as ArrowMetal returns them.
  3. A filter over a scan that carries a date32 column was rejected by the expression compiler (test_core_filter_carrying_a_date). Temporal columns now go to ArrowMetal as their own types.
  4. Sorting by a String column that holds a null and a value of 8 bytes or more returned wrong rows (test_core_string_sort_with_nulls, run in a child process because one run ended in a bus error). The engine now sorts by String columns.
  5. A finite float literal of magnitude 2^63 or more trapped the process inside the expression compiler, which converted every float literal to Int64 as well. The engine now runs those plans on Metal (test_a_float_literal_of_magnitude_2_63_or_more_runs_on_metal).
  6. A UInt64 literal above 2^63 - 1 (found by the conformance grid below, not worked around first: is_in, a comparison or fill_null against a large unsigned value) was rejected by the expression parser, which read every integer literal as an Int64, and the plan stayed with Polars. The parser now keeps such a literal as its bit pattern, the way the code generator writes unsigned literals (test_a_u64_literal_above_int64_max_reaches_the_gpu, and testWideUnsignedLiteralsParsePrintAndStayUnfolded in Swift).

Which translatable subtrees it runs: the defaults

A subtree the engine can translate still has to be one where the GPU is ahead, because getting a Polars column onto the GPU is not free: a single-chunk numeric column is imported without a copy, but mapping its pages into Metal and releasing them costs time on every query. String columns are handed over in Polars' own view layout (see "Strings" under Limits), in the crossover sweep and in the benchmark of the defaults below alike. MetalEngine() (shapes="measured") decides per subtree from measured crossovers (python/arrowmetal/_engine_policy.py). Each translated subtree has shape classes:

  • rowwise -- filters and projections only;
  • aggregate:<family> (a whole-frame aggregate), group_by:<family> (one key) and group_by_multi:<family> (two or more keys), the families being sum, count (count and len), mean and minmax;
  • sort (a full sort) and top_k (a sort with a slice);
  • join:inner, join:left, join:semi, join:anti, and distinct (unique).

It also has a dtype class, string when a String column is among the columns it reads and numeric otherwise, and an input, in-memory frames or a Parquet file. A subtree runs on Metal when its input rows (the rows of its in-memory frames, or of its Parquet file as the footer states them; what a predicate will keep is not estimated) are at or above the crossover of every class in it for its dtype class and input; a group-by is judged at the bucket of its estimated number of groups ("Group-bys: the number of groups", below). A class whose row says not taken stays with Polars at every size. The decision reads nothing but the subtree's classes, dtypes, input rows and input, a group-by's estimate and the tables, and the estimate comes from a fixed-seed sample, so the same subtree over the same frame always gets the same answer (python/tests/test_engine_policy.py checks both across 200 calls and across processes). Where the policy leaves a node, the placement moves down to its inputs, so a smaller subtree below it can still be taken: under a whole-frame sum over an inner join, the join runs on Metal and Polars adds up its output.

The sweep, the fits, the tables and the benchmarks from here to On polars 2.0.0 below are polars 1.44.1's, the measurements behind the Polars 1.x table; the Polars 2.x table and its measurements are under On polars 2.0.0.

The crossovers. Benchmarks/polars_engine_bench.py --crossover ran 93 LazyFrames over in-memory frames, the 45 cases below and a group-by grid of 48 (each aggregate family over one int32 key and over two, the keys drawn from 200, 1,000, 10,000, 100,000 or 1,000,000 values or from half the frame's rows, (g1sum200) to (g2minmaxR2)), at 250,000, 500,000, 1,000,000, 2,000,000, 5,000,000, 10,000,000, 20,000,000 and 50,000,000 rows (the probe side of the joins grows with the size, the build side is 1,000,000 rows), and the four Parquet scan cases over files of 1,000,000, 2,000,000, 5,000,000, 10,000,000, 20,000,000 and 50,000,000 rows, snappy and uncompressed, each through Polars' in-memory and streaming engines and through MetalEngine(shapes="all", min_rows=0) cold, best of 7, every MetalEngine result equal to Polars': Benchmarks/results/polars_engine_crossover_2026-09-26-final.csv, run conditions (the load average, sampled each minute) in Benchmarks/results/bench_conditions_2026-09-26-final.txt. Each group-by row of the results file also records the number of groups the case's data holds at that size (groups). The in-memory sort and top-k cases ((b), (m), (n), (o), (p), (q), (x2), (x3), (y6), and (x4), a top 100 by a nullable Float64 key descending, added then) were swept again on 2026-09-29, after each Polars sort key became one plan key with Polars' null placement and float order as its options (see "Sort order" above), at the same eight sizes, best of 7: Benchmarks/results/polars_engine_crossover_2026-09-29-sort.csv holds those rows and the 2026-09-26 rows of every other case, run conditions in Benchmarks/results/polars_engine_crossover_2026-09-29-sort_conditions.txt. The group-by cases that run the grouped Float64 sum and mean or the grouped min/max ((c), (l), (t3), (t4), (t6), (t7), (v3), (v4) and the grid's mean and min + max cases; a mean of an int64 column then ran as a Float64 mean, the engine casting it first) were swept again on 2026-09-30, after both were rebuilt, at the same eight sizes, best of 7, and the grid gained a Float64 sum over each of its key sets and group counts ((g1fsum200) to (g2fsumR2), a sum of q / 1e9): Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv holds those rows and the earlier rows of every other case, run conditions in Benchmarks/results/polars_engine_crossover_2026-09-30-groupby_conditions.txt, and the table is fitted from it. Benchmarks/polars_engine_crossover.py fits it the way Benchmarks/router_table.py fits the router table (python/arrowmetal/_router_fit.py), with the MetalEngine as the GPU side and the faster Polars engine as the CPU side, over each case's input rows. A case's fit is the first size from which it is ahead at every larger size, placed between that size and the one below it where the two straight lines meet; a case ahead at the largest size alone has none, and a size where the MetalEngine's answer differed from Polars' counts as behind.

Four rules on the fit. The table keeps each case's and each bucket's fit as fit, and the rows the default uses as rows:

  • The margin (MARGIN). A case is ahead at a size when its MetalEngine time, raised by a margin, is at most the faster Polars engine's time: 15% for a numeric shape, 35% for a shape with a String column. A String shape's advantage grows slowly with size: the String sort (o) is 0.98x the faster Polars engine at 1,000,000 rows in the 2026-09-26 sweep (Benchmarks/results/polars_engine_crossover_2026-09-26-final.csv, vs_fastest_polars), 1.2x at 2,000,000, 1.37x at 5,000,000 and 1.5x at 10,000,000, so a fit at 15% would land where the two engines are within noise of each other.
  • The String floor (STRING_FLOOR). A shape with a String column is taken from 5,000,000 rows at the earliest, whatever its fit says. The floor is set from the 2026-09-26 runs, where the String sorts below it are close to Polars: (o) 1.2x and (p) 1.38x at 2,000,000 rows in the 2026-09-26 sweep, and 1.21x and 1.31x under shapes="all" at that size in the 2026-09-26 default benchmark (Benchmarks/results/polars_engine_bench_2026-09-26-final3.csv, cold), inside that benchmark's noise band (0.62x to 1.31x); from 5,000,000 rows that sweep has them at 1.37x and up. In the 0.4.0 benchmark below (Benchmarks/results/polars_engine_bench_2026-10-02.csv, cold) they are 1.39x and 1.84x under shapes="all" at 2,000,000 rows, against a noise band of 0.86x to 1.18x.
  • The headroom (HEADROOM). The default takes a shape from 1.5 times its fit (a String shape from the larger of that and the floor), and a shape whose 1.5 times passes the largest size measured is not taken. The fit interpolates between sizes measured 2 to 2.5 times apart (250,000, 500,000, 1,000,000, 2,000,000, 5,000,000 and so on), and shapes just past their crossover were within run-to-run noise of Polars: two runs of the same Polars plan differ by 0.62x to 1.31x in the 2026-09-26 default benchmark and by 0.86x to 1.18x in the 0.4.0 benchmark below (MetalEngine default, cold where it took nothing, against polars in-memory, rows of 5 ms or more in Benchmarks/results/polars_engine_bench_2026-09-26-final3.csv and Benchmarks/results/polars_engine_bench_2026-10-02.csv).
  • The default benchmark (BENCH_RATIO, BENCH). The sweep times each case back to back; the default is also held to Benchmarks/polars_engine_bench.py --idle (a 100 ms warm-up, a first run after 500 ms of idle on its own, another warm-up, then best of 5 and their median), over the files BENCH names (Benchmarks/results/polars_engine_default_groupby_raw_2026-09-30.csv, "Group-bys after the grouped Float64 sum, mean and min/max were rebuilt" below). A class, or a group-count bucket for a case judged by its estimate, of which a case the default took there is behind the faster Polars engine on the best run or on the median is taken only from the smallest benchmarked size above the largest size it was behind at, and not at all when it is behind at the largest. A case of several classes (a mean and a max in one group-by) that is behind counts against those of its classes whose one-class cases at that bucket and size are not all ahead, and against all of them only when every one of its classes has one-class cases there that are ahead, so a class that is ahead on its own keeps its rows and the case itself is still left. The table records each benchmarked row's cases and their ratios as benchmark.

A class's crossover is the largest over the cases whose classes all belong to its node, so every case that measures a class has to be ahead; if one of them has no fit, the class has no crossover. A case whose classes span two nodes, (b) (a group-by under a top-k) and (e) (a whole-frame sum over a join), measures no class. There is one fitted table per Polars major, chosen by the running Polars' major the first time the policy decides, and python -m arrowmetal.polars_engine check names the one in force (compatibility()["crossover_table"]):

Each module records its own SOURCE, BENCH and the sweep's HEADER, which names the Polars version the sweep ran on; polars_engine_crossover.py writes a major's module only from a sweep on that major, and polars_engine_crossover.py --check fails when any module and its results files disagree. The policy then takes the larger of that crossover and the kernels' own: the router table in force (am.router_table(), The crossover) for the kernels it routes, and for the sort classes the crossover of argsort int64, argsort float64 and lexsort (2 int32 keys) against the fastest CPU library in Benchmarks/results/router_2026-09-24.json, 1,000,000 rows. The fits below are the Polars 1.44 table's, from Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv.

class dtype input rows the default uses the fit it came from cases (their fit)
sort numeric in-memory 1,000,000 the sort kernels, above the table's 947,836 ((q) 631,891 x 1.5) (m) 456,986; (q) 631,891; (x2) 250,000, the smallest input measured
sort numeric Parquet 1,500,000 (s4) 1,000,000, the smallest file measured (s4) uncompressed 1,000,000; (s4) snappy 1,000,000
join:left numeric in-memory 1,875,000 (w2) 1,250,000, the smallest input measured (w2) 1,250,000
join:inner numeric in-memory 2,029,827 (w1) 1,353,218 (w1) 1,353,218
join:anti numeric in-memory 3,727,959 (w4) 2,485,306 (w4) 2,485,306
distinct numeric in-memory 5,494,090 (x1) 3,662,727 (r) 1,252,378; (x1) 3,662,727
sort string in-memory 5,000,000 the String floor, above (o) 2,439,140 x 1.5 = 3,658,710 (o) 2,439,140; (p) 1,131,461
distinct string in-memory 5,000,000 the String floor, above (y5) 2,463,352 x 1.5 = 3,695,028 (y5) 2,463,352
group_by_multi:sum string in-memory per group count (below); with no estimate, 9,744,372 (k) 6,496,248 (k) 6,496,248
group_by_multi:minmax numeric in-memory per group count (below); with no estimate, 11,675,406 (v4) 7,783,604 (v4) 7,783,604; (c) 710,717; the grid's two-key min + max 535,292 to 7,023,044
group_by:minmax numeric in-memory per group count (below); with no estimate, 19,740,807 (t4) 13,160,538 (t4) 13,160,538; (t7) 1,312,255; the grid's one-key min + max 473,148 to 13,139,506
group_by_multi:mean numeric in-memory per group count (below); with no estimate, 24,143,946 (v3) 16,095,964 (v3) 16,095,964; (c) 710,717; (l) 778,101; the grid's two-key means 635,715 to 6,615,362
group_by_multi:count numeric in-memory per group count (below); with no estimate, 26,547,327 (j) 17,698,218 (j) 17,698,218; (v2) 710,626; the grid's two-key counts 661,173 to 6,712,670
group_by:sum, group_by:count, group_by:mean, group_by_multi:sum numeric in-memory per group count (below); with no estimate, not taken each has a case with no fit: over one key the 200- and 1,000-group grid cases, (t1), (t2) and (t3); over two keys, (g2sum200)
join:semi numeric in-memory not taken (f) none (f) none; (w3) 2,464,828
aggregate:sum, :count, :mean, :minmax numeric in-memory not taken (a), (a2), (a3), (a4) none
top_k numeric in-memory not taken (x4) none (n) 6,600,767; (x3) 6,210,387; (x4) none
rowwise numeric in-memory not taken (d) none
top_k, rowwise, aggregate:sum, group_by:sum, join:inner string in-memory not taken (y6), (y2), (y3), (y1), (y4) none
group_by:sum, group_by:count, aggregate:sum, aggregate:count numeric Parquet not taken (s1) and (s3) none with both codecs; (s2) uncompressed none, snappy 1,000,000

A class with no row (a String column in any other class, and every Parquet class but these) has no measurement and is not taken. arrowmetal.polars_engine.placement_rules() returns the table under the router table in force, and group_placement_rules() the group-count buckets below.

Group-bys: the number of groups. Where the GPU group-by is ahead depends on the number of groups more than on the rows. In the sweep (Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv, vs_fastest_polars of MetalEngine all, cold) the one-key sum over 200 groups is behind at every size ((t1) at best 0.58x, (g1sum200) 0.51x); over 10,000, 100,000 and 1,000,000 groups it is ahead at 50,000,000 rows by 2.65x, 2.97x and 2.74x ((g1sum10k), (g1sum100k), (g1sum1M)); over about 0.43 times as many groups as rows it is at 1.02x there ((g1sumR2)), inside the margin. So a group-by class is judged at the bucket of its subtree's number of groups:

bucket groups
200 1 to 447
1,000 448 to 3,162
10,000 3,163 to 31,622
100,000 31,623 to 316,227
1,000,000 316,228 to 3,162,277
rows/2 at least a quarter of the input rows, whatever the count

A count between the 1,000,000 bucket and a quarter of the rows has no bucket and stays with Polars. Each bucket is fitted like a class, from the points of the group-by cases whose number of groups at that size falls in it (the grid, (c), (i), (j), (k), (l), (t1) to (t7), (v1) to (v4), (y1), (s1) and (s2)); at each size the bucket's point is the worst of its cases there, so its step is the largest of the cases' own, and a bucket ahead at its largest size alone has no crossover. The grid's 1,000,000-group cases hold a quarter of the rows or more below 5,000,000 rows, where they count in the rows/2 bucket, so the 1,000,000 bucket was measured from 5,000,000 rows, and that is where its fits start. The rows the default uses, with the fit each came from in brackets (the router table's group_by_sum row is below every one of them):

class 200 1,000 10,000 100,000 1,000,000 rows/2
group_by:sum not taken not taken 6,041,361 (4,027,574) 5,252,956 (3,501,971) 7,500,000 (5,000,000) not taken
group_by:count not taken not taken 3,123,562 (2,082,375) 3,801,232 (2,534,155) 7,500,000 (5,000,000) not taken
group_by:mean not taken not taken 2,436,520 (1,624,347) 2,524,249 (1,682,833) 7,500,000 (5,000,000) not taken
group_by:minmax 19,740,807 (13,160,538) 19,709,259 (13,139,506) 2,995,140 (1,996,760) 2,184,147 (1,456,098) 7,500,000 (5,000,000) not taken
group_by_multi:sum not taken 11,993,616 (7,995,744) 2,777,623 (1,851,749) 2,288,101 (1,525,401) 7,500,000 (5,000,000) not taken
group_by_multi:count 5,580,594 (3,720,396) 10,069,005 (6,712,670) 1,082,526 (721,684) 1,118,998 (745,999) 7,500,000 (5,000,000) not taken
group_by_multi:mean 9,923,043 (6,615,362) 9,720,262 (6,480,175) 50,000,000 (1,281,142)* 50,000,000 (635,715)* 7,500,000 (5,000,000) not taken
group_by_multi:minmax 10,534,566 (7,023,044) 8,162,302 (5,441,535) 1,171,750 (781,167) 802,938 (535,292) 7,500,000 (5,000,000) not taken
group_by_multi:sum, String column 9,744,372 (6,496,248) not taken
group_by:sum, String column not taken
group_by:sum, group_by:count, Parquet not taken

An empty cell has no measurement and is not taken. * Raised by the default benchmark (BENCH_RATIO; Benchmarks/results/polars_engine_default_groupby_raw_2026-09-30.csv, the faster Polars engine ÷ the default over every run in which the default took the case): at 2,000,000 rows the default took (c) (a mean and a max over two keys, 10,000 groups) at 0.82x of the faster Polars engine on the best run and 0.75x on the median, (g2mean10k) at 0.87x and 0.75x and (g2mean100k) at 0.65x and 0.57x (both tables' runs where the default took them), all ahead at 50,000,000 rows, so the two-key mean's 10,000 and 100,000 buckets are taken from 50,000,000 rows. (c)'s min + max class keeps its 10,000 bucket: (g2minmax10k), its one-class case there, was 1.18x and 1.09x. The rows/2 bucket is never taken (UNTAKEN_BUCKETS), whatever its fit, because the sweep (Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv) is not monotone there: the one-key count over 0.43 times as many groups as rows, (g1countR2), is 0.78x, 1.9x, 4.04x, 2.21x and 1.18x the faster Polars engine at 2,000,000, 5,000,000, 10,000,000, 20,000,000 and 50,000,000 rows, and the two-key rows/2 cases peak at 10,000,000 rows the same way ((g2countR2) 6.47x there, 2.38x at 50,000,000). (j), (v3) and (v4), whose keys range over 100,000 x 1,000 values and which hold 0.79 to 1.0 times as many groups as rows in the sweep, are 1.22x, 1.17x and 1.51x at 50,000,000 rows. The grid's Float64 sums set two sum buckets: the one-key 10,000 bucket ((g1fsum10k) 1.04x at 2,000,000 rows and 1.25x at 5,000,000, then 2.63x at 10,000,000) and the two-key 1,000 bucket ((g2fsum1k) 0.74x at 5,000,000 and 1.88x at 10,000,000). The one-key min + max over 200 and 1,000 groups is taken from about 20,000,000 rows: (g1minmax200) is 1.13x, 1.41x and 1.67x at 10,000,000, 20,000,000 and 50,000,000 rows, (g1minmax1k) 1.06x, 1.27x and 1.64x.

The probe. For a group-by whose keys are columns of one in-memory input frame, read as they are (through filters, projections, joins and unique below it, which can only drop key values, so the frame's count is an upper bound), the engine estimates the number of distinct key tuples in that frame at plan time. It reads the keys of a stratified sample of n of the frame's N rows, row i·(N / n) + h(i) with h a fixed-seed hash, so the same frame always gets the same sample; counts the distinct tuples d and those seen exactly once (f1) and twice (f2); and scales them with the bias-corrected Chao1 estimator (Chao, Biometrics 2005), D = d + f1 (f1 - 1) / (2 (f2 + 1)), clipped to [d, N]. Its range is D with f2 moved by two Poisson standard deviations (and one or two more) either way, the high end being the whole frame while fewer than about 6 pairs have been seen. The samples are 512, 2,048, 8,192 and so on, at most 65,536 rows and a quarter of the frame, and the probe stops at the first whose range the decision does not depend on: every bucket the range reaches is taken at these rows, or none is. So a few hundred groups or ten thousand settle on 512 rows, a hundred thousand on 2,048, and a count near a quarter of the rows takes more. A frame of at most 4,096 rows is counted exactly. One integer key is read as it is; several keys, and a String, Boolean or temporal key or one holding nulls, through Polars' hash. The samples' counts are cached per frame, key columns and sample size, keyed by the columns' buffers (the cache keeps those columns alive, so no other frame can take their addresses while it stands), so collecting the same frame again samples nothing and gets the same estimate; arrowmetal.polars_engine.clear_group_estimates() drops the cache. The probe runs only when some bucket of the class is taken at the input rows; below every bucket's crossover the subtree stays with Polars without one. A Parquet file's footer answers when it states a distinct count for every key column in every row group, and only when the largest row group's count and the sum over row groups fall in one bucket; the benchmark files state none. With no estimate (a key the plan computes, keys from an aggregate below, two group-bys in one subtree, a footer without distinct counts) a group-by class is judged by its row in the class table: group_by_multi:minmax from 11,675,406 rows, group_by:minmax from 19,740,807, group_by_multi:mean from 24,143,946, group_by_multi:count from 26,547,327, the (String, int32) sum from 9,744,372, and every other numeric group-by class at no size.

What the probe costs, and how close it gets. Benchmarks/group_probe_bench.py collects a group-by sum over the grid's frames (one int32 key, two, and one String key, at each group count) through MetalEngine() nine times with its caches cleared and reads the probe's time from the report: Benchmarks/results/group_probe_2026-09-26.csv, run conditions in Benchmarks/results/group_probe_2026-09-26_conditions.txt. Against the fastest of Polars' two engines and the default for the same group-by:

rows groups probe (median of 9) share of the group-by
50,000,000 200 to 100,000 66 to 199 µs 0.30% to 0.77%
50,000,000 1,000,000 279 µs (one key), 461 µs (two) 0.84%, 1.19%
50,000,000 half the rows 255 µs (one key), 458 µs (two) 0.12%, 0.10%
2,000,000 200 to 100,000, two keys 50 to 123 µs 1.09% to 3.17%
2,000,000 a quarter of the rows or more, two keys 366 and 393 µs 4.70%, 4.90%

That file was measured under the earlier group-count table. Under the table in force, a group-by at 2,000,000 rows is probed only for the two-key count and min/max, the classes with a bucket taken at that size; a one-key, String or two-key sum group-by there is not probed, and neither is a String group-by at 50,000,000 (only the (String, int32) sum has buckets). In the default benchmark below, where the probe runs in a process that holds every case's frames (probe_us in Benchmarks/results/polars_engine_bench_2026-10-02.csv, the probes of one collect summed), its median is 150.5 µs at 2,000,000 rows (2.90% of the default's time for the case; 1 of the 14 probes under 1%) and 126 µs at 50,000,000 (0.75%; 51 of 77 under 1%). The seven probes over 5 ms, 6.9 to 12.7 ms, are all at 50,000,000 rows on frames with 0.43 times as many groups as rows or more ((g1minmaxR2) 6.9 ms, 1.41% of the default's time; (g2sumR2) 12.7 ms, 1.99%), and its largest shares are 15.96% at 2,000,000 rows ((g2minmax100k), 390 µs of 2.4 ms) and 14.60% at 50,000,000 ((g2sum1M), 3.8 ms of 26.2 ms). A second collect of the same frame reads its samples from the cache. Sampled until its denominator settles instead of until the decision does (free_estimate in Benchmarks/results/group_probe_2026-09-26.csv), the estimate falls in the bucket of the true count for every frame and key set of the grid at both sizes, within 29% of it up to 1,000,000 groups at 50,000,000 rows, and 24% to 31% above it at half the rows.

Each probe is listed in the report, and the rule of a group-by the policy judged by its estimate names it:

  metal:  GroupBy#1 [GroupBy > DataFrameScan] over 50,000,000 rows, ran in <t> ms -> 100,000 rows
          rule: 50,000,000 input rows is at or above the 2,961,043-row crossover for group_by:minmax at an estimated 102,981 groups over (k1), a Chao1 estimate from a 2,048-row sample, 67,758 to 198,392 (engine table, the 100,000-group bucket, ...)
  groups: GroupBy#1 over (k1): 102,981 groups, a Chao1 estimate from a 2,048-row sample, 67,758 to 198,392 (probed in 132 us)

  polars: GroupBy#1: rule: estimated 191 groups over (region), a Chao1 estimate from a 512-row sample, 189 to 196: below the measured band for group_by:sum at 50,000,000 input rows (taken at 3,163 to 3,162,277 groups; no count in the estimate's range is; ...)
  groups: GroupBy#1 over (region): 191 groups, a Chao1 estimate from a 512-row sample, 189 to 196 (probed in 76 us)

The other classes in the sweep (Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv, vs_fastest_polars of MetalEngine all, cold):

  • Joins and unique on numeric keys: the left join is ahead from the smallest input measured, 1,250,000 rows, and taken from 1,875,000; the inner join is fitted at 1,353,218 and taken from 2,029,827, the anti join at 2,485,306 and from 3,727,959. The semi join is not taken: against a 1,000-row table, (f), it is behind at every size (at best 0.47x), though against a 1,000,000-row table, (w3), it is fitted at 2,464,828. unique is fitted at 1,252,378 rows over 10,000 groups (r) and at 3,662,727 over about as many groups as rows (x1), and taken from 5,494,090.
  • Sorts over in-memory frames are fitted at 250,000 rows (x2, the smallest input measured; 1.42x there), 456,986 (m) and 631,891 (q). The sort kernels' crossover, 1,000,000 rows, is above (q)'s 947,836 and sets sort.
  • Top-k is fitted at 6,600,767 rows by a Float64 key descending (n; 1.86x at 10,000,000 rows, 1.81x at 50,000,000) and at 6,210,387 by an int64 key (x3; 1.1x to 1.35x from 5,000,000), but the top 100 by a nullable Float64 key descending (x4), whose answer is 100 of the null rows, is ahead at no size (0.75x to 1.05x), so top_k is not taken.
  • Whole-frame aggregates and row-wise filters and projections are behind at every size: (a) to (a4) at 0.09x to 0.24x, (d) at 0.14x to 0.42x.
  • String shapes: the sorts (o) and (p) and unique (y5) are taken from the 5,000,000-row floor, and the (String, int32) group-by (k), about 1,000,000 groups from 5,000,000 rows, from 9,744,372. The filter by a String equality (y2, at best 0.13x), the prefix filter and sum (y3, 0.5x), the join on a String key (y4, 0.67x) and the top-k with a String column (y6, 0.88x) are behind at every size; the group-by on a String key with 1,000 values (y1) is ahead by the margin at no size (1.27x at 20,000,000 rows, 1.09x at 50,000,000).
  • Over a Parquet file, cold (the open-file cache cleared before each run), the sort is ahead from the smallest file measured, 1,000,000 rows, with both codecs (4.37x uncompressed and 4.9x snappy there), and taken from 1,500,000. The filter and group-by (s1) has no fit with either codec (uncompressed at best 1.09x, at 50,000,000 rows; snappy 1.42x and 1.26x at 1,000,000 and 2,000,000, then 0.79x to 0.92x), nor has the aggregate (s3) (at best 0.84x); the filter that skips row groups, (s2), is ahead at every size snappy (1.24x to 2.48x) and at none uncompressed (at best 0.99x). So the group-by and aggregate classes over a file are not taken.

The default against Polars and against shapes="all". Benchmarks/polars_engine_bench.py --scan over the 106 in-memory cases at 2,000,000 and 50,000,000 rows and the four Parquet scan cases over the 50,000,000-row files, snappy and uncompressed, 220 case-size pairs, best of 5, every result equal to Polars' (the cold runs of MetalEngine() clear the group-count cache too, so each pays its probe): Benchmarks/results/polars_engine_bench_2026-10-02.csv, run conditions in Benchmarks/results/bench_conditions_2026-10-02.txt. On polars 1.44.1 the default takes a subtree in 75 pairs (11 at 2,000,000 rows and 64 at 50,000,000), 55 of them group-bys, and every one is ahead of the faster Polars 1.44.1 engine, from 1.52x ((g1minmax200) at 50M) to 11.54x ((r) at 50M):

case rows Polars in-memory Polars streaming shapes="all", cold MetalEngine(), cold vs faster Polars
(e) inner join then sum (the join taken) 2M 7.6 ms 5.0 ms 1.8 ms 1.8 ms 2.74
(m) sort 3 columns by an int64 key 2M 12.6 ms 13.9 ms 2.5 ms 2.0 ms 6.40
(q) filter, then sort by (int32 asc, int64 desc) 2M 14.3 ms 14.3 ms 3.5 ms 3.2 ms 4.54
(v2) group-by (region, sub), count 2M 6.1 ms 4.8 ms 2.6 ms 2.7 ms 1.82
(w1) inner join, 1M-row build side 2M 7.3 ms 5.1 ms 3.2 ms 1.6 ms 3.24
(w2) left join, 1M-row build side 2M 9.8 ms 6.3 ms 1.9 ms 1.8 ms 3.46
(x2) sort by a nullable Float64 key, descending 2M 17.8 ms 18.7 ms 3.2 ms 3.2 ms 5.63
(g2count100k) group-by grid, 2 keys, 100,000 groups, count 2M 6.7 ms 5.9 ms 3.0 ms 3.8 ms 1.56
(g2count10k) group-by grid, 2 keys, 10,000 groups, count 2M 5.5 ms 5.0 ms 2.5 ms 2.7 ms 1.88
(g2minmax100k) group-by grid, 2 keys, 100,000 groups, min + max 2M 7.9 ms 6.4 ms 4.2 ms 2.4 ms 2.63
(g2minmax10k) group-by grid, 2 keys, 10,000 groups, min + max 2M 7.3 ms 5.9 ms 3.7 ms 2.4 ms 2.49
(c) group-by (region, sub) mean + max 50M 198.1 ms 104.7 ms 31.8 ms 32.0 ms 3.28
(e) inner join then sum (the join taken) 50M 21.1 ms 15.5 ms 5.9 ms 5.9 ms 2.63
(i) group-by 1 key, 100,000 groups, sum 50M 88.0 ms 46.6 ms 14.4 ms 14.8 ms 3.16
(k) group-by (String, int32), sum 50M 361.9 ms 157.2 ms 50.6 ms 50.5 ms 3.11
(l) group-by (region, sub), Float64 sum + mean 50M 206.8 ms 104.3 ms 29.7 ms 29.8 ms 3.50
(m) sort 3 columns by an int64 key 50M 380.5 ms 451.2 ms 53.6 ms 53.0 ms 7.17
(o) sort with a String column, by an int64 key 50M 395.9 ms 512.9 ms 244.1 ms 243.8 ms 1.62
(p) filter, then sort by (int32 asc, nullable Float64 desc) 50M 936.6 ms 994.0 ms 323.2 ms 316.0 ms 2.96
(q) filter, then sort by (int32 asc, int64 desc) 50M 694.1 ms 708.5 ms 88.1 ms 88.8 ms 7.82
(r) unique over (region, sub), keep first 50M 153.1 ms 162.4 ms 13.8 ms 13.3 ms 11.54
(s4) Parquet scan, sort 2 columns by a float64 key, uncompressed 50M 576.2 ms 574.9 ms 73.9 ms 74.4 ms 7.73
(s4) Parquet scan, sort 2 columns by a float64 key, snappy 50M 582.3 ms 577.4 ms 89.4 ms 94.3 ms 6.12
(t4) group-by 1 key, 200 groups, min + max 50M 16.6 ms 17.3 ms 10.2 ms 10.4 ms 1.59
(t5) group-by 1 key, 100,000 groups, count 50M 68.0 ms 44.6 ms 9.5 ms 9.8 ms 4.53
(t6) group-by 1 key, 100,000 groups, mean 50M 95.9 ms 53.2 ms 23.6 ms 23.5 ms 2.26
(t7) group-by 1 key, 100,000 groups, min + max 50M 119.8 ms 61.0 ms 16.3 ms 16.6 ms 3.68
(v1) group-by (region, sub), sum 50M 178.9 ms 107.5 ms 16.8 ms 16.3 ms 6.59
(v2) group-by (region, sub), count 50M 153.0 ms 95.8 ms 11.3 ms 11.3 ms 8.50
(w1) inner join, 1M-row build side 50M 93.2 ms 82.9 ms 22.2 ms 22.3 ms 3.71
(w2) left join, 1M-row build side 50M 269.3 ms 98.9 ms 25.3 ms 25.2 ms 3.93
(w4) anti join, 1M-row build side 50M 80.8 ms 63.8 ms 25.1 ms 24.7 ms 2.58
(x1) unique over (k1, k2), keep first 50M 755.7 ms 453.2 ms 240.1 ms 240.3 ms 1.89
(x2) sort by a nullable Float64 key, descending 50M 553.0 ms 598.1 ms 96.4 ms 97.0 ms 5.70
(y5) unique over (String, int32), keep first 50M 378.5 ms 255.8 ms 43.4 ms 42.8 ms 5.98
(g1count100k) group-by grid, 1 key, 100,000 groups, count 50M 74.0 ms 47.7 ms 9.7 ms 9.8 ms 4.85
(g1count10k) group-by grid, 1 key, 10,000 groups, count 50M 63.1 ms 56.2 ms 8.7 ms 9.0 ms 6.25
(g1count1M) group-by grid, 1 key, 1,000,000 groups, count 50M 149.5 ms 58.0 ms 11.7 ms 12.0 ms 4.85
(g1fsum100k) group-by grid, 1 key, 100,000 groups, Float64 sum 50M 97.0 ms 55.9 ms 20.0 ms 19.9 ms 2.81
(g1fsum10k) group-by grid, 1 key, 10,000 groups, Float64 sum 50M 88.1 ms 62.3 ms 19.3 ms 18.9 ms 3.29
(g1fsum1M) group-by grid, 1 key, 1,000,000 groups, Float64 sum 50M 177.3 ms 65.5 ms 26.9 ms 26.7 ms 2.46
(g1mean100k) group-by grid, 1 key, 100,000 groups, mean 50M 102.3 ms 55.2 ms 24.1 ms 24.0 ms 2.30
(g1mean10k) group-by grid, 1 key, 10,000 groups, mean 50M 89.3 ms 63.9 ms 22.9 ms 22.7 ms 2.81
(g1mean1M) group-by grid, 1 key, 1,000,000 groups, mean 50M 178.3 ms 68.4 ms 31.3 ms 31.3 ms 2.19
(g1minmax100k) group-by grid, 1 key, 100,000 groups, min + max 50M 118.0 ms 68.3 ms 16.7 ms 16.9 ms 4.03
(g1minmax10k) group-by grid, 1 key, 10,000 groups, min + max 50M 109.4 ms 73.6 ms 15.8 ms 15.7 ms 4.69
(g1minmax1M) group-by grid, 1 key, 1,000,000 groups, min + max 50M 196.3 ms 77.9 ms 19.0 ms 19.9 ms 3.92
(g1minmax1k) group-by grid, 1 key, 1,000 groups, min + max 50M 101.1 ms 18.6 ms 11.2 ms 11.1 ms 1.67
(g1minmax200) group-by grid, 1 key, 200 groups, min + max 50M 16.5 ms 16.9 ms 10.6 ms 10.8 ms 1.52
(g1sum100k) group-by grid, 1 key, 100,000 groups, sum 50M 97.3 ms 54.1 ms 15.0 ms 15.1 ms 3.58
(g1sum10k) group-by grid, 1 key, 10,000 groups, sum 50M 92.1 ms 60.6 ms 14.1 ms 14.1 ms 4.31
(g1sum1M) group-by grid, 1 key, 1,000,000 groups, sum 50M 169.8 ms 59.8 ms 22.5 ms 22.4 ms 2.67
(g2count100k) group-by grid, 2 keys, 100,000 groups, count 50M 166.9 ms 113.9 ms 12.2 ms 12.5 ms 9.12
(g2count10k) group-by grid, 2 keys, 10,000 groups, count 50M 161.6 ms 106.1 ms 11.7 ms 11.7 ms 9.08
(g2count1M) group-by grid, 2 keys, 1,000,000 groups, count 50M 355.4 ms 164.9 ms 14.1 ms 14.8 ms 11.12
(g2count1k) group-by grid, 2 keys, 1,000 groups, count 50M 150.1 ms 43.4 ms 10.1 ms 10.3 ms 4.23
(g2count200) group-by grid, 2 keys, 200 groups, count 50M 38.4 ms 38.5 ms 9.5 ms 11.8 ms 3.26
(g2fsum100k) group-by grid, 2 keys, 100,000 groups, Float64 sum 50M 202.9 ms 125.5 ms 23.3 ms 24.0 ms 5.23
(g2fsum10k) group-by grid, 2 keys, 10,000 groups, Float64 sum 50M 187.6 ms 111.1 ms 22.4 ms 22.0 ms 5.05
(g2fsum1M) group-by grid, 2 keys, 1,000,000 groups, Float64 sum 50M 387.7 ms 181.9 ms 29.8 ms 30.1 ms 6.04
(g2fsum1k) group-by grid, 2 keys, 1,000 groups, Float64 sum 50M 172.0 ms 45.2 ms 15.1 ms 15.7 ms 2.89
(g2mean100k) group-by grid, 2 keys, 100,000 groups, mean 50M 193.6 ms 134.9 ms 27.1 ms 26.8 ms 5.03
(g2mean10k) group-by grid, 2 keys, 10,000 groups, mean 50M 184.2 ms 109.9 ms 25.8 ms 25.7 ms 4.28
(g2mean1M) group-by grid, 2 keys, 1,000,000 groups, mean 50M 387.2 ms 188.4 ms 34.2 ms 34.9 ms 5.40
(g2mean1k) group-by grid, 2 keys, 1,000 groups, mean 50M 177.6 ms 46.9 ms 19.8 ms 19.4 ms 2.42
(g2mean200) group-by grid, 2 keys, 200 groups, mean 50M 41.1 ms 41.3 ms 19.4 ms 19.4 ms 2.12
(g2minmax100k) group-by grid, 2 keys, 100,000 groups, min + max 50M 216.7 ms 138.5 ms 20.0 ms 19.9 ms 6.94
(g2minmax10k) group-by grid, 2 keys, 10,000 groups, min + max 50M 211.0 ms 122.8 ms 18.9 ms 19.0 ms 6.46
(g2minmax1M) group-by grid, 2 keys, 1,000,000 groups, min + max 50M 405.5 ms 216.7 ms 22.0 ms 22.1 ms 9.79
(g2minmax1k) group-by grid, 2 keys, 1,000 groups, min + max 50M 197.0 ms 52.4 ms 13.9 ms 14.2 ms 3.70
(g2minmax200) group-by grid, 2 keys, 200 groups, min + max 50M 44.9 ms 46.3 ms 13.6 ms 13.6 ms 3.31
(g2sum100k) group-by grid, 2 keys, 100,000 groups, sum 50M 196.0 ms 126.7 ms 18.3 ms 18.0 ms 7.02
(g2sum10k) group-by grid, 2 keys, 10,000 groups, sum 50M 191.1 ms 106.7 ms 17.3 ms 17.3 ms 6.18
(g2sum1M) group-by grid, 2 keys, 1,000,000 groups, sum 50M 378.8 ms 176.2 ms 26.3 ms 26.2 ms 6.74
(g2sum1k) group-by grid, 2 keys, 1,000 groups, sum 50M 194.0 ms 44.7 ms 13.3 ms 13.2 ms 3.38

In the other 145 pairs the default takes nothing and runs Polars' in-memory plan; there its time is 0.40 to 1.42 times the polars in-memory row of the same case (wall_ms in Benchmarks/results/polars_engine_bench_2026-10-02.csv), and 0.86 to 1.18 times in the 112 of them where that row is 5 ms or more. That is the spread of two runs of the same Polars plan in this run, and the noise band the ratios above are read against. shapes="all" takes a subtree and is ahead of the faster Polars engine in 64 of those 145 (vs_fastest_polars of MetalEngine all, cold), 47 of them above 1.18x, each left by the default because its class or bucket has a crossover above that size or none:

  • at 2,000,000 rows: two-key min + max and count over about 1,000,000 groups, estimated above every bucket taken for their class at that size, (g2minmaxR2) 3.07x, (g2minmax1M) 2.54x, (g2countR2) 1.68x and (g2count1M) 1.64x; group-bys below the lowest crossover of their class, (g2fsum100k) 2.47x, (g2sumR2) 1.80x, (g2fsumR2) 1.61x, (g2sum10k) 1.58x, (v1) 1.57x, (g2sum100k) 1.51x, (g2fsum1M) 1.48x, (g2sum1M) 1.27x, (g2fsum1k) 1.26x and (g2fsum10k) 1.22x (group_by_multi:sum from 2,288,101), (g2mean100k) 2.41x, (g2mean10k) 2.05x, (l) 2.00x, (c) 1.98x, (g2meanR2) 1.67x and (g2mean1M) 1.65x (group_by_multi:mean from 7,500,000), (g1minmaxR2) 1.80x and (g1minmax1M) 1.56x (group_by:minmax from 2,184,147), (g1fsum10k) 1.75x and (g1fsum100k) 1.35x (group_by:sum from 5,252,956), (g1mean10k) 1.57x and (t6) 1.31x (group_by:mean from 2,436,520) and (g1count10k) 1.35x (group_by:count from 3,123,562); unique (r) 2.07x and (x1) 1.24x (below 5,494,090); the String shapes (p) 1.84x, (o) 1.39x and (y5) 1.31x (below the 5,000,000-row floor);
  • at 50,000,000 rows: group-bys estimated at 11,180,716 to 16,775,168 groups, above every bucket taken for their class, (g2minmaxR2) 2.26x, (g2countR2) 2.11x, (g2sumR2) 1.59x, (g2fsumR2) 1.52x, (g2meanR2) 1.51x, (v4) 1.45x and (g1minmaxR2) 1.34x; group-bys in buckets not taken for their class, (g2sum200) 3.08x and (g2fsum200) 2.70x (two-key sum, 200 groups), (t2) 1.61x and (g1count200) 1.58x (one-key count, 200 groups) and (g1count1k) 1.57x (1,000 groups); the semi join (w3) 2.60x (join:semi is not taken, (f)); the top-k (n) 1.85x and (x3) 1.23x (top_k is not taken, (x4)).

The other 17 are 1.01x to 1.18x, inside the noise band.

Sorts with one key per Polars key. The 2026-09-26 default benchmark (Benchmarks/results/polars_engine_bench_2026-09-26-final3.csv) ran when a nulls-first nullable key and a descending float key still carried an extra key column each. With Polars' order as options of the one key, the sort and top-k cases and four group-by controls were timed old against new, alternating, three rounds of best of 5 after a 100 ms warm-up (Benchmarks/results/polars_engine_sort_options_2026-09-30.csv, run conditions in …_conditions.txt); best and median of the rounds, in ms:

case rows engine before, best / median after, best / median after 500 ms idle, before → after
(x4) top 100 by a nullable Float64 key, descending 50M shapes="all", cold 129.53 / 129.89 11.07 / 11.09 168.14 → 15.84
(x4) top 100 by a nullable Float64 key, descending 50M shapes="all", warm 122.91 / 123.46 4.85 / 4.98 136.94 → 6.97
(x4) top 100 by a nullable Float64 key, descending 2M shapes="all", cold 12.31 / 13.77 2.57 / 2.63 20.43 → 6.72
(n) top 100 by Float64, descending 50M shapes="all", cold 79.93 / 80.10 11.50 / 13.00 103.92 → 33.13
(n) top 100 by Float64, descending 2M shapes="all", cold 8.56 / 10.00 2.17 / 2.36 11.48 → 4.87
(x2) sort by a nullable Float64 key, descending 50M MetalEngine(), cold 134.03 / 134.24 93.54 / 93.96 163.88 → 106.02
(x2) sort by a nullable Float64 key, descending 2M MetalEngine(), cold 15.71 / 16.64 8.79 / 10.67 20.52 → 15.77
(p) filter, then sort by (int32 asc, nullable Float64 desc) 50M MetalEngine(), cold 350.54 / 351.84 317.39 / 317.96 374.03 → 344.01
(p) filter, then sort by (int32 asc, nullable Float64 desc) 2M shapes="all", cold 25.82 / 26.77 16.01 / 16.46 43.11 → 36.91

The faster Polars engine in the same runs (best of the rounds of the new build, new_best_ms of the Polars rows): (x4) 9.75 ms at 50M and 1.21 ms at 2M (streaming), (n) 18.43 ms and 2.23 ms (streaming), (x2) 542.42 ms and 17.54 ms, (p) 978.94 ms and 22.02 ms (in-memory). The top-k cases are still left to Polars by the default (top_k has no crossover, above), so its rows for them are Polars' in either build. The sorts whose keys carry no option ((m), (o), (q), the Parquet (s4)), the top-k by an int64 key (x3), (y6), (b) and the group-by controls (c), (i), (j), (t1) are unchanged: every row that was more than 5% slower in both best and median over the three rounds, all of them on paths this change does not touch, was timed again alone over eight alternating rounds of 100 runs (the rows flagged row re-timed alone in the same file), where before ÷ after is 0.95 to 1.25 in best and 0.96 to 1.22 in median. No row's median process CPU time rose by more than 1.5x.

Group-bys after the grouped Float64 sum, mean and min/max were rebuilt. The 75 group-by cases (the grid's 60, (c), (i), (j), (l), (t1) to (t7), (v1) to (v4)) at 2,000,000 and 50,000,000 rows, MetalEngine() with the table fitted before the 2026-09-30 re-sweep and with the table fitted from it (before the default-benchmark rule), the same build, three alternating rounds of best of 5 after a 100 ms warm-up, the first run after 500 ms of idle recorded on its own: Benchmarks/results/polars_engine_default_groupby_2026-09-30.csv (per row best of the rounds and median of the rounds' medians; every run in …_raw_2026-09-30.csv, run conditions in …_2026-09-30_conditions.txt). Every answer was equal to Polars'. The re-swept table took 57 of the 150 case-size pairs (8 at 2,000,000 rows, 49 at 50,000,000), the earlier one 48, and it left none that the earlier one took. What it took that the earlier one left, in ms (best of the rounds; the earlier table's default ran Polars' in-memory plan there):

case rows faster Polars engine earlier table table in force vs faster Polars, best / median after 500 ms idle
(g2minmax10k) group-by grid, 2 keys, 10,000 groups, min + max 2M 5.50 6.37 4.66 1.18x / 1.09x 11.50
(g2minmax100k) group-by grid, 2 keys, 100,000 groups, min + max 2M 6.14 8.09 5.46 1.12x / 1.12x 13.36
(c) group-by (region, sub) mean + max 2M 5.49 6.97 6.73 0.82x / 0.75x 9.63
(g2mean100k) group-by grid, 2 keys, 100,000 groups, mean 2M 5.82 8.38 8.90 0.65x / 0.57x 14.84
(g1mean1M) group-by grid, 1 key, 1,000,000 groups, mean 50M 58.08 172.67 31.13 1.87x / 2.27x 60.58
(g2mean200) group-by grid, 2 keys, 200 groups, mean 50M 39.25 40.50 19.31 2.03x / 2.03x 58.85
(g1minmax200) group-by grid, 1 key, 200 groups, min + max 50M 16.54 16.82 12.33 1.34x / 1.09x 32.69
(t4) group-by 1 key, 200 groups, min + max 50M 15.61 15.87 12.02 1.30x / 1.13x 26.90
(g1minmax1k) group-by grid, 1 key, 1,000 groups, min + max 50M 16.05 97.29 12.94 1.24x / 1.01x 34.00

Every other pair it took was ahead of the faster Polars engine on the best run and on the median (new_vs_fastest_polars_best and _median in the same file): at 50,000,000 rows from 1.24x ((g1minmax1k)) to 10.69x ((g2count1M)), at 2,000,000 rows from 1.12x to 1.63x ((g2count10k)). Taken and behind at 2,000,000 rows were the two-key means (c) at 0.82x, (g2mean100k) at 0.65x and (g2mean10k), which the earlier table took as well, at 0.87x (5.83 against 5.08 ms; 0.73x under the earlier table). The default-benchmark rule (above) raises the two-key mean's 10,000 and 100,000 buckets to 50,000,000 rows, so the table in force takes 54 of the 150 pairs (5 at 2,000,000 rows, 49 at 50,000,000), every one at 1.0x or more of the faster Polars engine on the best run and the median in this benchmark, and leaves (c), (g2mean10k) and (g2mean100k) at 2,000,000 rows to Polars, (g2mean10k) being the one pair the earlier table took that this one leaves. Those three and (g2minmax10k), which shares (c)'s 10,000-group bucket of the min + max class, re-timed, the table before the rule against the table in force, three alternating rounds (Benchmarks/results/polars_engine_default_groupby_retime_2026-10-01.csv, run conditions in …_retime_2026-10-01_conditions.txt), in ms, best / median, against the faster Polars engine's best of 5.49, 4.98, 5.61 and 5.44 ms:

case before the rule table in force
(c) taken: 8.28 / 9.81 (0.66x / 0.58x) Polars' in-memory plan: 6.83 / 7.49 (0.80x / 0.76x)
(g2mean10k) taken: 6.54 / 7.28 (0.76x / 0.72x) Polars' in-memory plan: 6.78 / 7.22 (0.73x / 0.72x)
(g2mean100k) taken: 8.24 / 8.87 (0.68x / 0.66x) Polars' in-memory plan: 7.39 / 8.32 (0.76x / 0.70x)
(g2minmax10k) taken: 4.63 / 4.83 (1.17x / 1.17x) taken: 4.04 / 4.35 (1.34x / 1.30x)

Left to Polars, MetalEngine() runs Polars' in-memory plan, which uses the CPU cores (77 to 90 CPU-ms against 4 to 6 on the GPU path for these three, new_cpu_ms and old_cpu_ms in the retime file). In this benchmark each engine's timed runs follow Polars' runs of the same case and a 500 ms idle gap, and these three take 4.75 to 9.79 ms (best of 5) under shapes="all", cold or warm, as well (the reported rounds of …_raw_2026-09-30.csv); run back to back, 15 runs of each way of collecting in turn, three rounds and no idle gap (Benchmarks/results/polars_engine_groupby_backtoback_2026-10-01.csv), the default runs them in 2.89 ms (c), 2.41 ms (g2mean10k) and 3.03 ms (g2mean100k) against Polars' streaming engine's 5.48, 5.09 and 5.74 ms, which is where the crossover sweep, measured the same back-to-back way, put them (2.09x, 2.19x and 2.34x at 2,000,000 rows in Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv). In the 150-pair benchmark the pairs whose decision did not change ran the same plan under both tables; the rows of those that were more than 5% slower in best and median under one table than the other ((g1count10k) and (g1count1M) at 50,000,000 rows, taken by both; (g2count100k) at 2,000,000, taken by both; (g1minmax100k) and (t4) at 2,000,000, Polars' plan under both) differ between the two runs of one plan.

Float64 group sums and means at 2^24 groups. The (v3) case of the crossover sweep, a mean over two keys with about as many groups as rows, found ArrowMetal's group-by returning null for most groups at 16,777,216 groups and above (16,777,215 were right): the per-group kernels dispatched one threadgroup per group and the grid wrapped past 2^32 threads. Fixed in the core (Findings against pyarrow, round 13; python/tests/test_group_by_2_24.py), so no engine rule is needed and every group-by shape follows the policy above. The sweep and the benchmark above ran with the fix: under shapes="all" the cases the guard once kept with Polars, (c), (l), (t3), (t6), (v3) and the Parquet cases (s1) and (s2), run on Metal and equal Polars' answers. At 50M rows, under shapes="all", cold (Benchmarks/results/polars_engine_bench_2026-09-26-final3.csv), (c) is 1.78, (l) 2.03 and (t6) 1.21 times the faster Polars engine; (t3) is behind at 35.5 ms against 12.4 and (v3) at 612.0 ms against 421.7.

On polars 2.0.0. Polars 2.0.0 (released 2026-10-06) runs collect() on its streaming engine by default (1.44.1: the in-memory engine), and runs group-bys, joins and sorts faster than 1.44.1. Over the 62 cases the 1.44 default takes at 50,000,000 rows, the faster Polars engine's time on 2.0.0 is 0.72x of its time on 1.44.1 at the median, and 0.25x on (r), unique over two keys (153.1 ms to 38.5 ms); on 2.0.0 the streaming engine is the faster of the two in 56 of the 62. ArrowMetal's times on the same cases are unchanged (median ratio 1.03). Files: Benchmarks/results/polars_engine_bench_2026-10-02.csv (1.44.1) and Benchmarks/results/polars_engine_bench_2026-10-07-polars2.csv (2.0.0, conditions in bench_conditions_2026-10-07-polars2.txt), 50,000,000 rows, best of 5, AC power. A mean over an integer column now runs in one pass from the integer column (CHANGELOG, Unreleased); on 2.0.0 the two-key means (c), (g2mean200) and (g2mean1k) went from 0.98x, 0.88x and 0.97x of the faster Polars engine to 1.41x, 1.26x and 1.41x (polars_engine_bench_2026-10-07-polars2.csv before, polars_engine_bench_2026-10-08-polars2-after.csv after).

The Polars 2.x table, python/arrowmetal/_engine_crossovers_pl2.py, is fitted by the same rules from the sweep on 2.0.0 (Benchmarks/results/polars_engine_crossover_2026-10-08-polars2.csv: the 107 in-memory cases at the same eight sizes, 250,000 to 50,000,000 rows, and the Parquet scan cases at 1,000,000 to 50,000,000 rows, best of 7, one process per size, conditions in polars_engine_crossover_2026-10-08-polars2_conditions.txt) and the default benchmark on 2.0.0 (Benchmarks/results/polars_engine_default_groupby_raw_2026-10-08-polars2.csv: the group-by, unique and sort cases at 2,000,000 and 50,000,000 rows, and the sort, unique and 1,000- and 10,000-group cases at 5,000,000, 10,000,000 and 20,000,000). Where its rows differ from the 1.x table's:

class dtype input Polars 1.x table Polars 2.x table
sort numeric in-memory 1,000,000 10,000,000
distinct numeric in-memory 5,494,090 2,943,034
join:inner numeric in-memory 2,029,827 1,942,068
join:anti numeric in-memory 3,727,959 1,875,000
sort string in-memory 5,000,000 not taken
group_by_multi:sum string in-memory, 1,000,000 groups 9,744,372 9,359,932

join:left (1,875,000), the Parquet sort (1,500,000) and String distinct (5,000,000) are the same in both, and so is every class the 1.x table does not take. The group-count buckets of the 2.x table:

class 200 1,000 10,000 100,000 1,000,000 rows/2
group_by:sum not taken not taken not taken 4,610,202 9,281,986 not taken
group_by:count not taken 15,336,822 20,000,000 3,873,481 7,500,000 not taken
group_by:mean not taken not taken 50,000,000 2,397,246 7,500,000 not taken
group_by:minmax 16,863,831 17,064,774 20,000,000 2,088,660 7,500,000 not taken
group_by_multi:sum not taken 28,976,995 not taken 1,560,030 7,500,000 not taken
group_by_multi:count 15,909,808 12,127,063 20,000,000 1,024,383 7,500,000 not taken
group_by_multi:mean not taken not taken not taken 1,582,216 7,500,000 not taken
group_by_multi:minmax 10,117,528 9,447,895 1,906,210 1,485,513 7,500,000 not taken

With the 2.x table in force (Benchmarks/results/polars_engine_bench_2026-10-08-polars2-refit.csv, the 107 in-memory cases at 50,000,000 rows, best of 5, AC power, conditions in bench_conditions_2026-10-08-polars2-refit.txt) the default takes a subtree in 52, every result equal to Polars', and every one is ahead of the faster Polars 2.0.0 engine, from 1.35x ((e) at 50M) to 7.94x ((q) at 50M), none behind. Some of them:

case Polars in-memory Polars streaming MetalEngine(), cold vs faster Polars
(q) filter, then sort by (int32 asc, int64 desc) 713.18 ms 719.69 ms 89.82 ms 7.94
(y5) unique over (String, int32), keep first 384.01 ms 296.29 ms 42.14 ms 7.03
(g2count1M) group-by grid, 2 keys, 1,000,000 groups, count 378.68 ms 99.04 ms 15.17 ms 6.53
(g2mean1M) group-by grid, 2 keys, 1,000,000 groups, mean 417.40 ms 130.15 ms 24.62 ms 5.29
(r) unique over (region, sub), keep first 141.95 ms 38.01 ms 13.42 ms 2.83
(m) sort 3 columns by an int64 key 373.01 ms 148.79 ms 52.70 ms 2.82
(w1) inner join, 1M-row build side 105.04 ms 57.97 ms 22.63 ms 2.56
(x2) sort by a nullable Float64 key, descending 538.43 ms 173.75 ms 93.54 ms 1.86
(g1minmax200) group-by grid, 1 key, 200 groups, min + max 17.20 ms 17.34 ms 10.54 ms 1.63
(e) inner join then sum 20.76 ms 8.24 ms 6.12 ms 1.35

To improve on polars 2.0.0, left to Polars by the 2.x table (the sweep on 2.0.0, vs_fastest_polars of MetalEngine all, cold, 250,000 to 50,000,000 rows): whole-frame aggregates (a) to (a4) 0.11x to 0.47x; row-wise filters and projections (d) 0.16x to 0.40x; the semi join against a 1,000-row table (f) 0.42x to 0.82x, which keeps join:semi with Polars, the semi join against a 1,000,000-row table (w3, 1.21x to 2.14x) with it; top-k (n), (x3), (x4) and (y6) 0.20x to 1.02x; the join on a String key (y4) 0.24x to 0.48x; group-by sum and mean over one or two keys at 200 to 10,000 groups ((t1), (t3) and the grid's sums, Float64 sums and means) 0.36x to 0.97x at 250,000 and 500,000 rows, in the buckets the table above leaves; and the sort with a String column (o), 0.89x at 50,000,000 rows under the 1.x table on 2.0.0 (244.77 ms against Polars streaming 217.88 ms, Benchmarks/results/polars_engine_bench_2026-10-08-polars2-after.csv), which leaves the String sort class, and with it (p), with Polars. A plan the default leaves runs on Polars' in-memory engine, where a plain collect() on 2.0.0 runs the streaming engine: for the sort (m) at 5,000,000 rows, below the 2.x table's sort crossover, Polars' in-memory engine takes 32.88 ms and its streaming engine 13.55 ms (Benchmarks/results/polars_engine_default_groupby_raw_2026-10-08-polars2.csv).

am.MetalEngine()                          # shapes="measured": the crossovers and buckets above
am.MetalEngine(shapes="all")              # every translatable subtree of at least 1,000,000 rows
am.MetalEngine(shapes="all", min_rows=0)  # everything it can translate (what the tests use)
am.MetalEngine(shapes={"group_by", "sort"})  # the classes named (or their prefix), any size
am.MetalEngine(min_rows=5_000_000)        # the crossovers, and at least 5,000,000 rows
am.MetalEngine(raise_on_fail=True)        # raise instead of leaving anything to Polars

Each subtree the report lists as taken carries the rule that took it, and each node the policy left has a rule: line (here under the Polars 1.x table):

  metal:  Sort#2 [Sort > DataFrameScan] over 2,000,000 rows, ran in <t> ms -> 2,000,000 rows
          rule: 2,000,000 input rows is at or above the 1,000,000-row crossover for sort (crossover sweep, ...)
  polars: Select#3: rule: aggregate:sum was not measured ahead of Polars up to 50,000,000 input rows (...)
  polars: Join#2: rule: 900,000 input rows is below the 2,029,827-row crossover for join:inner (...)

shapes="all" is there for plans the benchmark did not cover and for moving work off the CPU cores: the CPU-ms column of the results file is the process CPU time of each run.

The import cache

Each Polars column the engine imports is kept, keyed by its Arrow type, length, offset and the address and size of every buffer, and the next query that reads the same column reuses the import. That is safe because the cached import holds the exported array and, through it, Polars' own buffer: while an entry lives, Polars can neither free that memory (so no other column can appear at the same address) nor write to it in place (Polars copies a buffer it shares before writing). Only a column that was imported without a copy is cached; a String column (handed over as views, without a copy, but outside the cache's key) and a multi-chunk column (concatenated on every export) are not. Entries are evicted least recently used above a byte budget, a quarter of physical memory by default:

from arrowmetal import polars_engine
polars_engine.import_cache_info()       # {"entries", "bytes", "limit", "hits", "misses"}
polars_engine.import_cache_limit(2 << 30)
polars_engine.clear_import_cache()      # and with it the Polars buffers it kept alive

The "warm" column of the results file is a second collect over the same frame; the defaults above were read from the cold one.

Parquet scans

lf = (pl.scan_parquet("trades.parquet")
        .filter(pl.col("price") > 500.0)
        .group_by("qty").agg(pl.col("weight").sum()))
lf.collect(engine=am.MetalEngine(shapes="all"))

A Scan of one local Parquet file becomes a leaf the engine reads itself, so the subtree above it needs no import at all: the columns are decoded on the GPU straight into Metal memory (Parquet on the GPU) and the plan runs over them.

  • Projection. The columns Polars' projection pushdown left on the Scan are the reader's column list; no other column chunk is touched. Dictionary-encoded columns are read materialised, which is what Polars reads them as.
  • Predicate. Polars pushes a filter into the Scan as its predicate. The engine translates it like any Filter and runs it on the GPU over the rows the reader returns. The comparisons in it that the file's statistics can judge the way Polars compares go to the reader as well, which skips row groups (and, with a page index, pages) that cannot hold a matching row: a comparison of a column with a literal, joined by &, on an integer column (all six operators), a String column (==, !=) or a float column (<, <= and == only, against a literal exact in the column's type). Polars orders NaN above every number, so NaN rows pass >, >= and !=, and writers leave NaN out of min/max; those three never reach the reader for a float column, so != x keeps a NaN in a row group whose statistics say x .. x (the case of apache/arrow#51491). |, functions and comparisons of two columns stay on the GPU filter only. A Filter Polars left directly above the Scan (with its predicate pushdown off) is handed to the reader the same way. scan_parquet(use_statistics=False) hands it nothing.
  • Nulls. A column is treated as nullable unless the footer's statistics say it holds no null (ParquetFile.column_null_count), so a sort key by a column without nulls carries no null placement. The read checks the footer's word, and a file whose data holds nulls its statistics deny fails the query with ArrowMetalError rather than answering differently.
  • The open file is kept. The reader goes through the open-file cache (Parquet on the GPU, "The open-file cache"), keyed by the file's path, inode, modification time and size, so the second query over a file does not map it again, and a rewritten file is read afresh. am.clear_parquet_cache() and am.parquet_cache_limit() control it.
  • Checked once per plan and file. As for in-memory inputs, the plan first runs over a prefix of the file (64 rows of its first row group) and its output schema is compared with Polars'. A file whose stored Arrow schema Polars applies and ArrowMetal's reader does not (one with a different number of fields than the file, which Arrow's own reader ignores) fails that check or names a column the file does not have, and stays with Polars.
  • A bare scan stays with Polars. A Scan with nothing above it that does GPU work is Polars' to read.

What stays with Polars, with the reason in the report: several files (a list, or a glob or directory that matches more than one), hive partition columns, a URL or cloud path (file:// included), row_index_name, n_rows (and a head, tail or slice Polars pushed into the scan), include_file_paths, schema=, deletion files, column mapping, a column whose dtype the plan does not carry (Decimal, Categorical, Enum, List, Struct, Binary, ...), a predicate that does not translate, and CSV and NDJSON scans. polars 1.44.1 cannot show an IPC scan to an engine (NodeTraverser.view_current_node raises NotImplementedError: ipc scan), so scan_ipc stays with Polars as well.

Each taken subtree's report entry lists what the reader did (scans: the file, the filters it was given, row groups read and skipped, pages skipped):

  metal:  GroupBy#2 [GroupBy > SimpleProjection > Scan] over 50,000,000 rows, ran in <t> ms -> 1,000 rows
          parquet /data/bench-none-50000000.parquet: filters [('id', '<', 5000000)], 5 row groups read, 45 skipped, 0 pages skipped

MetalEngine() judges a scan subtree by the same rule as an in-memory one, with the Parquet rows of the crossover table and the file's row count from its footer: a sort of a file of at least 1,500,000 rows is taken, and the filter, group-by and aggregate shapes over a file are not taken at any size ("Which translatable subtrees it runs: the defaults" above). The scan cases below are in line with that: the sort is ahead of both Polars engines cold and warm, and the filter, group-by and aggregate cases are behind cold.

The report

MetalEngine report for collect (polars 1.44.1, IR (14, 7))
  metal:  Sort#3 [Sort > HStack > Filter > DataFrameScan] over 10,000,000 rows, ran in <t> ms -> <n> rows
  polars: Select#1: Function rank has no ArrowMetal translation.

engine.last_report is a MetalPlanReport: taken (one entry per subtree that ran on Metal: the node at its top, the node kinds inside it, the rows it read, the ArrowMetal plan text, and after the run its wall time and output rows), fallbacks (one Kind#id: reason line per node that stayed with Polars for a reason of its own; every translation reason is a full sentence, which test_every_unsupported_reason_is_a_full_sentence checks, and a node the placement policy left has a rule: ... line, as above), walked (every node of the optimised plan), nodes, and path (the collect path it is for, "Collect paths" above). With POLARS_VERBOSE=1 the fallback lines are also issued as a PerformanceWarning. The report belongs to the engine object, so two threads collecting through one MetalEngine overwrite each other's.

Tests

PYTHONPATH=python python -m pytest python/tests/test_polars_engine.py -q
DIFF_QUICK=1 PYTHONPATH=python python -m pytest python/tests/test_polars_engine.py -q   # without the 100,003-row datasets
PYTHONPATH=python python -m pytest python/tests/test_engine_policy.py -q

test_engine_policy.py checks the default policy: the same decision for the same subtree across 200 calls and in a second process, every row of the crossover table one row below its crossover (left, with the reason) and at it (taken), a subtree needing every class's crossover, the String and Parquet rows, the router-table and sort-kernel floors, the overrides, a Parquet scan judged by its footer's row count under a filter that keeps a handful of rows, the report's rule lines, that the committed table is the fit of the results file it names, and the Float64 group-by guard at 2^24 groups. For the group-count buckets it checks that the buckets tile the counts, every bucket one row below its crossover and at it, a losing bucket named below or above the band, an estimate's range taken only where every count in it is, the fallback to the class row without an estimate, where the probe finds a group-by's keys, the probe's estimate in the true count's bucket over one int key, two and a String key at 2,000,000 rows, the same estimate across 200 calls and in a second process, the cache, and the engine taking a group-by in a winning bucket and leaving one in a losing bucket at the same rows.

The differential cases collect each LazyFrame on Polars and through MetalEngine(raise_on_fail=True, min_rows=0, shapes="all") -- so nothing may fall back -- and compare the frames: schema first, then values, exactly for integers, Booleans, Strings, nulls and element-wise floats, with a relative tolerance for float aggregates. The inputs are test_differential.py's generators at 0, 1, 33, 4,097 and 100,003 rows, null ratios 0, 0.3 and 1, its "sliced" and "special" (extremes, NaN, infinities, subnormals) flavours, and two Polars-side shapes, a two-chunk frame and a frame sliced with DataFrame.slice. Every numeric dtype runs through the comparisons, arithmetic, null logic, is_in, casts, group-by and whole-frame aggregates, sorts in both directions with nulls at both ends, and top-k; Boolean logic, String predicates and carried temporal columns have their own cases, and so do the four join kinds on integer, String and two-column keys with null keys and duplicates on both sides (a two-chunk right side among the shapes), suffix collisions, different key names, a join inside a filter-join-aggregate plan, and unique with and without a subset. Each fallback in the tables above has a case that checks the result is still Polars' and the report names the reason. One case reruns test_polars.py and test_lazy.py with every LazyFrame.collect() also collected through the engine (python/tests/metal_engine_everywhere.py) and requires the two to agree.

test_every_collect_path_is_explicit runs every row of the collect-path table and checks where the plan ran, the report's path and reason, and that the warning comes once. test_the_capability_table_is_current regenerates Polars engine capabilities and compares it with the committed file; no cell of it may be a Metal answer that differs from Polars' or an engine exception. The committed table is from polars 1.44.1, IR (14, 7); on a Polars of another IR major (2.0.0) the test is skipped. test_an_unknown_node_kind_keeps_the_whole_plan_on_polars and test_a_node_polars_cannot_describe_keeps_the_whole_plan_on_polars check the fail-closed walk, and test_the_check_command_prints_versions_and_the_capability_header the command below.

python -m arrowmetal.polars_engine check prints the installed Polars version and whether it is one of TESTED_POLARS, the IR version against TESTED_IR_VERSION, whether the callback API is present, the IR node kinds of this Polars the engine does not know, whether ARROWMETAL_METAL_ENGINE is set, and the header of the capability table (in a source checkout), with a line naming both versions when the table was generated on another Polars. It exits 1 when the callback API is missing or the IR major is not a tested one, since then every plan stays with Polars.

The Parquet scan cases (212 tests) run fourteen scan shapes -- filters over every numeric dtype, Polars' float total order, !=, String predicates, projections, group-by on one and two keys, whole-file aggregates, sorts with nulls at both ends, top-k, a join with an in-memory frame and unique -- over one 20,011-row dataset with nulls, NaN, -0.0 and infinities written by pyarrow (snappy; uncompressed with a page index and no dictionary; ZSTD with v2 pages), by Polars and by DuckDB; a filter, a sort and an aggregate over the flat columns of 30 nested fixtures from all three writers; seventeen predicate-pushdown cases on every writer, each checking the answer, the filters the reader was given and the row groups it skipped, including NaN under >, >= and != (the apache/arrow#51491 shape, also on the pageindexnan__pa_constpage fixture); every fallback reason above; the open-file cache's reuse, invalidation and bounds; and footer null counts, including a file whose statistics deny its nulls.

The conformance grid. python/tests/engine_polars_grid.py generates the cases instead of choosing them: every shape the engine translates (filters, select and with_columns expressions, slice, sorts in both directions with nulls at both ends and over two keys, top-k, group-by with each aggregate with the column as the key and as the value, whole-frame aggregates, the four join kinds, unique) over every dtype it carries (the eight integer widths, Float32, Float64, Boolean, String, Date, Datetime in ms, us and ns and with a time zone, Duration in ms, us and ns, Time), with no, 5%, 70% or all nulls, at 0, 1, 7, 1,000 and 100,000 rows, plus the special values (integer extremes, NaN, infinities, subnormals, -0.0). Each case collects through MetalEngine(shapes="all", min_rows=0) and through Polars and compares the frames bit for bit. The sorts cover each direction with the nulls first and last, a stable sort in each direction (maintain_order=True: tied rows, the two zeros and every NaN keep their input order, compared bit for bit), two and three keys with a per-key null placement, a tie on one key falling through to the next, and top-k as sort().head(), top_k, bottom_k and over two keys. In the run recorded in Benchmarks/results/engine_conformance_2026-10-02.csv (the per-shape table in engine_conformance_2026-10-02_report.txt): 15,376 cases, 15,096 identical on Metal, 33 within the float-summation bound above, none different otherwise, and 247 where Polars' optimised plan had nothing to run (all of them sorts over 0 or 1 row, which Polars leaves out). python/tests/engine_report.py --engine polars reruns it and prints the per-shape table.

To verify on your own machine

  1. DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build -c release --product ArrowMetalC, then PYTHONPATH=python python -m pytest python/tests/test_polars.py python/tests/test_lazy.py -q: tiers 1 to 3 still green.
  2. PYTHONPATH=python python -m pytest python/tests/test_polars_engine.py -q, after that fresh build.
  3. In a REPL: import sys, arrowmetal as am and check "polars" not in sys.modules; then collect one plan through am.MetalEngine(shapes="all", min_rows=0) and read engine.last_report.
  4. With POLARS_VERBOSE=1, collect a plan with an unsupported node (pl.col("v").rank()) and see the PerformanceWarning listing it.
  5. am.zero_copy_report(df["v"]) on a single-chunk numeric column of a million rows says the two addresses are the same.
  6. On polars 1.44, out, timings = engine.profile(lf) shows a metal: row, and PYTHONPATH=python python -m arrowmetal.polars_engine check prints the versions it was tested with.
  7. Run PYTHONPATH=python python Benchmarks/polars_engine_bench.py --sizes 2000000,50000000 --out a.csv twice on a quiet machine, compare the two files, and read the taken and rule columns of the MetalEngine default, cold rows against the table above. --crossover with more sizes, then python Benchmarks/polars_engine_crossover.py a.csv --print, fits the table from your own run.
  8. Read this section against engine.last_report on your machine.

Numbers

These are tiers 1 and 2, and tier 4 over a Parquet file at the end; tier 4's measurements over in-memory frames are in Benchmarks/results/polars_engine_bench_2026-10-02.csv and Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv (see "Tier 4" above).

Apple M4 Max, macOS 26.6.2, polars 1.44.1 (16 threads), pyarrow 25.0.1, ArrowMetal 0.1.0. Best of 5 runs after a warm-up, one process, one data set. Every figure below is from Benchmarks/results/polars_bench_50000000_2026-09-07.txt, except the String rows: those are ArrowMetal 0.2.0 with String columns handed over in Polars' view layout, best of 5, Benchmarks/results/polars_bench_strings_view_2026-09-26-quiet.txt (run conditions in Benchmarks/results/bench_conditions_2026-09-26-quiet.txt). Reproduce with:

PYTHONPATH=python python Benchmarks/polars_bench.py 50000000 5

Columns: k Int32 with 1000 distinct values, v Int64, amount Float64, name String drawn from 4096 distinct values. The "Polars" column is Polars' eager idiom; the lazy engine and pyarrow's Acero are on the Compare tab and in the benchmark matrix.

50M rows

Operation Polars tier 1 namespace tier 2 plugin GPU-resident
sum(Int64) 4.1 ms / 4.1 CPU-ms 10.1 ms (0.4x) 8.5 ms (0.5x) 1.1 ms (3.8x)
filter(k == 2) + sum(v) 4.3 ms / 9.4 CPU-ms 13.9 ms (0.3x) 11.8 ms (0.4x) 0.8 ms (5.1x)
group-by sum(v) by 1000 keys 79.3 ms / 1121 CPU-ms 20.1 ms (4.0x) 19.7 ms (4.0x) 1.9 ms, aggregate only, group ids cached
top_k(100) 60.0 ms / 60.1 CPU-ms 18.2 ms (3.3x) 18.0 ms (3.3x) 10.5 ms (5.7x)
string contains (literal) 671.1 ms / 670.8 CPU-ms 38.1 ms (17.6x) 290.4 ms (2.3x) 6.0 ms (112.4x)
string contains (literal), string_layout="offsets" 671.1 ms / 670.8 CPU-ms 316.7 ms (2.1x) 6.3 ms (106.9x)

The String hand-off of that column, 50M rows, in the same run:

String hand-off ms CPU-ms
pl.Series -> Metal, view layout (0 bytes copied, 40 data buffers) 30.0 27.7
pl.Series -> Metal, offsets (large_string) 309.0 308.9
Metal -> pl.Series, view layout 0.0 0.0

Reading the table

  • The "Polars" column is Polars' eager idiom, which is what Benchmarks/polars_bench.py measures and what Benchmarks/results/polars_bench_50000000_2026-09-07.txt records. The published baseline is the parallel run, Benchmarks/results/full_matrix_2026-09-07-parallel.csv, which adds Polars' lazy engine (polars-lazy, a median of 11.5 of the 16 cores); ratios there are lower on the rows where the lazy engine parallelises. The full matrix has both.
  • "GPU-resident" is the same kernel with the column already in Metal memory -- the import is outside the timed region, and, for the group-by rows, the am.group_by([k]) key-mapping pass as well: those rows time the aggregate only. It is what a pipeline that stays on the GPU sees, and it is the column that shows what the kernels are worth.
  • The group-by row is not a like-for-like ratio against Polars, whose 79.3 ms includes its whole hash group-by. The comparable end-to-end figure is in the benchmark matrix: sum by int32 key (1000 groups) at 50M rows is 4.89 ms against Polars lazy's 81.93 ms, 16.8x (Benchmarks/results/full_matrix_2026-09-07-parallel.csv), and 1.73 ms against 20.24 ms, 11.7x, at 10M. The fastest CPU idiom on that row is pyarrow's Acero (pyarrow-threaded), 18.45 ms at 50M, which puts the ratio at 3.8x.
  • The hand-off is the whole difference between the middle columns and the right one. At 50M rows the import is 5.25 ms and the export 0.01 ms, and the run records the import as a no-copy -- the Polars source buffer and the Metal buffer are the same address (zero copy: ... -> SAME). Every tier-1 and tier-2 row pays it once per call, so on a single sum Polars is ahead, and a group-by is 4x.
  • CPU-ms is the other half of the story. The 50M group-by costs Polars 1121 CPU-ms across 16 threads; ArrowMetal costs 14.0 CPU-ms end to end and 0.4 CPU-ms resident. On a laptop that is battery, and on a shared box it is 16 cores left free for something else.
  • Strings cross in Polars' own Utf8View layout: 50M rows hand over in 30.0 ms with no bytes copied, against 309.0 ms through large_string (offsets + bytes), which is a copy. Through the namespace contains is 17.6x with views and 2.1x with offsets; resident it is 112.4x (106.9x over offsets). The tier-2 plugin still asks Polars for large_string and is 2.3x. Keeping a string column resident (s.arrowmetal.to_metal()) takes the 38.1 ms call to 6.0 ms.

Where each tier is worth using

Row Use it when
Tier 1, one call The kernel is expensive relative to 400 MB of page mapping: group-by, sort, top-k, string search. Not a bare sum.
Tier 1, resident You run several kernels over the same column. to_metal() once, then every kernel in the table above is 0.8-10.5 ms.
Tier 2 The GPU op belongs inside a plan you want Polars to keep optimising -- scans, pushdown, and lazy composition still apply.
Tier 3 Polars should do the IO and the reshaping and ArrowMetal should do one heavy pass at the end.
Tier 4 You want Polars' own collect() and its answers, with the parts of the plan the GPU is measured ahead on (by default, from their measured crossovers: sorts, numeric-key inner, left and anti joins, unique, group-bys by their number of groups, sorts of Parquet files, and with a String column sorts, unique and the two-key group-by sum) run there.

Tier 4 over a Parquet file, 50M rows

pl.scan_parquet(file) under a filter, a group-by, an aggregate or a sort, over the 50,000,000-row, 8-column files of Benchmarks/parquet_bench.py (snappy, 1.65 GB, and uncompressed, 2.23 GB), collected by Polars' in-memory and streaming engines and by MetalEngine(shapes="all", min_rows=0). "cold" clears the open-file cache before every run, so the engine opens and maps the file each time; "warm" keeps it open between runs. Polars reads the file on every run; the file stays in the OS page cache throughout. Best of 5, every engine result equal to Polars', Benchmarks/results/polars_engine_scan_2026-09-26-quiet.csv (run conditions in Benchmarks/results/bench_conditions_2026-09-26-quiet.txt).

case codec Polars in-memory Polars streaming MetalEngine cold MetalEngine warm MetalEngine() default, cold
(s1) filter price > 500, group-by qty (1,000 keys), sum + count snappy 98.7 ms 45.6 ms 101.9 ms 91.3 ms 99.7 ms (Polars)
none 87.6 ms 36.1 ms 47.2 ms 31.6 ms 90.3 ms (Polars)
(s2) filter id < 5,000,000 (45 of 50 row groups skipped), group-by, sum snappy 21.8 ms 12.1 ms 35.0 ms 33.4 ms 24.1 ms (Polars)
none 16.2 ms 5.1 ms 8.5 ms 6.3 ms 17.2 ms (Polars)
(s3) filter on two columns, sum + count snappy 14.0 ms 13.7 ms 21.0 ms 13.9 ms 14.7 ms (Polars)
none 13.8 ms 13.1 ms 13.3 ms 5.9 ms 14.0 ms (Polars)
(s4) sort 2 columns by a Float64 key snappy 542.3 ms 559.4 ms 123.7 ms 116.3 ms 123.5 ms (Metal)
none 562.0 ms 563.2 ms 73.7 ms 64.7 ms 75.0 ms (Metal)
  • The sort is ahead cold and warm: 4.4x (snappy) and 7.6x (uncompressed) the faster Polars engine cold, 4.7x and 8.7x warm, and the default takes it (4.4x and 7.5x).
  • Cold, the other three are behind and to improve, from 35.0 ms against 12.1 ((s2), snappy) to 13.3 ms against 13.1 ((s3), uncompressed) (vs_fastest_polars). The cold runs include what the open-file cache removes: opening the file and handing the pages the query reads to Metal; a one-column read of these files is 6.9-12.2 ms through a fresh open and 3.2-7.6 ms through the cache (Parquet on the GPU, "The open-file cache"). The default leaves them to Polars.
  • Warm, over the uncompressed file (s3) is ahead of Polars' streaming engine (5.9 ms against 13.1, 2.2x) and so is (s1) (31.6 ms against 36.1, 1.1x); (s2) is behind at 6.3 ms against 5.1. Over the Snappy file, which the GPU decompresses first (Parquet on the GPU, "What the numbers say"), (s1) is behind at 91.3 ms against 45.6, (s2) at 33.4 against 12.1 and (s3) at 13.9 against 13.7.
  • CPU time: the warm engine runs cost 2.9-7.8 ms of process CPU, against 37.5-6733.8 ms for Polars (cpu_ms).
PYTHONPATH=python python Benchmarks/polars_engine_bench.py --scan-only --scan-rows 50000000 \
    --scan-codecs snappy,none --out scan.csv

Limits

Types. This paragraph is about what the bridge round-trips, which is a tier-1 and tier-3 question; tier 2's expressions accept a shorter list, set out in "What tier 2 accepts" above. Every dtype Polars and Arrow share round-trips: all signed and unsigned integer widths, Float32/64, Boolean, Date, Datetime (all units), Time, Duration, String, Binary. Categorical and Enum also work, but Polars encodes them as dictionary<uint32> and dictionary<uint8> while ArrowMetal wants int32 or int64 indices, so the bridge recodes the index buffer -- 4 bytes a row, values untouched. An Enum comes back from to_polars as a Categorical: the dictionary crosses, the fact that its value set was closed does not. List and Struct cross and round-trip, but only the structural kernels operate on them -- you cannot sum or group by one. Object is not bridged.

Chunking. am.from_polars takes one Arrow array. A multi-chunk Series is rechunked once, which does copy; am.from_polars(s, rechunk=False) raises instead, so the copy is never silent. The MetalEngine hands a multi-chunk column of an in-memory frame over as its own chunks, which the chunked import (am_import_chunks) copies straight into the final Metal buffers without Polars concatenating them first; Categorical, Enum and nested columns are still concatenated by to_arrow.

Order. Group order is ArrowMetal's, not Polars': ascending by key for numeric, boolean, temporal and decimal keys, first-seen for utf8 and binary, lexicographic in column order for several keys. Polars' group_by promises no order at all, so sort both sides before comparing. s.arrowmetal.unique() is first-seen, the order Polars' unique(maintain_order=True) gives, and it keeps a null as one of the distinct values rather than dropping it. Sorts put nulls last in both directions, where Polars' ascending default is nulls first -- pass nulls_last=True when comparing.

Strings. A String or Binary column crosses in Polars' own Utf8View / BinaryView layout (to_arrow(compat_level=newest)): its 16-byte views and data buffers are mapped into Metal without a copy, and export hands them back to Polars the same way. s.arrowmetal.to_metal().string_layout says "view". The string kernels read the views directly (the list is am.STRING_VIEW_KERNELS); the rest (am.STRING_VIEW_CONVERTS: str_concat, the splits, match_like, strptime, the byte-counting pads, utf8_zero_fill, the byte slices and reversals, casts from strings, the file writers) convert the column to offsets + bytes once, on the GPU, and keep that form, which string_layout then reports as "view (converted)". am.from_polars(s, string_layout="offsets") asks Polars for large_string instead, the layout used before the kernels read views; the tier-2 plugin still uses that. upper/lower are Unicode's simple 1:1 case mapping over every script (the GPU table covers U+0000–U+017F exactly; a row holding anything above is mapped on the host), so Greek and Cyrillic come back mapped. Simple, not full: the multi-character expansions are not applied, so U+00DF becomes ẞ where Polars' str.to_uppercase() gives SS, and fi stays put. contains / starts_with / ends_with are literal, not regex.

Arithmetic. .add/.sub/.mul/.truediv in tier 2 keep the column's own type and follow Arrow's unchecked rules for the operation: integers wrap, and integer division by zero yields 0 where Polars raises. The operand is checked -- one the column's type cannot hold raises rather than being clamped or truncated, and an integer operand is exact past 2^53. See "The scalar in .add" above.

Joins. GPU path only for a single-column inner or left join against a unique right key; everything else falls back to Polars (or raises with allow_cpu_fallback=False).

Aggregation inside group_by. Tier 2's group_by_sum is a projection, not a hash aggregate Polars calls per group -- the plugin API has no hook for that. See the tier-2 section.

Threads. ArrowMetal serialises command-buffer commits behind its own lock and keeps its error state thread-local, so Polars is free to call the plugin from several worker threads. The plugin takes no lock of its own.

Version coupling. Tier 2: the plugin is pinned to polars 0.55.1 / pyo3-polars 0.28 for py-polars 1.44.x. Tiers 1 and 3 speak the C Data Interface and are not coupled to a Polars version. Tier 4 is pure Python but reads Polars' optimised IR through an API Polars calls unstable; it was written against IR version (14, 7) of polars 1.44.1 and (15, 2) of polars 2.0.0, a test pins them, and an IR major outside these makes it leave every plan to Polars.


Tests

PYTHONPATH=python python -m pytest python/tests/test_polars.py -q     # 90 tests
cd polars-plugin/arrowmetal-sys && cargo test --release               # 10 tests
PYTHONPATH=python python -m pytest python/tests/test_polars_engine.py -q   # tier 4

test_polars_engine.py is described under "Tier 4", "Tests".

test_polars.py covers round trips for every shared dtype (plus Categorical, Enum, empty, all-null and chunked), the namespace methods against native Polars at five sizes from 0 to 100,003 rows, the plugin expressions inside lazy plans, the join against pl.DataFrame.join, zero-copy assertions on buffer addresses, and 50M-row timings as assertions with bounds generous enough not to flake. The plugin tests skip when the Rust library has not been built, so a checkout without a Rust toolchain still runs green -- build it before you trust a green run, or 28 of the 90 are skips (62 passed, 28 skipped).

The tier-2 block at the end of the file is the hostile-input pass: the scalar operand against the tier-1 bridge, nulls against native Polars, a sliced Series and a two-chunk one, an empty frame, an all-set validity bitmap with no nulls, the twelve dtypes the numeric expressions refuse (each one a Polars error carrying ArrowMetal's wording, never a pyo3 panic), and the expression inside group_by().agg() and inside collect(engine="streaming").

docs/ENGINE_CAPABILITIES.md

MetalEngine capability table

Generated by python/tests/engine_capabilities.py from runs of the engine; test_polars_engine.py fails when this file differs from a fresh run.

  • Polars 1.44.1, IR (14, 7)
  • ArrowMetal 0.5.0, commit 7c9d33d058
  • Engine: MetalEngine(shapes="all", min_rows=0), every node it can translate (the default MetalEngine() takes a subset: Polars, "Which translatable subtrees it runs: the defaults")
  • Input: a 64-row frame with the column x under test, k (Int32) and v (Int64); null patterns: none, some (every third row null), all

Cells: Metal -- the whole plan ran on Metal and matched Polars; Polars [n] -- the plan stayed with Polars for reason n below; partly [n] -- part of the plan ran on Metal, the rest stayed with Polars for reason n; - -- Polars' optimised plan had nothing to run; n/a -- Polars itself rejects the plan for this dtype.

Summary

shape runs Metal partly Polars nothing to run n/a
filter_other_column 87 63 0 24 0 0
filter_is_not_null 87 36 0 51 0 0
filter_eq_literal 87 36 0 51 0 0
filter_gt_literal 87 33 0 45 0 9
select_add 87 33 0 30 0 24
select_is_null 87 36 0 51 0 0
with_columns 87 63 0 24 0 0
slice 87 63 0 24 0 0
sort 87 63 0 24 0 0
sort_desc_nulls_last 87 63 0 24 0 0
top_k 87 63 0 24 0 0
group_by_key 87 57 0 30 0 0
group_by_value_sum 87 33 0 39 0 15
group_by_value_min 87 27 0 60 0 0
group_by_value_max 87 27 0 60 0 0
group_by_value_mean 87 30 0 57 0 0
group_by_value_count 87 33 0 54 0 0
aggregate_sum 87 33 0 15 0 39
aggregate_min 87 30 0 48 0 9
aggregate_max 87 30 0 48 0 9
aggregate_mean 87 30 0 36 0 21
aggregate_count 87 33 0 54 0 0
join_inner 87 57 0 30 0 0
join_left 87 57 0 30 0 0
join_semi 87 57 0 30 0 0
join_anti 87 57 0 30 0 0
unique 87 57 0 30 0 0
parquet_filter 87 63 0 24 0 0
parquet_sort 87 63 0 24 0 0
all 2523 1326 0 1071 0 126

filter_other_column

filter(k > 1): a Filter that carries x

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String Metal Metal Metal
Date Metal Metal Metal
Datetime(ms) Metal Metal Metal
Datetime(us) Metal Metal Metal
Datetime(ns) Metal Metal Metal
Datetime(us, UTC) Metal Metal Metal
Duration(ms) Metal Metal Metal
Duration(us) Metal Metal Metal
Duration(ns) Metal Metal Metal
Time Metal Metal Metal
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

filter_is_not_null

filter(x.is_not_null())

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String Metal Metal Metal
Date Polars [9] Polars [9] Polars [9]
Datetime(ms) Polars [10] Polars [10] Polars [10]
Datetime(us) Polars [11] Polars [11] Polars [11]
Datetime(ns) Polars [12] Polars [12] Polars [12]
Datetime(us, UTC) Polars [13] Polars [13] Polars [13]
Duration(ms) Polars [14] Polars [14] Polars [14]
Duration(us) Polars [15] Polars [15] Polars [15]
Duration(ns) Polars [16] Polars [16] Polars [16]
Time Polars [17] Polars [17] Polars [17]
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

filter_eq_literal

filter(x == <a value of x>)

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String Metal Metal Metal
Date Polars [9] Polars [9] Polars [9]
Datetime(ms) Polars [10] Polars [10] Polars [10]
Datetime(us) Polars [11] Polars [11] Polars [11]
Datetime(ns) Polars [12] Polars [12] Polars [12]
Datetime(us, UTC) Polars [13] Polars [13] Polars [13]
Duration(ms) Polars [14] Polars [14] Polars [14]
Duration(us) Polars [15] Polars [15] Polars [15]
Duration(ns) Polars [16] Polars [16] Polars [16]
Time Polars [17] Polars [17] Polars [17]
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

filter_gt_literal

filter(x > <a value of x>)

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String Polars [18] Polars [18] Polars [18]
Date Polars [9] Polars [9] Polars [9]
Datetime(ms) Polars [10] Polars [10] Polars [10]
Datetime(us) Polars [11] Polars [11] Polars [11]
Datetime(ns) Polars [12] Polars [12] Polars [12]
Datetime(us, UTC) Polars [13] Polars [13] Polars [13]
Duration(ms) Polars [14] Polars [14] Polars [14]
Duration(us) Polars [15] Polars [15] Polars [15]
Duration(ns) Polars [16] Polars [16] Polars [16]
Time Polars [17] Polars [17] Polars [17]
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) n/a n/a n/a
Array(Int64, 2) n/a n/a n/a
Struct n/a n/a n/a
Null Polars [8] Polars [8] Polars [8]

select_add

select(x + x, v)

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String Polars [19] Polars [19] Polars [19]
Date n/a n/a n/a
Datetime(ms) n/a n/a n/a
Datetime(us) n/a n/a n/a
Datetime(ns) n/a n/a n/a
Datetime(us, UTC) n/a n/a n/a
Duration(ms) Polars [20] Polars [20] Polars [20]
Duration(us) Polars [21] Polars [21] Polars [21]
Duration(ns) Polars [22] Polars [22] Polars [22]
Time n/a n/a n/a
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical n/a n/a n/a
Enum n/a n/a n/a
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

select_is_null

select(x.is_null(), v)

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String Metal Metal Metal
Date Polars [23] Polars [23] Polars [23]
Datetime(ms) Polars [24] Polars [24] Polars [24]
Datetime(us) Polars [25] Polars [25] Polars [25]
Datetime(ns) Polars [26] Polars [26] Polars [26]
Datetime(us, UTC) Polars [27] Polars [27] Polars [27]
Duration(ms) Polars [20] Polars [20] Polars [20]
Duration(us) Polars [21] Polars [21] Polars [21]
Duration(ns) Polars [22] Polars [22] Polars [22]
Time Polars [28] Polars [28] Polars [28]
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

with_columns

with_columns(k * 2): an HStack that carries x

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String Metal Metal Metal
Date Metal Metal Metal
Datetime(ms) Metal Metal Metal
Datetime(us) Metal Metal Metal
Datetime(ns) Metal Metal Metal
Datetime(us, UTC) Metal Metal Metal
Duration(ms) Metal Metal Metal
Duration(us) Metal Metal Metal
Duration(ns) Metal Metal Metal
Time Metal Metal Metal
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

slice

filter(k > 1).slice(2, 10): a Slice that carries x

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String Metal Metal Metal
Date Metal Metal Metal
Datetime(ms) Metal Metal Metal
Datetime(us) Metal Metal Metal
Datetime(ns) Metal Metal Metal
Datetime(us, UTC) Metal Metal Metal
Duration(ms) Metal Metal Metal
Duration(us) Metal Metal Metal
Duration(ns) Metal Metal Metal
Time Metal Metal Metal
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

sort

sort(x) (nulls first)

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String Metal Metal Metal
Date Metal Metal Metal
Datetime(ms) Metal Metal Metal
Datetime(us) Metal Metal Metal
Datetime(ns) Metal Metal Metal
Datetime(us, UTC) Metal Metal Metal
Duration(ms) Metal Metal Metal
Duration(us) Metal Metal Metal
Duration(ns) Metal Metal Metal
Time Metal Metal Metal
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

sort_desc_nulls_last

sort(x, descending=True, nulls_last=True)

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String Metal Metal Metal
Date Metal Metal Metal
Datetime(ms) Metal Metal Metal
Datetime(us) Metal Metal Metal
Datetime(ns) Metal Metal Metal
Datetime(us, UTC) Metal Metal Metal
Duration(ms) Metal Metal Metal
Duration(us) Metal Metal Metal
Duration(ns) Metal Metal Metal
Time Metal Metal Metal
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

top_k

sort(x).head(5)

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String Metal Metal Metal
Date Metal Metal Metal
Datetime(ms) Metal Metal Metal
Datetime(us) Metal Metal Metal
Datetime(ns) Metal Metal Metal
Datetime(us, UTC) Metal Metal Metal
Duration(ms) Metal Metal Metal
Duration(us) Metal Metal Metal
Duration(ns) Metal Metal Metal
Time Metal Metal Metal
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

group_by_key

group_by(x).agg(v.sum()): x as the key

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Polars [29] Polars [29] Polars [29]
Float64 Polars [29] Polars [29] Polars [29]
Boolean Metal Metal Metal
String Metal Metal Metal
Date Metal Metal Metal
Datetime(ms) Metal Metal Metal
Datetime(us) Metal Metal Metal
Datetime(ns) Metal Metal Metal
Datetime(us, UTC) Metal Metal Metal
Duration(ms) Metal Metal Metal
Duration(us) Metal Metal Metal
Duration(ns) Metal Metal Metal
Time Metal Metal Metal
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

group_by_value_sum

group_by(k).agg(x.sum()): x as the value

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String n/a n/a n/a
Date Polars [30] Polars [30] Polars [30]
Datetime(ms) Polars [31] Polars [31] Polars [31]
Datetime(us) Polars [32] Polars [32] Polars [32]
Datetime(ns) Polars [33] Polars [33] Polars [33]
Datetime(us, UTC) Polars [34] Polars [34] Polars [34]
Duration(ms) Polars [35] Polars [35] Polars [35]
Duration(us) Polars [36] Polars [36] Polars [36]
Duration(ns) Polars [37] Polars [37] Polars [37]
Time Polars [38] Polars [38] Polars [38]
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary n/a n/a n/a
List(Int64) n/a n/a n/a
Array(Int64, 2) n/a n/a n/a
Struct n/a n/a n/a
Null Polars [8] Polars [8] Polars [8]

group_by_value_min

group_by(k).agg(x.min()): x as the value

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Polars [39] Polars [39] Polars [39]
Boolean Polars [40] Polars [40] Polars [40]
String Polars [41] Polars [41] Polars [41]
Date Polars [30] Polars [30] Polars [30]
Datetime(ms) Polars [31] Polars [31] Polars [31]
Datetime(us) Polars [32] Polars [32] Polars [32]
Datetime(ns) Polars [33] Polars [33] Polars [33]
Datetime(us, UTC) Polars [34] Polars [34] Polars [34]
Duration(ms) Polars [35] Polars [35] Polars [35]
Duration(us) Polars [36] Polars [36] Polars [36]
Duration(ns) Polars [37] Polars [37] Polars [37]
Time Polars [38] Polars [38] Polars [38]
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

group_by_value_max

group_by(k).agg(x.max()): x as the value

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Polars [42] Polars [42] Polars [42]
Boolean Polars [43] Polars [43] Polars [43]
String Polars [41] Polars [41] Polars [41]
Date Polars [30] Polars [30] Polars [30]
Datetime(ms) Polars [31] Polars [31] Polars [31]
Datetime(us) Polars [32] Polars [32] Polars [32]
Datetime(ns) Polars [33] Polars [33] Polars [33]
Datetime(us, UTC) Polars [34] Polars [34] Polars [34]
Duration(ms) Polars [35] Polars [35] Polars [35]
Duration(us) Polars [36] Polars [36] Polars [36]
Duration(ns) Polars [37] Polars [37] Polars [37]
Time Polars [38] Polars [38] Polars [38]
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

group_by_value_mean

group_by(k).agg(x.mean()): x as the value

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Polars [44] Polars [44] Polars [44]
String Polars [41] Polars [41] Polars [41]
Date Polars [32] Polars [32] Polars [32]
Datetime(ms) Polars [31] Polars [31] Polars [31]
Datetime(us) Polars [32] Polars [32] Polars [32]
Datetime(ns) Polars [33] Polars [33] Polars [33]
Datetime(us, UTC) Polars [34] Polars [34] Polars [34]
Duration(ms) Polars [35] Polars [35] Polars [35]
Duration(us) Polars [36] Polars [36] Polars [36]
Duration(ns) Polars [37] Polars [37] Polars [37]
Time Polars [38] Polars [38] Polars [38]
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

group_by_value_count

group_by(k).agg(x.count()): x as the value

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String Polars [45] Polars [45] Polars [45]
Date Polars [46] Polars [46] Polars [46]
Datetime(ms) Polars [47] Polars [47] Polars [47]
Datetime(us) Polars [48] Polars [48] Polars [48]
Datetime(ns) Polars [49] Polars [49] Polars [49]
Datetime(us, UTC) Polars [50] Polars [50] Polars [50]
Duration(ms) Polars [51] Polars [51] Polars [51]
Duration(us) Polars [52] Polars [52] Polars [52]
Duration(ns) Polars [53] Polars [53] Polars [53]
Time Polars [54] Polars [54] Polars [54]
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

aggregate_sum

select(x.sum()): a whole-frame aggregate

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String n/a n/a n/a
Date n/a n/a n/a
Datetime(ms) n/a n/a n/a
Datetime(us) n/a n/a n/a
Datetime(ns) n/a n/a n/a
Datetime(us, UTC) n/a n/a n/a
Duration(ms) Polars [55] Polars [55] Polars [55]
Duration(us) Polars [56] Polars [56] Polars [56]
Duration(ns) Polars [57] Polars [57] Polars [57]
Time n/a n/a n/a
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical n/a n/a n/a
Enum n/a n/a n/a
Binary n/a n/a n/a
List(Int64) n/a n/a n/a
Array(Int64, 2) n/a n/a n/a
Struct n/a n/a n/a
Null Polars [8] Polars [8] Polars [8]

aggregate_min

select(x.min()): a whole-frame aggregate

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Polars [58] Polars [58] Polars [58]
String Polars [59] Polars [59] Polars [59]
Date Polars [60] Polars [60] Polars [60]
Datetime(ms) Polars [61] Polars [61] Polars [61]
Datetime(us) Polars [62] Polars [62] Polars [62]
Datetime(ns) Polars [63] Polars [63] Polars [63]
Datetime(us, UTC) Polars [64] Polars [64] Polars [64]
Duration(ms) Polars [55] Polars [55] Polars [55]
Duration(us) Polars [56] Polars [56] Polars [56]
Duration(ns) Polars [57] Polars [57] Polars [57]
Time Polars [65] Polars [65] Polars [65]
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) n/a n/a n/a
Array(Int64, 2) n/a n/a n/a
Struct n/a n/a n/a
Null Polars [8] Polars [8] Polars [8]

aggregate_max

select(x.max()): a whole-frame aggregate

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Polars [66] Polars [66] Polars [66]
String Polars [59] Polars [59] Polars [59]
Date Polars [60] Polars [60] Polars [60]
Datetime(ms) Polars [61] Polars [61] Polars [61]
Datetime(us) Polars [62] Polars [62] Polars [62]
Datetime(ns) Polars [63] Polars [63] Polars [63]
Datetime(us, UTC) Polars [64] Polars [64] Polars [64]
Duration(ms) Polars [55] Polars [55] Polars [55]
Duration(us) Polars [56] Polars [56] Polars [56]
Duration(ns) Polars [57] Polars [57] Polars [57]
Time Polars [65] Polars [65] Polars [65]
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) n/a n/a n/a
Array(Int64, 2) n/a n/a n/a
Struct n/a n/a n/a
Null Polars [8] Polars [8] Polars [8]

aggregate_mean

select(x.mean()): a whole-frame aggregate

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Polars [67] Polars [67] Polars [67]
String n/a n/a n/a
Date Polars [62] Polars [62] Polars [62]
Datetime(ms) Polars [61] Polars [61] Polars [61]
Datetime(us) Polars [62] Polars [62] Polars [62]
Datetime(ns) Polars [63] Polars [63] Polars [63]
Datetime(us, UTC) Polars [64] Polars [64] Polars [64]
Duration(ms) Polars [55] Polars [55] Polars [55]
Duration(us) Polars [56] Polars [56] Polars [56]
Duration(ns) Polars [57] Polars [57] Polars [57]
Time Polars [65] Polars [65] Polars [65]
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical n/a n/a n/a
Enum n/a n/a n/a
Binary n/a n/a n/a
List(Int64) n/a n/a n/a
Array(Int64, 2) n/a n/a n/a
Struct n/a n/a n/a
Null Polars [8] Polars [8] Polars [8]

aggregate_count

select(x.count()): a whole-frame aggregate

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String Polars [68] Polars [68] Polars [68]
Date Polars [23] Polars [23] Polars [23]
Datetime(ms) Polars [24] Polars [24] Polars [24]
Datetime(us) Polars [25] Polars [25] Polars [25]
Datetime(ns) Polars [26] Polars [26] Polars [26]
Datetime(us, UTC) Polars [27] Polars [27] Polars [27]
Duration(ms) Polars [20] Polars [20] Polars [20]
Duration(us) Polars [21] Polars [21] Polars [21]
Duration(ns) Polars [22] Polars [22] Polars [22]
Time Polars [28] Polars [28] Polars [28]
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

join_inner

join(right, on=x, how="inner"): x as the key

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Polars [69] Polars [69] Polars [69]
Float64 Polars [69] Polars [69] Polars [69]
Boolean Metal Metal Metal
String Metal Metal Metal
Date Metal Metal Metal
Datetime(ms) Metal Metal Metal
Datetime(us) Metal Metal Metal
Datetime(ns) Metal Metal Metal
Datetime(us, UTC) Metal Metal Metal
Duration(ms) Metal Metal Metal
Duration(us) Metal Metal Metal
Duration(ns) Metal Metal Metal
Time Metal Metal Metal
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

join_left

join(right, on=x, how="left"): x as the key

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Polars [69] Polars [69] Polars [69]
Float64 Polars [69] Polars [69] Polars [69]
Boolean Metal Metal Metal
String Metal Metal Metal
Date Metal Metal Metal
Datetime(ms) Metal Metal Metal
Datetime(us) Metal Metal Metal
Datetime(ns) Metal Metal Metal
Datetime(us, UTC) Metal Metal Metal
Duration(ms) Metal Metal Metal
Duration(us) Metal Metal Metal
Duration(ns) Metal Metal Metal
Time Metal Metal Metal
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

join_semi

join(right, on=x, how="semi"): x as the key

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Polars [69] Polars [69] Polars [69]
Float64 Polars [69] Polars [69] Polars [69]
Boolean Metal Metal Metal
String Metal Metal Metal
Date Metal Metal Metal
Datetime(ms) Metal Metal Metal
Datetime(us) Metal Metal Metal
Datetime(ns) Metal Metal Metal
Datetime(us, UTC) Metal Metal Metal
Duration(ms) Metal Metal Metal
Duration(us) Metal Metal Metal
Duration(ns) Metal Metal Metal
Time Metal Metal Metal
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

join_anti

join(right, on=x, how="anti"): x as the key

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Polars [69] Polars [69] Polars [69]
Float64 Polars [69] Polars [69] Polars [69]
Boolean Metal Metal Metal
String Metal Metal Metal
Date Metal Metal Metal
Datetime(ms) Metal Metal Metal
Datetime(us) Metal Metal Metal
Datetime(ns) Metal Metal Metal
Datetime(us, UTC) Metal Metal Metal
Duration(ms) Metal Metal Metal
Duration(us) Metal Metal Metal
Duration(ns) Metal Metal Metal
Time Metal Metal Metal
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

unique

unique(subset=[x])

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Polars [70] Polars [70] Polars [70]
Float64 Polars [70] Polars [70] Polars [70]
Boolean Metal Metal Metal
String Metal Metal Metal
Date Metal Metal Metal
Datetime(ms) Metal Metal Metal
Datetime(us) Metal Metal Metal
Datetime(ns) Metal Metal Metal
Datetime(us, UTC) Metal Metal Metal
Duration(ms) Metal Metal Metal
Duration(us) Metal Metal Metal
Duration(ns) Metal Metal Metal
Time Metal Metal Metal
Decimal(10, 2) Polars [1] Polars [1] Polars [1]
Categorical Polars [2] Polars [2] Polars [2]
Enum Polars [3] Polars [3] Polars [3]
Binary Polars [4] Polars [4] Polars [4]
List(Int64) Polars [5] Polars [5] Polars [5]
Array(Int64, 2) Polars [6] Polars [6] Polars [6]
Struct Polars [7] Polars [7] Polars [7]
Null Polars [8] Polars [8] Polars [8]

parquet_filter

scan_parquet(f).filter(k > 1): a Parquet Scan that reads x

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String Metal Metal Metal
Date Metal Metal Metal
Datetime(ms) Metal Metal Metal
Datetime(us) Metal Metal Metal
Datetime(ns) Metal Metal Metal
Datetime(us, UTC) Metal Metal Metal
Duration(ms) Metal Metal Metal
Duration(us) Metal Metal Metal
Duration(ns) Metal Metal Metal
Time Metal Metal Metal
Decimal(10, 2) Polars [71] Polars [71] Polars [71]
Categorical Polars [72] Polars [72] Polars [72]
Enum Polars [73] Polars [73] Polars [73]
Binary Polars [74] Polars [74] Polars [74]
List(Int64) Polars [75] Polars [75] Polars [75]
Array(Int64, 2) Polars [76] Polars [76] Polars [76]
Struct Polars [77] Polars [77] Polars [77]
Null Polars [78] Polars [78] Polars [78]

parquet_sort

scan_parquet(f).sort(x)

dtype of x none null some null all null
Int8 Metal Metal Metal
Int16 Metal Metal Metal
Int32 Metal Metal Metal
Int64 Metal Metal Metal
UInt8 Metal Metal Metal
UInt16 Metal Metal Metal
UInt32 Metal Metal Metal
UInt64 Metal Metal Metal
Float32 Metal Metal Metal
Float64 Metal Metal Metal
Boolean Metal Metal Metal
String Metal Metal Metal
Date Metal Metal Metal
Datetime(ms) Metal Metal Metal
Datetime(us) Metal Metal Metal
Datetime(ns) Metal Metal Metal
Datetime(us, UTC) Metal Metal Metal
Duration(ms) Metal Metal Metal
Duration(us) Metal Metal Metal
Duration(ns) Metal Metal Metal
Time Metal Metal Metal
Decimal(10, 2) Polars [71] Polars [71] Polars [71]
Categorical Polars [72] Polars [72] Polars [72]
Enum Polars [73] Polars [73] Polars [73]
Binary Polars [74] Polars [74] Polars [74]
List(Int64) Polars [75] Polars [75] Polars [75]
Array(Int64, 2) Polars [76] Polars [76] Polars [76]
Struct Polars [77] Polars [77] Polars [77]
Null Polars [78] Polars [78] Polars [78]

Reasons

As the engine's report gives them (Kind: reason, for the first node that stayed with Polars).

  1. DataFrameScan: Column 'x' has dtype Decimal(precision=10, scale=2), which the Metal plan does not carry.
  2. DataFrameScan: Column 'x' has dtype Categorical, which the Metal plan does not carry.
  3. DataFrameScan: Column 'x' has dtype Enum(categories=['apple', 'pear', 'fig', 'kiwi', 'plum']), which the Metal plan does not carry.
  4. DataFrameScan: Column 'x' has dtype Binary, which the Metal plan does not carry.
  5. DataFrameScan: Column 'x' has dtype List(Int64), which the Metal plan does not carry.
  6. DataFrameScan: Column 'x' has dtype Array(Int64, shape=(2,)), which the Metal plan does not carry.
  7. DataFrameScan: Column 'x' has dtype Struct({'a': Int64}), which the Metal plan does not carry.
  8. DataFrameScan: Column 'x' has dtype Null, which the Metal plan does not carry.
  9. Filter: Column 'x' of dtype Date cannot be read by a fused expression.
  10. Filter: Column 'x' of dtype Datetime(time_unit='ms', time_zone=None) cannot be read by a fused expression.
  11. Filter: Column 'x' of dtype Datetime(time_unit='us', time_zone=None) cannot be read by a fused expression.
  12. Filter: Column 'x' of dtype Datetime(time_unit='ns', time_zone=None) cannot be read by a fused expression.
  13. Filter: Column 'x' of dtype Datetime(time_unit='us', time_zone='UTC') cannot be read by a fused expression.
  14. Filter: Column 'x' of dtype Duration(time_unit='ms') cannot be read by a fused expression.
  15. Filter: Column 'x' of dtype Duration(time_unit='us') cannot be read by a fused expression.
  16. Filter: Column 'x' of dtype Duration(time_unit='ns') cannot be read by a fused expression.
  17. Filter: Column 'x' of dtype Time cannot be read by a fused expression.
  18. Filter: The string comparison Gt is not translated.
  19. Select: Function StringFunction.ConcatHorizontal has no ArrowMetal translation.
  20. Select: Column 'x' of dtype Duration(time_unit='ms') cannot be read by a fused expression.
  21. Select: Column 'x' of dtype Duration(time_unit='us') cannot be read by a fused expression.
  22. Select: Column 'x' of dtype Duration(time_unit='ns') cannot be read by a fused expression.
  23. Select: Column 'x' of dtype Date cannot be read by a fused expression.
  24. Select: Column 'x' of dtype Datetime(time_unit='ms', time_zone=None) cannot be read by a fused expression.
  25. Select: Column 'x' of dtype Datetime(time_unit='us', time_zone=None) cannot be read by a fused expression.
  26. Select: Column 'x' of dtype Datetime(time_unit='ns', time_zone=None) cannot be read by a fused expression.
  27. Select: Column 'x' of dtype Datetime(time_unit='us', time_zone='UTC') cannot be read by a fused expression.
  28. Select: Column 'x' of dtype Time cannot be read by a fused expression.
  29. GroupBy: A group_by on a float key is not translated (NaN and -0.0 grouping).
  30. GroupBy: The aggregate 'x' has dtype Date, which the Metal plan does not compute.
  31. GroupBy: The aggregate 'x' has dtype Datetime(time_unit='ms', time_zone=None), which the Metal plan does not compute.
  32. GroupBy: The aggregate 'x' has dtype Datetime(time_unit='us', time_zone=None), which the Metal plan does not compute.
  33. GroupBy: The aggregate 'x' has dtype Datetime(time_unit='ns', time_zone=None), which the Metal plan does not compute.
  34. GroupBy: The aggregate 'x' has dtype Datetime(time_unit='us', time_zone='UTC'), which the Metal plan does not compute.
  35. GroupBy: The aggregate 'x' has dtype Duration(time_unit='ms'), which the Metal plan does not compute.
  36. GroupBy: The aggregate 'x' has dtype Duration(time_unit='us'), which the Metal plan does not compute.
  37. GroupBy: The aggregate 'x' has dtype Duration(time_unit='ns'), which the Metal plan does not compute.
  38. GroupBy: The aggregate 'x' has dtype Time, which the Metal plan does not compute.
  39. GroupBy: A group_by min over Float64 is not translated (ArrowMetal's GroupBy min/max kernels take up to 32-bit floats).
  40. GroupBy: The min of a boolean is not translated.
  41. GroupBy: The aggregate 'x' has dtype String, which the Metal plan does not compute.
  42. GroupBy: A group_by max over Float64 is not translated (ArrowMetal's GroupBy min/max kernels take up to 32-bit floats).
  43. GroupBy: The max of a boolean is not translated.
  44. GroupBy: The mean of a boolean is not translated.
  45. GroupBy: The aggregate count over dtype String is not translated.
  46. GroupBy: Column 'x' of dtype Date cannot be read by a fused expression.
  47. GroupBy: Column 'x' of dtype Datetime(time_unit='ms', time_zone=None) cannot be read by a fused expression.
  48. GroupBy: Column 'x' of dtype Datetime(time_unit='us', time_zone=None) cannot be read by a fused expression.
  49. GroupBy: Column 'x' of dtype Datetime(time_unit='ns', time_zone=None) cannot be read by a fused expression.
  50. GroupBy: Column 'x' of dtype Datetime(time_unit='us', time_zone='UTC') cannot be read by a fused expression.
  51. GroupBy: Column 'x' of dtype Duration(time_unit='ms') cannot be read by a fused expression.
  52. GroupBy: Column 'x' of dtype Duration(time_unit='us') cannot be read by a fused expression.
  53. GroupBy: Column 'x' of dtype Duration(time_unit='ns') cannot be read by a fused expression.
  54. GroupBy: Column 'x' of dtype Time cannot be read by a fused expression.
  55. Select: The aggregate 'x' has dtype Duration(time_unit='ms'), which the Metal plan does not compute.
  56. Select: The aggregate 'x' has dtype Duration(time_unit='us'), which the Metal plan does not compute.
  57. Select: The aggregate 'x' has dtype Duration(time_unit='ns'), which the Metal plan does not compute.
  58. Select: The min of a boolean is not translated.
  59. Select: The aggregate 'x' has dtype String, which the Metal plan does not compute.
  60. Select: The aggregate 'x' has dtype Date, which the Metal plan does not compute.
  61. Select: The aggregate 'x' has dtype Datetime(time_unit='ms', time_zone=None), which the Metal plan does not compute.
  62. Select: The aggregate 'x' has dtype Datetime(time_unit='us', time_zone=None), which the Metal plan does not compute.
  63. Select: The aggregate 'x' has dtype Datetime(time_unit='ns', time_zone=None), which the Metal plan does not compute.
  64. Select: The aggregate 'x' has dtype Datetime(time_unit='us', time_zone='UTC'), which the Metal plan does not compute.
  65. Select: The aggregate 'x' has dtype Time, which the Metal plan does not compute.
  66. Select: The max of a boolean is not translated.
  67. Select: The mean of a boolean is not translated.
  68. Select: The aggregate count over dtype String is not translated.
  69. Join: A join on a float key is not translated (NaN and -0.0 equality).
  70. Distinct: A unique over a float column is not translated (NaN and -0.0 equality).
  71. Scan: Column 'x' has dtype Decimal(precision=10, scale=2), which the Metal plan does not carry.
  72. Scan: Column 'x' has dtype Categorical, which the Metal plan does not carry.
  73. Scan: Column 'x' has dtype Enum(categories=['apple', 'pear', 'fig', 'kiwi', 'plum']), which the Metal plan does not carry.
  74. Scan: Column 'x' has dtype Binary, which the Metal plan does not carry.
  75. Scan: Column 'x' has dtype List(Int64), which the Metal plan does not carry.
  76. Scan: Column 'x' has dtype Array(Int64, shape=(2,)), which the Metal plan does not carry.
  77. Scan: Column 'x' has dtype Struct({'a': Int64}), which the Metal plan does not carry.
  78. Scan: Column 'x' has dtype Null, which the Metal plan does not carry.
docs/DUCKDB.md

ArrowMetal and DuckDB

DuckDB is an excellent query engine. It reads Parquet, plans joins, pushes down predicates, keeps statistics, spills to disk, and saturates every core you have. ArrowMetal is not a query engine at all: it is a pile of Metal kernels that run one columnar operation very fast on the GPU that is already sitting in your Mac.

Those are complementary, and they meet for free. DuckDB hands results out over the Arrow C Data interface; ArrowMetal takes Arrow buffers in. On Apple silicon the CPU and GPU share one physical memory pool, so am_import copies nothing - it wraps DuckDB's own pages in a Metal buffer. The address on the far side is the same address. python/tests/test_duckdb.py asserts it. Getting to one contiguous chunk does copy when DuckDB returns many; see §5.

The summary. The Python bridge, at 50M rows: a 100,000-key group-by is 16x faster than DuckDB's once the column and its group ids are already on the GPU (1.1x for a single cold query), and a string LIKE scan is 9.3x resident (0.6x cold). DuckDB is ahead on a plain sum over a column, which is memory-bound and which DuckDB does while it scans. The loadable extension works and matches DuckDB's answers exactly; it is behind DuckDB's own SQL as measured, and §4 says why.

A third piece needs no change to the SQL at all. The rewrite extension is a DuckDB optimizer extension: loaded into a connection, it moves eligible aggregates of ordinary queries onto the GPU, with DuckDB's exact answers, and in its default mode only for the shape classes whose every benchmarked query was ahead of DuckDB's own operators, from the size where that held. §4b has what it rewrites, the feasibility finding behind it, and where it is ahead and where it is not.


1. Which tier you want

Row Tier 1: Python bridge Tier 2: loadable extension Tier 3: rewrite extension
You write Python around SQL SQL only SQL only, unchanged
Install pip install duckdb, nothing else build a .duckdb_extension, connect with allow_unsigned_extensions build it against the installed DuckDB release (build_rewrite.sh), connect with allow_unsigned_extensions, or am.duckdb_connect()
Data crossing zero-copy where DuckDB returns one chunk DataChunks assembled into one buffer (a copy) DuckDB's scan output copied into page-aligned buffers the GPU reads in place
Types everything DuckDB emits, including strings, decimals, lists, structs, maps the fixed-width numeric types, DATE, TIMESTAMP integer columns (MIN/MAX also DATE, TIMESTAMP); an integer, DATE, TIMESTAMP or VARCHAR group key
Speed resident: 16.0x on 100k-key group-by, 9.3x on LIKE, 2.2x on sort; one-shot (crossing included): 1.1x, 0.6x, 0.7x (§5) behind DuckDB in 0.1.0, see §4 in auto, rewrites only the shape classes whose every benchmarked query was ahead of DuckDB, from that size (§4b)
Larger than memory yes, am.duckdb_batches no no: the aggregate's input is gathered in memory (a streamed plan holds only the blocks in flight, §4b)

If you are reading this to make something faster: use tier 1, or tier 3 when the SQL must stay as it is and its aggregates fall in §4b's table. Tier 2 exists because "call it from SQL" is a real requirement for some people.


2. Install

Both tiers need the library built:

DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build -c release --product ArrowMetalC

Tier 1

pip install duckdb
PYTHONPATH=python python -c "import duckdb, arrowmetal as am; print(am.device_name())"

The bridge is imported lazily, so import arrowmetal still works with no duckdb installed; the am.from_duckdb attribute is what pulls it in.

Tier 2

./duckdb-extension/build.sh

That compiles one translation unit, links libArrowMetalC.dylib, and appends the 512-byte metadata footer DuckDB checks before it will dlopen anything. On the first run it downloads duckdb.h and duckdb_extension.h into duckdb-extension/third_party/; afterwards it needs no network. duckdb-extension/CMakeLists.txt drives the same compile if you would rather use cmake:

cmake -S duckdb-extension -B duckdb-extension/build -DCMAKE_BUILD_TYPE=Release
cmake --build duckdb-extension/build

Then load it. Unsigned extensions have to be allowed explicitly, and that is a per-connection config setting, not a SET you can issue afterwards:

import duckdb
con = duckdb.connect(config={"allow_unsigned_extensions": "true"})
con.execute("LOAD 'duckdb-extension/build/arrowmetal.duckdb_extension'")
con.sql("SELECT arrowmetal_version(), arrowmetal_device()").show()

From the CLI: duckdb -unsigned, then LOAD '.../arrowmetal.duckdb_extension';.

Versions. Built and tested against DuckDB 1.5.6 (source_id 069cc9f9b5), platform osx_arm64, pyarrow 25.0.1, on an Apple M4 Max. The extension declares the C extension API version v1.2.0, not the DuckDB version - a C_STRUCT extension is compatible with any DuckDB whose C API is at least what it declares, so it is not pinned to 1.5.6. The platform string is checked strictly: an osx_arm64 build will not load into an osx_amd64 DuckDB. Rebuild with DUCKDB_PLATFORM=... ./duckdb-extension/build.sh to target another one.

Tier 3

PYTHON=/path/to/the/python/with/duckdb ./duckdb-extension/build_rewrite.sh

It reads the DuckDB release and source_id from that Python's duckdb module, shallow-clones the matching DuckDB tag into duckdb-extension/build/duckdb-<version>/ on first run (only the headers are used; nothing of DuckDB is compiled), checks the clone's commit against the source_id, compiles src/arrowmetal_rewrite.cpp, checks that the module exports every DuckDB symbol the extension needs, and stamps the footer with the CPP ABI and that exact release. The result is duckdb-extension/build/arrowmetal_rewrite.duckdb_extension. Built and tested against DuckDB 1.5.6 (source_id 069cc9f9b5) in the duckdb 1.5.6 Python module.

import arrowmetal as am
con = am.duckdb_connect()     # duckdb.connect + allow_unsigned_extensions + LOAD + SET arrowmetal_rewrite = 'auto'

or by hand: duckdb.connect(config={"allow_unsigned_extensions": "true"}), then LOAD '.../arrowmetal_rewrite.duckdb_extension'.


3. Tier 1: the Python bridge

python/arrowmetal/duckdb_bridge.py. Nine entry points, all reachable straight off am; six of them are below, and the other three are duckdb_reader, duckdb_table and duckdb_is_zero_copy.

Pull a result onto the GPU

import duckdb, arrowmetal as am
con = duckdb.connect()

cols = am.from_duckdb(con.sql("select region, amount from sales where placed_at >= date '2024-01-01'"))
# -> {"region": MetalArray, "amount": MetalArray}

from_duckdb takes a DuckDBPyRelation, a SQL string with con=, a connection holding a pending result, or any pyarrow table or stream. Columns whose type ArrowMetal cannot lift are left as pyarrow arrays in the same dict; pass on_unsupported="raise" to be told instead.

test_every_duckdb_type_round_trips checks 25 of DuckDB's types: every integer width and signedness, both floats, BOOLEAN, VARCHAR, BLOB, DATE, TIME, TIMESTAMP, TIMESTAMPTZ, INTERVAL, DECIMAL(10,2), DECIMAL(38,2), HUGEINT, LIST, STRUCT and MAP. UUID is checked as its VARCHAR rendering; the native type is not exercised.

Push results back

gb = am.group_by([cols["region"]])
rel = am.to_duckdb(con, "gpu_totals", {"region": gb.keys()[0], "total": gb.sum(cols["amount"])})

con.sql("select * from gpu_totals order by total desc limit 10").show()
con.sql("select r.name, g.total from gpu_totals g join regions r on r.id = g.region").show()

to_duckdb registers through con.register, which takes the Arrow table over the C Data interface - DuckDB reads the GPU-produced buffers in place. The view behaves like any other relation: join it, filter it, insert from it. It lives until con.unregister(name).

Both halves at once

rel = am.duckdb_gpu_query(
    con,
    """select o.customer, o.amount
       from orders o join customers c on c.id = o.customer
       where o.placed_at >= date '2024-01-01'""",
    then=lambda cols: {
        "customer": am.group_by([cols["customer"]]).keys()[0],
        "total":    am.group_by([cols["customer"]]).sum(cols["amount"]),
    },
    name="per_customer")

rel.order("total desc").limit(10).show()

DuckDB does the scan, the predicate and the join. ArrowMetal does the aggregate. That is the whole idea, and it is the arrangement that pays: the join is a planning problem DuckDB is good at, and the group-by over the join's output is a throughput problem the GPU is good at.

A fused query over a DuckDB result

am.query compiles a whole filter-and-aggregate expression into one Metal kernel, so the column is read once no matter how many operators the expression has (see Fused expressions):

q = am.filter((am.col("amount") > 100) & am.col("region").is_in([2, 7])).aggregate([
        ("sum", "total", am.col("amount")),
        ("count", "n", None)])
am.query(am.from_duckdb(con.sql("select region, amount from sales")), q)
# {"total": ..., "n": ...}

Tables larger than memory

fetch_record_batch gives an Arrow C Stream that produces batches lazily. am.duckdb_batches iterates it as {name: MetalArray} with only one batch resident at a time:

total = 0
for batch in am.duckdb_batches(con.sql("select amount from huge_parquet"), rows_per_batch=1 << 22):
    total += batch["amount"].sum()

Two wrappers do the merging for you:

am.duckdb_aggregate(con.sql("select amount, region from huge"),
                    {"total": ("sum", "amount"), "n": ("count", "amount"),
                     "regions": ("count_distinct", "region")},
                    rows_per_batch=1 << 22)

am.duckdb_group_by(con.sql("select region, amount from huge"), "region",
                   {"total": ("sum", "amount"), "avg": ("mean", "amount")})

What is exact, and what is refused. This is the part that usually goes wrong quietly elsewhere, so the bridge is explicit about it:

aggregate exact across batches? how it merges
sum, count, min, max yes the merge is the same associative operator as the aggregate
mean yes, to float64 rounding total sum over total count - never a mean of batch means, which is wrong for unequal batches
count_distinct yes the host unions each batch's distinct values, so memory is proportional to cardinality, not to the batch
median, quantile, stddev, mode, tdigest no refused with an error. These have no exact merge from independent partials, and returning an approximation under the exact aggregate's name would be a lie. Compute them over a whole batch, or over a sample.

test_streaming_aggregate_is_batch_size_independent runs the same sum at 3,000 / 30,000 / 1,000,000 rows per batch and requires all three answers to be identical, and identical to DuckDB's.


4. Tier 2: the loadable extension

duckdb-extension/. Built against DuckDB's C extension API, which means it needs no DuckDB source tree, no C++ ABI match with the host engine, and no rebuild for each DuckDB patch release. The loader hands it a struct of function pointers and everything after that is plain C.

The functions

SELECT arrowmetal_version();                          -- '0.5.0'
SELECT arrowmetal_device();                           -- 'Apple M4 Max'

SELECT * FROM arrowmetal_agg('sales', 'amount');      -- sum, count, min, max, mean
SELECT * FROM arrowmetal_group_by('sales', 'region', 'amount');  -- key, count, sum, min, max
SELECT * FROM arrowmetal_top_k('sales', 'amount', 100);          -- the 100 largest, descending
SELECT * FROM arrowmetal_sort('sales', 'amount');                -- every value, ascending
SELECT * FROM arrowmetal_query('sales',
    '(query (filter (gt (col "amount") (int 100)))
            (aggregate (sum "total" (col "amount")) (count "n")))');

The results are ordinary relations, so they join:

SELECT r.name, g.sum
FROM arrowmetal_group_by('sales', 'region', 'amount') g
JOIN regions r ON r.id = g.key
ORDER BY g.sum DESC;

arrowmetal_query takes the s-expression from Fused expressions and returns one row per aggregate: name, value (a DOUBLE) and exact (a BIGINT, non-null when the aggregate is integral). Both columns exist because a sum past 2^53 is exact in exact and rounded in value, and pretending otherwise would silently corrupt large integer totals.

Aggregate functions - SELECT am_sum(x) FROM t - are deliberately not registered even though duckdb_create_aggregate_function exists. DuckDB would call them once per 2048-row DataChunk, and a 2048-row GPU dispatch is all latency and no work; the CPU would be ahead and it would look like ArrowMetal's fault. Table functions get the whole column at once, which is the only shape where a GPU dispatch amortises.

It is correct, and DuckDB's own SQL is ahead of it

Every function's answer matches DuckDB's own SQL exactly, nulls included - python/tests/test_duckdb.py checks each one against the equivalent query on the same connection.

The header compiles: am_plan_source is the typedef and am_plan_source_create the function. test_the_public_c_header_compiles compiles the public header as C on every run; the @extension tests skip only when the extension has not been built. Tier 1 does not go through the header and is unaffected either way.

And plain SQL is ahead of it. The extension's timings are not recorded: Benchmarks/duckdb_bench.py measures the Python bridge only, and python/tests/test_duckdb.py checks the extension's answers without timing them.

The same GPU group-by from the Python bridge runs in 4.5 ms at 50M rows (§5), so the time is in the extension's path. Three things account for it:

  1. The DataChunk assembly is single-threaded. DuckDB emits ~2048-row chunks and one ArrowMetal array is one contiguous buffer, so the extension memcpys chunk after chunk on one thread while DuckDB's own aggregate is running on all of them. This is the dominant cost.
  2. duckdb_query materialises the whole result first, so the rows are copied once inside DuckDB before the extension has touched them.
  3. The extension re-reads the table on every call, because a table function has nowhere to cache. The bridge pays the crossing once and then runs twenty operations on resident data.

4b. Tier 3: ordinary SQL, rewritten

duckdb-extension/src/arrowmetal_rewrite.cpp is a DuckDB optimizer extension. The query does not change. After DuckDB's own optimizers have run, the extension looks at every aggregate in the plan, and where the shape is one it answers exactly (and, in the default mode, the size is one where the GPU was measured ahead) it puts ARROWMETAL_AGGREGATE where DuckDB's HASH_GROUP_BY or UNGROUPED_AGGREGATE would have been. The scan, the pushed-down filters and the projections below it are still DuckDB's, planned and run exactly as before; so is everything above it.

LOAD 'duckdb-extension/build/arrowmetal_rewrite.duckdb_extension';

SELECT region, sum(amount), count(*), max(amount) FROM sales GROUP BY region;   -- as written

EXPLAIN SELECT region, sum(amount) FROM sales GROUP BY region;   -- ARROWMETAL_AGGREGATE when rewritten
SELECT * FROM arrowmetal_rewrites();    -- every decision, with the reason, the threshold and the GPU path

SET arrowmetal_rewrite = 'auto';        -- the default: supported shapes, at the sizes measured faster
SET arrowmetal_rewrite = 'off';         -- plain DuckDB
SET arrowmetal_rewrite = 'force';       -- every supported shape, whatever its size

The three values are read in any letter case, and 'off' is also spelled 'false' or '0'. Any other value ('on', 'true', ' force' with a space, NULL) is an error from the SET itself, arrowmetal_rewrite: unrecognised value 'on'; expected 'auto', 'off' (also 'false' or '0') or 'force', and the mode stays what it was. RESET arrowmetal_rewrite returns to 'auto'.

From Python, am.duckdb_connect() opens a connection with the extension loaded, am.duckdb_is_rewritten(con, sql) says whether the plan of sql has an ARROWMETAL_AGGREGATE, and am.duckdb_rewrites(con) returns the decision log as a pyarrow.Table.

Why a C++ extension: the feasibility finding

  • DuckDB's C extension API has no optimizer hook. Tier 2 is built on it. Its header (duckdb_extension.h, the v1.2.0 C API that DuckDB 1.5.6 ships) has one planner-adjacent entry point, duckdb_add_replacement_scan, which swaps a table function in for a table name before binding; nothing in it sees or changes a logical plan.
  • The C++ API has one. OptimizerExtension (duckdb/optimizer/optimizer_extension.hpp) takes an optimize_function that DuckDB calls with the whole logical plan after its own optimizers, registered with OptimizerExtension::Register(DBConfig &, ...). LogicalExtensionOperator and PhysicalOperator let the extension put its own operator into the plan.
  • A C++ extension loads into the Python module. It leaves DuckDB's symbols unresolved and takes them from the process that loads it, and the duckdb 1.5.6 Python module exports DuckDB's C++ symbols, OptimizerExtension::Register among them. Compiled against the v1.5.6 headers (the module's source_id, 069cc9f9b5) with -undefined dynamic_lookup and stamped CPP / v1.5.6, the extension loads into that module with allow_unsigned_extensions. No DuckDB build is needed, only its headers. build_rewrite.sh checks the symbols on every build, because a missing one would abort the process at its first call instead of failing the LOAD.
  • So the Python-level alternative is not needed. Intercepting queries in a wrapper and routing them by their EXPLAIN would work only from Python and only for queries sent through the wrapper; the optimizer route works for any client of the loaded connection. am.duckdb_connect only loads it.

The price of the C++ route is the pin: DuckDB loads a CPP extension only into the exact release in its footer, where the C-API extension of tier 2 loads into any DuckDB with C API 1.2 or later. A new DuckDB release means rebuilding with build_rewrite.sh, which fetches that release's headers, and possibly changing the source, since DuckDB's C++ API is not stable across releases: against the 1.4.5 headers it does not compile (OptimizerExtension::Register and PhysicalOperator::GetDataInternal are not there yet).

What is rewritten

Row Eligible
Aggregates none (SELECT k FROM t GROUP BY k), or any of: sum and avg over integer columns of every width and signedness except UBIGINT (which DuckDB sums through a cast to HUGEINT); min and max over integer, DATE and TIMESTAMP columns; count(x) over a column of one of those types, count(*). Without GROUP BY, a query whose aggregates are all counts stays DuckDB's (the log's reason: "only counts, which need no kernel"). The argument is a column, or a column under the widening integer cast DuckDB inserts itself (sum over TINYINT is sum(CAST(x AS BIGINT))). No DISTINCT, FILTER or ORDER BY inside the aggregate.
Grouping none, or one column that is an integer, DATE, TIMESTAMP or VARCHAR column. No GROUPING SETS, ROLLUP or CUBE.
Input projections and filters over one table function whose row count DuckDB's planner knows: a table's seq_scan, read_parquet. A join, a window or another aggregate below the aggregate, or a source that does not report its size (a Python-registered Arrow table's arrow_scan), leaves it to DuckDB, and so does an input DuckDB has already replaced with an empty result (a WHERE v IS NOT NULL over a column its statistics know to be all NULL).

What DuckDB has already done to the plan stays done: the filters it pushed into the scan, its compressed materialization of the group key, its rewrite of sum(x + 1) into sum(x) plus a count, and its switch from sum to sum_no_overflow where its statistics prove the total fits in 64 bits. test_unsupported_shapes_are_left_alone covers the shapes left to DuckDB and the reason the log gives for each; test_a_parquet_scan and test_a_registered_arrow_table_is_left_to_duckdb the two sources.

Exactly DuckDB's answers

python/tests/test_duckdb_rewrite.py runs every query twice on the same connection, with arrowmetal_rewrite off and forced, and requires the same column types and the same values bit for bit, avg included; only the order of an unordered GROUP BY may differ, since SQL does not define it, and with ORDER BY the order is compared too. Every test that takes a connection runs twice, once where its table fits one block and once with 2,048-row blocks, so the streamed path is covered by the same queries. The tables come from hash() of the row number and reach both ends of every integer type.

  • Integer sums are exact to HUGEINT. DuckDB's sum over an integer column returns HUGEINT. A 64-bit column is summed as its high and low 32-bit halves, each of which fits in 64 bits over fewer than 2^31 rows, and the halves are recombined in 128 bits; where DuckDB's statistics already proved the total fits in 64 bits (sum_no_overflow), it is summed directly, and narrower columns cannot leave 64 bits. test_sums_past_int64_are_exact_hugeints sums values at both ends of BIGINT.
  • avg uses DuckDB's own arithmetic: double(sum) / double(count) over SMALLINT, and Hugeint::Cast<long double>(sum) / count over INTEGER and BIGINT, the two finalizers in DuckDB's avg.cpp, so the doubles are the same doubles.
  • NULLs: count(x) skips them; sum, avg, min and max of a group with no value are NULL; an empty input gives one row of zero counts and NULLs without GROUP BY and no rows with it; the NULL key is one group; a table whose keys are all NULL is one group. Each has a test.
  • Floating-point sum and avg are not rewritten: DuckDB's own float sums depend on how its threads split the input, so there is no single answer to match. min and max over floats are not rewritten either: DuckDB orders NaN above every number, where ArrowMetal's min/max skip NaN.

The conformance grid. python/tests/engine_duckdb_grid.py generates the same comparison over a grid instead of chosen queries: sum, avg, min, max, count(v), count(*), all of them in one query, and a GROUP BY with no aggregate, over the ten value types the extension rewrites and six it leaves to DuckDB (DOUBLE, DECIMAL(18,3), HUGEINT, BOOLEAN, TIMESTAMP_NS, TIMESTAMPTZ), with no key or one key of each kind it takes (INTEGER with NULLs, a wide BIGINT, a negative SMALLINT, UTINYINT, DATE, TIMESTAMP, VARCHAR with NULLs), no filter or one of two, value columns with no, 5%, 70% or all NULLs, and tables of 0, 1, 7, 1,000 and 100,000 rows (the last also in 2,048-row blocks), plus integer extremes. Each query runs with the rewrite 'off' and 'force' on one connection. In the run recorded in Benchmarks/results/engine_conformance_2026-10-04.csv, on DuckDB 1.5.6, 33,376 queries: 21,844 rewritten and identical to DuckDB's answer, 11,532 left to DuckDB by the extension (and identical, as they must be), and none different. The per-shape counts are in Benchmarks/results/engine_conformance_2026-10-04_shapes.csv; Coverage summary has the summary.

How it runs

  • The sink. ARROWMETAL_AGGREGATE is a parallel sink. Each DuckDB thread reserves row positions for its chunk with one atomic add and copies the aggregate's input columns into page-aligned buffers without a lock; a NULL clears its bit in an Arrow validity bitmap. A string key reserves its rows and bytes together under a lock, so the offsets stay in row order.
  • Slabs. A fixed-width column's buffer comes from a pool kept across queries. Each slab is imported into ArrowMetal once, over its whole capacity; a query writes its rows into the same memory and hands ArrowMetal a zero-copy slice of the first rows, so the GPU wrapping is made once per slab rather than once per query. The pool keeps at most 4 GB of mappings, least recently used out first.
  • Three GPU paths. Without GROUP BY, one fused query computes every aggregate in one pass (am_query, Fused expressions). With an integer key whose values span at most 2^20, the fused dense group-by keeps one slot per key value, split into several passes when the table would not fit in threadgroup memory. Anything else - a VARCHAR key, a wider key range, a 64-bit min/max under a GROUP BY - takes the hash group-by (am_group_by_keys and am_group_agg_ex). A short VARCHAR key that DuckDB's compressed materialization has already turned into an integer arrives as that integer and takes the integer paths.
  • Streamed plans. An ungrouped aggregate, and a group-by whose key DuckDB's statistics put within 65,536 values (with min/max over at most 32-bit columns), is processed in blocks of arrowmetal_rewrite_block_rows rows (default 16,777,216). Each block goes to a GPU worker thread the moment its last row lands, while DuckDB is still scanning; Finalize runs the last, partly filled one and merges the per-block results on the host, exactly (sums in 128 bits). The other plans take up to 2^31 - 1 rows, the GPU kernels' 32-bit row index; a streamed plan is not held to that, since each block is smaller and the partial sums are added in 128 bits.
  • Rows past the reservation. The buffers are sized from the source's row count at planning time. Rows beyond that - a prepared statement run after the table grew - are kept aside and appended in Finalize (test_a_prepared_statement_after_the_table_grew, test_a_streamed_plan_past_its_block_directory).
  • One GPU. Rewritten queries on different connections take turns on the GPU, under one process-wide lock (test_concurrent_queries_on_two_connections).

When auto rewrites

Two conditions, both recorded for each decision in arrowmetal_rewrites():

  1. The router's crossover. DuckDB's estimate of the rows reaching the aggregate is at or above the largest crossover among the query's aggregates in Benchmarks/results/router_2026-09-24.json: the reductions rows without GROUP BY, the group-by rows with it, in the 1,000-group or 100,000-group class by DuckDB's estimate of the group count (split at 10,000, the geometric middle), and the utf8 rows for a VARCHAR key. test_crossovers_match_the_router_sweep holds the constants to the JSON. The constants are read from Benchmarks/results/router_2026-09-24.json, which supersedes the 2026-09-17 sweep; of the rows used here only min(int64, 10% nulls) moved between the two, 50,000,000 then and 10,000,000 now. min without GROUP BY is gated by the ungrouped class's floor of 50,000,000 rows below (or not rewritten, with one or two aggregates), so no auto decision changes; at 10,000,000 rows the reason for such a query now reads "below the measured floor" where the results file, measured before the change, says "below the crossover".
  2. A measured floor. The router's crossovers compare kernels on data already on the GPU. Here every row is first copied out of DuckDB's scan, and DuckDB's own aggregate runs while it scans, so the query's shape class must also have been measured faster than DuckDB's operators, from the size in this table, by Benchmarks/duckdb_rewrite_bench.py:
Shape class auto from
no GROUP BY, with three or more of sum/min/max/avg 50,000,000 rows
fused group-by, an estimated 10,000 groups or more (with no aggregates too) 10,000,000 rows
fused group-by, fewer groups, three or more of sum/min/max/avg 50,000,000 rows
hash group-by, an estimated 10,000 groups or more (with no aggregates too) 50,000,000 rows
no GROUP BY with one or two of sum/min/max/avg; fused group-by with fewer groups and at most two; hash group-by with fewer groups; a VARCHAR key not rewritten in auto

The benchmark's queries in each class are the rows of the results file whose shape_class column names it; a query the file does not list is in auto because of its class, not because it was timed. test_measured_floors_are_in_the_benchmark_results requires every row of a rewritten class to be ahead at the class's floor, and no row of any other class to be rewritten.

The measurements are in Benchmarks/results/duckdb_rewrite_2026-10-04-ac.csv, a quiet run on DuckDB 1.5.6 on AC power (its conditions in Benchmarks/results/duckdb_rewrite_2026-10-04-ac_conditions.txt): for each query and size (1M, 10M, 50M rows), DuckDB's time and the rewrite's in three alternating rounds, each mode's untimed warm-up of at least 100 ms followed by five timed runs: the best wall time with its CPU time, the median, the best first run after 500 ms of idle, the GPU path taken, and what auto decided. Its header records the load average at the start and the end of the run. test_measured_floors_are_in_the_benchmark_results requires every class in the table to be faster in that file at its floor. The floors were first fitted to a run taken while other work shared the machine, kept as history in Benchmarks/results/duckdb_rewrite_2026-09-23_provisional.csv, and checked by a second run on 2026-09-24 (Benchmarks/results/duckdb_rewrite_2026-09-24.csv, one warm-up and the best of five, its conditions in Benchmarks/results/bench_conditions_2026-09-24.txt); the run of 2026-10-02 on DuckDB 1.5.5 (Benchmarks/results/duckdb_rewrite_2026-10-02.csv) and the two runs of 2026-10-04 on DuckDB 1.5.6, on battery power (Benchmarks/results/duckdb_rewrite_2026-10-04.csv) and on AC power, give the same floors; DuckDB's own best times in the AC run are 0.96 to 1.12 times those of the 1.5.5 run, query for query. Reproduce with:

./duckdb-extension/build_rewrite.sh
PYTHONPATH=python python Benchmarks/duckdb_rewrite_bench.py        # 1M, 10M and 50M rows

Each floor is the smallest of the three sizes at which every query of the class is ahead of DuckDB, at that size and above, on the best and on the median run. Every query auto rewrote is ahead of DuckDB, by 1.08x (sum, max, avg (BIGINT) at 50M rows; 1.04x on the median) to 5.32x (100k INTEGER keys alone (no aggregates) at 50M rows; 3.76x on the median). One query of each rewritten class keeps the class out of auto at the size below its floor: sum, max, avg (BIGINT) at 10M rows (0.87x) for the ungrouped class, 100k INTEGER keys: sum, min, max, avg at 1M (0.71x) for the fused group-by with many groups, 1k INTEGER keys: sum, count, min, max, avg at 10M (0.55x) for the fused group-by with fewer groups, and ~1M wide BIGINT keys: sum, count at 10M (0.94x) for the hash group-by with many groups.

The classes left to DuckDB are the ones where its own operators came out ahead in that file, or not ahead consistently: a single sum (DuckDB aggregates while it scans, and the rewrite has to copy the column out first), few groups with at most two aggregates, and VARCHAR keys that DuckDB keeps as strings. In each of them at least one query is behind DuckDB at every size, 50M rows included: sum(INTEGER) at 0.32x, 1k INTEGER keys: sum at 0.59x, ~3k wide BIGINT keys: sum at 0.92x and 1k long VARCHAR keys: sum, count at 0.22x. Some of their queries were ahead: sum and sum, avg over full-range BIGINT values (1.92x and 3.76x at 50M rows), 10k INTEGER keys: sum, count (1.48x at 50M, 0.85x at 10M) and 1k short VARCHAR keys: sum, count (1.12x at 50M). In the ungrouped case DuckDB's time depends on the values, which the plan does not show: avg and sum, avg over non-negative BIGINT values are to improve (0.64x and 0.83x at 50M rows), while the same aggregates over full-range BIGINT values are ahead. Below a rewritten class's floor, or below the router's crossover, some queries were ahead as well, for example sum, count, min, max (BIGINT, 10% NULL) at 10M rows (1.44x) and 100k INTEGER keys alone (no aggregates) at 1M (1.52x); the floor holds each class to its slowest query.

To improve: on the first run after 500 ms of idle, four of the thirteen rewritten query-size pairs are behind DuckDB's first run after the same idle, all at 50M rows: sum, max, avg (BIGINT) 22.38 ms against 8.55 ms (0.38x), sum, count, min, max (BIGINT, 10% NULL) 29.39 against 23.72 ms (0.81x), sum, min, max, avg (INTEGER) 12.54 against 10.34 ms (0.82x) and 1k INTEGER keys: sum, count, min, max, avg 36.68 against 32.61 ms (0.89x). The other nine are 1.09x to 2.26x ahead on that run.

Limits

  • Built for one DuckDB release and one platform string at a time; unsigned.
  • The decision is made when the plan is: a prepared statement keeps the decision made at PREPARE, and EXPLAIN records a decision of its own.
  • A plan keeps the key statistics it was made with. Where DuckDB would have run the group-by as its PERFECT_HASH_GROUP_BY (an integer key whose statistics span at most 2^perfect_ht_threshold - 2 values, 12 bits by default), a key that lands past the table those statistics sized raises DuckDB's own error, Perfect hash aggregate: aggregate group N exceeded total groups M. This likely means that the statistics in your data source are corrupt., as DuckDB does with the rewrite off. That happens when a prepared statement runs after keys outside the planned range were inserted: the plan's compressed materialization narrowed the key to key - min in a smaller unsigned type, in which such keys wrap. Where DuckDB would have used its hash group-by, which does not check, the rewrite does not either, and both answer over the same narrowed keys (test_a_prepared_statement_whose_key_outgrew_its_statistics, test_where_duckdb_does_not_check_the_statistics_neither_does_the_rewrite).
  • One known difference in that error: for an 8-bit key (TINYINT, UTINYINT, which the plan does not narrow) below the planned minimum, the group number. Such a key's slot is negative, key - min + 1; DuckDB prints it as 2^128 plus the slot and the rewrite as 2^64 plus the slot, so statistics of [10, 14] and a key of 3 read group 340282366920938463463374607431768211450 from DuckDB and group 18446744073709551610 from the rewrite. The difference is deterministic; the exception class (InvalidInputException) and the rest of the text are the same, and keys of 16 bits or more, or above the maximum, give the same number. A key exactly one below the minimum has slot 0, which is the slot DuckDB keeps for the NULL key: DuckDB returns that key's rows as the NULL group, with no error, and the rewrite returns them under the key itself (test_an_8_bit_key_below_the_planned_minimum).
  • The log keeps the last 1,024 decisions of the process and is shared by every connection; a rewritten plan's path, rows_seen, groups and gpu_ms describe its latest run.
  • The aggregate's input is held in memory: the buffers are reserved for the source's row count and filled as rows arrive (a streamed plan holds only its blocks in flight). Freed slabs stay mapped in the pool, up to 4 GB.
  • The shapes in "What is rewritten" only: one group key, no floating-point sum/avg, no DECIMAL, HUGEINT or BOOLEAN values, no min/max over strings.

5. Numbers

Apple M4 Max, DuckDB 1.5.5, pyarrow 25.0.1, ArrowMetal 0.1.0. Best of 5, wall ms, with the process's CPU time beside it. Every figure in this section is from Benchmarks/results/duckdb_bench_50000000_2026-09-07.txt. Reproduce with:

PYTHONPATH=python python Benchmarks/duckdb_bench.py 50000000 5

Both sides compute the same answer from the same in-memory DuckDB table, and the benchmark checks that they agree before it reports a time.

50M rows

operation DuckDB CPU-ms GPU (resident) CPU-ms GPU + crossing resident one-shot
filter + sum 1.1 13.7 3.1 0.8 42.8 0.4x 0.0x
filter + sum, fused query 1.1 13.6 1.1 0.4 40.6 1.0x 0.0x
group-by sum, 1k keys 7.6 112.2 2.1 0.6 68.8 3.6x 0.1x
group-by sum, 100k keys 71.4 1056.2 4.5 0.6 65.9 16.0x 1.1x
top_k(100) 5.7 12.2 9.7 3.4 54.1 0.6x 0.1x
sort 39.2 570.4 17.6 1.4 57.3 2.2x 0.7x
count(s like '%user1%') 47.4 718.5 5.1 0.4 84.7 9.3x 0.6x
avg(f) 2.8 40.8 2.5 0.4 42.8 1.1x 0.1x

Look at the CPU-ms columns as well as the wall-ms. DuckDB's 100k-key group-by at 50M rows costs 1,056 CPU-ms to produce 71.4 ms of wall time - it is using about fifteen cores. The GPU's costs 0.6 CPU-ms. If anything else on the machine wants those cores, that difference is the real one.

What the crossing costs

At 50M rows, one BIGINT column (406 MB):

to_arrow_table       19.7 ms   (407 chunks - DuckDB handing over pointers, not bytes)
combine_chunks       26.6 ms   (a real copy: 407 chunks into one buffer, 15.3 GB/s)
am_import             5.7 ms   (buffer shared, not copied - the pointer is unchanged)
                    -------
total                52.1 ms

The Metal import copies nothing; combine_chunks is the largest single item. It is the price of ArrowMetal wanting one contiguous array per column and DuckDB producing many. am.duckdb_batches avoids it entirely by keeping the chunks separate. Streaming a sum through the GPU one record batch at a time, never materialising the column:

rows per batch wall ms CPU-ms
262,144 98.7 114.1
1,048,576 67.5 78.9
4,194,304 76.1 76.0

All three are exact. On this run the whole-table crossing (52.1 ms) is ahead of the streaming path (67.5 ms at its best batch size), so streaming is a memory-footprint argument rather than a speed one.

This is also why the "one-shot" column above is mostly below 1.0x: for a single operation the crossing dominates, and DuckDB is ahead. The GPU pays off when you do several things to one dataset, or one expensive thing.

50M rows out of Parquet

con.execute("copy (select i::BIGINT amount, (hash(i) % 1000)::INTEGER region "
            "from range(50000000) r(i)) to 'big.parquet' (format parquet)")

con.sql("select sum(amount) from read_parquet('big.parquet')")
am.from_duckdb(con.sql("select amount from read_parquet('big.parquet')"))["amount"].sum()
am.duckdb_aggregate(con.sql("select amount from read_parquet('big.parquet')"),
                    {"total": ("sum", "amount")}, rows_per_batch=1 << 22)

All three give 1249999975000000. DuckDB is ahead here: a single sum over a Parquet file is exactly the case where DuckDB aggregates during the scan and never materialises anything. Reach for the GPU when the aggregate is the expensive part, not the scan.

The matrix rows, DuckDB alongside

The full matrix has no DuckDB column, because DuckDB is a query engine rather than an array library: the comparison needs a table, a query, and a decision about where the result lands. Benchmarks/duckdb_matrix.py makes those decisions explicit and measures DuckDB on the matrix's sort, group-by, filter and sum rows, with the matrix's own generators, sizes and best-of-five protocol, in four idioms: the same SQL over a registered Arrow table in one chunk and in sixteen batches, and over a native DuckDB table with the result exported to Arrow or kept in a DuckDB temp table. Every result asserts its row count. Reproduce with:

PYTHONPATH=python python Benchmarks/duckdb_matrix.py 10000000 50000000
python Benchmarks/duckdb_matrix.py --report Benchmarks/results/duckdb_matrix_2026-09-12.csv

DuckDB rows measured 2026-09-12: duckdb 1.5.5, threads=16, preserve_insertion_order=True, pyarrow 25.0.1, python 3.13.9, Apple M4 Max, macOS 26.6.2, loadavg at start 2.5, 2026-09-12T23:34:30. ArrowMetal, Polars and pyarrow are the published 2026-09-07 matrix rows (Benchmarks/results/full_matrix_2026-09-07-parallel.csv), not re-measured. Wall ms, best of up to 5 after one warm-up, process CPU ms in brackets. "Best" is the faster of a library's eager and parallel idioms. The last column is the fastest CPU number on the row, any library or idiom, over ArrowMetal.

family op rows ArrowMetal Polars best pyarrow best DuckDB native, to Arrow DuckDB native, stays in DuckDB DuckDB Arrow scan, 16 batches DuckDB Arrow scan, 1 chunk fastest CPU / ArrowMetal
reductions sum(int64, 10% nulls) 10,000,000 0.30 (0) 0.57 (6) 1.29 (14) 1.07 (15) 1.08 (14) 1.90 (18) 12.0 1.9x
compare+select filter int64 (30% kept) 10,000,000 0.78 (1) 0.65 (6) 5.33 (60) 35.9 (53) 8.52 (88) 39.9 (73) 42.4 0.8x
sort argsort int64 10,000,000 7.81 (1) 57.6 (655) 841.2 (841) 212.7 (647) 52.3 (674) 248.6 (688) 403.7 6.7x
sort argsort float64 10,000,000 8.11 (1) 78.6 (1,004) 984.1 (983) 231.4 (725) 60.1 (767) 266.2 (779) 460.0 7.4x
sort sort float64 10,000,000 6.85 (1) 26.2 (250) 1,006.5 (1,006) 204.9 (628) 54.6 (718) 223.6 (669) 410.9 3.8x
sort lexsort (2 int32 keys) 10,000,000 7.29 (2) 111.4 (1,217) 1,122.7 (1,122) 197.5 (635) 49.9 (660) 264.6 (893) 625.9 6.8x
group-by sum by int32 key (1000 groups) 10,000,000 1.73 (1) 20.2 (242) 4.61 (51) 2.09 (28) 2.11 (27) 7.67 (80) 53.7 1.2x
group-by mean by int32 key (1000 groups) 10,000,000 2.21 (1) 20.3 (232) 4.63 (51) 2.36 (33) 2.36 (32) 8.31 (79) 55.2 1.1x
group-by count by int32 key (1000 groups) 10,000,000 1.52 (1) 21.2 (166) 4.43 (48) 2.14 (25) 2.03 (26) 6.70 (72) 48.4 1.3x
group-by sum by two int32 keys (~1024 groups) 10,000,000 2.19 (2) 34.0 (385) 5.50 (60) 3.04 (41) 2.98 (40) 9.25 (100) 69.5 1.4x
group-by sum by int32 key (100000 groups) 10,000,000 1.95 (1) 22.7 (275) 16.4 (98) 20.5 (292) 21.0 (295) 29.3 (357) 116.4 8.4x
group-by mean by int32 key (100000 groups) 10,000,000 2.48 (1) 23.3 (284) 16.2 (98) 24.1 (345) 24.3 (344) 29.4 (351) 112.0 6.6x
group-by count by int32 key (100000 groups) 10,000,000 1.96 (1) 27.4 (208) 13.0 (81) 19.8 (279) 19.9 (282) 23.3 (287) 90.1 6.6x
group-by sum by int32 key (10000000 groups) 10,000,000 9.46 (2) 69.5 (942) 312.4 (627) 187.4 (815) 38.0 (524) 178.0 (880) 278.4 4.0x
reductions sum(int64, 10% nulls) 50,000,000 1.11 (0) 2.30 (26) 5.09 (66) 4.40 (65) 4.67 (64) 7.26 (77) 58.9 2.1x
compare+select filter int64 (30% kept) 50,000,000 2.23 (1) 2.85 (36) 22.6 (298) 186.8 (242) 32.1 (416) 204.8 (337) 216.3 1.3x
sort argsort int64 50,000,000 39.1 (1) 302.0 (3,574) 5,196.8 (5,196) 1,525.2 (3,954) 320.7 (4,340) 1,471.2 (4,009) 2,082.6 7.7x
sort argsort float64 50,000,000 39.9 (1) 420.1 (5,531) 5,885.3 (5,885) 1,403.5 (3,854) 344.0 (4,349) 1,479.2 (4,014) 2,358.4 8.6x
sort sort float64 50,000,000 32.1 (1) 132.9 (1,284) 6,044.5 (6,044) 1,308.4 (3,377) 293.5 (3,866) 1,248.9 (3,483) 2,038.2 4.1x
sort lexsort (2 int32 keys) 50,000,000 37.6 (2) 902.9 (9,948) 7,773.5 (7,773) 1,253.8 (3,088) 264.9 (3,532) 1,557.7 (4,595) 3,378.6 7.0x
group-by sum by int32 key (1000 groups) 50,000,000 4.89 (1) 81.9 (1,186) 18.5 (250) 9.00 (135) 9.07 (135) 26.2 (357) 262.6 1.8x
group-by mean by int32 key (1000 groups) 50,000,000 4.91 (1) 81.5 (1,180) 18.6 (251) 10.1 (151) 10.5 (159) 30.4 (350) 265.5 2.0x
group-by count by int32 key (1000 groups) 50,000,000 4.23 (1) 89.0 (810) 17.4 (235) 8.35 (126) 8.37 (126) 25.6 (318) 241.0 2.0x
group-by sum by two int32 keys (~1024 groups) 50,000,000 5.91 (2) 164.2 (2,216) 23.9 (322) 13.9 (199) 14.0 (201) 37.3 (434) 344.6 2.3x
group-by sum by int32 key (100000 groups) 50,000,000 7.41 (1) 99.4 (1,461) 46.4 (542) 76.1 (1,087) 75.9 (1,130) 92.3 (1,248) 444.2 6.3x
group-by mean by int32 key (100000 groups) 50,000,000 7.39 (1) 99.2 (1,458) 47.0 (540) 86.5 (1,277) 85.5 (1,291) 93.9 (1,290) 445.1 6.4x
group-by count by int32 key (100000 groups) 50,000,000 4.92 (1) 120.7 (1,060) 38.3 (421) 64.9 (974) 67.2 (992) 70.8 (937) 400.0 7.8x
group-by sum by int32 key (10000000 groups) 50,000,000 46.1 (2) 321.7 (4,585) 1,303.3 (3,682) 324.4 (2,356) 148.6 (2,210) 360.1 (2,712) 1,371.0 3.2x
sort sort utf8 1,000,000 - - - 25.8 (84) 11.3 (98) 30.6 (104) 70.4 -
sort sort utf8 10,000,000 23.0 (7) 133.1 (1,326) 1,864.9 (1,865) 295.0 (852) 79.4 (1,063) 345.7 (926) 723.7 3.5x

SQL per operation, over the native table; the Arrow-scan idioms run the same text over the registered Arrow table, and the stays-in-DuckDB idiom wraps it in CREATE OR REPLACE TEMP TABLE r AS:

  • sum(int64, 10% nulls): SELECT sum(x) FROM tn (DuckDB sum(BIGINT) returns HUGEINT)
  • filter int64 (30% kept): SELECT x FROM tn WHERE m
  • argsort int64: SELECT i FROM tn ORDER BY x (DuckDB has no argsort)
  • argsort float64: SELECT i FROM tn ORDER BY x (DuckDB has no argsort)
  • sort float64: SELECT x FROM tn ORDER BY x
  • lexsort (2 int32 keys): SELECT i FROM tn ORDER BY a, b (DuckDB has no argsort)
  • sum by int32 key (1000 groups): SELECT k, sum(x) FROM tn GROUP BY k
  • mean by int32 key (1000 groups): SELECT k, avg(x) FROM tn GROUP BY k
  • count by int32 key (1000 groups): SELECT k, count(x) FROM tn GROUP BY k
  • sum by two int32 keys (~1024 groups): SELECT a, b, sum(x) FROM tn GROUP BY a, b
  • sum by int32 key (100000 groups): SELECT k, sum(x) FROM tn GROUP BY k
  • mean by int32 key (100000 groups): SELECT k, avg(x) FROM tn GROUP BY k
  • count by int32 key (100000 groups): SELECT k, count(x) FROM tn GROUP BY k
  • sum by int32 key (10000000 groups): SELECT k, sum(x) FROM tn GROUP BY k
  • sort utf8: SELECT s FROM tn ORDER BY s

What the table says:

  • DuckDB is the fastest CPU engine on low-cardinality group-by and on multi-key sort, and it is a long way ahead of Polars on both: 9.0 ms against Polars' 81.9 for sum by int32 key at 1,000 groups and 50M rows, 265 ms against 903 for lexsort. Against DuckDB those two ArrowMetal rows are 1.8x and 7.0x, where the matrix shows 3.8x (against Acero; 16.8x against Polars) and 24.0x. At 10M rows and 1,000 groups the margin is 1.2x. This is the honest group-by story: the GPU is ahead by 3x or more from 100,000 groups upward and roughly even with DuckDB at a thousand groups.
  • DuckDB does not win every CPU row. At 100,000 groups Acero is faster than DuckDB (46.4 ms against 75.9 at 50M rows), on sort float64 Polars is (133 against 294), and on the filter and the plain sum Polars lazy is the fastest CPU idiom by a wide margin. The fastest-CPU column picks the winner per row.
  • Exporting the result to Arrow is a large part of DuckDB's wall time on wide results. Sorting 50M float64 takes 294 ms inside DuckDB and 1,308 ms delivered as an Arrow table; the argsorts add about 1.1 to 1.2 s the same way. A Python caller pays that; ArrowMetal's result is already an Arrow buffer. For a group-by result of a thousand rows the two idioms are the same.
  • The CPU-time gap does not narrow. DuckDB's 1,000-group sum at 50M rows costs 135 CPU-ms across about fifteen cores, its sorts 3,100 to 4,350; the ArrowMetal rows cost 1.2 to 2.4 CPU-ms.
  • A registered Arrow table in one chunk scans on two cores. That idiom is 1.2x slower than the native table on the filter and 29x slower on the 1,000-group sum. Register a chunked table, or create a DuckDB table, before benchmarking DuckDB over Arrow data.

Two things to carry with the numbers. The ArrowMetal, Polars and pyarrow rows were measured on 2026-09-07 and the DuckDB rows on 2026-09-12, on the same machine with the same protocol, so the day differs. And the sort row in the 50M table above (39.2 ms) sorts v = i, a column that is already in order; on uniform random float64 of the same size DuckDB's sort takes 294 ms.


6. When this is worth it, and when it is not

Worth it

  • High-cardinality group-by over a resident column. 100,000 keys at 50M rows: 16x, at 0.6 CPU-ms against DuckDB's 1,056; a single cold query, crossing included, is 1.1x.
  • String matching over a column you are keeping resident. LIKE '%...%' at 50M rows: 9.3x resident; a single cold query is 0.6x.
  • Sorting a resident column. 2.2x at 50M rows (17.6 ms against DuckDB's 39.2), and 1.4 CPU-ms against 570.
  • Several operations over one dataset. The crossing is paid once; every kernel after that is free of it. This is the single biggest lever - from_duckdb once, then loop.
  • Anything where you want the cores back. The GPU group-by uses 0.6 CPU-ms where DuckDB uses 1,056.
  • Fused filter-and-aggregate expressions, which read the column once no matter how long they are.

Not worth it

  • A single sum/avg over one column, whether from memory or Parquet. Memory-bound, and DuckDB does it while scanning.
  • Sorting from cold. The GPU radix sort is ahead of DuckDB's parallel sort on a resident column (2.2x) but behind once the crossing is included (0.7x one-shot).
  • Small data. Below a few million rows the dispatch latency is most of the time — about 60-70 µs measured (The persistent-kernel result), 110-160 µs per call for sum and filter at 1,000 rows in the matrix's latency family; use am.batch() to amortise it, or do not bother.
  • Anything the extension can do, if speed is the reason. See §4.

The rule of thumb: the GPU is ahead when the compute is large relative to the bytes. Group-by with many keys and string matching are compute-heavy per byte; sum is not.


7. Limits

Tier 1

  • One MetalArray is one contiguous array, so a multi-chunk DuckDB result is concatenated on the way in. At 50M rows that is 26.6 ms (§5). am.duckdb_batches skips it.
  • to_duckdb registers a view on one connection; other connections to the same database will not see it. Use CREATE TABLE ... AS SELECT * FROM the_view to make it durable.
  • Nested columns (list, struct, map) import and round-trip, but only the structural kernels operate on them - you cannot group by a struct.
  • Streaming refuses median, quantile, stddev, mode and tdigest by design (§3).

Tier 2

  • Behind DuckDB's own SQL as measured (§4).
  • The eight integer widths, FLOAT, DOUBLE, DATE and TIMESTAMP only -- exactly what arrow_format_for in duckdb-extension/src/arrowmetal_extension.cpp lists. BOOLEAN is not among them; nor are VARCHAR, DECIMAL, TIME, TIMESTAMPTZ, HUGEINT or the nested types. All of them are refused by name at bind time with a message pointing here, and the bridge handles every one.
  • The table functions take a table or view name, not a subquery - DuckDB's C table-function API has no way to accept a relation. A Python-registered relation (con.register(...)) is connection-local and the extension's own connection cannot see it; CREATE VIEW first.
  • arrowmetal_group_by returns key as BIGINT or DOUBLE, so a key column wider than that (there is none among the supported types) would not fit.
  • arrowmetal_query accepts only an (aggregate ...) terminal. (project ...) and (group_by ...) produce columns, which this function's fixed output schema cannot carry; use arrowmetal_group_by or the bridge.
  • The extension opens one DuckDB connection when it loads and keeps it for the life of the process, serialised by a mutex. It cannot connect lazily instead: the duckdb_database handle the loader passes does not outlive the load call. That connection is never closed, so the database it belongs to is not freed until the process exits.
  • Unsigned. allow_unsigned_extensions has to be set when the connection is created.
  • Built for one platform string at a time; osx_arm64 unless you set DUCKDB_PLATFORM.

Tier 3

  • §4b, "Limits".

Files

Path What
python/arrowmetal/duckdb_bridge.py tier 1, the whole bridge; duckdb_connect, duckdb_rewrites, duckdb_is_rewritten for tier 3
duckdb-extension/src/arrowmetal_extension.cpp tier 2, the extension
duckdb-extension/src/arrowmetal_rewrite.cpp tier 3, the optimizer extension
duckdb-extension/build.sh builds the tier 2 extension without cmake
duckdb-extension/build_rewrite.sh builds the tier 3 extension against the installed DuckDB release
duckdb-extension/CMakeLists.txt the tier 2 build, for cmake
duckdb-extension/scripts/append_metadata.py writes DuckDB's 512-byte extension footer (C_STRUCT or CPP)
python/tests/test_duckdb.py tiers 1 and 2, with DuckDB's own SQL as the oracle
python/tests/test_duckdb_rewrite.py tier 3, differential against DuckDB with the rewrite off
Benchmarks/duckdb_bench.py the tables in §5
Benchmarks/duckdb_rewrite_bench.py tier 3 against DuckDB's own operators at 1M, 10M and 50M rows
Benchmarks/results/duckdb_rewrite_2026-10-04-ac.csv its quiet run on DuckDB 1.5.6 on AC power, which the auto floors cite; run conditions in duckdb_rewrite_2026-10-04-ac_conditions.txt
Benchmarks/results/duckdb_rewrite_2026-10-04.csv its run on DuckDB 1.5.6 on battery power the same day, kept as history; run conditions in duckdb_rewrite_2026-10-04_conditions.txt
Benchmarks/results/duckdb_rewrite_2026-10-02.csv its quiet run on DuckDB 1.5.5, kept as history
Benchmarks/results/duckdb_rewrite_2026-09-24.csv its run of 2026-09-24 (one warm-up, best of five), kept as history
Benchmarks/results/duckdb_rewrite_2026-09-23_provisional.csv its first run, on a shared machine, kept as history
docs/DATAFUSION.md

ArrowMetal for DataFusion

Apache DataFusion is a query engine in Rust: SQL and DataFrames over Arrow, with its own planner, optimizer and multi-threaded operators. datafusion-arrowmetal (in datafusion/) is a physical optimizer rule for DataFusion 55.1. Registered on a SessionContext, it replaces DataFusion's full sort (an ORDER BY without LIMIT) with MetalExec, which runs the sort on the Apple GPU through ArrowMetal's plan runner and hands DataFusion the RecordBatches it expects. It also replaces the hash aggregates (GROUP BY, DISTINCT) of the shapes a measured table takes: count(*) or DISTINCT over two int32 keys, or MIN/MAX of an integer column over one int64 key or two integer keys, of a MemTable of at least 50,000,000 rows. Such an aggregate estimates its number of groups from a sample of its keys when it runs, runs on the GPU at the numbers of groups the table takes at that row count, and otherwise hands the node back to DataFusion's own operators. Hash joins (inner, left, right) are translated too, and a measured join table decides them; it takes no measured join. The SQL does not change, and every other node of the plan stays DataFusion's.

The summary. On an Apple M4 Max with DataFusion's default of one partition per core, a full sort of 250,000 to 50,000,000 rows is 6.9x to 28.8x faster with the rule than DataFusion alone, and at 50M rows it uses 96 to 126 CPU-ms where DataFusion uses 6,104 to 8,311. Over DataFusion's own Parquet reader, a Float64 sort is 10.8x to 16.5x faster at 10M and 50M rows, and a sort over a Parquet file with a string column 3.6x to 5.5x at 1M to 50M rows. The aggregates the default runs on the GPU, count(*) and DISTINCT over two int32 keys and MIN/MAX of an integer column, all at 50M rows, were 2.31x to 4.27x faster than DataFusion alone warm; on the first run after 500 ms of idle they were 1.24x to 2.17x faster than DataFusion alone's first run after the same idle, and after 5 s of idle 1.11x to 1.68x; against DataFusion alone's warm time, that first run was 0.89x to 1.78x after 500 ms and 0.69x to 1.35x after 5 s (Aggregates). The answers are DataFusion's: a differential grid of 13,632 query pairs runs every query with and without the rule and finds 0 mismatches. Top-k (ORDER BY … LIMIT), filters, joins and every other aggregate shape are left to DataFusion by default; the measured numbers for them are under To improve and Joins, rule on and off.


Install

The crate needs an Apple silicon Mac and the ArrowMetal dylib:

# 1. The GPU library, from the repository root.
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
    swift build -c release --product ArrowMetalC

# 2. The example, against that build.
cd datafusion
ARROWMETAL_LIB=$PWD/../.build/release/libArrowMetalC.dylib cargo run --example quickstart

The crate is on crates.io:

# Cargo.toml
[dependencies]
datafusion = { version = "=55.1.0", default-features = false, features = ["sql"] }
datafusion-arrowmetal = "0.5.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

From a checkout, depend on it by path instead: datafusion-arrowmetal = { path = "../ArrowMetal/datafusion" }.

It depends on the Rust crate, which finds libArrowMetalC.dylib through ARROWMETAL_LIB and the other locations listed in RUST.md, "Finding the dylib". A binary that uses the rule needs the dylib's directory as an rpath entry, set the way that section shows (a build.rs or RUSTFLAGS); the crate's own build.rs does it for its tests and examples.

Versions. DataFusion is pinned to =55.1.0, the release the grid and the benchmark ran on (arrow-rs 59.3.0, the same arrow-rs major as the arrowmetal crate, so batches pass between the two with no conversion). DataFusion 55.1.0 declares rust-version 1.94; the crate was built with rustc 1.95.0.


Use

use std::sync::Arc;

use datafusion::arrow::array::{ArrayRef, Int64Array, RecordBatch};
use datafusion::datasource::MemTable;
use datafusion::error::Result;
use datafusion::physical_plan::{collect, displayable};
use datafusion::prelude::SessionConfig;
use datafusion_arrowmetal::{session_context, ArrowMetalConfig, ArrowMetalRule};

#[tokio::main]
async fn main() -> Result<()> {
    let rule = ArrowMetalRule::new(ArrowMetalConfig::default()); // full sorts from 250,000 rows
    let ctx = session_context(SessionConfig::new(), rule.clone()); // DataFusion's defaults + the rule

    let v: Int64Array = (0..1_000_000i64).map(|i| (i * 7919) % 1_000_003).collect();
    let batch = RecordBatch::try_from_iter([("v", Arc::new(v) as ArrayRef)])?;
    ctx.register_table("t", Arc::new(MemTable::try_new(batch.schema(), vec![vec![batch]])?))?;

    // EXPLAIN shows MetalExec in the physical plan.
    ctx.sql("EXPLAIN SELECT v FROM t ORDER BY v DESC").await?.show().await?;

    // Or plan once, print the plan and the rule's report, and run that plan.
    rule.clear_report();
    let plan = ctx.sql("SELECT v FROM t ORDER BY v DESC").await?.create_physical_plan().await?;
    println!("{}", displayable(plan.as_ref()).indent(false));
    println!("{}", rule.report()); // TAKEN / LEFT, with the reason
    let batches = collect(plan, ctx.task_ctx()).await?;
    println!("{} rows", batches.iter().map(|b| b.num_rows()).sum::<usize>());
    Ok(())
}

The EXPLAIN it prints:

+---------------+-----------------------------------------------------+
| plan_type     | plan                                                |
+---------------+-----------------------------------------------------+
| logical_plan  | Sort: t.v DESC NULLS FIRST                          |
|               |   TableScan: t projection=[v]                       |
| physical_plan | MetalExec: sort=[v DESC NULLS FIRST]                |
|               |   DataSourceExec: partitions=1, partition_sizes=[1] |
|               |                                                     |
+---------------+-----------------------------------------------------+

Three ways to register it, all in src/lib.rs:

Function Gives
session_context(config, rule) a SessionContext with DataFusion's default features and optimizer rules plus rule
with_arrowmetal(builder, rule) the same on a SessionStateBuilder you are already configuring
physical_optimizer_rules(rule) DataFusion's default physical optimizer rules with rule inserted, for with_physical_optimizer_rules

The rule goes in before DataFusion's last two physical rules, the post-optimization FilterPushdown and SanityCheckPlan, so the rewritten plan is still checked for distribution and ordering. After a run, MetalExec's metrics (ExecutionPlan::metrics) hold input_time, import_time, kernel_time, export_time and input_batches, and for an aggregate probe_time (the group-count estimate), groups_estimate and handed_back; the benchmark reads its time split from them.

examples/quickstart.rs registers a 1,000,000-row MemTable (an int64, a nullable Float64 and a string column) with the default configuration and 4 partitions, and runs three queries. Its output, from cargo run --example quickstart on an M4 Max:

== SELECT name, region, amount FROM sales ORDER BY amount DESC NULLS LAST, name

ProjectionExec: expr=[name@2 as name, region@0 as region, amount@1 as amount]
  MetalExec: sort=[amount DESC NULLS LAST, name ASC NULLS LAST]
    DataSourceExec: partitions=1, partition_sizes=[1]

TAKEN    SortExec: expr=[amount@1 DESC NULLS LAST, name@2 ASC NULLS LAST], preserve_partitioning=[false] -- input rows 1000000 (exact) vs min_rows 250000

1000000 rows; the first 3:
+---------+--------+--------------------+
| name    | region | amount             |
+---------+--------+--------------------+
| n001395 | 0      | 1429.4285714285713 |
| n005102 | 5      | 1429.4285714285713 |
| n015952 | 4      | 1429.4285714285713 |
+---------+--------+--------------------+

== SELECT name, amount FROM sales ORDER BY amount DESC NULLS LAST LIMIT 3

ProjectionExec: expr=[name@1 as name, amount@0 as amount]
  SortExec: TopK(fetch=3), expr=[amount@0 DESC NULLS LAST], preserve_partitioning=[false]
    DataSourceExec: partitions=1, partition_sizes=[1]

LEFT     SortExec: TopK(fetch=3), expr=[amount@0 DESC NULLS LAST], preserve_partitioning=[false] -- top-k (sort with fetch 3) disabled in config

3 rows; the first 3:
+---------+--------------------+
| name    | amount             |
+---------+--------------------+
| n300762 | 1429.4285714285713 |
| n421196 | 1429.4285714285713 |
| n110979 | 1429.4285714285713 |
+---------+--------------------+

== SELECT region, count(*) AS n, avg(amount) AS mean FROM sales GROUP BY region ORDER BY region

SortPreservingMergeExec: [region@0 ASC NULLS LAST]
  ProjectionExec: expr=[region@0 as region, count(Int64(1))@1 as n, avg(sales.amount)@2 as mean]
    SortExec: expr=[region@0 ASC NULLS LAST], preserve_partitioning=[true]
      AggregateExec: mode=FinalPartitioned, gby=[region@0 as region], aggr=[count(Int64(1)), avg(sales.amount)]
        RepartitionExec: partitioning=Hash([region@0], 4), input_partitions=4
          AggregateExec: mode=Partial, gby=[region@0 as region], aggr=[count(Int64(1)), avg(sales.amount)]
            RepartitionExec: partitioning=RoundRobinBatch(4), input_partitions=1
              DataSourceExec: partitions=1, partition_sizes=[1]

LEFT     SortPreservingMergeExec: [region@0 ASC NULLS LAST] -- input rows ~1000000 are an estimate (accept_inexact is off)
LEFT     SortExec: expr=[region@0 ASC NULLS LAST], preserve_partitioning=[true] -- per-partition sort (preserve_partitioning) with no replaced merge above it
LEFT     AggregateExec: mode=FinalPartitioned, gby=[region@0 as region], aggr=[count(Int64(1)), avg(sales.amount)] -- input rows 1000000 (exact) vs min_rows 250000; count + sum_avg_f64 over 1 i64 key (memory input): the measured table takes it at no group count and size (results/datafusion_groupby_sweep_2026-10-01.csv)
LEFT     AggregateExec: mode=Partial, gby=[region@0 as region], aggr=[count(Int64(1)), avg(sales.amount)] -- Partial aggregate whose Final was not replaced

13 rows; the first 3:
+--------+-------+-------------------+
| region | n     | mean              |
+--------+-------+-------------------+
| 0      | 76924 | 714.7095888611585 |
| 1      | 76923 | 714.7337580454488 |
| 2      | 76923 | 714.739149199677  |
+--------+-------+-------------------+

The report (rule.report(), a Report of Decisions) has one line per node the rule looked at: TAKEN or LEFT with the reason; for a replaced aggregate, one line per run, GPU or HANDBACK, with the estimated number of groups, its range, the sample it came from and the table row that decided (Decision::groups holds the estimate); and FALLBACK for a MetalExec that handed its node back to DataFusion on an ArrowMetal error, on data only DataFusion answers exactly, or when the memory pool refused its reservation. Clones of a rule share one report. It keeps the decisions of the last report_plans plans the rule optimized (default 64; an EXPLAIN and each executed plan count one each), until clear_report() empties it.


What the default takes

ArrowMetalConfig::default():

Field Default Meaning
sort on full sorts: ORDER BY without LIMIT
min_rows 250,000 take a node only when its input has at least this many rows
topk off ORDER BY … LIMIT
aggregate on hash GROUP BY and DISTINCT of the shapes the measured table takes (below)
filter off WHERE
join on hash joins (inner, left, right on equal keys) the measured join table takes (Joins)
accept_inexact off use an estimated row count as if it were exact
take_when_unknown off take a node whose input row count is unknown
aggregate_choice Measured who runs a replaced aggregate: Measured (the group-count estimate and the measured table), ArrowMetal (always the GPU), DataFusion (always handed back)
table_rows none look the measured table up at this row count instead of the input's
join_choice Measured which translatable joins are replaced: Measured (the measured join table), ArrowMetal (every one from min_rows)
report_plans 64 how many plans the report keeps

ArrowMetalConfig::all() switches topk and filter on as well. The fields are public to read; a config is built from default() or all() with the with_* setters, e.g. ArrowMetalConfig::all().with_min_rows(0), which the grid and the benchmark use.

A full sort is taken when:

  • every sort key is a column (not an expression) of type int8 to int64, uint8 to uint64, Float32, Float64, Utf8, LargeUtf8 or Utf8View;
  • every column the sort carries is one of those types or Boolean;
  • DataFusion's statistics give the sort's input an exact row count of at least min_rows (MemTables and DataFusion's Parquet scan gave exact counts in the tests and the benchmark);
  • the plan shape is SortExec (one partition, or preserve_partitioning = false), SortPreservingMergeExec over SortExec(preserve_partitioning) with the same ordering, or SortPreservingMergeExec → ProjectionExec → SortExec, which DataFusion plans when the SELECT list reorders or computes columns; the projection stays above MetalExec.

MetalExec emits one partition. When the replaced node had more and something above it requires a distribution, the rule adds a RepartitionExec with the replaced node's partitioning; with only projections above it, the output stays at one partition.

Why 250,000 rows. In datafusion_rule_2026-09-29.csv every key type is ahead of DataFusion at 100,000 rows (1.35x to 2.63x, both layouts), and 250,000 rows is the first measured size at which every key type is at 2.64x or more. The file measured below with a warm-up (datafusion_sort_warm_2026-09-29.csv) puts every key type at 6.9x or more at 250,000 rows.

Aggregates. The rule sees an aggregate's row count but not its number of groups, and the same aggregate is ahead of DataFusion at some group counts and behind at others. So an aggregate is taken in two steps:

  • At plan time the rule describes it by its shape: the aggregate families (count, sum/avg over an integer column, sum/avg over Float64, min/max over an integer column, min/max over a float column (Float64 or Float32), and DISTINCT for a GROUP BY with no aggregate function), one key or several, the key type class (up to 32-bit integers, 64-bit integers, floats, strings) and whether it reads a MemTable scan directly. It replaces the node with a MetalExec only when the measured table (src/agg_table.rs) takes that shape at some number of groups at the input's exact row count.
  • When it runs, MetalExec reads the first batches of each input partition (262,144 rows in all), estimates the number of groups from a stratified sample of their keys (the bias-corrected Chao1 estimator over 512 to 8,192 sampled rows), and looks the shape up at that number of groups. If the table takes it, the rest of the input is collected, a 2,048-row sample of the whole input must agree with the estimate, and the aggregate runs on the GPU. Otherwise the batches read so far and the rest of each partition stream into DataFusion's own partial and final aggregates, over the same partitions as without the rule.

The table is generated by scripts/groupby_table.py from the measurements it names:

  1. The sweep (datafusion_groupby_sweep_2026-10-01.csv: every family over one and two int32 and int64 keys, at 200 to about rows / 2 groups, 1M to 50M rows, both table layouts, the rule forced onto the GPU against DataFusion alone; min/max over Float64 and over Float32, one family as the rule describes them, measured on 2026-10-01 after float MIN/MAX moved to the plan runner's own grouped min/max; sum/avg over Float64 and min/max over int64 on 2026-09-30, the other families on 2026-09-29). A series (shape and number of groups) qualifies from the smallest measured row count at which its worst case (over the layouts and the family's queries) was at least 1.65x faster than DataFusion alone, at that size and every larger one, at two sizes or more. 38 of the 120 measured series qualify, from 2M to 10M rows.
  2. Idle against idle, after 500 ms and after 5 s: a series is taken at a size only if, at that size and every larger one, the default's first run after 500 ms of idle and its first run after 5 s of idle (the Metal pipelines already compiled) were each at least as fast as DataFusion alone's first run after the same idle, for the same query, size and layout, in every case (the sweep's queries and SELECT DISTINCT region, sub) and layout (the median of three runs of each, contexts and gaps alternating; datafusion_groupby_idle_gaps_2026-09-29.csv, datafusion_groupby_resweep_check_2026-10-01.csv, datafusion_groupby_default_import_2026-10-01.csv (its rows of the current crate), datafusion_groupby_float_minmax_check_2026-10-01.csv and datafusion_groupby_refit_check_2026-10-02.csv, which measure both gaps in one session, and the earlier files). A case with no measurement at one of the gaps counts as below.
  3. The hand-back: a shape is replaced at a size only if, at that size and every larger one, every case the default handed back at run time was at 0.97x of DataFusion alone or better on the best run and on the median run (datafusion_groupby_float_minmax_check_2026-10-01.csv, datafusion_groupby_refit_check_2026-10-02.csv, datafusion_groupby_resweep_check_2026-10-01.csv, datafusion_groupby_idle_gaps_recheck_2026-09-29.csv, datafusion_groupby_idle_vs_idle_2026-09-29.csv and the files before them).

Ten series are taken, all over integer keys of a MemTable scan of at least 50,000,000 rows (an int32 class includes narrower keys):

aggregate keys groups taken from sweep, worst case 10M / 50M
count (count(*), count(x)) two int32 31,623 to 316,227 50,000,000 rows 2.7x / 3.2x
count two int32 316,228 to 3,162,277 50,000,000 rows 2.8x / 4.0x
DISTINCT two int32 1 to 1,414 50,000,000 rows 1.7x / 2.2x
DISTINCT two int32 31,623 to 316,227 50,000,000 rows 2.6x / 3.0x
DISTINCT two int32 316,228 to 3,162,277 50,000,000 rows 2.1x / 3.4x
min, max over an integer column one int64 316,228 to 3,162,277 50,000,000 rows 2.6x / 3.1x
min, max over an integer column two int32 31,623 to 316,227 50,000,000 rows 2.4x / 2.6x
min, max over an integer column two int32 316,228 to 3,162,277 50,000,000 rows 2.7x / 3.4x
min, max over an integer column two, at least one int64 31,623 to 316,227 50,000,000 rows 2.6x / 2.8x
min, max over an integer column two, at least one int64 316,228 to 3,162,277 50,000,000 rows 2.9x / 3.4x

The last column is the series' worst ratio at 10M and at 50M rows (DataFusion alone ÷ ArrowMetal, best run, every case of the series in both layouts, two significant digits) in datafusion_groupby_sweep_2026-10-01.csv, as src/agg_table.rs records it.

Each series' row in src/agg_table.rs ends with the reason for its threshold. count(*) over two int32 keys is taken from 50M rows: at 10M its first run after 5 s of idle was below DataFusion alone's first run after the same idle (0.81x at 100,000 groups with 8,192-row batches, 0.88x at 1,000,000 groups with one batch per partition; datafusion_groupby_default_import_2026-10-01.csv). Of the other 28 series that qualify in the sweep, 23 are left by their first run after idle at 50M rows (7 slower than DataFusion alone's first run after the same idle, 16 with no measurement after 5 s of idle there), and 5 because a hand-back of the same shape at 50M rows cost more than 3% (To improve). The float MIN/MAX series are among them: four qualify warm (one and two keys, 100,000 groups, from 5M or 10M rows), and none holds at 50M rows on both other rules.

Joins

The rule translates a HashJoinExec when:

  • its join type is inner, left or right (DataFusion plans the SQL probe LEFT JOIN build of a larger table as a right join with the smaller table on the build side), with equal keys only: no join filter, SQL's = (NullEqualsNothing), no fetch;
  • every key is a column, of the same type on both sides, int32, int64 or Utf8 (one key or several);
  • every column it outputs is one of the types a sort carries;
  • both inputs have an exact row count, and the larger is at least min_rows;
  • an output ordering, if the join has one, is the probe side's in one partition.

MetalExec reads below the exchanges under the join (its RepartitionExecs and CoalescePartitionsExecs), collects both inputs, runs the join on the GPU, and deals the result out to as many output partitions as the join had; a hash-partitioned join keeps a RepartitionExec by the same hash above it where the parent requires that distribution.

Which joins the default replaces is a measured table, src/join_table.rs, generated by scripts/join_table.py from datafusion_join_2026-10-01.csv: per join type, key type and build-side size (10,000, 1,000,000 and 10,000,000 rows, each standing for half a decade either side), a cell is taken from the smallest measured probe-side size (10M, 50M rows) from which every case, in both layouts, is at least 1.5x faster than DataFusion alone warm and at least 1.0x on the first run after 500 ms and after 5 s of idle against DataFusion alone's first run after the same idle, at that size and every larger one. The cases: SELECT count(*), sum(b.w) FROM probe p [LEFT] JOIN build b ON p.k = b.k and SELECT p.k, count(*), sum(p.v) … GROUP BY p.k over the join, half the probe rows matching. The table takes none of its 18 cells, so the default replaces no join; every row of the table ends with the reason (Joins, rule on and off).


What it leaves, and why

Shape Default Why
top-k, ORDER BY … LIMIT left behind DataFusion at every measured size from 250,000 to 50M rows: 0.14x to 0.41x (datafusion_sort_warm_2026-09-29.csv, the median of three rounds; To improve)
GROUP BY, DISTINCT of a shape the table does not take at the input's row count left behind DataFusion, not 1.65x ahead (the table's 1.5 x 1.1) in the worst case at two sizes in datafusion_groupby_sweep_2026-10-01.csv, slower than DataFusion alone on the first run after the same idle, or its hand-backs cost more than 3% (the check files of Aggregates with the default; To improve)
count(*) or DISTINCT over two int32 keys, or min/max over an integer column with one int64 key or two integer keys, at 50M rows or more, whose estimated number of groups is not in a taken range handed back at run time HANDBACK in the report
WHERE left behind on every measured shape: 0.31x to 0.79x over MemTables (datafusion_filter_2026-10-01.csv), 0.51x to 0.55x over Parquet (datafusion_rule_2026-09-29.csv) (To improve)
hash joins (inner, left, right on int32, int64 or Utf8 keys) left no measured cell is 1.5x ahead warm in every case and at or above DataFusion alone on the first run after the same idle at both gaps (datafusion_join_2026-10-01.csv; Joins, rule on and off)
full, semi and anti joins, a join with a condition besides the equal keys, IS NOT DISTINCT FROM keys, keys of another type left not translated
a sort over an estimated row count (above a filter, a join or an aggregate) left the threshold needs an exact count; accept_inexact takes it
a sort key that is an expression, or a key or carried column of another type (Date, Timestamp, Decimal, Dictionary, nested) left not in the types the grid covers
a per-partition sort with no merge above it, a merge whose ordering differs from its sort's left one sorted partition cannot stand in for several
a GROUP BY whose output carries an ordering (its input sorted on the keys, where DataFusion drops the ORDER BY above it) left the GPU group-by does not keep that order

Each of these is a LEFT line in the report with its reason; the rule does not report the nodes it has no operator for (a nested-loop or sort-merge join, a window, a union).


DataFusion's semantics

Each ORDER BY key becomes one key of ArrowMetal's sort, with two options that give DataFusion's order in the sort itself, with no extra key or pass:

  • Nulls first or last, as the query says, in either direction (NULLS FIRST, NULLS LAST, or DataFusion's default for the direction).
  • Floats in IEEE 754 totalOrder, the order arrow-rs and DataFusion use: -NaN < -inf < … < -0.0 < +0.0 < … < +inf < +NaN, NaNs ordered by payload, and descending the exact mirror. So a NaN comes first in a descending sort and -0.0 sorts before +0.0. tests/arrowmetal_repros.rs (sort_float_order_total_is_arrow_rs_order) checks this bit for bit against arrow::compute::sort_to_indices, for Float64 and Float32, with -NaN, NaN payloads, ±0.0, ±inf, subnormals and nulls, in both directions with both null placements.
  • Strings (Utf8) in byte order, as arrow-rs compares them; the grid's string keys include "", "B" against "b", and "ü".
  • Ties: SQL leaves the order of rows with equal keys open. The grid checks the key sequence exactly and the rows within each run of equal keys as a multiset.

Handed back to DataFusion at run time. MetalExec keeps the subtree it replaced. If ArrowMetal returns an error or the GPU path panics, or the session's memory pool refuses the reservation for the collected input or the result, MetalExec runs that subtree over the batches it collected and the rest of its input, returns DataFusion's answer, and adds a FALLBACK line to the report. No query in the grid or in the benchmarks took that path; tests/rule.rs runs a sort and a group-by in a 3 MiB memory pool, which hand back, and a join in a 6 MiB pool. With aggregates taken, two checks on the data also hand the node back, because on such data only DataFusion can give DataFusion's answer:

  • a GROUP BY over a float key that holds NaNs of more than one bit pattern (DataFusion keeps each pattern as its own group; ArrowMetal puts every NaN in one group);
  • a grouped float MIN or MAX over a group that holds a NaN, or both -0.0 and +0.0 with a zero result (DataFusion's answer there depends on the order the rows arrive in).

Float comparisons in a WHERE. DataFusion 55.1 compares floats with arrow-rs's IEEE 754 totalOrder kernels after rewriting -0.0 to +0.0 on both sides: a NaN with the sign bit set is below every value, one without it above every value, and -0.0 equals +0.0. A column compared with a literal that is not a NaN (finite, zero or infinite, Float64 or Float32, on either side) becomes one fused expression, IEEE comparisons plus the expression grammar's signbit and is_nan (Fused expressions): x > c is (or (gt x c) (and (is_nan x) (not (signbit x)))), x < c is (or (lt x c) (and (is_nan x) (signbit x))), x = c and x <> c are the IEEE eq and its not. A comparison with a NaN literal is left (totalOrder tells NaN payloads apart). Nothing about the data hands such a filter back.

MIN and MAX over Float64 and Float32 run as the plan runner's own grouped min and max. DataFusion's grouped float MIN/MAX starts each group at the type's largest finite value (MIN) or lowest finite value (MAX) and replaces the running value whenever the new one compares lower (higher) or does not compare at all. So a group whose values are all -inf has a MAX of the lowest finite value (f64::MIN, f32::MIN) and one whose values are all +inf a MIN of the largest; -0.0 and +0.0 compare equal, so the first zero to arrive is kept; and a NaN makes the answer depend on the order the rows arrive in. Before the GPU call, MetalExec reads the value column once on the CPU (up to 8 threads, above 2,000,000 rows) for NaN, -0.0 and +0.0. A column with no NaN and at most one zero sign runs as the plan runner's min/max, with the infinities and the sign of a zero result set as DataFusion gives them. A column with a NaN or both zero signs adds four per-group counts (NaN, value, zero and negative-zero counts); a group with a NaN, or both zero signs and a zero result, hands the node back.

Joins. A replaced join gives DataFusion's answer for an inner, left or right HashJoinExec with equal keys only: a null in a key matches nothing on either side (SQL's =, DataFusion's NullEqualsNothing); a left join keeps every row of the build (left) side and a right join every row of the probe (right) side, with nulls for the other side's columns; the output columns are DataFusion's (the left input's, then the right's, through the join's projection). The plan runner's join keeps the order of its left input, and MetalExec makes the probe side that input for an inner and a right join, so a join whose output carries the probe side's ordering (DataFusion sorts the probe side below an inner join when the probe side is one partition) keeps it.


The differential grid

tests/grid.rs runs every query in a plain SessionContext and in one with the rule at ArrowMetalConfig::all().with_min_rows(0), and compares the answers. Every GROUP BY runs three times with the rule: forced onto the GPU (AggregateChoice::ArrowMetal), forced back to DataFusion (AggregateChoice::DataFusion), and with the measured choice looked up at 50M rows (table_rows).

  • Tables:
  • t, 13 columns: Int32 and Int64 keys; Float64 keys with ±0.0, ±inf and NaN; Float64 keys with -NaN and NaN payloads of both signs; Float32 keys with NaN, ±0.0 and ±inf; Utf8 keys and the same keys as Utf8View and as LargeUtf8; Int32 and Int64 values; Float64 values with NaN, NaN payloads and both zeros; Float64 values whose sums round; Float32 values with -NaN, a NaN payload and -inf.
  • u, the other side of the joins (a fiftieth of t's rows, at least 10): Int32, Int64 and Utf8 keys that partly overlap t's and repeat on both sides, Float64 values with NaN and -0.0, and a Boolean column; e, the same columns with no rows.
  • ti, float MIN/MAX data: groups whose values are only -inf, only +inf, both infinities, -inf and finite values, only -0.0 or only +0.0, in Float64 and Float32.
  • 24 table configurations: 0, 1, 1,000 and 20,000 rows × null fraction 0, 0.1 and 1.0 on every column × two layouts (one partition with target_partitions 1; three partitions with target_partitions 4, which plans per-partition sorts under a merge and hash-partitioned joins).
  • 460 queries:
  • 154 ORDER BY (12 columns × ASC/DESC × NULLS FIRST/NULLS LAST/default × with and without LIMIT 7, plus 10 multi-column orderings);
  • 52 GROUP BY (every aggregate over each key and value column, count over string columns, and MIN/MAX over ti);
  • 228 WHERE: 18 mixed predicates, and over four float columns and vf every operator (=, <>, <, <=, >, >=) against +0.0, -0.0, +inf, -inf, 1.5 and -2.5, four with the literal on the left, and two against a NaN literal;
  • 26 joins: inner, left and right joins on int32, int64, Utf8 and two-column keys, with an empty side, with an ORDER BY and a GROUP BY above, and the shapes the rule leaves (full, semi, anti, a condition besides the keys, IS NOT DISTINCT FROM, a Float64 key).
  • Comparison: floats by bit pattern (so -0.0 ≠ +0.0, and NaN sign and payload must match), except float sum and every avg, compared within 1e-9 relative.
  • Joins are forced onto the GPU in every variant (JoinChoice::ArrowMetal).

Run on 2026-10-01 (cargo test, debug build):

query pairs 13,632: 11,040 with every node forced onto the GPU, 1,296 GROUP BY forced back, 1,296 GROUP BY with the measured choice
pairs with a node replaced 11,931
hash joins replaced 555
run-time choices forced onto the GPU: 1,248 on the GPU; forced back: 1,248 handed back; measured: no aggregate replaced (no GROUP BY of the grid has a shape the table takes; the 48 pairs with a node replaced there are joins under a GROUP BY)
mismatches 0
hand-backs on an ArrowMetal error 0
hand-backs on the data (the checks above) 168: 128 float MIN over a group with NaN, 40 group keys with several NaN patterns
largest relative deviation in a float sum/avg 2.2e-13

The pairs of the forced variant with nothing taken are a CAST in a predicate, a comparison with a NaN literal, the joins the rule leaves, and aggregates over an estimated row count (above a join), each left by the rule with its reason. tests/rule.rs checks both branches of the measured choice with the table looked up at 50M rows: count(*) over two int32 keys drawn from 1,000,000 values runs on the GPU, over 150 values it is handed back, on one batch per partition and on 8,192-row batches. A unit test in src/exec.rs checks that a prefix repeating a few thousand keys, followed by keys seen once, is not confirmed by the 2,048-row sample of the whole input.


Numbers

Machine and method. Apple M4 Max (16 CPU cores: 12 performance, 4 efficiency), 64 GB, macOS 27.0; rustc 1.95.0, DataFusion 55.1.0, arrow-rs 59.3.0; the benchmark is examples/bench.rs, built --release with thin LTO.

  • SessionConfig::new(): 16 partitions, 8,192-row batches.
  • Each query runs in a context without the rule (off) and in one with ArrowMetalConfig::all().with_min_rows(0).with_accept_inexact(true) (on); both answers are compared before anything is timed. A third context with ArrowMetalConfig::default() is only planned, and its decisions are recorded in the CSV's rule_default column. The aggregate files below time the default configuration itself (def).
  • Timed: SQL to logical plan, physical planning (the rule runs there) and collect. Best of 5 wall-clock runs. CPU-ms is the process CPU time (user + system, all threads) of the best run; GPU time is not in it.
  • datafusion_sort_warm_2026-09-29.csv: before the timed runs of each context, untimed runs repeat until they add up to 100 ms (the GPU runs small plans about three times slower after it idles and reaches its fast state after about 20-25 ms of back-to-back work). The file also records on_first_ms, the first run with the rule, taken with the GPU idle before it. Three passes at 250,000 to 2M rows, of which the tables show the median, each column on its own; one pass at 10M and 50M.
  • Tables: MemTables with 16 partitions, of 8,192-row batches dealt round-robin (b8192, the layout DataFusion's scans produce) or one batch per partition (part1). Queries: SELECT q, k1, x FROM extra ORDER BY q (int64 key), … ORDER BY x (Float64), SELECT q, k1, y … ORDER BY y (Float32), SELECT name, q, k1 FROM extra ORDER BY name (String, 1,000 values); keys drawn uniformly (int64 in [0, 10^9), floats in [0, 1)).
  • Before each block of queries, the harness waited for a 1-minute load average below 3.5 and no compiler or test process; the load at block start and end (1.49 to 3.39 in this file) is in every row.

Every timed row gave the same answer with the rule as without it, with 0 hand-backs.

Full sorts, rule off and on

8,192-row batches, from datafusion/results/datafusion_sort_warm_2026-09-29.csv (rows crate = now):

key rows rule off, ms rule on, ms off ÷ on CPU-ms off / on first run on, idle GPU, ms
int64 250,000 8.08 0.98 8.2x 16.0 / 0.9 31.86*
int64 500,000 15.66 1.40 11.2x 34.1 / 1.5 31.67*
int64 1,000,000 31.75 1.77 17.9x 74.2 / 2.7 34.32*
int64 2,000,000 62.49 2.58 24.2x 159.4 / 4.9 39.54*
int64 10,000,000 312.87 13.31 23.5x 974.3 / 25.1 68.53*
int64 50,000,000 1,581.22 69.74 22.7x 6,104.1 / 125.8 176.36*
Float64 250,000 8.43 1.11 7.6x 16.8 / 1.0 1.95
Float64 500,000 16.81 1.39 12.1x 37.5 / 1.5 4.44
Float64 1,000,000 33.76 2.02 16.7x 78.9 / 2.6 6.00
Float64 2,000,000 65.97 3.25 20.3x 169.2 / 5.3 9.66
Float64 10,000,000 331.75 16.88 19.7x 1,032.6 / 23.5 24.06
Float64 50,000,000 1,672.57 87.90 19.0x 6,406.7 / 119.5 127.94
Float32 250,000 8.64 0.98 8.8x 16.4 / 0.9 2.75
Float32 500,000 16.75 1.35 12.4x 36.5 / 1.4 3.83
Float32 1,000,000 33.47 1.61 20.8x 76.5 / 2.3 4.70
Float32 2,000,000 65.55 2.51 26.1x 165.7 / 4.3 6.93
Float32 10,000,000 329.33 11.63 28.3x 1,015.3 / 20.4 21.17
Float32 50,000,000 1,625.59 61.14 26.6x 6,227.0 / 108.2 107.53
String 250,000 10.69 1.42 7.5x 27.2 / 1.3 3.52
String 500,000 19.34 1.93 10.0x 51.1 / 2.1 3.63
String 1,000,000 38.86 2.73 14.2x 107.1 / 3.1 7.53
String 2,000,000 77.33 3.37 22.9x 226.0 / 5.5 9.99
String 10,000,000 375.98 16.86 22.3x 1,340.7 / 23.6 27.51
String 50,000,000 1,861.23 87.62 21.2x 8,310.5 / 119.5 129.86

* The int64 sort in this layout was the first query each benchmark process ran, so its first run also compiled the GPU pipelines. The same query in the one-batch layout, run next in the same process, took 3.36, 3.44, 4.29, 7.27, 22.25 and 116.46 ms on its first run at the six sizes.

One batch per partition, off ÷ on, same file:

key 250,000 500,000 1,000,000 2,000,000 10,000,000 50,000,000
int64 7.8x 11.2x 16.0x 24.0x 23.4x 22.8x
Float64 7.7x 11.1x 16.5x 19.9x 19.9x 19.1x
Float32 9.2x 12.3x 19.6x 27.1x 28.8x 26.9x
String 6.9x 9.4x 15.2x 21.8x 22.3x 21.4x

The first run from an idle GPU. A single ORDER BY after the GPU has idled takes longer than the warm figure: 1.95 against 1.11 ms for the Float64 sort at 250,000 rows, 127.94 against 87.90 ms at 50M. Every first-run figure in the 8,192-row table except the starred ones, and every one in the one-batch layout, is below DataFusion's warm time for the same query.

Over DataFusion's Parquet reader

SELECT id, price FROM f ORDER BY price (a Float64 key), with DataFusion's own Parquet scan feeding both runs, from datafusion/results/datafusion_rule_2026-09-29.csv (rows crate = now, case p_sort). The files come from Benchmarks/parquet_bench.py build.

codec rows rule off, ms rule on, ms off ÷ on CPU-ms off / on input / import / sort, ms
snappy 10,000,000 289.63 21.35 13.6x 921.5 / 83.7 7.11 / 1.72 / 11.68
zstd 10,000,000 294.26 27.14 10.8x 964.2 / 135.8 12.82 / 1.69 / 11.86
snappy 50,000,000 1,643.01 99.43 16.5x 5,477.1 / 448.8 30.49 / 8.77 / 58.26
zstd 50,000,000 1,659.64 120.45 13.8x 5,729.9 / 719.6 52.03 / 8.51 / 58.11

The default configuration takes this sort: the scan reports an exact row count (the rule_default column reads TAKEN SortPreservingMergeExec (input rows 50000000 (exact) vs min_rows 250000)). This file was measured without the warm-up; in it, the in-memory sorts at 10M and 50M rows are within 13% of the warm-up file's figures, rule on. With the rule, input_time includes the Parquet decode.

A Parquet file with a string column

DataFusion 55.1 reads Parquet strings as Utf8View, which the rule carries and sorts by. A file the benchmark writes (the columns above, 1,048,576-row row groups), off and on, two rounds of best of 5 (best of 2 above 2 s), 8,192-row batches, from datafusion/results/datafusion_parquet_string_sort_2026-09-29.csv:

query rows rule off, ms rule on, ms off ÷ on CPU-ms off / on
SELECT name, q, k1 FROM f ORDER BY name 1,000,000 32.64 9.04 3.6x 67.5 / 11.5
10,000,000 201.05 50.96 3.9x 786.9 / 107.5
50,000,000 1,030.14 252.93 4.1x 4,351.5 / 546.8
SELECT q, name, k1 FROM f ORDER BY q 1,000,000 45.30 9.27 4.9x 70.3 / 12.4
10,000,000 278.41 57.67 4.8x 961.7 / 115.5
50,000,000 1,590.83 288.79 5.5x 5,911.6 / 568.1

The default configuration takes both (exact row counts from the file's footer).

Aggregates with the default

Every GROUP BY and DISTINCT case of the sweep (with one and two int32 and int64 keys, 200 to about rows / 2 groups) and SELECT DISTINCT region, sub FROM fact (two int32 keys, 10,000 groups), with ArrowMetalConfig::default() timed against DataFusion alone, both layouts, 1M to 50M rows. A case whose default ran on the GPU: three rounds of best of 5 with the two contexts in rotated order, then three runs of each after 500 ms of sleep and three after 5 s, the contexts and the gaps alternating (the median of the three is the first-run time). Any other case (both contexts on the CPU): 30 runs of each, alternating run by run, after a 100 ms warm-up. The files: datafusion_groupby_default_2026-09-29.csv (every case, an earlier table) and the re-measurements after it, whose rows replace its rows for the same case, size and layout (…_taken_…, …_cap_…, …_idle_…, …_handback_…, …_recheck_gpu_…, …_recheck_…, …_recheck2_…, …_idle_vs_idle_…, …_idle_vs_idle_recheck_…, …_idle_gap_…, …_idle_gaps_…, …_idle_gaps_recheck_…, …_resweep_check_2026-10-01…, …_resweep_recheck_2026-10-01…, …_default_import_2026-10-01… and …_default_import_retime_2026-10-01… (their rows of the current crate), …_float_minmax_check_2026-10-01…, …_refit_check_2026-10-02…). …_idle_vs_idle_… holds every case whose decision an earlier version of this table changed and a random fifth of the rest; …_idle_gaps_… every case that version ran on the GPU, at both gaps (22 rows, 19:03 to 19:17 EDT, load 0.94 to 3.14 at the start of each block); …_idle_gaps_recheck_… every case whose decision the final table changed (18 rows, 19:51 to 21:42 EDT, load 1.50 to 3.44 at the start of each block). After the Float64 sum/avg and the min/max families were swept again (2026-09-30), …_resweep_check_… holds every case of those families that a table from that sweep alone replaced (256 rows, 03:34 to 08:34 EDT on 2026-10-01), and …_resweep_recheck_… every case whose decision the final table changed (the 30 min/max cases at 50M rows) and a seeded fifth of the rest (303 rows), 09:09 to 12:55 EDT, load 1.18 to 3.50 at the start of each block; in it the idle runs were taken only for the cases the default ran on the GPU from its 65th row on. After float MIN/MAX moved to the plan runner's own min/max and its two families were swept again (2026-10-01), …_float_minmax_check_… holds every Float64 and Float32 min/max case at 5M, 10M and 50M rows under a table from that sweep alone (240 rows, 23:35 on 2026-10-01 to 00:42 EDT, load 1.69 to 3.49 at the start of each block), and …_refit_check_… every case whose decision the final table changed (count(*) over two int32 keys at 10M rows) and a seeded fifth of the rest (356 rows, 00:48 to 04:04 EDT on 2026-10-02, load 2.95 to 3.50 at the start of each block). Every answer was equal to DataFusion's; no block of the last two files spans a sleep.

rows cases left at plan time handed back at run time run on the GPU
1,000,000 to 10,000,000 1,376 1,376 0 0
50,000,000 362 310 32 20

The cases are those of datafusion_groupby_sweep_2026-10-01.csv (360 per size, 288 at 2M rows) and SELECT DISTINCT region, sub in both layouts, each on the path the table gives it. In …_refit_check_… every case took the path this table gives it.

On the GPU (the latest measurement of each case: datafusion_groupby_default_import_2026-10-01.csv, its rows of the current crate, and datafusion_groupby_refit_check_2026-10-02.csv). Warm: best run, DataFusion alone ÷ the default. Idle vs idle: DataFusion alone's first run after the idle ÷ the default's first run after the same idle. GPU idle vs DataFusion warm: DataFusion alone warm ÷ the default's first run after idle, the comparison of a single query on an idle GPU with DataFusion's time when it has just run the same query. CPU-ms: process CPU time of the best warm run and of the median run after 500 ms of idle.

query groups rows layout DataFusion warm, ms default warm, ms warm after 500 ms idle, DataFusion / default, ms idle vs idle, 500 ms after 5 s idle, DataFusion / default, ms idle vs idle, 5 s GPU idle vs DataFusion warm, 500 ms / 5 s CPU-ms warm, DataFusion / default CPU-ms after 500 ms idle, DataFusion / default
count(*) 100,000 50M 8,192-row batches 51.77 16.50 3.14x 68.01 / 48.98 1.39x 79.27 / 59.31 1.34x 1.06x / 0.87x 748 / 49 945 / 98
count(*) 100,000 50M one per partition 53.19 14.94 3.56x 76.28 / 39.83 1.92x 74.94 / 53.98 1.39x 1.34x / 0.99x 758 / 50 988 / 92
count(*) 1,000,000 50M 8,192-row batches 73.80 18.25 4.04x 95.74 / 55.12 1.74x 82.14 / 61.55 1.33x 1.34x / 1.20x 1,104 / 53 1,313 / 77
count(*) 1,000,000 50M one per partition 75.36 17.63 4.27x 92.57 / 47.61 1.94x 85.67 / 58.03 1.48x 1.58x / 1.30x 1,128 / 62 1,292 / 88
DISTINCT 200 50M 8,192-row batches 30.16 13.05 2.31x 47.06 / 32.76 1.44x 46.07 / 35.65 1.29x 0.92x / 0.85x 422 / 48 572 / 95
DISTINCT 200 50M one per partition 29.12 11.19 2.60x 51.86 / 27.82 1.86x 47.62 / 41.32 1.15x 1.05x / 0.70x 418 / 51 646 / 89
DISTINCT 100,000 50M 8,192-row batches 40.94 14.58 2.81x 69.61 / 46.03 1.51x 71.37 / 59.17 1.21x 0.89x / 0.69x 629 / 47 862 / 76
DISTINCT 100,000 50M one per partition 43.98 14.20 3.10x 60.09 / 41.13 1.46x 69.42 / 51.21 1.36x 1.07x / 0.86x 629 / 64 835 / 87
DISTINCT 1,000,000 50M 8,192-row batches 56.56 15.88 3.56x 75.31 / 60.60 1.24x 80.86 / 62.87 1.29x 0.93x / 0.90x 865 / 50 1,044 / 99
DISTINCT 1,000,000 50M one per partition 57.99 15.44 3.76x 80.87 / 48.91 1.65x 80.62 / 60.76 1.33x 1.19x / 0.95x 870 / 64 1,106 / 72
min, max over int64 (two int32 keys) 100,000 50M 8,192-row batches 68.40 24.71 2.77x 89.32 / 69.80 1.28x 87.37 / 76.24 1.15x 0.98x / 0.90x 1,014 / 91 1,129 / 203
min, max over int64 (two int64 keys) 100,000 50M 8,192-row batches 80.77 27.75 2.91x 104.40 / 58.35 1.79x 99.99 / 89.97 1.11x 1.38x / 0.90x 1,235 / 127 1,357 / 177
min, max over int64 (one int64 key) 1,000,000 50M 8,192-row batches 83.50 26.15 3.19x 95.38 / 50.22 1.90x 107.16 / 86.51 1.24x 1.66x / 0.97x 1,200 / 94 1,337 / 140
min, max over int64 (two int32 keys) 1,000,000 50M 8,192-row batches 94.10 26.84 3.51x 114.68 / 52.77 2.17x 117.73 / 84.27 1.40x 1.78x / 1.12x 1,407 / 104 1,560 / 140
min, max over int64 (two int64 keys) 1,000,000 50M 8,192-row batches 111.07 30.53 3.64x 123.25 / 63.45 1.94x 134.71 / 103.63 1.30x 1.75x / 1.07x 1,628 / 125 1,746 / 200
min, max over int64 (two int32 keys) 100,000 50M one per partition 71.13 22.80 3.12x 93.16 / 70.43 1.32x 94.72 / 71.29 1.33x 1.01x / 1.00x 1,053 / 98 1,248 / 126
min, max over int64 (two int64 keys) 100,000 50M one per partition 83.56 26.20 3.19x 99.28 / 53.34 1.86x 102.95 / 84.73 1.22x 1.57x / 0.99x 1,211 / 120 1,376 / 190
min, max over int64 (one int64 key) 1,000,000 50M one per partition 82.93 23.78 3.49x 104.62 / 48.63 2.15x 102.91 / 61.27 1.68x 1.71x / 1.35x 1,202 / 87 1,416 / 134
min, max over int64 (two int32 keys) 1,000,000 50M one per partition 93.46 24.28 3.85x 114.72 / 73.70 1.56x 114.84 / 84.75 1.36x 1.27x / 1.10x 1,402 / 88 1,624 / 144
min, max over int64 (two int64 keys) 1,000,000 50M one per partition 110.70 27.42 4.04x 129.83 / 83.02 1.56x 130.43 / 91.49 1.43x 1.33x / 1.21x 1,627 / 124 1,813 / 229

Over the 20 rows above (the latest measurement of each case: datafusion_groupby_default_import_2026-10-01.csv, its rows of the current crate, and datafusion_groupby_refit_check_2026-10-02.csv combined), at 50M rows the default was 2.31x to 4.27x faster warm (2.32x lowest on the median of the three rounds), 1.24x to 2.17x idle against idle after 500 ms and 1.11x to 1.68x after 5 s, and on its first run after idle 0.89x to 1.78x (500 ms) and 0.69x to 1.35x (5 s) of DataFusion's warm time. DataFusion alone's first run took 1.11x to 1.78x its warm time after 500 ms and 1.11x to 1.74x after 5 s; the default's took 1.92x to 3.82x and 2.58x to 4.06x. The min/max rows: 2.77x to 4.04x warm, 1.28x to 2.17x idle against idle after 500 ms and 1.11x to 1.68x after 5 s, and 0.98x to 1.78x (500 ms) and 0.90x to 1.35x (5 s) of DataFusion's warm time.

The idle ratios move between measurements of the same case. For the 22 cases in both …_idle_vs_idle_… and …_idle_gaps_…, the later 500 ms idle-against-idle ratio is 0.65x to 1.80x of the earlier one (median 1.00x); for the 6 cases measured after 5 s in both …_idle_gap_… and …_idle_gaps_…, 0.74x to 1.09x (median 1.02x).

count(*) over two int32 keys at 10M rows ran on the GPU under the earlier version of this table (100,000 and 1,000,000 groups). In …_default_import_… its first run after 5 s of idle was below DataFusion alone's first run after the same idle on two of its four rows with the current crate (0.81x at 100,000 groups with 8,192-row batches, 0.88x at 1,000,000 groups with one batch per partition) and on three with the crate before (0.84x to 0.94x), so the table takes it from 50M rows. At 10M rows the default now leaves all ten of its cases to DataFusion at plan time (…_refit_check_…): 0.928x to 1.065x of DataFusion alone on the best run and 0.992x to 1.008x on the median, where the earlier table ran the 100,000 and 1,000,000-group cases 2.58x to 2.88x faster warm (…_default_import_…, its rows of the current crate).

Handed back at run time: the other group counts of the taken shapes at 50M rows, both layouts (32 cases, the latest measurement of each: …_resweep_recheck_2026-10-01…, …_idle_vs_idle_…, …_idle_gaps_recheck_…, …_recheck_…, …_default_import_…, …_default_import_retime_… and …_refit_check_…): 0.980x to 1.030x of DataFusion alone on the best run and 0.971x to 1.081x on the median run; the group-count estimate took 0.018 to 0.242 ms.

Left at plan time: DataFusion's own plan in both contexts, with the rule's walk over the plan in one. The walk added 0.009 to 0.029 ms to planning (median, examples/plancost.rs, datafusion_plancost_2026-09-29.csv: 0.139 against 0.148 ms and 0.120 against 0.131 ms at 1M rows, 0.269 against 0.298 ms and 0.272 against 0.282 ms at 10M; plan and run 0.831 against 0.846 ms, 3.526 against 3.532 ms). The 350 left cases in …_refit_check_… were at 0.928x to 1.077x of DataFusion alone on the best run and 0.927x to 1.065x on the median run, none below 0.97x on both. The 344 left cases in …_idle_vs_idle_…, …_idle_vs_idle_recheck_… and …_idle_gaps_recheck_… were at 0.894x to 1.165x of DataFusion alone on the best run and 0.943x to 1.133x on the median run, none below 0.97x on both. The 298 left cases in …_resweep_recheck_… were at 0.901x to 1.143x on the best run and 0.927x to 1.053x on the median, one below 0.97x on both (avg(q) over one int64 key, 10,000 groups, 50M rows, 8,192-row batches: 21.27 against 22.54 ms); timed again alone, 200 runs of each alternating (datafusion_groupby_resweep_left_retime_2026-10-01.csv), it is at 1.000x best and 0.983x median.

Pipeline compilation (examples/coldstart.rs, datafusion_coldstart_2026-09-29.csv, 10M rows, three fresh processes per query): the first GPU query of a process compiles its Metal pipelines, 42 to 66 ms more than the same query's next run after the same 500 ms of idle (median per query: count(*) 52.8 ms, sum 54.1, min/max 49.1, DISTINCT 53.9, a full sort 49.7).

Where the time goes: a 50M-row sort

MetalExec's metrics for the best rule-on run, 8,192-row batches, from datafusion_sort_warm_2026-09-29.csv. Input is the wait for and collection of the input stream, import the copy of the collected batches into Metal buffers, sort the plan run on the GPU, export the hand-back of the result to arrow-rs; rest is the wall time outside those four (planning, the projection above, slicing the output into batches).

key wall, ms input import sort export rest input batches
int64 69.74 7.31 10.21 48.21 0.02 3.99 6,104
Float64 87.90 7.32 10.60 66.24 0.01 3.73 6,104
Float32 61.14 7.14 8.87 41.22 0.01 3.90 6,104
String 87.62 7.54 10.73 65.46 0.01 3.88 6,104

The sort itself is 67% to 75% of the wall time. The import writes each column's 6,104 chunks straight into that column's Metal buffers (Array::from_arrow_chunks), with no concat_batches copy before it.

The columns of one node imported at the same time

From 1,000,000 input rows MetalExec imports its columns on threads of their own, one per column, at the same time (each column's import keeps its own measured copy-thread count). Against the crate before the change, alternating block by block in one session on the same core library (datafusion_sort_import_2026-10-01.csv, the sort family with the rule forced on, warm, three rounds of best of 5), before ÷ now:

rows full sorts, best / median of rounds top-k, best / median of rounds process CPU, now ÷ before
1,000,000 0.959x-1.236x / 0.877x-1.143x 0.951x-1.134x / 1.000x-1.035x 1.04x-1.24x
10,000,000 1.008x-1.060x / 1.004x-1.064x 1.045x-1.128x / 0.976x-1.134x 1.07x-1.49x
50,000,000 1.017x-1.046x / 1.019x-1.044x 1.096x-1.166x / 1.103x-1.183x 0.92x-1.07x

At 50M rows with 8,192-row batches the int64 sort's import is 9.85 → 8.07 ms (68.79 → 67.31 ms in all), the Float64 sort's 10.26 → 8.02 ms, the string sort's 10.10 → 7.83 ms. At 10M rows the process CPU time of a full sort is 18.4 to 27.1 CPU-ms, against 15.3 to 20.6 before (int64, 8,192-row batches: 18.4 → 27.1).

The aggregates the default runs on the GPU, timed the same way with ArrowMetalConfig::default() (datafusion_groupby_default_import_2026-10-01.csv, the default's GPU cases at 10M and 50M rows, both layouts, 24 rows): before ÷ now 0.942x to 1.139x on the best run and 0.976x to 1.320x on the median of the rounds, process CPU now ÷ before 0.85x to 1.41x. The first run after idle against DataFusion alone's first run after the same idle: now 1.24x to 2.19x after 500 ms and 0.81x to 1.68x after 5 s; before, in the same session, 0.89x to 2.15x and 0.84x to 1.48x (count(*) over two int32 keys at 10M rows was below 1.0x after 5 s in both crates: now 0.81x and 0.88x on two of its four rows, before 0.84x to 0.94x on three). The cases the default leaves to DataFusion at those sizes (20 rows) were at 0.936x to 1.154x best and 0.928x to 1.177x median of the crate before; the two below 0.95x on both, timed again alone (datafusion_groupby_default_import_retime_2026-10-01.csv, four blocks of each crate alternating, 104 runs of each context per block alternating run by run), are at 0.987x best and 0.988x median (min/max over int64, two int32 keys, 100,000 groups, 10M rows, 8,192-row batches: 18.88 → 19.13 ms) and 1.000x and 1.000x (SELECT DISTINCT region, sub, 50M rows, one batch per partition: 30.08 → 30.09 ms); both run DataFusion's plan in both crates.

Float MIN and MAX

SELECT k, min(x), max(x) FROM grid GROUP BY k (and over two keys k1, k2) with every replaced aggregate forced onto the GPU (AggregateChoice::ArrowMetal), x Float64 in [0, 1), against the crate before the change (which ran each extreme with four per-group helper counts), alternating block by block in one session (datafusion_minmax_2026-10-01.csv, warm, three rounds of best of 5, 8,192-row batches):

keys groups 10M: DataFusion / before / now, ms 10M: before ÷ now 10M: DataFusion ÷ now 50M: DataFusion / before / now, ms 50M: before ÷ now 50M: DataFusion ÷ now
one int32 200 4.20 / 13.90 / 5.63 2.47x 0.75x 18.13 / 45.12 / 17.95 2.51x 1.01x
one int32 10,000 5.81 / 15.27 / 5.42 2.82x 1.07x 20.36 / 64.11 / 20.19 3.18x 1.01x
one int32 100,000 16.02 / 16.28 / 7.34 2.22x 2.18x 45.00 / 66.98 / 22.06 3.04x 2.04x
one int32 1,000,000 19.41 / 27.46 / 12.05 2.28x 1.61x 75.50 / 96.31 / 28.46 3.38x 2.65x
one int32 rows / 2 32.48 / 70.34 / 34.92 2.01x 0.93x 198.40 / 652.08 / 282.13 2.31x 0.70x
two int32 200 8.60 / 14.87 / 6.50 2.29x 1.32x 32.86 / 49.40 / 21.03 2.35x 1.56x
two int32 10,000 10.41 / 16.10 / 6.58 2.45x 1.58x 37.64 / 68.53 / 22.76 3.01x 1.65x
two int32 100,000 19.91 / 17.67 / 7.62 2.32x 2.61x 70.77 / 70.34 / 23.98 2.93x 2.95x
two int32 1,000,000 23.99 / 29.01 / 13.13 2.21x 1.83x 96.12 / 101.26 / 30.72 3.30x 3.13x
two int32 rows / 2 36.56 / 71.68 / 35.61 2.01x 1.03x 211.36 / 682.60 / 300.96 2.27x 0.70x

Over both layouts: now 1.95x to 3.53x faster than before on the best run and 1.90x to 3.54x on the median of the rounds, and 0.70x to 3.26x of DataFusion alone (behind it at about rows / 2 groups at 50M rows and over one key at 10M, and at 200 groups over one key at 10M rows). Over a Float32 column, which the crate before left to DataFusion, 0.44x to 2.49x of DataFusion alone.

Swept again for the measured table (datafusion_groupby_sweep_2026-10-01.csv: Float64 and Float32 value columns, one and two int32 and int64 keys, 200 groups to rows / 2, 1M to 50M rows, both layouts, rule forced on, 0 answers differ), DataFusion alone ÷ with the rule is 0.33x to 3.41x over Float64 and 0.29x to 2.63x over Float32. The 100,000-group series are at least 1.65x faster in their worst case from 5M rows (one int32 key, one int64 key, two int64 keys) and from 10M rows (two int32 keys); no other float series is at two sizes. With the default timed against DataFusion alone over those cases (datafusion_groupby_float_minmax_check_2026-10-01.csv), the GPU cases were 1.74x to 3.10x faster warm, and each of the four series is left by one of the other two rules at 50M rows:

keys warm, worst case 5M / 10M / 50M left at 50M rows by
one int32 1.73x / 2.18x / 1.86x first run after 5 s of idle: 0.98x of DataFusion alone's (Float32, one batch per partition)
one int64 1.71x / 2.15x / 1.77x a hand-back of the shape: min/max over Float32, about rows / 2 groups, 8,192-row batches, 0.974x best and 0.952x median
two int32 1.62x / 2.24x / 2.48x a hand-back of the shape: min/max over Float64, 200 groups, one batch per partition, 0.984x best and 0.951x median
two int64 1.71x / 2.20x / 2.55x first run after 5 s of idle: 0.96x of DataFusion alone's (Float32, one batch per partition)

So the measured table takes no float MIN/MAX shape.

Joins, rule on and off

DataFusion alone against the default configuration with every translatable join replaced (JoinChoice::ArrowMetal; an aggregate above the join stays DataFusion's, as under the default), datafusion_join_2026-10-01.csv: probe tables of 10M and 50M rows, build tables of 10,000, 1,000,000 and 10,000,000 rows holding every other key of the probe's key domain (half the probe rows match), keys as int64, int32 and Utf8 (decimal text of the same values), both layouts, warm (three rounds of best of 5), then three runs of each context after 500 ms and three after 5 s of idle (the median), the contexts and the gaps alternating. Warm is DataFusion alone ÷ with the rule, over the two layouts, for the count-and-sum query and for the GROUP BY query; idle vs idle is DataFusion alone's first run after the idle ÷ the rule's first run after the same idle, over the two queries and layouts. Every answer was equal to DataFusion's.

key SQL join build rows 10M probe: warm, count + sum / group by 10M: idle vs idle 500 ms / 5 s 50M probe: warm, count + sum / group by 50M: idle vs idle 500 ms / 5 s
int32 INNER 10,000 1.53-2.19x / 1.25-1.30x 0.71-1.26x / 0.47-0.78x 1.24-2.29x / 1.11-1.54x 0.70-1.45x / 0.71-1.04x
int32 INNER 1,000,000 1.89-1.92x / 1.40-1.42x 0.91-1.40x / 0.77-0.93x 1.91-1.99x / 1.58-1.60x 0.99-1.05x / 0.87-1.08x
int32 INNER 10,000,000 2.07-2.17x / 1.44-1.46x 1.02-1.49x / 0.80-1.23x 1.53-1.57x / 1.30-1.32x 1.08-1.35x / 1.01-1.12x
int32 LEFT 10,000 1.85-1.98x / 1.33-1.36x 0.78-1.11x / 0.81-1.04x 1.58-2.14x / 1.19-1.64x 1.04-1.48x / 0.85-1.09x
int32 LEFT 1,000,000 1.77-1.85x / 1.22-1.26x 0.73-1.13x / 0.63-0.80x 1.88-1.89x / 1.37-1.37x 0.94-1.36x / 0.98-0.99x
int32 LEFT 10,000,000 1.98-2.05x / 1.33-1.34x 0.93-1.41x / 0.88-1.38x 1.51-1.55x / 1.15-1.18x 0.99-1.21x / 1.00-1.04x
int64 INNER 10,000 1.45-2.15x / 1.25-1.41x 0.54-1.21x / 0.59-0.74x 1.16-2.17x / 1.03-1.50x 0.86-1.43x / 0.58-0.83x
int64 INNER 1,000,000 1.74-1.87x / 1.35-1.40x 0.81-1.05x / 0.69-0.81x 1.73-1.79x / 1.49-1.53x 0.93-1.14x / 0.95-1.09x
int64 INNER 10,000,000 2.09-2.14x / 1.46-1.48x 0.88-1.28x / 0.81-1.12x 1.51-1.53x / 1.28-1.30x 0.97-1.27x / 0.95-1.06x
int64 LEFT 10,000 1.36-1.82x / 1.16-1.32x 0.93-1.08x / 0.70-1.06x 1.53-2.04x / 1.16-1.19x 0.69-1.52x / 0.63-0.93x
int64 LEFT 1,000,000 1.64-1.67x / 1.22-1.25x 0.93-1.47x / 0.67-0.87x 1.68-1.69x / 1.26-1.32x 0.99-1.15x / 0.78-0.98x
int64 LEFT 10,000,000 2.04-2.05x / 1.26-1.27x 0.85-1.25x / 0.86-1.07x 1.49-1.51x / 1.13-1.14x 0.94-1.03x / 0.93-0.99x
Utf8 INNER 10,000 0.95-1.16x / 1.02-1.15x 0.39-0.90x / 0.47-0.67x 0.90-1.60x / 0.93-1.14x 0.60-0.98x / 0.56-0.78x
Utf8 INNER 1,000,000 0.93-0.94x / 0.92-0.93x 0.62-0.77x / 0.49-0.64x 1.01-1.01x / 0.98-1.01x 0.64-0.80x / 0.59-0.71x
Utf8 INNER 10,000,000 0.79-0.79x / 0.79-0.79x 0.56-0.71x / 0.54-0.60x 0.81-0.83x / 0.89-0.89x 0.64-0.74x / 0.60-0.73x
Utf8 LEFT 10,000 0.28-0.33x / 0.38-0.47x 0.32-0.45x / 0.29-0.45x 0.22-0.36x / 0.38-0.42x 0.22-0.38x / 0.24-0.37x
Utf8 LEFT 1,000,000 0.35-0.36x / 0.50-0.51x 0.37-0.45x / 0.26-0.43x 0.33-0.33x / 0.50-0.50x 0.31-0.46x / 0.30-0.45x
Utf8 LEFT 10,000,000 0.51-0.52x / 0.60-0.62x 0.38-0.55x / 0.39-0.50x 0.41-0.42x / 0.60-0.61x 0.36-0.56x / 0.36-0.55x

On integer keys the joins with a count and a sum above them are 1.16x to 2.29x faster warm; the GROUP BY of the probe key above the join, which DataFusion runs over the join's partitions (for a hash-partitioned join, after the same hash re-partitioning), is 1.03x to 1.64x. In every integer cell the rule's first run after idle is below DataFusion alone's first run after the same idle for some case at some probe size and gap (lowest 0.47x). Utf8 keys go through ArrowMetal's key densification before the hash join and are 0.22x to 1.60x warm.

A matmul chain in SQL (attention over coordinate-format matrices)

The query is single-head attention written as one SQL statement, from the attention test of the ddx project (crates/ddx-datafusion/tests/attention.rs, Apache-2.0), copied verbatim with its table names and its value function. Every matrix is a table of rows (i BIGINT, j BIGINT, val DOUBLE): the input x[t, d], the weights wq, wk, wv[d, e] and the target tgt[t, e]. Each matrix product is a SUM(a.val * b.val) over a JOIN … GROUP BY: the projections q, k, v (x joined with a weight matrix on d), the scores s (q joined with k on e), and the output o (the softmax weights joined with v); a max, a normalizing sum and a squared-error loss complete it. DataFusion inlines each common table expression at every read, so one plan holds 18 hash joins and 18 aggregates (53 nodes the rule looks at, counting each partial aggregate). The example is examples/attention.rs.

The joins produce t·d·e rows for each projection and t·t·e rows for s and for o before the grouped sums, counted with SQL for every shape (datafusion/results/attention_2026-10-03.csv, rows rows_*):

shape (t, d, e) joined rows of q (k, v) grouped rows of q joined rows of s grouped rows of s joined rows of o grouped rows of o
256, 128, 32 1,048,576 8,192 2,097,152 65,536 2,097,152 8,192
512, 256, 64 8,388,608 32,768 16,777,216 262,144 16,777,216 32,768
1024, 512, 64 33,554,432 65,536 67,108,864 1,048,576 67,108,864 65,536
2048, 512, 64 67,108,864 131,072 268,435,456 4,194,304 268,435,456 131,072
4096, 1024, 64 268,435,456 262,144 1,073,741,824 16,777,216 1,073,741,824 262,144

Three configurations, timed in the same block per shape in alternating order: alone, SessionContext::new(); default, session_context(SessionConfig::new(), rule) with ArrowMetalConfig::default(); forced, ArrowMetalConfig::all().with_min_rows(0) with AggregateChoice::ArrowMetal and JoinChoice::ArrowMetal. MemTables of 8,192-row batches over 16 partitions. Each configuration's first run gives the loss and the rule's report; then five rounds, in each of which every configuration, in an order rotated by one per round, runs untimed for at least 100 ms and then once timed; then three runs of each after 500 ms of idle. Timed: SQL to logical plan, physical planning and collect. CPU-ms is the process CPU time (user + system, all threads). Each shape's block started at a one-minute load of 2.97 to 3.48. From datafusion/results/attention_2026-10-03.csv:

shape (t, d, e) configuration best ms median ms CPU-ms of the best run median CPU-ms after 500 ms idle, median ms nodes taken / left
256, 128, 32 alone 48.83 52.29 479.0 473.4 77.53
default 48.79 50.25 468.3 468.5 76.71 0 / 53
forced 49.43 52.31 457.6 456.0 73.24 9 / 44
512, 256, 64 alone 252.39 255.71 3,256.0 3,250.2 283.78
default 248.90 257.40 3,258.8 3,250.5 281.82 0 / 53
forced 252.95 256.32 2,929.8 2,932.6 292.98 9 / 44
1024, 512, 64 alone 1,067.26 1,175.74 15,282.9 16,937.2 1,225.16
default 1,082.48 1,190.98 15,329.9 17,134.7 1,206.56 0 / 53
forced 1,060.19 1,126.93 13,685.3 14,711.7 1,213.69 9 / 44
2048, 512, 64 alone 4,086.14 4,621.95 60,509.8 67,384.0 6,571.78
default 4,242.26 4,654.40 62,642.1 68,246.9 6,078.59 0 / 53
forced 4,326.88 4,619.84 60,124.3 62,949.1 6,648.13 9 / 44
4096, 1024, 64 alone 18,049.96 22,959.19 245,669.9 323,961.9 23,874.41
default 18,536.87 23,913.96 254,263.2 329,441.7 24,029.14 0 / 53
forced 24,805.81 29,472.75 238,503.0 286,946.1 27,568.15 9 / 44

From 1024, 512, 64 on, every configuration's later rounds are slower than its first (the CSV's wall_runs_ms rows, in round order); at 4096, 1024, 64 the first round's runs are 18,049.96 ms (alone), 18,536.87 ms (default) and 24,805.81 ms (forced), and the later rounds' 19,599.71 to 30,177.41 ms.

The loss. Every run of every configuration gives DataFusion alone's loss to within 1.464e-14 relative (the largest difference, over every run of every shape and configuration, against DataFusion alone's first run of the shape); DataFusion alone's own runs differ from each other by up to that same 1.464e-14. The report records no run-time choice and no runtime fallback under the default or the forced configuration.

What the rule did. Under both configurations, the 14 grouped sums of a product (SUM(x.val * w.val), SUM(q.val * k.val), SUM(a.val * v.val)) are left, since the argument of the sum is an expression, not a column, and their 14 partial aggregates with them; so are the loss's whole-table sum (no GROUP BY keys) and its partial, and the nine joins and three aggregates whose input row counts DataFusion gives as estimates (above a join or an aggregate), with the partial aggregates of two of those three. The remaining nine joins are the x ⋈ weight joins, whose inputs have exact row counts.

  • 256, 128, 32: the default leaves those nine joins too, at 32,768 rows for the larger input against min_rows 250,000 (0 taken, 53 left); forced takes them (9 taken, 44 left).
  • 512, 256, 64: the default leaves the nine joins at 131,072 rows against min_rows 250,000 (0 / 53); forced takes them (9 / 44).
  • 1024, 512, 64: the nine joins pass min_rows (524,288 rows), and the default leaves them since no build-side bucket of the measured join table holds 32,768 rows (0 / 53); forced takes them (9 / 44).
  • 2048, 512, 64: as at 1024 with 1,048,576 and 32,768 rows (0 / 53); forced takes the nine joins (9 / 44).
  • 4096, 1024, 64: as at 1024 with 4,194,304 and 65,536 rows (0 / 53); forced takes the nine joins, each producing 268,435,456 rows (9 / 44).

With estimated row counts accepted. The forced configuration with with_accept_inexact(true) (the benchmark's on), against DataFusion alone in the same blocks, the same method, from datafusion/results/attention_inexact_2026-10-03.csv: it takes 21 nodes (all 18 joins and three aggregates: the maximum of each row of s, twice, and the normalizing sum, which ran on the GPU) and leaves 30 (the 14 grouped sums of a product with their partial aggregates, and the whole-table sum with its partial).

shape (t, d, e) alone, best / median ms accepted, best / median ms CPU-ms of the best run, alone / accepted after 500 ms idle, median ms, alone / accepted
256, 128, 32 49.72 / 53.77 39.92 / 43.03 469.7 / 276.3 75.63 / 56.97
512, 256, 64 245.36 / 254.59 168.71 / 174.74 3,254.8 / 1,885.9 268.14 / 232.04
1024, 512, 64 1,077.51 / 1,092.99 801.28 / 1,299.61 15,167.4 / 9,744.9 1,205.43 / 1,252.80

At 1024, 512, 64 the accepted configuration's first timed round is 801.28 ms and the next four 1,255.76 to 1,391.90 ms, with CPU-ms between 9,659.2 and 10,454.2 in all five. Its losses are within 5.297e-15 relative of DataFusion alone's. At 2048, 512, 64 the accepted configuration was not timed: the process ended without output after its first run (6,217.90 ms), and one process running that shape's first run alone and then accepted has a peak memory footprint of 55,534,428,720 bytes on this 64 GB machine. 4096, 1024, 64 was not run accepted.


To improve

The measured shapes where the rule is behind DataFusion alone. The default leaves top-k and filters to DataFusion, and the aggregate shapes below.

Top-k

ORDER BY … LIMIT 100, 8,192-row batches, rule off / rule on in ms (off ÷ on), the median of three rounds from datafusion_sort_warm_2026-09-29.csv (one round at 10M and 50M rows):

rows int64 Float64 DESC Float32 DESC
250,000 0.39 / 1.12 (0.35x) 0.37 / 1.15 (0.32x) 0.40 / 0.98 (0.41x)
500,000 0.41 / 1.23 (0.33x) 0.44 / 1.24 (0.35x) 0.43 / 1.46 (0.29x)
1,000,000 0.46 / 2.86 (0.16x) 0.52 / 2.12 (0.25x) 0.55 / 2.01 (0.27x)
2,000,000 0.63 / 3.94 (0.16x) 0.73 / 2.45 (0.30x) 0.71 / 2.40 (0.30x)
10,000,000 1.59 / 7.44 (0.21x) 1.87 / 6.07 (0.31x) 1.93 / 5.28 (0.37x)
50,000,000 4.81 / 30.46 (0.16x) 6.21 / 26.70 (0.23x) 6.00 / 22.04 (0.27x)

One batch per partition: 0.14x to 0.38x. DataFusion's TopK operator keeps the best 100 rows of each partition while its input streams; MetalExec first collects and imports every row. At 50M rows the Float64 top-k's input_time alone is 7.72 ms and its import 9.83 ms, against DataFusion's 6.21 ms for the whole query.

Aggregates

With every replaced aggregate forced onto the GPU (AggregateChoice::ArrowMetal), DataFusion alone ÷ with the rule, the range over one and two keys, int32 and int64 keys, both layouts and the family's queries, from datafusion_groupby_sweep_2026-10-01.csv (the float min/max rows measured on 2026-10-01, the Float64 sum/avg and int64 min/max rows on 2026-09-30). Of these cells the default takes, at 50M rows, the two-int32-key cases of count(*) at 100,000 and 1,000,000 groups and of DISTINCT at 200, 100,000 and 1,000,000 groups, and of min/max over int64 at 100,000 and 1,000,000 groups over two int32 keys and over two int64 keys, and at 1,000,000 groups over one int64 key.

aggregate groups 1M 2M 5M 10M 50M
count(*) 200 0.40-0.58 0.40-0.92 0.64-1.32 0.91-1.68 1.32-2.31
count(*) 10,000 0.70-0.88 0.70-0.95 0.67-1.63 1.14-1.83 1.25-2.38
count(*) 100,000 1.17-1.31 1.49-2.20 1.35-2.76 2.45-2.95 2.45-3.25
count(*) 1,000,000 2.03-2.46 1.88-2.97 3.39-4.10
count(*) rows / 2 1.13-1.59 1.39-1.76 1.25-2.06 1.88-2.13 0.89-0.94
sum, avg over int64 200 0.35-0.62 0.45-0.83 0.64-1.23 0.69-1.62 0.90-1.94
sum, avg over int64 10,000 0.58-0.76 0.66-1.17 0.82-1.53 0.81-1.68 0.94-1.75
sum, avg over int64 100,000 0.88-1.20 1.23-2.08 1.67-2.23 1.61-2.57 1.55-3.20
sum, avg over int64 1,000,000 1.31-1.74 1.58-2.45 2.03-3.27
sum, avg over int64 rows / 2 0.89-1.24 1.06-1.27 1.01-1.36 1.04-1.42 0.63-0.78
sum, avg over Float64 200 0.21-0.57 0.50-1.01 0.62-1.36 0.65-1.36 0.89-1.75
sum, avg over Float64 10,000 0.42-0.97 0.55-1.14 0.77-1.38 0.71-1.42 0.74-1.55
sum, avg over Float64 100,000 0.63-1.32 0.84-2.47 1.72-2.30 1.41-2.51 1.30-2.47
sum, avg over Float64 1,000,000 1.33-1.75 1.42-2.10 1.75-2.81
sum, avg over Float64 rows / 2 0.33-1.55 0.79-1.45 1.02-1.38 0.93-1.33 0.59-0.79
min, max over int64 200 0.38-0.76 0.57-0.98 0.73-1.34 0.77-1.37 1.02-1.75
min, max over int64 10,000 0.59-1.08 0.93-1.25 1.00-1.59 0.95-1.55 0.87-1.61
min, max over int64 100,000 1.06-1.65 1.34-2.81 2.58-3.07 2.41-3.02 2.03-2.88
min, max over int64 1,000,000 2.52-3.00 2.56-2.97 3.11-3.70
min, max over int64 rows / 2 0.75-3.24 1.78-2.19 2.28-2.51 2.14-2.32 1.03-1.11
min, max over Float64 200 0.36-0.52 0.33-0.68 0.52-0.91 0.66-1.34 0.92-1.81
min, max over Float64 10,000 0.66-0.83 0.59-0.92 0.78-1.20 0.95-1.79 0.94-1.82
min, max over Float64 100,000 0.93-1.24 1.12-1.93 2.11-2.31 2.40-2.97 2.23-3.25
min, max over Float64 1,000,000 1.27-1.55 1.65-2.08 2.68-3.41
min, max over Float64 rows / 2 0.70-0.90 0.86-1.02 0.98-1.16 0.96-1.16 0.70-0.75
min, max over Float32 200 0.29-0.40 0.37-0.59 0.46-0.93 0.71-1.40 0.98-1.89
min, max over Float32 10,000 0.62-0.75 0.60-0.75 0.74-1.19 0.89-1.44 0.89-1.62
min, max over Float32 100,000 0.71-0.83 0.94-1.30 1.62-1.84 2.15-2.35 1.77-2.63
min, max over Float32 1,000,000 0.71-0.87 0.93-1.26 1.51-2.12
min, max over Float32 rows / 2 0.40-0.59 0.44-0.66 0.46-0.62 0.50-0.62 0.44-0.48
DISTINCT 200 0.43-0.60 0.59-0.92 0.70-1.48 1.00-1.73 1.20-2.32
DISTINCT 10,000 0.74-0.92 0.64-1.04 0.94-1.92 1.24-1.92 1.29-2.34
DISTINCT 100,000 1.26-1.36 1.50-2.55 1.69-2.92 2.36-2.82 2.04-3.36
DISTINCT 1,000,000 2.08-2.62 2.10-2.93 2.73-3.84
DISTINCT rows / 2 1.25-1.84 1.45-1.99 2.13-2.45 2.10-2.30 0.80-0.88

At 1M and 2M rows the 1,000,000-group data holds at least rows / 4 groups and is in the rows / 2 row. The rule describes min/max over Float64 and over Float32 as one family, so a float series' worst case is the lower of the two.

The first run after the GPU idles. 38 series are at least 1.65x faster warm at two sizes or more; the default takes 10. The first run after 500 ms of idle (pipelines compiled) of every case the default has run on the GPU (including SELECT DISTINCT region, sub at 50M rows), over the latest measurement of each case and layout (datafusion_groupby_default_idle_2026-09-29.csv, datafusion_groupby_recheck_gpu_2026-09-29.csv, datafusion_groupby_idle_vs_idle_2026-09-29.csv, datafusion_groupby_idle_gap_2026-09-29.csv, datafusion_groupby_idle_gaps_2026-09-29.csv, datafusion_groupby_resweep_check_2026-10-01.csv, datafusion_groupby_resweep_recheck_2026-10-01.csv, datafusion_groupby_default_import_2026-10-01.csv, datafusion_groupby_float_minmax_check_2026-10-01.csv, datafusion_groupby_refit_check_2026-10-02.csv; the first two with DataFusion's idle run always before the default's, the others alternating):

rows cases idle vs idle: DataFusion idle ÷ default idle, min / median / max at or above 1.0x GPU idle vs DataFusion warm, min / median / max DataFusion idle ÷ its warm time
2,000,000 10 0.79 / 1.35 / 2.57 8 0.35 / 0.43 / 0.72 1.72-4.08
5,000,000 62 0.64 / 1.22 / 1.82 54 0.37 / 0.50 / 0.71 1.56-3.63
10,000,000 98 0.73 / 1.23 / 2.07 84 0.41 / 0.64 / 1.14 1.11-2.82
50,000,000 100 0.87 / 1.39 / 2.37 96 0.48 / 1.04 / 1.94 1.11-1.82

The 32 cases measured in both …_idle_… (DataFusion's idle run first) and …_idle_vs_idle_… (alternating) differ between the two: the later idle-against-idle ratio is 0.69x to 1.51x of the earlier one (median 0.93x).

Series left at 50M rows by the first run after 5 s of idle, lowest DataFusion idle ÷ default idle over the cases and layouts: count(*) over two int32 keys, 10,000 groups 0.94 (datafusion_groupby_idle_gaps_2026-09-29.csv), and DISTINCT over two int32 keys, 10,000 groups 0.84 (SELECT DISTINCT region, sub, one batch per partition, the same file); min/max over int64 with one int32 key, 100,000 groups 0.84, and with one int64 key, 100,000 groups 0.80, and sum/avg over Float64 with two int64 keys, 100,000 groups 0.91 (datafusion_groupby_resweep_check_2026-10-01.csv); float min/max with one int32 key, 100,000 groups 0.98, and with two int64 keys, 100,000 groups 0.96 (Float32 both, datafusion_groupby_float_minmax_check_2026-10-01.csv). The 16 other series that qualify warm and are left at 50M have no first run after 5 s measured there (count(*) and DISTINCT over one key and over two int64 keys, sum/avg over int64 over two keys). DISTINCT over two int32 keys at 200 groups is left at 10M rows by its first run after 5 s: 0.53x and 0.50x; count(*) over two int32 keys at 100,000 and 1,000,000 groups is left at 10M rows by its first run after 5 s: 0.81x and 0.88x (datafusion_groupby_default_import_2026-10-01.csv).

The hand-back. Handing an aggregate back at run time (reading the first batches, estimating the groups, then running DataFusion's plan) cost, against DataFusion alone, measured run by run (datafusion_groupby_default_handback_2026-09-29.csv, the shapes the earlier table replaced), on the best run / the median run: at 2M rows a median of 0.969x / 0.967x, lowest 0.934x and 0.921x (the latter count(*) over one int64 key, 864,909 groups: 5.30 → 5.61 ms best); at 5M a median of 0.984x / 0.984x, lowest 0.936x and 0.914x; at 10M 0.990x / 0.991x, lowest 0.884x and 0.968x; at 50M 0.993x / 0.995x, lowest 0.944x and 0.924x. Series left at 50M because a hand-back of their shape there is below 0.97x (best / median, datafusion_groupby_resweep_check_2026-10-01.csv): min/max over int64 with one int32 key, 1,000,000 groups (minmax_int_1i32_10k, 0.994x / 0.951x); sum/avg over Float64 with two int32 keys, 100,000 groups (sum_f64_2i32_200, 0.989x / 0.925x) and with two int64 keys, 1,000,000 groups (sum_f64_2i64_200, 0.986x / 0.942x); and (datafusion_groupby_float_minmax_check_2026-10-01.csv) float min/max with one int64 key, 100,000 groups (minmax_f32_1i64_R2, 0.974x / 0.952x) and with two int32 keys, 100,000 groups (minmax_f64_2i32_200, 0.984x / 0.951x). In that file the float shapes' hand-backs at 5M rows were 0.930x to 1.007x best and 0.940x to 1.014x median (23 of 48 below 0.97x on one of the two), at 10M 0.948x to 1.042x and 0.954x to 1.051x (12 of 64), at 50M 0.951x to 1.093x and 0.951x to 1.074x (9 of 64). The 16 series above with no first run after 5 s at 50M have hand-backs of their shape there below 0.97x as well, by shape, with the shape's lowest hand-back at 50M (best / median; the latest measurement of each case in the check files): count(*) over one int32 key, 100,000 and 1,000,000 groups (0.961x / 1.003x); over one int64 key, 100,000 and 1,000,000 groups (0.948x / 0.943x); over two int64 keys, 100,000 and 1,000,000 groups (0.972x / 0.958x); DISTINCT over one int32 key, 1,000,000 groups (0.968x / 0.996x); over one int64 key, 100,000 and 1,000,000 groups (0.901x / 1.039x); over two int64 keys, 100,000 and 1,000,000 groups (0.953x / 1.002x); sum/avg over int64 with two int32 keys, 100,000 and 1,000,000 groups (0.990x / 0.924x); with two int64 keys, 100,000 and 1,000,000 groups (0.944x / 0.992x).

Filters

With ArrowMetalConfig::all(), from datafusion_rule_2026-09-29.csv:

query rows off ÷ on
SELECT sum(amount), count(*) FROM fact WHERE region < 20 AND qty > 10 (a_sumcnt) 1M / 10M / 50M 0.56-0.59 / 0.62-0.72 / 0.74
SELECT qty, sum(weight), count(*) FROM f WHERE price > 500.0 GROUP BY qty, Parquet (p_filtgroup) 10M / 50M 0.51 snappy, 0.52 zstd / 0.54 snappy, 0.55 zstd

DataFusion's FilterExec streams batch by batch; MetalExec collects the whole input first.

Float comparisons against a literal, now one fused expression each (see DataFusion's semantics), with the rule switched on for every node it translates, from datafusion_filter_2026-10-01.csv (warm: three rounds of best of 5; after idle: the median of three runs after 500 ms and three after 5 s, the contexts and the gaps alternating; f_gt: SELECT region, amount FROM fact WHERE amount > 1000.0, f_lt0: … WHERE amount < 0.0, f_ne0: SELECT region, qty FROM fact WHERE amount <> 0.0, f_sum_ge0: SELECT sum(qty), count(*) FROM fact WHERE amount >= 0.0):

case rows layout DataFusion alone, ms with the rule, ms warm after 500 ms idle, DataFusion / rule, ms idle vs idle 500 ms after 5 s idle, DataFusion / rule, ms idle vs idle 5 s CPU-ms DataFusion / rule
a_sumcnt 10M 8,192-row batches 3.58 4.87 0.74x 11.95 / 8.57 1.39x 11.69 / 21.59 0.54x 36 / 25
a_sumcnt 10M one per partition 3.26 4.54 0.72x 14.56 / 14.01 1.04x 5.76 / 14.76 0.39x 34 / 24
a_sumcnt 50M 8,192-row batches 15.54 19.79 0.79x 30.50 / 50.91 0.60x 28.82 / 67.35 0.43x 174 / 127
a_sumcnt 50M one per partition 13.22 18.72 0.71x 29.05 / 30.67 0.95x 30.92 / 55.68 0.56x 170 / 118
f_gt 10M 8,192-row batches 1.55 3.67 0.42x 9.49 / 13.94 0.68x 7.67 / 22.81 0.34x 17 / 15
f_gt 10M one per partition 1.40 3.42 0.41x 9.30 / 5.15 1.81x 6.82 / 23.66 0.29x 16 / 14
f_gt 50M 8,192-row batches 6.42 16.77 0.38x 19.64 / 35.28 0.56x 19.35 / 52.42 0.37x 83 / 82
f_gt 50M one per partition 5.68 15.62 0.36x 18.54 / 28.14 0.66x 19.93 / 44.42 0.45x 77 / 71
f_lt0 10M 8,192-row batches 1.53 3.85 0.40x 6.65 / 10.08 0.66x 9.42 / 20.87 0.45x 17 / 14
f_lt0 10M one per partition 1.36 3.56 0.38x 7.10 / 15.26 0.47x 10.27 / 23.67 0.43x 16 / 14
f_lt0 50M 8,192-row batches 6.48 16.98 0.38x 12.50 / 29.96 0.42x 14.67 / 44.23 0.33x 83 / 76
f_lt0 50M one per partition 5.91 15.15 0.39x 15.77 / 27.32 0.58x 18.03 / 44.47 0.41x 79 / 65
f_ne0 10M 8,192-row batches 2.08 6.67 0.31x 8.43 / 23.38 0.36x 9.39 / 33.91 0.28x 13 / 23
f_ne0 10M one per partition 2.13 6.42 0.33x 6.45 / 20.24 0.32x 6.29 / 23.49 0.27x 14 / 22
f_ne0 50M 8,192-row batches 11.52 31.79 0.36x 26.64 / 77.21 0.35x 26.60 / 78.99 0.34x 73 / 125
f_ne0 50M one per partition 11.67 30.78 0.38x 16.70 / 50.11 0.33x 25.64 / 76.37 0.34x 77 / 114
f_sum_ge0 10M 8,192-row batches 3.67 6.39 0.57x 12.91 / 16.57 0.78x 13.65 / 26.38 0.52x 47 / 26
f_sum_ge0 10M one per partition 3.57 5.91 0.60x 11.36 / 9.43 1.20x 11.64 / 26.56 0.44x 47 / 24
f_sum_ge0 50M 8,192-row batches 16.65 28.29 0.59x 33.31 / 48.20 0.69x 33.16 / 72.00 0.46x 232 / 136
f_sum_ge0 50M one per partition 15.89 27.17 0.58x 30.47 / 44.37 0.69x 30.94 / 61.33 0.50x 230 / 124

Every case is behind DataFusion alone warm (0.31x to 0.79x), so filter stays off by default.

A matmul chain in SQL with the joins taken

The attention query of A matmul chain in SQL, DataFusion alone ÷ the rule, from attention_2026-10-03.csv and attention_inexact_2026-10-03.csv:

  • Forced (the nine x ⋈ weight joins taken), 4096, 1024, 64: 18,049.96 / 24,805.81 ms best (0.73x) and 22,959.19 / 29,472.75 ms median (0.78x); each of the nine joins produces 268,435,456 rows. At 2048, 512, 64: 4,086.14 / 4,326.88 ms best (0.94x), 4,621.95 / 4,619.84 ms median.
  • With estimated row counts accepted (all 18 joins and three aggregates taken), 1024, 512, 64: 1,092.99 / 1,299.61 ms median (0.84x), while the best run is 1,077.51 / 801.28 ms. At 2048, 512, 64 one process running the shape's first run alone and then accepted has a peak memory footprint of 55,534,428,720 bytes, and the shape was not timed.

The first query of a process

The first GPU query in a process also compiles the Metal pipelines. In the table above, the int64 sort's first run at 250,000 to 1,000,000 rows (31.67 to 34.32 ms, starred) is slower than DataFusion's warm time for the same query (8.08 to 31.75 ms).


Limits

  • It collects its input. MetalExec reads every input partition to the end before it sorts, joins or runs an aggregate on the GPU (an aggregate it hands back reads only the first batches of each partition before DataFusion takes over); a join reads both of its inputs to the end. It has no spill path of its own. The collected batches and the result are counted in DataFusion's memory pool (a MemoryConsumer named MetalExec, at the bytes each batch's slices reference); when the pool refuses, MetalExec hands the node back to DataFusion, whose operators can spill. The Metal buffers themselves (the imported columns) are not counted in the pool.
  • The GPU call is not cancelled. It runs inside one tokio::task::spawn_blocking call; when the query is dropped, it runs to its end and its result is discarded.
  • One GPU job at a time per process. A process-wide lock serialises every MetalExec, so two queries that both reach the GPU run their GPU parts one after the other.
  • One blocking thread per node. All GPU work of one MetalExec runs inside one tokio::task::spawn_blocking call, off DataFusion's async worker threads. From 1,000,000 input rows its columns are imported on threads of their own, one per column, at the same time; each imported column is then used only by that blocking thread.
  • Exact row counts. By default a node is taken only when DataFusion's statistics give its input an exact row count (a join: both inputs). A sort above a filter, a join or an aggregate has an estimate and is left unless accept_inexact is set; an unknown count is left unless take_when_unknown is set.
  • Aggregates. The measured table takes three shapes: count over two int32 (or narrower) keys of a MemTable scan with at least 10,000,000 rows, and DISTINCT over the same keys and MIN/MAX of an integer column over one int64 key or two integer keys with at least 50,000,000 rows. Every other aggregate is left, including every aggregate over a Parquet scan, a filter or a join, and every one with a float or string key.
  • Joins. The measured join table takes no join; JoinChoice::ArrowMetal replaces every translatable one. A join reads both inputs to the end before it runs, where DataFusion's hash join streams its probe side.
  • Pipeline compilation. The first GPU query of a process compiles its Metal pipelines: 42 to 66 ms at 10M rows (datafusion_coldstart_2026-09-29.csv).
  • macOS on Apple silicon only. build.rs stops the build for any other target with a message naming the target; arrowmetal-sys refuses non-macOS targets as well.
  • DataFusion 55.1.0 only. The dependency is pinned with =; DataFusion's physical-plan API changes between releases.

Tests and files

cd datafusion
ARROWMETAL_LIB=/path/to/libArrowMetalC.dylib cargo test                       # 42 tests + 1 doc-test
ARROWMETAL_LIB=/path/to/libArrowMetalC.dylib cargo test --test rule -- --ignored   # default config at 2M rows
File What it checks
tests/grid.rs the differential grid above (1 test)
tests/rule.rs 20 tests: the threshold and its reason, inexact statistics, the config switches, what the default leaves, unsupported shapes, EXPLAIN (an aggregate and a join), ORDER BY through a projection, replaced aggregates under a partitioned join, count(DISTINCT), the forced hand-back, the measured choice's two branches, a refused memory reservation (a sort and a group-by; a join); joins replaced and matching with one and four partitions (collected and hash-partitioned plans, an aggregate above, an empty projection), an ordered inner join keeping the probe order, the joins the rule leaves with their reasons, the default config's join table; plus 1 ignored test of the default configuration at 2,000,000 rows
src/probe.rs, src/exec.rs (unit tests) 7 tests: the group-count estimate (exact small inputs, 200 to 1,000,000 groups, sorted keys, key tuples, the same estimate for the same input, a prefix of the input) and the whole-input check of a prefix that under-counts
tests/edge_paths.rs 5 tests: a GROUP BY over input declared sorted on its keys keeps the order (left), a replaced join's plan collected twice with nothing reserved after the query, the hand-back when the result's reservation is refused, a top-k under an OFFSET, an ordered right join keeping the probe order
tests/arrowmetal_repros.rs 9 tests of ArrowMetal's plan runner alone, no DataFusion: totalOrder against arrow-rs, NaN and zero group keys, count on every path, the chunked import, signbit and is_nan placing NaNs in a comparison

On 2026-10-02: 42 passed, 2 ignored, doc-test passed.

Path What it is
datafusion/src/rule.rs ArrowMetalRule, ArrowMetalConfig, AggregateChoice, Report, Decision
datafusion/src/exec.rs MetalExec, MetalOp: collection, the run-time choice, the hand-back
datafusion/src/probe.rs the group-count estimate
datafusion/src/choice.rs an aggregate's shape and the table lookup
datafusion/src/agg_table.rs the measured table (generated)
datafusion/scripts/groupby_table.py generates the table from the sweep and the default-check CSVs (--check, --print)
datafusion/src/join_table.rs the measured join table (generated)
datafusion/scripts/join_table.py generates the join table from the join CSV (--check, --print)
datafusion/src/translate.rs the checks on node shapes and types; predicates to ArrowMetal expressions
datafusion/src/gpu.rs the plans sent to ArrowMetal, the import, and the run-time checks
datafusion/examples/quickstart.rs the example above
datafusion/examples/bench.rs the benchmark
datafusion/examples/coldstart.rs the first GPU query of a process, against the next one after idle
datafusion/examples/plancost.rs the rule's planning cost on a plan it leaves
datafusion/examples/attention.rs the attention query of the ddx test over coordinate-format matrices, DataFusion alone and with the rule
datafusion/results/datafusion_sort_warm_2026-09-29.csv sorts and top-k with the warm-up, 100,000 to 50M rows
datafusion/results/datafusion_rule_2026-09-29.csv every case, rule off and on, 100,000 to 50M rows, and the Parquet cases
datafusion/results/datafusion_groupby_sweep_2026-10-01.csv the aggregate sweep, rule forced on against off, 1M to 50M rows: the table's source (the Float64 and Float32 min/max cases measured on 2026-10-01, with the idle runs of the forced rule after 500 ms and 5 s; the Float64 sum/avg and int64 min/max cases from datafusion_groupby_sweep_2026-09-30.csv; the other cases from datafusion_groupby_sweep_2026-09-29.csv, which also has the rule forced back)
datafusion/results/datafusion_groupby_float_minmax_check_2026-10-01.csv, datafusion/results/datafusion_groupby_refit_check_2026-10-02.csv the default-take checks after the 2026-10-01 sweep: every float min/max case at 5M to 50M rows under the sweep-only table, then every case whose decision the final table changed and a seeded fifth of the rest
datafusion/results/datafusion_groupby_resweep_check_2026-10-01.csv, datafusion/results/datafusion_groupby_resweep_recheck_2026-10-01.csv, datafusion/results/datafusion_groupby_resweep_left_retime_2026-10-01.csv the default-take checks after the 2026-09-30 sweep: every case of the re-swept families the sweep-only table replaced, then every case whose decision the final table changed and a seeded fifth of the rest; one left case timed again alone
datafusion/results/datafusion_groupby_default_2026-09-29.csv and …_taken_…, …_cap_…, …_idle_…, …_handback_…, …_recheck_gpu_…, …_recheck_…, …_recheck2_…, …_idle_vs_idle_…, …_idle_vs_idle_recheck_…, …_idle_gap_…, …_idle_gaps_…, …_idle_gaps_recheck_… the aggregates with the default configuration timed; …_idle_gap_…: twelve aggregates after 5 s and after 500 ms of idle; …_idle_gaps_…: both gaps per case
datafusion/results/datafusion_coldstart_2026-09-29.csv pipeline compilation per process
datafusion/results/datafusion_plancost_2026-09-29.csv the rule's planning cost
datafusion/results/datafusion_parquet_string_sort_2026-09-29.csv Parquet sorts with a string column
datafusion/results/datafusion_join_2026-10-01.csv joins, rule off and on, warm and after 500 ms and 5 s of idle: the join table's source
datafusion/results/datafusion_minmax_2026-10-01.csv float MIN/MAX, rule forced on, the crate before and now (column crate)
datafusion/results/datafusion_sort_import_2026-10-01.csv the sort family, the crate before and now: the columns imported at the same time
datafusion/results/datafusion_groupby_default_import_2026-10-01.csv, …_retime_… the default's aggregates, the crate before and now; two rows timed again alone
datafusion/results/datafusion_filter_2026-10-01.csv float comparisons in a filter, rule off and on
datafusion/results/attention_2026-10-03.csv, datafusion/results/attention_inexact_2026-10-03.csv (each with a _conditions.txt) the attention query, DataFusion alone, the default and the rule forced; the rule forced with estimated row counts accepted; the row counts of the joins and grouped sums; each configuration's report

The first two CSVs also hold rows with crate = before: an earlier state of this crate, measured in the same sessions. The tables on this page use the rows with crate = now.

docs/PANDAS.md

pandas on the Apple silicon GPU

ArrowMetal gives pandas two ways onto the GPU, and you pick how much of your code changes.

Tier You write What it does Falls back?
1. Accessor s.am.sum(), df.am.groupby("k").sum("v") Explicit. Always dispatched to the GPU, with a documented per-row host fallback for the Unicode case transforms on non-Latin rows. Results come back Arrow-backed, so they go straight out again with no copy. No — an unsupported dtype raises, so you always know where the work ran.
2. Accel mode import arrowmetal.pandas_accel; arrowmetal.pandas_accel.install() then unchanged pandas Patches a documented set of pandas methods. Routes to the GPU only when the dtype, the size and the arguments all qualify. Yes — always, silently, with the reason in stats().

Both live in the same package and can be used together.

# macOS arm64. pandas is not a hard dependency.
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
    swift build -c release --product ArrowMetalC
export PYTHONPATH=python

import arrowmetal never imports pandas: the bridge is loaded the first time you touch am.from_pandas, am.to_pandas, am.pandas_bridge or am.pandas_accel.


Tier 1: the .am accessor

import pandas as pd, arrowmetal as am
import arrowmetal.pandas_bridge      # registers .am on Series and DataFrame

df = pd.read_parquet("trades.parquet", dtype_backend="pyarrow")   # Arrow-backed: zero-copy

df["price"].am.sum()                    # scalar
df["price"].am.nlargest(100)            # Series, index taken along
df["symbol"].am.contains("BRK")         # boolean Series
df.am.groupby("symbol").sum("size")     # DataFrame indexed by the key
df.am.sort_values(["symbol", "price"])
df.am.merge(reference, on="symbol")
df.am.query(am.filter(am.col("price") > 100).sum(am.col("size")))   # one fused kernel

Series accessor

Method Notes
sum min max mean product median any all sum of an empty or all-null column is 0, as in pandas — each bridge follows its own host library, so the Polars bridge answers None there (Polars)
std(ddof=1) var(ddof=1) count() nunique(dropna=True)
value_counts(sort, ascending, dropna) counts descending, ties in first-seen order — pandas' own order
sort_values(ascending) stable, nulls last; the index is taken along
argsort(ascending) uint32 positions
top_k(k, largest) / nlargest(n) / nsmallest(n) pandas' keep="first" tie behaviour
isin(values) abs() round(ndigits=0) round is half-to-even, as pandas is
compare(op, other) and > >= < <= == != scalar or another column
filter(mask) boolean-mask selection, index taken along
contains startswith endswith upper lower len utf8
to_metal() to_arrow() zero_copy() the escape hatches

DataFrame accessor

Method Notes
groupby(by, dropna=True, sort=True, as_index=True).sum/mean/min/max/count(cols) and .size() one or several key columns of any type
sort_values(by, ascending) one or several columns, per-column direction, stable, nulls last
filter(mask)
merge(right, on=..., how="inner") needs a unique, null-free key on the right frame
query(expr) a fused ArrowMetal expression query over the whole frame — see Fused expressions
to_metal() zero_copy()

Tier 2: zero-code-change accel mode

import arrowmetal.pandas_accel
arrowmetal.pandas_accel.install()        # or set ARROWMETAL_PANDAS_ACCEL=1

# ... your existing pandas script, unchanged ...

print(arrowmetal.pandas_accel.stats())

or without touching the script at all:

python -m arrowmetal.pandas_accel my_script.py
ARROWMETAL_PANDAS_ACCEL=1 python my_script.py

A call is routed to the GPU only when all four hold:

  1. dtype — the column is numeric, boolean or utf8, in any of the three pandas storage flavours (numpy-backed, pd.ArrowDtype / int64[pyarrow], or the masked nullable dtypes Int64, Float64, boolean, string), including pandas 3's default str dtype;
  2. size — the frame is at least threshold rows, 2,000,000 by default (install(threshold=...), set_threshold(...), or ARROWMETAL_PANDAS_ACCEL_THRESHOLD). Below that the launch latency costs more than the kernel saves. The measured row counts behind that judgement, per operation, are in The crossover: where the GPU path overtakes the fastest CPU library, and where it overtakes the engine's own CPU loop. This threshold is the pandas caller's veto on top of the engine (it counts the one-time page mapping of a pandas-owned column, which a column already resident in Metal does not pay); a call that passes it still goes through the engine's CPU/GPU router (Design), which gives byte-identical output on either path;
  3. arguments — the call uses arguments the kernels implement exactly (the "not routed" column below);
  4. the operation is worth it — see the next section. Five reductions, abs and the scalar comparisons are intercepted but deliberately left to pandas.

Everything else runs in pandas, unchanged. Any Exception inside the GPU path is caught, recorded in stats(), and the original pandas method is run instead, so a GPU failure does not surface as a program failure. The one exception is repeated install()/uninstall() cycling — see Limits.

Why some operations are intercepted but not routed

Handing a pandas column to Metal is zero-copy — the GPU reads the very bytes pandas holds, and am.zero_copy_report proves it by address — when the buffer is page aligned, which it is for every allocation at these sizes; a small unaligned buffer costs one copy. But it is not free: the pages have to be mapped into the device's address space once. On an M4 Max, for a 50M-row (400 MB) int64[pyarrow] column:

Step Time
Series.array._pa_array → pyarrow.Array 0.00 ms (the same buffer)
that buffer mapped into Metal (MetalArray.from_arrow) 6.7 ms
sum on the GPU, column already mapped 1.07 ms (374 GB/s)
Series.sum() in pandas 4.9 ms

The kernel is 4.6x faster than pandas. The map is not, and one sum cannot amortise it. So the default routing table leaves alone every operation that reads each byte once and writes at most one byte back — sum, min, max, mean, count, abs, and the six scalar comparisons — and routes the ones that do enough per byte to pay for the map many times over: sorts, hash group-bys, merges, string scans, round, isin, nunique, value_counts, boolean-mask selection.

That table is data, not a hard-coded rule:

accel.NEVER_BY_DEFAULT     # ('sum', 'min', 'max', 'mean', 'count', 'abs', 'eq', 'ne', ...)
accel.ROW_FACTOR           # op -> rows needed as a multiple of the threshold, or None for "never"
accel.route_all()          # route everything anyway
accel.ROW_FACTOR["sum"] = 1

The .am accessor has no such table: it always dispatches to the GPU — bar the per-row host fallback the Unicode case transforms take on non-Latin rows — and s.am.to_metal() gives you the mapped column so that a chain of operations pays the map once. Nothing about correctness changes either way — only where the work runs.

What is intercepted

pandas call Routed to the GPU when Not routed (falls through)
Series.sum/min/max/mean numeric column, and route_all() by default, always — see above; also skipna=False, min_count>0, numeric_only
Series.count numeric, boolean or utf8, and route_all() by default, always — see above
Series.nunique numeric, boolean or utf8 —
Series.value_counts numeric, boolean or utf8 normalize=True, bins=
Series.sort_values numeric, boolean or utf8 key=, na_position="first", inplace=True
Series.nlargest/nsmallest numeric keep != "first"
Series.isin numeric or utf8 any NaN/None in values (pandas matches NaN to NaN; Arrow's is_in does not)
Series.abs numeric, and route_all() by default, always — see above
Series.round float integer columns (pandas' answer is the identity, so there is nothing to win)
Series.__eq__ __ne__ __lt__ __le__ __gt__ __ge__ numeric vs a numeric scalar, or utf8 vs a string for ==/!=, and route_all() by default, always — see above; also another Series (pandas aligns indexes first), NaN as the operand, ordering comparisons on strings
Series[bool_mask] any supported dtype, mask of the same length and index anything but a full-length boolean mask
Series.str.contains literal pattern a real regex, case=False, flags=, explicit na=
Series.str.startswith/endswith a single string pattern a tuple of patterns
Series.str.upper/lower the column is pure ASCII (checked on the GPU: byte length == code point count) any non-ASCII value
Series.str.len utf8 —
DataFrame[bool_mask] every column a supported dtype a mixed frame with, say, a datetime column
DataFrame.sort_values one or several supported columns key=, na_position="first", inplace=True
DataFrame.merge how="inner", one key column, unique and null-free on the right outer/left/right joins, several key columns, duplicate or null right keys, indicator, validate
DataFrame.groupby(...).sum/mean/min/max/count key columns of a supported dtype, numeric value columns min_count, numeric_only, a callable or array grouper, level=, non-numeric values

Anything not in that table — apply, rolling, pivot_table, resample, describe, arithmetic between columns, and every dtype outside the list (categorical, datetime, timedelta, object, interval, period) — is untouched pandas.

Control

from arrowmetal import pandas_accel as accel

accel.install(threshold=5_000_000)
accel.set_threshold(1_000_000)
with accel.disabled():                  # a block of guaranteed plain pandas
    ...
accel.stats()                           # what ran where
accel.stats().to_frame()                # the same as a DataFrame: gpu / cpu / errors per op
accel.reset_stats()
accel.uninstall()                       # every original method back
accel.REGISTRY                          # the contract, as data
accel.ROW_FACTOR                        # the routing table, as data
accel.route_all()                       # route the operations pandas is otherwise faster at

stats() reports four things per operation: gpu (routed), cpu (fell through, with the reason implied by the table above), errors (the GPU path raised and pandas ran instead — each with the exception text), and intercepted, the total number of calls that reached a patched method at all.


Zero-copy: what happens

Series.array._pa_array is a pyarrow.ChunkedArray for any Arrow-backed pandas column. The bridge takes its single chunk and hands those buffers to Metal — the same bytes, no copy. Apple silicon's unified memory means the GPU then reads the pages the CPU wrote, with no transfer either.

am.zero_copy_report(df) does not guess: it compares the buffer address pandas holds with the one handed over.

>>> am.zero_copy_report(pd.Series(pd.array([1, 2, 3], dtype="int64[pyarrow]")))
{'zero_copy': True, 'reason': 'Arrow-backed pandas column, buffers shared',
 'dtype': 'int64[pyarrow]', 'arrow_type': 'int64'}

The "none" rows are copy-free when the buffer is page aligned, which it is for every allocation at these sizes; a small unaligned buffer costs one copy.

pandas column Copy? Why
pd.ArrowDtype / int64[pyarrow], one chunk none the Arrow buffers are handed straight over
pandas 3 default str dtype (ArrowStringArray) none same — it is a large_string Arrow array
Arrow-backed but split into several chunks one combined once into a contiguous array; reported as "...split into N chunks; combined once"
numpy int*/uint* none pyarrow adopts the numpy buffer; there is no validity bitmap to build
numpy bool one Arrow packs booleans to one bit per value, so the buffer cannot be adopted
numpy float*, datetime64 values none, validity one pass the values buffer is adopted, and one pass builds the validity bitmap from the NaNs/NaTs
Int64, Float64, boolean, string (masked nullable) one pandas keeps values and mask in two arrays; Arrow needs a bitmap
Categorical one becomes an Arrow dictionary array, indices widened to int32

Results come back the same way: am.to_pandas(...) and every .am method wrap the Metal buffers in a pd.arrays.ArrowExtensionArray, so the returned Series shares memory with the GPU result. Accel mode adds one cast on top, to give you back the dtype pandas itself would have produced.

Nulls, NaN and the index

  • A numpy float column has no null, so NaN becomes Arrow null on the way in. That is exactly what skipna=True — pandas' default — already means, so sum, mean, min and max agree.
  • An Arrow-backed float column has both, and the bridge does not touch either: a NaN stays a NaN and a null stays a null.
  • Coming back, accel mode restores pandas' own convention: a null becomes NaN for a numpy float result, False for a plain bool result, True for a plain bool result of != (NaN != x is True in numpy), and the dtype's own NA for a nullable dtype. The rule is not hand-written per operation: each wrapper runs the original pandas method over a two-row probe of the same dtype and copies the dtype and NA convention it sees.
  • The index is carried along by the same permutation as the values — sort_values, nlargest, nsmallest and boolean-mask selection all return the original index rows, in the new order. ignore_index=True gives a fresh RangeIndex, as in pandas. Group-by results are indexed by the key (or by a MultiIndex for several keys), and as_index=False resets it, as in pandas.
  • Group-by follows pandas: null keys are dropped unless dropna=False, groups come back sorted by key unless sort=False (then in first-seen order), and a group whose values are all null sums to 0.
  • Inner merge preserves left-row order and gives the result a fresh RangeIndex, as pandas does, with the same _x/_y suffixing for overlapping columns.

The one deliberate difference

sort_values on the GPU is stable. pandas' default is kind="quicksort", which is not, so rows with equal sort keys can come back in a different order than pandas would give them (the same order as kind="stable"). Every other value, dtype and index matches. If that matters, use the accessor explicitly or keep sort_values out of the accelerated set with accel.disabled().

Numbers

Measured with PYTHONPATH=python python Benchmarks/pandas_bench.py <rows> <iters> on an Apple M4 Max, pandas 3.0.5 / pyarrow 25.0.1 / Python 3.13, best of five, on the same in-process frame. The accessor and accel rows include the conversion the dtype forces, the map into Metal, the GPU work, and the trip back into a pandas object — nothing is pre-converted or cached between iterations, so these are the numbers a pandas user gets, not kernel times.

routes to is where accel mode sent the call with the default table; the accessor column is always the GPU, so it also shows what those operations would cost if you routed them.

Arrow-backed frame (pd.ArrowDtype, what read_parquet(dtype_backend="pyarrow") gives you)

Operation 10M pandas 10M accessor 10M accel 10M accel x 50M pandas 50M accessor 50M accel 50M accel x routes to
sum (int64) 1.0 2.4 1.0 1.01x 4.9 10.5 4.8 1.02x pandas
mean (float64) 1.2 2.6 1.2 0.96x 5.6 14.5 5.7 0.98x pandas
s > 0 1.5 2.6 1.4 1.14x 6.4 11.1 6.5 0.99x pandas
abs (float64) 1.6 2.6 1.6 0.98x 7.6 13.0 7.5 1.02x pandas
round(2) 22.3 4.1 4.9 4.55x 110.9 18.5 22.6 4.91x GPU
isin (10 values) 66.8 3.3 8.8 7.62x 337.4 13.8 39.3 8.59x GPU
nunique 35.5 9.8 9.6 3.69x 176.1 18.8 19.2 9.17x GPU
value_counts (utf8) 95.5 38.9 39.5 2.42x 476.6 157.7 170.4 2.80x GPU
groupby-sum, 1k keys 99.4 15.4 15.9 6.26x 504.3 32.4 35.6 14.18x GPU
groupby-sum, 100k keys 103.0 17.7 16.4 6.26x 450.7 34.9 35.2 12.80x GPU
sort_values (int64) 392.7 69.9 62.4 6.30x 2312.1 324.9 305.3 7.57x GPU
nlargest(100) 53.3 7.7 8.0 6.64x 264.0 28.9 29.0 9.10x GPU
str.contains 97.6 3.4 3.7 26.06x 492.6 16.3 18.8 26.22x GPU
df[df.i > 0] 58.2 35.6 30.0 1.94x 284.6 124.6 105.2 2.71x GPU
merge, 100k int keys 233.4 37.9 38.0 6.15x 1258.5 132.6 134.8 9.34x GPU

numpy-backed frame (plain pd.DataFrame({...}))

Every column has to be converted to Arrow first, and pandas' numpy kernels are much faster than its pyarrow ones, so the speedups are smaller — and round/isin are left to pandas here.

Operation 10M pandas 10M accessor 10M accel 10M accel x 50M pandas 50M accessor 50M accel 50M accel x routes to
sum (int64) 1.1 2.7 1.1 1.00x 5.1 11.2 5.2 0.97x pandas
mean (float64) 4.1 23.3 4.1 0.99x 21.1 116.3 20.6 1.02x pandas
s > 0 1.2 3.0 1.1 1.04x 5.4 12.6 5.5 0.99x pandas
abs (float64) 1.5 22.7 1.5 1.00x 7.4 110.8 7.3 1.01x pandas
round(2) 4.1 25.6 4.1 0.99x 20.3 120.5 20.6 0.98x pandas
isin (10 values) 8.0 4.6 7.7 1.05x 41.5 14.2 41.2 1.01x pandas
nunique 16.7 9.6 9.8 1.69x 89.8 20.3 19.0 4.72x GPU
value_counts (utf8) 91.5 43.4 43.2 2.12x 460.6 178.1 185.1 2.49x GPU
groupby-sum, 1k keys 38.7 9.4 9.3 4.16x 198.9 33.2 36.7 5.42x GPU
groupby-sum, 100k keys 77.8 18.9 18.6 4.19x 362.7 40.9 36.8 9.85x GPU
sort_values (int64) 1068.4 95.6 89.5 11.94x 6178.0 473.1 434.6 14.22x GPU
nlargest(100) 52.3 11.3 11.1 4.73x 267.6 31.3 29.8 8.98x GPU
str.contains 98.0 8.4 13.0 7.53x 520.2 32.0 56.6 9.19x GPU
df[df.i > 0] 56.7 61.4 57.7 0.98x 274.2 259.5 237.1 1.16x GPU
merge, 100k int keys 147.6 120.5 77.6 1.90x 638.0 262.1 285.3 2.24x GPU

Read it this way: an Arrow-backed frame is where the GPU pays. Group-by, sort, top-k, merge and string scanning are 6x to 26x on a 50M-row frame with no code change at all; the operations pandas already does at memory bandwidth are left alone and cost nothing; and the whole-frame operations (sort_values on a DataFrame, df[mask], merge) carry every column across, which is why they land lower than the single-column ones.

Two caveats visible in the numbers. The GPU CPU-ms column (in the benchmark output, not repeated here) is roughly a third of the wall time on group-by and sort, so the cores are free while the GPU works — a real advantage the wall-clock ratio understates. And a numpy float column costs about 2 ms per million values to convert, because building the validity bitmap from the NaNs is a single-threaded pyarrow pass: that is the whole difference between the accessor's 13 ms for abs on an Arrow-backed 50M-row float column and its 111 ms on the numpy one, and it is why every single-pass float operation stays in pandas there.

Limits

  • Only these dtypes. Numeric, boolean and utf8. Categorical, datetime, timedelta, object, interval and period columns are left to pandas: their semantics are richer than the kernels here, and a wrong answer is worse than a slow one. (The bridge converts categoricals and datetimes fine — am.from_pandas handles them; it is the accel layer that will not route them.)
  • merge is the validate="m:1" inner join. Duplicate or null keys on the right frame, and any outer/left/right join, fall back. Many-to-many joins are not supported in accel mode.
  • str.upper/str.lower are ASCII-guarded in accel mode. The kernels implement Unicode's simple 1:1 mapping over every script, but pandas applies the full mapping (ß → SS), so accel mode checks for pure ASCII on the GPU and falls back otherwise; it never returns a different string than pandas would. The .am accessor runs the simple mapping on anything.
  • str.contains with a real regex falls back. A literal pattern runs on the GPU.
  • Float reductions add in a different order. A GPU tree reduction is not bit-identical to pandas' pairwise sum; expect agreement to about 1e-12 relative, not to the last bit.
  • Below the threshold nothing is accelerated. At a million rows most of these operations are already sub-millisecond in pandas and the GPU launch cannot pay for itself. The default of two million rows is deliberately conservative; lower it if your columns are wide.
  • Single-pass operations are not accelerated at all, at any size: sum, min, max, mean, count, abs and the scalar comparisons, plus round and isin on a numpy-backed column. The measurement and the reasoning are above; route_all() overrides it, and the .am accessor never applies it.
  • A numpy float column costs about 2 ms per million values to convert, because pyarrow builds the validity bitmap from the NaNs in a single-threaded pass. Arrow-backed columns skip it entirely — which is the single biggest thing you can do to make this faster: read your data with dtype_backend="pyarrow".
  • Install once. install() and uninstall() rewrite pandas' method slots. Cycling them a hundred-odd times in one process can leave an already-specialized x[mask] call site bound to whichever __getitem__ CPython first saw — an interpreter-level artifact of repeatedly rewriting a dunder. Use accel.disabled() to turn the layer off for a block instead.
  • Threads. The wrappers are re-entrancy-guarded per thread, but the GPU queue underneath is a single device queue; several Python threads calling into it will serialise. disabled(), set_threshold() and route_all() are process-wide, not per thread: while one thread is inside a disabled() block every thread runs in pandas. The answers do not change — only where the work happens, and those calls land in neither stats().gpu nor stats().cpu.

Tests

swift build -c release --product ArrowMetalC
PYTHONPATH=python python -m pytest python/tests/test_pandas.py -q

python/tests/test_pandas.py covers: a GPU round trip for twenty pandas dtypes; zero-copy proved by buffer address for each Arrow-backed flavour and disproved for each copying one; every accessor method against its pandas equivalent; and, for accel mode, forty-plus operations run twice — once in plain pandas, once with the layer installed — over numpy-backed, Arrow-backed and masked frames carrying nulls, NaNs, strings and duplicate keys, with assert_series_equal / assert_frame_equal checking values, dtype and index. Each of those also asserts that the GPU path really was taken, so a test cannot pass by quietly falling back. The rest of the file is the fallback surface: unsupported dtypes, unsupported arguments, regex patterns, non-ASCII case changes, NaN in isin, duplicate and null merge keys, an injected exception inside the GPU path, the row threshold in both directions, uninstall() restoring every original, and the ARROWMETAL_PANDAS_ACCEL=1 and python -m arrowmetal.pandas_accel entry points in real subprocesses.

docs/NUMPY.md

numpy

How a numpy array reaches the GPU, what crosses without a copy and what does not, what the benchmark matrix measures against numpy, and what is not implemented. Every number here is from Benchmarks/results/full_matrix_2026-09-07.csv or from python/tests/test_numpy.py, which asserts each claim in the first two sections on every run. That CSV is the eager run, and the numpy rows quoted below are numpy's plain eager idiom; the published baseline is the parallel run in Benchmarks/results/full_matrix_2026-09-07-parallel.csv, which measures each CPU library's most parallel idiom too (numpy has none, so its numpy-parallel column is empty), and The full matrix and Where ArrowMetal needs to improve are written against it.

1. The crossing

There is no numpy bridge, because none is needed: pyarrow already speaks numpy, and ArrowMetal speaks Arrow.

import numpy as np, pyarrow as pa, arrowmetal as am

x = np.random.rand(50_000_000)          # float64, the numpy default
col = am.array(pa.array(x))             # no copy: pyarrow adopts the numpy buffer, Metal wraps it
r = col.sqrt()                          # GPU, software IEEE-754 binary64
y = r.to_arrow().to_numpy(zero_copy_only=True)   # no copy out either

Two things make this copy-free, and both are checked:

  • pa.array(x) adopts the numpy buffer for integer and float dtypes: the Arrow array's data buffer has the numpy array's address.
  • am.array(...) wraps a page-aligned buffer in a Metal buffer in place. macOS hands numpy page-aligned memory for every allocation we measured from 1,000 elements up to 50M; a buffer that is not page aligned (tiny arrays of ten or a hundred elements were not) is copied once on the way in, which costs nothing worth measuring at that size.

test_numpy.py proves the import is in place by writing to the numpy array after the import and reading the new value back through the Metal column, and proves the export is in place by comparing the address of to_numpy(zero_copy_only=True) with the Arrow buffer's.

2. What crosses and what it costs

numpy dtype copies in why
int8…int64, uint8…uint64, float32, float64 none pyarrow adopts the buffer; Metal wraps it
bool one Arrow packs booleans to one bit per value, so pyarrow has to write a new buffer
datetime64 none adopted as an Arrow timestamp
str_ (U), object strings one conversion become an Arrow string array; numpy's fixed-width UTF-32 layout is not Arrow's
a slice with a stride one Arrow has no strides; pyarrow copies to contiguous

Results come back the same way: any numeric result with no nulls is a numpy view over the Metal buffer through to_numpy(zero_copy_only=True); a result with nulls needs to_numpy(zero_copy_only=False), which materialises NaNs, or stays as Arrow.

NaN is a value, not a null. pa.array(np.array([1.0, np.nan])) has a null count of zero, and ArrowMetal treats it the way numpy does: sum and mean return NaN. That is numpy's semantics and Arrow's default; a pandas column takes the other path (from_pandas=True), where NaN becomes null and is skipped, and pandas covers it. If you want numpy's NaNs skipped on the GPU, pass from_pandas=True to pa.array and pay one pass to build the validity bitmap.

numpy has no nulls, so the GPU column has no validity bitmap and every kernel takes its no-null fast path.

3. Measured against numpy

The benchmark matrix (Benchmarks/full_matrix.py) compares each operation with pandas, and where pandas has no vectorised idiom of its own it measures numpy directly on the same values, without a validity bitmap because numpy has none. Those rows, at their largest measured size, from the 2026-09-07 run on an Apple M4 Max (numpy 2.5.3, the eager idiom, best of up to five calls after a warm-up; cores = CPU-ms / wall ms):

family operation rows numpy ms (cores) ArrowMetal ms (cores) speedup
sort lexsort (2 int32 keys) 50,000,000 28,585 (1.0) 37.4 (0.1) 763.9x
sort argsort float64 50,000,000 5,489 (1.0) 40.0 (0.0) 137.4x
sort top_k (k=100, int64) 50,000,000 328.5 (1.0) 2.685 (0.5) 122.4x
sort argsort int64 50,000,000 4,555 (1.0) 39.2 (0.0) 116.4x
sort top_k (k=10000, int64) 50,000,000 328.3 (1.0) 2.944 (0.6) 111.5x
chains compare + filter + take 50,000,000 206.7 (1.0) 4.569 (0.3) 45.2x
sort sort float64 50,000,000 1,762 (1.0) 49.6 (0.0) 35.5x
element-wise exp (float32) 50,000,000 61.9 (1.0) 1.912 (0.2) 32.4x
chains filter two columns + sum 50,000,000 113.6 (1.0) 3.545 (0.5) 32.0x
element-wise case_when (2 conditions) 50,000,000 199.5 (1.0) 6.896 (0.2) 28.9x
element-wise index_in (int64, 100-value set) 50,000,000 163.0 (1.0) 5.848 (0.6) 27.9x
sort partition_nth_indices (n/2) 50,000,000 341.6 (1.0) 19.8 (0.4) 17.2x
compare+select indices_nonzero (bool) 50,000,000 16.8 (1.0) 3.151 (0.3) 5.3x
element-wise if_else (bool ? int64 : int64) 50,000,000 14.7 (1.0) 3.699 (0.3) 4.0x
element-wise is_nan (float64) 50,000,000 4.926 (1.0) 1.320 (0.3) 3.7x
element-wise shift_left (int64 << 2) 50,000,000 6.817 (1.0) 1.915 (0.2) 3.6x
element-wise bit_wise_and (int64) 50,000,000 10.1 (1.0) 2.935 (0.2) 3.4x
element-wise sqrt (float64) 50,000,000 12.3 (1.0) 3.951 (0.1) 3.1x
element-wise sin (float64) 50,000,000 301.7 (1.0) 150.2 (0.0) 2.0x
element-wise ln (float64) 50,000,000 87.3 (1.0) 81.2 (0.0) 1.1x

Two readings. numpy runs every one of these on one core, so this is one CPU core against the GPU, with the crossing free because the memory is shared; a 16-thread CPU library is the other comparison, and it is on the same Compare tab. The transcendentals are the narrow rows: ln at 1.1x and sin at 2.0x are software binary64 on a GPU with no double hardware against a vectorised libm, and they are listed in Where ArrowMetal needs to improve as open work.

4. Limits

  • NEP 18 dispatch is not implemented: np.sum(x) on a Metal column runs numpy's kernel, not the GPU's.
  • Multi-dimensional arrays are not Arrow arrays; pass one column (x[:, i] is strided, so it copies once; np.ascontiguousarray first if you will reuse it).
  • bool costs one pass in each direction because of the bit packing.
  • Strings are converted, in and out.
  • A numpy array smaller than about a thousand elements is usually not page aligned and is copied on import; at that size the dispatch floor, not the copy, is the cost, and the GPU is not the right place for it anyway (see the latency family in the matrix).
python/README.md

arrowmetal (Python)

ArrowMetal runs Apache Arrow compute on the Apple silicon GPU: Arrow arrays that live in Metal shared memory and GPU kernels that keep Arrow's semantics, reached from Python through a ctypes wrapper over libArrowMetalC.dylib whose input and output go through the Arrow C Data Interface, so it composes with pyarrow, Polars, pandas and DuckDB.

Apple silicon has one physical memory shared by the CPU and the GPU, so an Arrow buffer placed in a Metal shared buffer is at once a valid CPU Arrow buffer and a valid GPU buffer: a column is used where it already is, with no copy across a bus in either direction. While a kernel runs, the CPU is free for the rest of the application, and every table in the repository shows the CPU time of each call next to its wall time.

One row, the same in-process data in every column:

sum by int32 key (1000 groups), 50,000,000 rows wall ms CPU ms of that call
ArrowMetal 4.89 1.2
pyarrow Acero (Table.group_by, 16 threads) 18.45 250
Polars lazy (16 threads) 81.93 1,186
pandas 247.93 248

From Benchmarks/results/full_matrix_2026-09-07-parallel.csv, Apple M4 Max (16 CPU cores, 64 GB), best of up to five calls after one warm-up, release build; the 339-row matrix that row comes from, including the 77 rows where the CPU idiom is ahead, is in docs/BENCHMARKS_MATRIX.md.

pip install arrowmetal          # macOS 14 or later on Apple silicon; pyarrow is the only dependency
python -m arrowmetal.bench      # 30 seconds or less
python -m arrowmetal.bench --parquet data.parquet   # read, sum, filter, group-by on your own file

The second line generates 10,000,000 rows, runs sum, filter, sort and group-by sum through pyarrow (and Polars when it is installed) and through ArrowMetal, and checks every ArrowMetal answer against pyarrow's. It prints one table for your Mac, wall and CPU milliseconds per call, with a block ready to paste into a benchmark result issue or the #benchmarks channel of the Discord linked from the README; nothing is sent. The dataset (int64 values uniform in [-1,000,000, 1,000,000) with 10% nulls, standard-normal float64s, int32 keys uniform over 1,000 values) is drawn with pyarrow.compute from seeded SplitMix64 streams, so the command needs nothing beyond pip install arrowmetal.

python -m arrowmetal.bench --parquet data.parquet runs on your own file instead. It reads the file's integer, floating-point and string columns with pyarrow, Polars and ArrowMetal, then runs sum and filter > median on the largest numeric column and group-by sum keyed on the lowest-cardinality integer or string column, CPU against Metal, with the same protocol and every ArrowMetal answer checked against pyarrow's. The report, the Share it block and the prefilled issue link carry the file's row count, column count, row groups, size and codecs and the timings, never its path, column names or values. A file whose columns would take more than a quarter of physical memory is refused, with the limit printed (the bench holds the columns up to four times at once), and a file with no integer, floating-point or string column gets one line saying so.

import pyarrow as pa, arrowmetal as am
amount = am.MetalArray.from_arrow(pa.array([10.0, 20.0, 30.0, 40.0]))
region = am.MetalArray.from_arrow(pa.array([1, 2, 1, 2], pa.int32()))
print(am.group_by([region]).sum(amount).to_arrow())     # [40, 60]: GPU group-by sum, one row per region
print(amount.filter_where(">", 15).to_arrow())          # [20, 30, 40]: GPU filter, back as a pyarrow array

Repository, documentation and the other language bindings: https://github.com/singhpratech/ArrowMetal. Site: https://arrowmetal.org.

Install

Two ways: a wheel built with scripts/build_wheel.sh and installed with pip install python/dist/arrowmetal-0.5.0-*.whl (macOS 14 or later on Apple silicon; the wheel bundles libArrowMetalC.dylib and the Polars expression plugin libarrowmetal_polars.dylib, and pyarrow is its only dependency), or from this repository.

From source, the development path. Needs the Swift toolchain:

DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
  swift build -c release --product ArrowMetalC
# that builds .build/release/libArrowMetalC.dylib
pip install pyarrow
PYTHONPATH=python python -c "import arrowmetal as am; print(am.device_name())"

From a wheel, which carries both dylibs and needs no Swift or Rust toolchain at install time:

# the wheel build frontend, once
pip install build
# swift build, cargo build of the Polars plugin, then the wheel
scripts/build_wheel.sh
# pyarrow comes with it
pip install python/dist/arrowmetal-0.5.0-*.whl
# optional bridges
pip install "$(echo python/dist/arrowmetal-0.5.0-*.whl)[polars,duckdb,pandas]"
python -c "import arrowmetal as am; print(am.device_name())"

The wheel is arrowmetal-0.5.0-py3-none-macosx_14_0_arm64.whl; the build script prints its size. It is macOS arm64 only: it links Metal and holds an arm64 binary. Extras polars, duckdb, pandas and test pull in the libraries the corresponding bridges and the test suite want; none of them are needed to import arrowmetal.

The first thing to run needs nothing beyond the wheel itself (pyarrow comes with it):

import pyarrow as pa, arrowmetal as am

# this small array is copied in; large pyarrow buffers are page aligned
# and are borrowed; out is zero-copy
col = am.array(pa.array([1, None, 3, 40]))
big = col.filter_where(">", 2)                 # GPU
print(big.sum(), big.to_arrow())               # 43  [3, 40]

keys = am.array(pa.array([0, 1, 0, 2], pa.int32()))
print(keys.group_by(3).sum(col).to_arrow())    # [4, null, 40]

With the polars extra (or any installed Polars), a Series crosses through Arrow the same way:

import polars as pl
col = am.array(pl.Series([1, None, 3, 40]).to_arrow())
print(pl.from_arrow(col.filter_where(">", 2).to_arrow()))

Float64 columns (NumPy's and pandas' default) run entirely on the GPU: compare, filter, take, sum and arithmetic use a software IEEE-754 implementation that is bit-exact with the CPU. Use with am.batch(): around a chain of operations to pay one GPU round trip instead of one per call.

See Benchmarks/python_gpu_bench.py for a side-by-side with Polars, pyarrow.compute and pandas on the same data.

Building a wheel

pip install build                    # the wheel build frontend
scripts/build_wheel.sh               # swift build, then the wheel (cargo builds the Polars plugin)
# just the wheel, when .build/release/libArrowMetalC.dylib exists
python/build_wheel.sh
# install the wheel and Polars into a fresh virtualenv and run all four Polars tiers
scripts/check_wheel.sh

python/build_wheel.sh copies .build/release/libArrowMetalC.dylib into python/arrowmetal/_lib/, builds the Polars expression plugin with cargo build --release in polars-plugin/ against that same dylib and copies libarrowmetal_polars.dylib beside it, replaces the plugin's build-machine rpath with @loader_path (so it loads the packaged libArrowMetalC.dylib), re-signs both, runs python -m build --wheel in python/ with the platform tag macosx_14_0_arm64, checks that both dylibs really are inside the archive, and prints the wheel's path and size. scripts/build_wheel.sh is the same thing with swift build -c release --product ArrowMetalC in front. The version comes from one place, __version__ in arrowmetal/__init__.py; pyproject.toml reads it.

ARROWMETAL_DYLIB points the script at a dylib somewhere other than .build/release, ARROWMETAL_POLARS_PLUGIN_DYLIB at an already built plugin (then cargo is not needed), and PLAT_TAG overrides the platform tag.

scripts/check_wheel.sh [wheel] installs the wheel with its polars extra (Polars from PyPI) into a fresh virtualenv in a temporary directory, and from there, with a scrubbed environment (no PYTHONPATH, ARROWMETAL_* or DYLD_* variables, no cargo on PATH), runs a tier-1 group-by, tier-2 sum, filter_sum and device expressions, a tier-3 collect_gpu and a tier-4 MetalEngine collect against Polars' own answers, checks that the process loaded exactly one libArrowMetalC.dylib, the packaged one, and runs python -m arrowmetal.bench (10,000,000 generated rows) and python -m arrowmetal.bench --parquet on a 1,000,000-row file.

Where the dylib is found

arrowmetal looks for libArrowMetalC.dylib in this order:

  1. $ARROWMETAL_LIB, the full path to a dylib. It wins over everything else, so one specific build can be pinned; a bundled arrowmetal/_lib/ copy in the same checkout never shadows it. Setting it to a path that does not exist is an error, not a silent fall-back to some other dylib.
  2. Bundled in the installed package, arrowmetal/_lib/libArrowMetalC.dylib — what a wheel install has.
  3. The development build, .build/release/libArrowMetalC.dylib beside a source checkout (then .build/debug, then /usr/local/lib and /opt/homebrew/lib).

When none of the three exists the import fails with an OSError that names all three and how to satisfy each.

Tests

pip install pytest
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build -c release --product ArrowMetalC
PYTHONPATH=python python -m pytest python/tests -q

python/tests/test_arrowmetal.py checks import and round trip for int64/float64/float32/bool/string with nulls, reductions, compare/filter/filter_where, take, slice, cast, arithmetic (float64 included), boolean logic, group-by, the string kernels, with am.batch(): against the unbatched results, and Polars interop. Every result is compared to pyarrow.compute or plain Python. Polars tests skip when Polars is absent; the suite needs a real Metal device.

Index arrays — argsort, top_k, partition_nth_indices, lexsort_indices, am.join, rank and the window functions' row_number / rank / dense_rank — are pa.uint32(), row numbers up to 2^32 - 1 (a longer input raises). pyarrow returns uint64 for the same functions, with the same values. take accepts int32, int64 and uint32 arrays, NumPy arrays and lists (python/tests/test_index_type.py).

Not covered by the dense-key MetalArray.group_by(key_count) path: min/max on 64-bit values, mean on Float32, and Float64 values for any aggregate; python/tests pins those as expected errors. am.group_by(keys) runs every aggregate on Float64 values (python/tests/test_arrowmetal.py::test_group_by_arbitrary_keys_matches_pyarrow).

Polars

Four tiers, all in this repository — see docs/POLARS.md:

import polars as pl, arrowmetal as am

# -> dict[str, MetalArray], zero copy
am.from_polars(df)
# tier 1: the GPU around Polars
df.arrowmetal.group_by("k").sum("v")
# tier 2: inside a lazy plan (the wheel carries the plugin)
pl.col("v").arrowmetal.sum()
# tier 3: Polars runs the plan, the GPU finishes it
lf.arrowmetal.collect_gpu(query)

import arrowmetal still does not import Polars: the bridge loads on first use (or at import when Polars is already loaded). Tier 2 is a Rust plugin that a wheel built from this tree carries in arrowmetal/_lib/; from a source checkout it is one extra build, cd polars-plugin && cargo build --release. The plugin is built against the Polars 1.44 plugin ABI. The other tiers are pure Python over the Arrow C Data Interface. PYTHONPATH=python python -m pytest python/tests/test_polars.py -q runs the suite.

docs/SWIFT.md

Swift

The native API. The Python package, the C ABI and every other binding call these types. Everything on this page is in Getting started, Sources/ArrowMetalExamples/main.swift or the tests under Tests/ArrowMetalTests, and the numbers are the ones those cite.

Install

// Package.swift
dependencies: [.package(url: "https://github.com/singhpratech/ArrowMetal", from: "0.5.0")],
targets: [.target(name: "MyApp", dependencies: [.product(name: "ArrowMetal", package: "ArrowMetal")])]

macOS 14 or later, iOS 17 or later (Package.swift); Swift 6.3.3 is the toolchain the tests run under. Build and test from a checkout with the Xcode toolchain selected:

export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
swift build -c release
swift test -c release        # release is required: a release-only miscompile has bitten this project once

Example

import ArrowMetal

let prices = try MetalArray<Float>([9.5, nil, 12.0, 3.25])       // nullable, lives in Metal shared memory
let mask   = try prices.compare(.gt, 5)                          // GPU, Arrow boolean bitmap out
let picked = try prices.filter(mask)                             // GPU stream compaction
print(try picked.sum(), try picked.max(), picked.nullCount)      // .float(21.5), 12.0, 0

// Multi-column batches behave like an Arrow RecordBatch.
let orders = try MetalRecordBatch(names: ["region", "amount"], columns: [.int32(region), .float32(amount)])
let hits = try orders.filter(try region.compare(.eq, 2).and(try amount.compare(.gt, 100)))
let window = try hits.slice(offset: 32, length: 1000)             // zero-copy view

// Chains share one GPU round trip.
let total = try MetalContext.shared.batch {
    try amount.filter(try region.compare(.eq, 2).and(try amount.compare(.gt, 100))).sum()
}

batchAsync is the same without the wait; return a pending array from the body and use the async accessors (sumAsync and friends) for scalars. Six end-to-end scenarios (analytics on a record batch, feature preparation, Float64 with NaN, C Data Interface interop, sliced windows, one batched command buffer) run with swift run -c release arrowmetal-examples.

What the API is

Type What it is Where it is documented
MetalArray<T> one Arrow array in Metal shared memory: reductions, compare, arithmetic, cast, filter, take, slice, sort, top-k, strings, temporal, decimal Design, Coverage summary
AnyMetalArray the type-erased array every entry point in the C ABI hands around Arrow functions, one by one
MetalRecordBatch named columns with filter, take, slice and group-by across them Query engine
MetalContext the device, the command queue, batch and batchAsync Design, The persistent-kernel result
Expressions and LazyFrame a fused expression compiler and a lazy engine with an optimizer, six join kinds and an as-of join Fused expressions, Query engine
ParquetFile Parquet decoded on the GPU Parquet on the GPU
StreamQuery larger-than-memory scans, disk through the GPU Streaming
importArrowArray, exportArrowArray, exportArrowDeviceArray (CArrowABI) the Arrow C Data, C Device and C Stream interfaces; ImportResult.zeroCopy says whether the import wrapped or copied Coverage summary

The copy rule

Copy-free out, always. Copy-free in when the producer's buffers are page aligned; one copy otherwise. importArrowArray reports which it did. Details and the measurements behind them are in Design and numpy.

Tests

1,046 test functions in 87 files under Tests/ArrowMetalTests, run in release; the oracles are plain-Swift CPU references, hand-computed vectors and pyarrow answers pinned as literals (Testing). Tests that need a real GPU skip on virtual Metal devices.

Numbers

The Swift-level baselines (one core, all 16 cores, Accelerate) are in Getting started and Benchmark method; the operation-by-operation matrix is measured from Python over the same kernels (The full matrix).

Limits

  • One GPU, one device; no multi-GPU.
  • The kernels are generated Metal Shading Language, compiled at first use and cached per process, so the first call of each kernel family pays a compile of a few milliseconds.
  • Everything the other pages list under limits applies here first, since this is the layer they wrap.
  • Index arrays (argsort, topK, partitionNthIndices, lexsortIndices, hashJoin, the ranks) are MetalArray<UInt32>, row numbers up to 2^32 - 1; a call past that throws. take accepts MetalArray<Int32>, MetalArray<Int64> and MetalArray<UInt32> (Design).
docs/C.md

C

One header, one dynamic library, no Swift in sight for the caller. include/arrowmetal.h declares 286 entry points over libArrowMetalC.dylib; every other binding in this repository (Python, Rust, Go, TypeScript, R, the Polars plugin, the DuckDB extension) is built on it, so it is tested by all of their suites as well as by python/tests, which calls it through ctypes, and by a test that compiles the header as C.

Build the library

export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
swift build -c release --product ArrowMetalC        # .build/release/libArrowMetalC.dylib

Conventions

  • Handles: am_array* is an opaque, Metal-resident Arrow array; am_release frees it.
  • Every function returns 0 on success and non-zero on error; am_last_error() has the message, thread-local, valid until the next call on that thread. Argument guards return 2 without setting a message.
  • In and out through the Arrow C Data Interface: am_import moves the caller's ArrowArray (its release is nulled) and borrows the schema; am_export fills an ArrowArray/ArrowSchema pair whose release callback owns the buffers. am_import_device/am_export_device speak the C Device interface with ARROW_DEVICE_METAL; am_stream_* speak the C Stream interface.
  • Scalars are passed as a pointer to a value of the array's compute element type: am_compute_format names it (for a dictionary array am_format reports the index type, and the kernels compute on the values).
  • Copy-free out, always. Copy-free in when the producer's buffers are page aligned; one copy otherwise.
  • Index arrays are uint32 (I): am_argsort*, am_top_k*, am_lexsort*, am_partition_nth*, am_join and the ranks (am_window ops 0-2, am_rank_ex, a plan's row_number / rank / dense_rank). A call whose row numbers would pass 2^32 - 1 returns an error. am_take accepts int32, int64 and uint32 indices. Dictionary codes, index_in, group ids, inverse_permutation and list_parent_indices stay int32; indices_nonzero is uint64 (Design).
  • A column held as several arrays (a ChunkedArray, one column of a stream of RecordBatches) imports in one call: am_import_chunks(schema, arrays, n, &out) takes n contiguous ArrowArray structs of the one type schema describes and returns one array of their total length, the same array am_import of their concatenation gives. Each chunk's offset, length, validity bitmap (or its absence) and null count (-1 included) are honoured, and each chunk is copied straight into the final buffers on the CPU cores in parallel, with no concatenated copy in between. On success every chunk is moved; on failure a chunk whose release is still set is still the caller's. It takes the integer, float, float16, boolean, null, decimal, temporal, interval and fixed_size_binary types, utf8 / large_utf8 / binary / large_binary (a total over 2 GB is refused, as for one large_utf8 array) and utf8_view / binary_view; for any other type (dictionary, nested, run-end encoded, extension) it returns 3 and reads and moves nothing, and am_import_chunks_supported(schema) answers the question up front. One chunk is am_import of it.

Example

#include "arrowmetal.h"

struct ArrowSchema schema; struct ArrowArray array;   /* filled by any Arrow producer */
am_array* col = NULL;
if (am_import(&schema, &array, &col) != 0) { fprintf(stderr, "%s\n", am_last_error()); return 1; }

int64_t two = 2; am_array* mask = NULL; am_array* hits = NULL;
am_compare_scalar(col, 4 /* gt */, &two, &mask);
am_filter(col, mask, &hits);

int64_t sum; double f; int kind, is_null;
am_reduce(hits, 0 /* sum */, &sum, &f, &kind, &is_null);

struct ArrowSchema out_schema; struct ArrowArray out_array;
am_export(hits, &out_schema, &out_array);             /* copy-free; the consumer calls out_array.release */
am_release(hits); am_release(mask); am_release(col);

What is in the header

Reductions, element-wise compare and arithmetic, casts, boolean logic, selection, sorts and top-k, group-by with its aggregates, strings, temporal, decimal, nested types, hashing and set lookup, window functions, joins, the fused expression runner (am_query), the JSON plan runner (am_plan_*), Parquet, the streaming executor (am_stream_*), batching (am_batch_*) and the device interfaces. The header's comments are the reference for each; Arrow functions, one by one maps every Arrow function name to its entry point.

Limits

  • Thread-local errors mean a green-threaded runtime must read am_last_error on the thread that made the call (the Go binding pins the OS thread around every call for this reason).
  • am_import does not report whether it wrapped or copied; a caller infers it from buffer alignment.
  • macOS arm64 only: the library links Metal.
docs/RUST.md

ArrowMetal for Rust users

arrow-rs is where Rust keeps Arrow-shaped data. This document is how you point that data at the GPU.

Two crates live in rust/:

Crate What it is
arrowmetal-sys Raw extern "C" declarations over include/arrowmetal.h, plus the build.rs that finds and links libArrowMetalC.dylib.
arrowmetal The safe crate. An arrow::array::ArrayRef goes in, an ArrayRef comes out; every failure is a Result carrying am_last_error()'s message.

Both are on crates.io at 0.5.0; a path or git dependency on a checkout works too.

For Apache DataFusion, datafusion/ holds datafusion-arrowmetal, built on the arrowmetal crate: a physical optimizer rule that runs full ORDER BY sorts on the GPU with DataFusion's answers (DataFusion).

Everything below was run in this repository on 2026-09-07 on an Apple M4 Max, macOS 26.6.2, rustc 1.95.0, arrow-rs 59.3.0, ArrowMetal 0.1.0.


Install

ArrowMetal is a Metal library. It needs an Apple silicon Mac; there is no other target.

# 1. Build the GPU library. This is the only non-cargo step.
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
    swift build -c release --product ArrowMetalC

That produces .build/release/libArrowMetalC.dylib. Then, in your own crate:

# Cargo.toml
[dependencies]
arrow = "59"
arrowmetal = "0.5.0"          # crates.io; or { path = "../ArrowMetal/rust/arrowmetal" } from a checkout

and build with the dylib's location known:

ARROWMETAL_LIB=/path/to/libArrowMetalC.dylib cargo build --release

The dylib comes from the swift build above, or from the Python wheel: pip install arrowmetal places it at site-packages/arrowmetal/_lib/libArrowMetalC.dylib, and the crates were checked against that copy (a scratch crate depending on arrowmetal = "0.1.0" from crates.io, built with ARROWMETAL_LIB pointing at the wheel's dylib, prints 0.1.0 on Apple M4 Max).

Finding the dylib

arrowmetal-sys/build.rs searches, first hit wins:

  1. $ARROWMETAL_LIB — the full path to the dylib. This is the one to set.
  2. $ARROWMETAL_LIB_DIR — a directory holding it.
  3. <repo>/.build/release, then <repo>/.build/debug — a SwiftPM build in the ArrowMetal checkout.
  4. /usr/local/lib, /opt/homebrew/lib.

Anything else is a hard build error naming every path it looked in and the swift build line, rather than a link that succeeds and a dyld failure at run time.

The dylib's install name is @rpath/libArrowMetalC.dylib, so the directory that was found is baked into arrowmetal's own binaries, examples and tests as an LC_RPATH entry. Your binary needs the same entry, and then no DYLD_LIBRARY_PATH at run time: without it the link succeeds and dyld reports no LC_RPATH's found on launch (measured with the published crate). Because Cargo hands a build script's link arguments only to the crate that owns it, arrowmetal-sys republishes that directory through its links = "ArrowMetalC" key (DEP_ARROWMETALC_LIB_DIR) and arrowmetal/build.rs repeats the -rpath for its own binaries, examples and tests. A crate that depends on arrowmetal and builds a binary should do the same:

// your-crate/build.rs
fn main() {
    // DEP_ARROWMETALC_LIB_DIR is visible only to crates that depend on arrowmetal-sys directly,
    // so read the same variable the build searched with.
    println!("cargo:rerun-if-env-changed=ARROWMETAL_LIB");
    if let Ok(lib) = std::env::var("ARROWMETAL_LIB") {
        if let Some(dir) = std::path::Path::new(&lib).parent() {
            println!("cargo:rustc-link-arg=-Wl,-rpath,{}", dir.display());
        }
    }
}

Without a build script, one environment variable at build time does the same:

RUSTFLAGS="-C link-arg=-Wl,-rpath,$(dirname "$ARROWMETAL_LIB")" cargo build --release

Both were run against the published arrowmetal = "0.1.0" and the wheel's dylib; the binary then starts from any directory. If you move the dylib after building, set DYLD_LIBRARY_PATH or rebuild.


Example

This is the body of rust/arrowmetal/examples/quickstart.rs, so it is compiled by every cargo build --examples. Run it with cargo run --release --example quickstart; it prints 2 groups: [0, 1] -> [10, 27] and sum of everything: Some(Int64(42)).

use arrow::array::Int64Array;
use arrowmetal::{group_by, Array, CompareOp};

fn main() -> Result<(), arrowmetal::Error> {
    let region = Int64Array::from(vec![0i64, 1, 0, 2, 1]);
    let amount = Int64Array::from(vec![Some(10i64), Some(20), None, Some(5), Some(7)]);

    let region = Array::from_arrow(&region)?;   // to the GPU
    let amount = Array::from_arrow(&amount)?;

    let big = amount.compare_scalar(CompareOp::Gt, 5i64)?;   // boolean mask
    let kept_amount = amount.filter(&big)?;                  // 10, 20, 7
    let kept_region = region.filter(&big)?;

    let gb = group_by(&[&kept_region])?;
    let totals = gb.sum(&kept_amount)?.to_arrow()?;          // back to arrow-rs
    let keys = gb.keys(0)?.to_arrow()?;

    let ints = |a: &dyn arrow::array::Array| -> Vec<i64> {
        a.as_any().downcast_ref::<Int64Array>().unwrap().values().to_vec()
    };
    println!("{} groups: {:?} -> {:?}", gb.group_count(), ints(&keys), ints(&totals));
    println!("sum of everything: {:?}", amount.sum()?);
    Ok(())
}

The copy rule

Copy-free out always; copy-free in when the producer's buffers are page aligned, one copy otherwise.

The decision is made per buffer, so a nullable column can have its values wrapped and its validity bitmap copied. That case is common; see the table.

Out (Array::to_arrow) is always copy-free. ArrowMetal's buffers are MTLBuffers in shared memory; it hands their pointers straight to the C Data Interface with a release callback, and arrow-rs reads the GPU's memory in place. Measured over inputs that all carry nulls, so both buffers are exercised: the exported values and validity pointers were page aligned in every case (tests/copy_rule.rs::arrowmetal_exported_buffers_are_page_aligned), and the export call itself took 0.001 ms (median) for a 10M-row column.

In (Array::from_arrow) is copy-free only when the buffer pointer is aligned to a 16 KiB page, because that is what MTLDevice.makeBuffer(bytesNoCopy:) requires; otherwise that buffer is copied once. Whether an arrow-rs array clears the bar is a property of the allocator, not of arrow-rs, so it is measured rather than assumed. 32 allocations at each size, exported through arrow::ffi and read back as the pointers am_import receives:

Column length Values bytes Values page aligned Validity bitmap page aligned
16 128 B 1/32 0/32
512 4 KiB 8/32 0/32
8,192 64 KiB 32/32 2/32
131,072 1 MiB 32/32 32/32
1,250,000 10 MB 32/32 32/32
10,000,000 80 MB 32/32 32/32

So in practice:

  • Values are wrapped from a few thousand rows up, and copied below that.
  • A nullable column of fewer than roughly 130,000 rows normally has its validity bitmap copied, even when its values are wrapped. The bitmap is one bit per row, so the copy is about a kilobyte at 8,192 rows and about 16 KB at the point it stops happening — small, but real.

Both are the system allocator serving a large enough request with mmap and handing back page-aligned memory. The bitmap is eight times smaller than the values, so it crosses that threshold eight times later. None of it is a guarantee — a custom global allocator, a different macOS, or a buffer sliced out of an arena can all change it. Run cargo test --test copy_rule -- --nocapture in rust/ to print the table for your own machine.

Copy-free is not free: at 10M int64 rows the import still cost 1.11 ms, which is Metal mapping 80 MB of pages into the GPU's address space. See the timing below.

Two more claims, inferred rather than tested:

  • A slice does not cost a copy either way. An Arrow offset is carried on the handle rather than applied, so array.slice(7, n) should import the same buffers the unsliced array would.
  • An array that came out of ArrowMetal and goes back in is never copied in either buffer, whatever the original alignment: ArrowMetal recognises its own exported buffers and shares the MTLBuffer objects directly.

Both follow from reading Sources/ArrowMetal/CInterop.swift plus the alignment measurement above, and both have tests behind their observable halves — a sliced array round-trips exactly (round_trip_of_a_sliced_array, a_slice_survives_the_round_trip), and an ArrowMetal export is always page aligned, which is the precondition for the no-copy path (arrowmetal_exported_buffers_are_page_aligned). But whether a given import copied is not observable through the C ABI: the Swift side computes an ImportResult.zeroCopy flag and am_import discards it. The ABI does not report it, so these two are arguments, not measurements, and are labelled as such.


What is covered

Each row is backed by a test in rust/arrowmetal/tests/ against arrow-rs's own kernel on the same data, at lengths 0, 1, 33, 1024, 1025, 100,001 and 1,000,001, with and without nulls.

Rust ArrowMetal ABI Oracle
Array::from_arrow / to_arrow am_import / am_export round trip equals the input, including sliced arrays
Array::from_arrow_chunks, Source::from_batches am_import_chunks, am_import_chunks_supported equals Array::from_arrow of arrow::compute::concat and the concatenation itself, over 11 types (integers, Float64, Boolean, Date32, Timestamp, Decimal128, Utf8, LargeUtf8, Utf8View, Binary) and chunk layouts (empty, one-row, sliced, all-null and no-null chunks, one chunk, 10,000 chunks), then through sum, sort, argsort and group_by (tests/chunked.rs)
len, null_count, format am_length, am_null_count, am_format arrow::array::Array
sum, min, max, mean am_reduce arrow::compute::{sum, min, max}; mean against arrow's exact sum over the valid count
compare_scalar, compare (6 ops) am_compare_scalar, am_compare_array arrow::compute::kernels::cmp
filter am_filter arrow::compute::filter, including a mask with nulls
take am_take arrow::compute::take, with repeated, out-of-order and null indices
slice am_slice arrow::array::Array::slice
sort, argsort am_sort, am_argsort arrow::compute::sort with nulls_first: false; argsort index for index against sort_to_indices (both UInt32Array) on distinct values at 1, 1,025 and 1,000,001 rows
sort_with, argsort_with, top_k_with with SortOptions { descending, nulls_first, float_order }; lexsort(columns, options) am_sort_ex2, am_argsort_ex2, am_top_k_ex, am_lexsort_ex2 arrow::compute::sort / lexsort_to_indices with every SortOptions, bit for bit on Float64 and Float32 columns holding NaN of both signs, ±0.0, ±inf and subnormals (tests/sort_options.rs)
cast am_cast arrow::compute::cast
group_by(keys) with sum, min, max, mean, count, count_all; keys(i), ids(), agg_raw am_group_by_keys, am_group_agg_ex a plain HashMap fold — arrow-rs's arrow crate has no hash aggregation (it lives in DataFusion)
Source, run_plan, explain_plan, PlanResult am_plan_source_create, am_plan_run, am_plan_explain, am_plan_column* the same plan assembled by hand from arrow-rs kernels
batch(|| …) am_batch_begin / am_batch_end the same chain unbatched, and arrow's answer; plus deferred-failure, nesting and panic-unwind cases

Element types: Int64 and Float64 are swept against arrow-rs. Boolean arrays are exercised as comparison output and filter masks, and UInt32 as sort indices: argsort, argsort_with, top_k_with and lexsort return an arrow UInt32Array, the type sort_to_indices returns (row numbers up to 2^32 - 1; a longer input is an error), and take accepts Int32, Int64 and UInt32 indices. Every other Arrow type the ABI supports reaches the GPU through the same entry points but has no test in this crate — the Swift and Python suites cover them (Testing).

Errors are Result<T, arrowmetal::Error>, and the message is am_last_error()'s, read on the spot because that slot is thread-local.

Handles (Array, GroupBy, Source, PlanResult) are !Send and !Sync on purpose: the ABI's error slot and its command-buffer batching are both thread-local. Use one set of handles per thread. That is asserted at compile time (assert_not_impl_any!), not just documented.

Scalar operands, and the one type that is refused

The ABI reads sizeof(element type) bytes through a void*, so a scalar of the wrong width would be an out-of-bounds read. Every scalar entry point therefore checks the Rust type against the array's own am_format string first: compare_scalar(Gt, 1i32) on an Int64Array is an Err, not a read off the end.

That check is only as good as am_format, and there is exactly one Arrow type for which am_format does not describe what the kernels compute on. A dictionary-encoded array reports its index type ("i") while every kernel decodes the dictionary and computes on the value type. A 4-byte i32 scalar would be accepted for a Dictionary(Int32, Float64) column whose kernel then reads 8 bytes — unsound from safe Rust, and wrong besides (compare_scalar(Lt, 2i32) returns [false, false, false] where arrow-rs says [true, false, false], and the correct 2.0f64 scalar is rejected).

Array::from_arrow therefore refuses DataType::Dictionary outright, with an error naming the key and value types and telling you to decode first:

let decoded = arrow::compute::cast(&dict, &DataType::Float64)?;
let gpu = arrowmetal::Array::from_arrow(decoded.as_ref())?;   // now type-checks correctly

This is a limitation of the C ABI, not of the Arrow type — the kernels handle dictionaries correctly, and Swift and Python callers use them. It is an ABI defect on ArrowMetal's side: am_format reports the C Data Interface's top-level format, which for a dictionary is its index type. The entry point that reports the type the kernels compute on exists (am_compute_format, include/arrowmetal.h:32); this crate does not use it and refuses dictionaries, which keeps the sentence above true for everything this crate accepts. tests/compute.rs::dictionary_arrays_are_refused_at_import pins the rejection and the decode path.

Sort order: arrow-rs's, on request

sort and argsort keep ArrowMetal's default order, which is Arrow C++'s: nulls last in both directions, -0.0 tied with +0.0, and NaN next to the nulls. arrow-rs orders differently — nulls where SortOptions::nulls_first says, floats by total_cmp (-NaN < -inf < … < -0.0 < +0.0 < … < +inf < +NaN) — and the _with forms take that order as an option:

use arrow::compute::SortOptions as ArrowSortOptions;
let opts: arrowmetal::SortOptions = ArrowSortOptions { descending: true, nulls_first: true }.into();
let idx = gpu.argsort_with(opts)?;          // what arrow::compute::sort_to_indices orders
let top = gpu.top_k_with(100, opts)?;       // its first 100 rows, by GPU selection
let multi = arrowmetal::lexsort(&[&a, &b], &[opts, opts])?;

From<arrow::compute::SortOptions> sets float_order: FloatOrder::Total; SortOptions::default() is the plain sort. FloatOrder::NanLargest is the third float order, Polars': every NaN one value above +inf in both directions, -0.0 tied with +0.0. Neither option adds a pass to the GPU sort: the null placement is where the partition that takes the null rows out of the radix sort puts them, and totalOrder is the key map in front of the radix passes. tests/sort_options.rs compares every combination against arrow::compute::sort bit for bit.

One divergence from arrow-rs, found and pinned

min / max on a float column containing NaN. The two libraries use different, each internally consistent, rules — neither is a bug:

Input (float64) ArrowMetal arrow-rs 59.3 pyarrow
[NaN, 2.0, null, -1.0, NaN] — min -1.0 -1.0 -1.0
the same — max 2.0 NaN 2.0
[NaN, NaN] — min and max null NaN NaN

arrow-rs orders NaN at the top of a total order. Both min and max say so in the same sentence — "For floating point arrays any NaN values are considered to be greater than any other non-null value" (arrow_arith::aggregate, 59.3.0) — so min returns the smallest non-NaN and max returns NaN. That was reported as apache/arrow-rs#101 and closed as intended behaviour in 2022.

ArrowMetal skips NaN in both, the way it skips a null, and reports "no valid value" when every valid element is NaN. That follows Arrow C++ / pyarrow on the mixed case; on an all-NaN column pyarrow returns NaN where ArrowMetal returns null, which the C header already documents.

tests/compute.rs::nan_handling_diverges_from_arrow_rs_and_is_pinned asserts the ArrowMetal and arrow-rs columns and fails if either moves. The pyarrow column is not asserted from Rust — pyarrow is not a dependency of this crate; it is pinned in the Python suite, by test_nan_is_skipped_by_min_and_max_in_both and test_all_nan_min_max_is_null_in_arrowmetal_and_nan_in_pyarrow in python/tests/test_differential.py. With no NaN in the data the two agree exactly, which is what every other reduction test relies on.


What is not wrapped

include/arrowmetal.h has 286 entry points. arrowmetal-sys declares 41 of them — every one called by the safe crate, none declared and unused — and the safe crate covers the list above. Everything below is reachable from Swift, Python and the C ABI, and not from this crate. There is no technical obstacle to any of it; it is unwrapped because it is untested here, and an untested wrapper does not go into 0.1.0.

ABI area Entry points
Strings am_str_unary, am_str_match, am_str_transform, am_str_concat, am_str_equals_array, am_str_dictionary_encode, am_string_predicate, am_string_transform, am_string_is_in, am_string_index_in, am_str_extra, am_to_strings, am_parse, am_byte_transform, am_binary_join, am_join_element_wise
Regex, LIKE, splitting am_regex, am_split, am_extract_struct
Temporal am_temporal_extract, am_temporal_cast_unit, am_temporal_math, am_temporal_extra, am_round_temporal_ex, am_assume_timezone, am_local_timestamp, am_utc_offset, am_to_timezone, am_add_interval, am_interval_between, am_interval_field
Decimals am_decimal_op, am_decimal_widen, am_decimal_narrow
Nested types (list, struct, map, union) am_list_value_length, am_list_flatten, am_list_element, am_list_slice, am_list_parent_indices, am_list_parent_indices64, am_struct_field, am_make_struct, am_child, am_child_count, am_map_lookup
Element-wise arithmetic and math am_arith_scalar, am_arith_array, am_unary, am_binary, am_trig, am_logical, am_float_class, am_math_extra, am_unary_checked, am_binary_checked, am_cumulative, am_cumulative_checked
Selection and casting variants of what is wrapped am_filter_where (fused compare + filter), am_group_by (the dense-key group-by; am_group_by_keys + am_group_agg_ex are wrapped instead), am_cast_ex (cast with child formats and a safety flag)
Boolean and Kleene logic am_bool_and, am_bool_or, am_bool_not, am_and_kleene, am_or_kleene
Structural and conditional am_is_null, am_is_valid, am_fill_null, am_fill_null_direction, am_drop_null, am_if_else, am_coalesce, am_case_when, am_choose, am_replace_with_mask, am_indices_nonzero, am_true_unless_null
Set lookup and hashing am_is_in, am_index_in, am_is_in_ex, am_index_in_ex, am_hash64, am_fixed_binary_hash64, am_fixed_binary_compare
Sorting and selection beyond the sorts above am_sort_ex, am_top_k, am_lexsort, am_lexsort_ex, am_argsort_ex, am_partition_nth_indices, am_partition_nth_ex, am_rank, am_rank_ex, am_rank_quantile_ex, am_inverse_permutation, am_scatter
Window functions and rolling am_window
The rest of the aggregates am_reduce_ex, am_reduce_ex2, am_count_all, am_first_last, am_winsorize, am_group_pivot_wider, am_pivot_wider
Dictionaries, run-end, uniqueness am_dictionary_encode, am_dictionary_encode_ex, am_dictionary_decode, am_run_end_encode, am_run_end_decode, am_unique, am_unique_ex, am_value_counts, am_value_counts_ex
Joins am_join
The fused expression query runner am_query, am_query_column*, am_query_scalar*, am_query_canonical
Parquet am_parquet_open, am_parquet_read, am_parquet_read_ex, am_parquet_write and the 15 metadata accessors
The streaming engine all 30 am_stream_* entry points
Device interop am_import_device, am_export_device (CPU am_import/am_export are wrapped)
Extension types, float16, null arrays, random am_extension_*, am_cast_float16, am_null_array, am_random
Type introspection am_compute_format (the type the kernels compute on, which for a dictionary is its value type)
Execution-mode knobs am_resident_mode, am_resident_mode_available, am_resident_mode_reason, am_low_latency_wait, am_spin_microseconds

Wrapping any of them is mechanical: add the extern "C" line to arrowmetal-sys/src/lib.rs, the method to arrowmetal/src/lib.rs, and a test to arrowmetal/tests/compute.rs against arrow-rs (or a hand oracle where arrow-rs has no counterpart). tests/signatures.rs will check the new declaration against the header on the next cargo test.


The measured timing

One run, 2026-09-07, Apple M4 Max, macOS 26.6.2, rustc 1.95.0, arrow-rs 59.3.0, ArrowMetal 0.1.0.

Method. One 10,000,000-element Int64Array of pseudo-random values in [-1_000_000, 1_000_000), no nulls, built once and shared by every row. Filter predicate x > 0, which keeps 5,000,125 of the 10,000,000 rows (50.0%). std::time::Instant around the call, wall time, single-threaded, nothing subtracted, std::hint::black_box on every input and result. "Single-threaded" describes the harness: it spawns no threads and pins nothing, and arrow-rs's kernels ran with their default parallelism, which for sum and filter is none — arrow-arith and arrow-select depend on no thread pool and these are single-threaded, autovectorised kernels, so arrow-rs got everything it has here. Three untimed warm-up iterations, then five timed ones; the table is the best of the five (medians were within a few percent, and are in rust/README.md). Both libraries' answers are asserted equal before anything is timed. Outside a batch every ArrowMetal call commits its command buffer and waits, so a "kernel" number is a complete GPU round trip, not an enqueue. Source: rust/arrowmetal/examples/bench.rs; reproduce with cargo run --release --example bench in rust/.

Operation, 10M Int64 arrow-rs ArrowMetal, kernel ArrowMetal, end to end
sum 0.91 ms 0.28 ms 2.11 ms
filter (mask ready) 3.55 ms 0.64 ms —
compare + filter 4.58 ms — 2.97 ms
  • kernel — the GPU call on an array already imported, mask already on the GPU. This is what each step of a longer chain costs.
  • end to end — what one operation on an arrow-rs array costs, import included. What that covers differs by row: the sum row is import + the reduction and has no export, because a reduction returns a scalar through out-parameters rather than an array; the compare + filter row is import + compare + filter + to_arrow. Supporting numbers (best of 5): import 1.11 ms, export 0.000 ms.

Where arrow-rs is ahead. On a single sum over an arrow-rs array, arrow::compute::sum is ahead by 2.3×: 0.91 ms against ArrowMetal's 2.11 ms. The kernel is 3.3× faster; the gap is entirely the 1.11 ms it takes to hand 80 MB (decimal MB; 76 MiB) to Metal, plus about 0.7 ms of handle setup and first-touch. The import is copy-free at this size (the values buffer came back aligned to 4 MiB), so that is page mapping, not a memcpy — and it is still 1.1 ms that one cheap kernel does not earn back.

The break-even is roughly "more than one pass over the data": compare + filter is two passes and ArrowMetal is already 1.5× faster end to end. Import once, chain, export once. A single wrapped reduction does not earn its import back.


Limits

  • Apple silicon only. arrowmetal-sys/build.rs refuses any non-macOS target; on Intel macOS it fails at the dylib search instead.
  • Two element types are tested against arrow-rs: Int64 and Float64, plus Boolean masks and Int32 indices. Anything else works through the same entry points but is untested from Rust.
  • One thread per handle set. Handles are !Send and !Sync; am_last_error and batching are thread-local.
  • No RecordBatch type of its own. Columns go across one at a time. The plan runner's Source takes a named set of columns, or a slice of arrow-rs RecordBatches (Source::from_batches, each column imported with Array::from_arrow_chunks).
  • arrowmetal-sys declares 41 of the ABI's 286 entry points, and every one of them is called by the safe crate (nothing is declared and unused). The table above lists what is missing.
  • The docs.rs build of 0.1.0 failed. Under DOCS_RS the -sys build script returns before it emits anything, so the env!("ARROWMETAL_SYS_LIB_DIR") and env!("ARROWMETAL_LINKED_LIB_DIR") lookups in the two crates have nothing to read and rustdoc stops. The documentation for 0.1.0 is this file and the crate-level comment in rust/arrowmetal/src/lib.rs.
  • arrow is pinned to major version 59. The C Data Interface structs are ABI-stable, so a different arrow-rs major would very likely work, but it is not tested here.
  • A batch defers validation. Inside batch(|| …) an operation that would fail — take with an out-of-range index, say — returns Ok at the call and fails the whole batch at the end, which batch reports as an Err. The value the closure produced is discarded: a readback (a reduction, an export) forces a mid-batch flush, so some kernels in a failed batch have run and some have not, and the ABI does not say where the line fell. Nested batch calls are safe — only the outermost opens and commits one — but that is counted in this crate, not in the ABI: am_batch_end closes whatever is open regardless of nesting.
  • No async. Every call is synchronous: outside a batch each one commits its command buffer and waits. The ABI has an async path (Sources/ArrowMetal/Async.swift); this crate does not use it.

Testing

cd rust
ARROWMETAL_LIB=/path/to/libArrowMetalC.dylib cargo test --release

66 tests, plus 5 no_run doc-tests (compiled, not executed); 0 failures on 2026-10-02 (Testing). What each file compares against is in rust/README.md and in Testing.

docs/GO.md

ArrowMetal for Go users

A Go module that hands an arrow.Array from Apache Arrow Go to the GPU and takes the answer back, over the Arrow C Data Interface. It wraps a deliberately small part of the C ABI: import and export (one array or a column in chunks), the four reductions, compare, filter, take, sort, argsort, top-k and lexsort (with per-key null placement and float order), group-by, and the JSON plan runner. Everything it wraps has a test against Arrow Go's own compute or against a plain Go loop on the same data. Everything it does not wrap is listed under What is not wrapped.

Module path: github.com/singhpratech/ArrowMetal/go/arrowmetal. Source: go/arrowmetal/.


Install

# 1. The GPU library. It is a build product of the Swift package; `go get` cannot fetch it.
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
    swift build -c release --product ArrowMetalC

# 2. The module.
go get github.com/singhpratech/ArrowMetal/go/arrowmetal

The module is tagged go/arrowmetal/v0.5.0: the module lives in a subdirectory, so the Go module proxy wants the tag prefixed with the module's path within the repository, not a bare vX.Y.Z. A checkout works too: clone the repository and add

replace github.com/singhpratech/ArrowMetal/go/arrowmetal => /path/to/ArrowMetal/go/arrowmetal

to your own go.mod, or just run inside go/arrowmetal.

Requirements: macOS on Apple silicon, a Metal device, and Go 1.25.0 or newer (the module's go directive, which is what arrow-go/v18 requires) with cgo enabled — CGO_ENABLED=1, the default when a C toolchain is present; Xcode's clang is what this was built and tested with. The module depends on github.com/apache/arrow-go/v18 and nothing else. Everything here was measured on Go 1.27.1.

Finding the dylib

The binding does not link libArrowMetalC.dylib at build time. It dlopens it the first time you use the package, so the location is a run-time decision and a missing library is a clear Go error rather than a dyld message. The search, in order:

  1. $ARROWMETAL_LIB — the full path to the dylib, not a directory. If it is set and does not load, that is the error; there is no fallback.
  2. .build/release/libArrowMetalC.dylib, and the same at one, two and three levels up, relative to the working directory. (go test runs in the package directory, so ../../.build/release is the repository's own build; a program run from the repository root finds .build/release.)
  3. The same four paths relative to the directory holding the running binary.
  4. Plain libArrowMetalC.dylib, letting dyld search DYLD_LIBRARY_PATH, /usr/local/lib and the rest.

When none of them loads, the error names ARROWMETAL_LIB, every path it tried and why each failed, and the swift build line that produces the file. arrowmetal.Init() performs the load and returns that error, so a program can fail early with it; every other entry point calls Init first. arrowmetal.LibraryPath() reports where it came from.


Example

package main

import (
    "fmt"

    "github.com/apache/arrow-go/v18/arrow/array"
    am "github.com/singhpratech/ArrowMetal/go/arrowmetal"
)

func main() {
    // PageAlignedAllocator is a memory.Allocator whose buffers ArrowMetal can borrow (see Copy rule).
    b := array.NewInt64Builder(am.NewPageAlignedAllocator())
    defer b.Release()
    b.AppendValues([]int64{5, 3, 9, 1}, []bool{true, true, false, true}) // 9 is null

    src := b.NewArray()
    defer src.Release()

    gpu, err := am.Import(src)             // arrow.Array -> GPU
    if err != nil {
        panic(err)
    }
    defer gpu.Release()

    sum, _ := gpu.Sum()                    // nulls skipped, like Arrow
    mask, _ := gpu.CompareScalar(am.Gt, int64(2))
    kept, _ := gpu.Filter(mask)
    out, _ := kept.Export()                // back to arrow-go, no copy
    defer out.Release()

    fmt.Println(sum, out)                  // 9 [5 3]
}

Run it with ARROWMETAL_LIB=/path/to/.build/release/libArrowMetalC.dylib go run ..


Sort options

Argsort, Sort, TopK and Lexsort keep the order they always had: nulls last and NaN after +Inf in both directions, -0.0 tied with +0.0. Argsort, TopK, Lexsort and their With forms return uint32 index arrays (row numbers up to 2^32 - 1; a longer input is an error), and Take accepts int32, int64 and uint32 indices. The With forms take a SortOptions per key:

type SortOptions struct {
    Descending bool
    Nulls      NullPlacement // NullsLast (the default) or NullsFirst, in either direction
    FloatOrder FloatOrder    // FloatIEEE (the default), FloatTotal or FloatNanLargest
}

func (a *Array) ArgsortWith(o SortOptions) (*Array, error)
func (a *Array) SortWith(o SortOptions) (*Array, error)
func (a *Array) TopK(k int64, largest bool) (*Array, error)
func (a *Array) TopKWith(k int64, o SortOptions) (*Array, error) // o.Descending: the k largest
func LexsortWith(columns []*Array, keys []SortOptions) (*Array, error)

FloatIEEE is Arrow C++'s order, which arrow-go's compute.SortIndices also uses: -0.0 ties +0.0, every NaN is one value, and the NaN rows sit next to the nulls in both directions. FloatTotal is IEEE 754 totalOrder, the order arrow-rs and Rust's total_cmp use: -NaN < -Inf < … < -0.0 < +0.0 < … < +Inf < +NaN, NaNs by payload, and a descending sort is its exact mirror. FloatNanLargest is Polars' and NumPy's order: -0.0 ties +0.0, and every NaN is one value greater than every number, +Inf included, in both directions (last ascending, first among the values descending); the null placement is independent of it. Integer, string and temporal keys ignore the float order. The zero SortOptions gives what Argsort(false) gives. TopKWith(k, o) answers with the first k indices ArgsortWith(o) gives. Neither option adds a pass to the GPU sort.

// src is [2, null, NaN, -0, 7]
plain, _ := gpu.Argsort(true)       // [4 0 3 2 1]: nulls and NaN stay last
opts := am.SortOptions{Descending: true, Nulls: am.NullsFirst, FloatOrder: am.FloatTotal}
total, _ := gpu.ArgsortWith(opts)   // [1 2 4 0 3]: the null, then +NaN > 7 > 2 > -0
top, _ := gpu.TopKWith(2, opts)     // [1 2]
polars, _ := gpu.ArgsortWith(am.SortOptions{Descending: true, FloatOrder: am.FloatNanLargest})
                                    // [2 4 0 3 1]: NaN > 7 > 2 > -0, the null last

(ExampleArray_ArgsortWith in example_test.go runs this.) A plan's sort key takes the same options as JSON: {"column": "x", "descending": true, "nulls": "first", "float_order": "total"} or ["x", true, {"nulls": "first", "float_order": "nan_largest"}] (Query engine).

Chunked import

A column held as several arrow.Arrays of one type imports as one Array of their total length, without array.Concatenate:

func ImportChunks(chunks []arrow.Array) (*Array, error)
func ImportChunked(c *arrow.Chunked) (*Array, error)
func ImportColumn(batches []arrow.RecordBatch, i int) (*Array, error)
func NewSourceFromBatches(name string, batches []arrow.RecordBatch) (*Source, error)
func ChunksSupported(dt arrow.DataType) (bool, error)
gpu, err := am.ImportChunked(chunked) // chunks [1 2] and [3 null 5]
sum, _ := gpu.Sum()                   // gpu.Len() 5, gpu.NullCount() 1, sum 11

(ExampleImportChunks runs this.) Every chunk crosses the C Data Interface on its own and am_import_chunks copies its buffers straight into the final Metal buffers, on the CPU cores in parallel. Each chunk's offset, length, validity bitmap (or its absence) and null count are honoured. One chunk is Import, with its copy rule. A type the chunked import does not take (dictionary, nested, run-end encoded, extension; ChunksSupported reports it) is concatenated with array.Concatenate and imported. The chunks stay the caller's: ImportChunks hands each one to C exactly once, pins its buffers for the call, and the library releases each export it took; the caller releases the chunks as usual. NewSourceFromBatches registers every column of a run of record batches this way, and the Source keeps those handles until its own Release.


Copy rule

The project's rule, unchanged: copy-free out always; copy-free in when the producer's buffers are page aligned, one copy otherwise.

Concretely, on the way in ArrowMetal wraps a buffer with MTLBuffer(bytesNoCopy:) when the buffer's start address is a multiple of the page size — 16384 bytes on Apple silicon, which arrowmetal.PageSize() reports — and copies the bytes into a page-aligned Metal buffer otherwise. On the way out the exported arrow.Array references the same unified memory the GPU wrote, always.

What Arrow Go's default allocator does — measured

memory.DefaultAllocator is memory.NewGoAllocator(), the Go heap, unless the program is built with the mallocator build tag — the same object, not a similar one, which is why only one of them appears below. The Go runtime backs large allocations with whole spans of its own 8 KiB pages, so an Arrow buffer from it is 8 KiB aligned and only incidentally 16 KiB aligned.

TestAllocatorAlignment builds an Int64 array 20 times per size and records the values buffer's offset past a page boundary. On an M4 Max, Go 1.27.1, arrow-go v18.7.0:

Allocator Offset past a 16384-byte page Page aligned?
memory.NewGoAllocator() (the default) only ever 0 or 8192, never any other value a coin flip
arrowmetal.NewPageAlignedAllocator() always 0 always

The hit rate is not a property, so it is not quoted here. Across fresh runs of the same test the count at 10M int64 came back 5/20, 15/20 and 14/20 — it depends on the state of the heap when the buffer is allocated, and it will differ on your machine and between two runs on mine. What is reproducible, and what the test asserts, is the offset: a Go-heap buffer is either exactly on a page boundary or exactly 8192 bytes past one. That follows from Go's 8 KiB page meeting Apple silicon's 16 KiB one, and it means a copy-free import with the default allocator is a coin flip whose outcome you cannot observe from Go.

arrowmetal.PageAlignedAllocator implements memory.Allocator on top of posix_memalign at page granularity, so the import can always borrow.

Go pointers, cgo, and why pinning is not optional

Alignment and legality are separate questions, and PageAlignedAllocator answers only the first.

cdata.ExportArrowArray writes &buf.Bytes()[0] straight into a malloc'd buffer array (cdata_exports.go:388). With the default allocator that address is in the Go heap, so this is an unpinned Go pointer stored into non-Go memory — which the cgo pointer rules forbid, and which GOEXPERIMENT=cgocheck2 turns into a hard fatal error: unpinned Go pointer stored into non-Go memory. This is apache/arrow-go#70, closed upstream without the default path being made legal. arrow-go's own answer is C memory: memory/mallocator (libc malloc, no build tag needed to use it, NewMallocatorWithAlignment(n) for a chosen power-of-two alignment) and, for programs linked against C++ Arrow, CgoArrowAllocator behind the ccalloc build tag. A mallocator buffer at alignment 16384 is page aligned and outside the Go heap, so it satisfies both the cgo rules and ArrowMetal's copy rule; PageAlignedAllocator is the same idea in forty lines, kept so the binding has no build-tag story to tell.

Import therefore pins every buffer it publishes with runtime.Pinner (Go 1.21+) before handing anything to C, and keeps the Pinner inside the returned Array until Release, unpinning only after am_release — ArrowMetal may have been borrowing those exact bytes. Pin ignores anything outside the Go heap, so a buffer from PageAlignedAllocator costs nothing here and needs nothing.

The consequence for you: any allocator is safe, including the default. The suite is run under GOEXPERIMENT=cgocheck2 as well as normally, and removing the pin makes it fail on the first round trip. Choose PageAlignedAllocator when you want the copy avoided, not to make the code legal.

What the copy costs, measured

Less than you would expect, because at 76 MB the copy is not the dominant cost of crossing the boundary; setting up the Metal buffer is. From the table below: importing 10M int64 rows takes 1.13 ms when the buffer can be borrowed and 1.41 ms when it must be copied — the copy adds about 0.3 ms to a 1.4 ms operation, roughly 20%. Export is 917 ns, three orders of magnitude cheaper, which is what "copy-free out always" buys.

There is no way to ask the library whether a given import copied. The Swift core computes it (ImportResult.zeroCopy) but the C ABI's am_import does not return it, so this binding cannot report it either and the table above measures alignment in Go instead.


Timing

10M Int64 rows (76 MB, i.e. MiB — 80 MB decimal), no nulls, one M4 Max, one process, wall clock, best of 5 timed runs after one untimed warm-up. Reproduce with go run ./cmd/amtiming from go/arrowmetal; the source of every row is cmd/amtiming/main.go.

ArrowMetal 0.1.0, Go 1.27.1, arrow-go v18.7.0. The filter predicate is x > 0 and keeps 50.0% of the rows.

Op Method Best of 5 Notes
Import arrow-go → ArrowMetal, page-aligned buffer 1.13 ms borrowed, no copy
Import arrow-go → ArrowMetal, buffer 64 B past a page 1.41 ms one copy of 76 MB
Import arrow-go → ArrowMetal, memory.NewGoAllocator 1.40 ms this run's buffer was 8192 B past a page
Export ArrowMetal → arrow-go 917 ns always copy-free
Sum plain Go loop over []int64 2.46 ms
Sum Arrow Go arrow/math (NEON) 1.16 ms arrow-go registers no sum compute function
Sum ArrowMetal, array already resident 296 µs 3.9× the Arrow Go kernel
Sum ArrowMetal, end to end from a page-aligned arrow.Array 2.22 ms Arrow Go is ahead
Sum ArrowMetal, end to end with a copying import 1.87 ms Arrow Go is ahead
Filter plain Go loop (one fused pass into a []int64) 29.18 ms
Filter Arrow Go compute (greater then filter) 48.93 ms
Filter ArrowMetal, array already resident 1.51 ms 32× Arrow Go compute, 19× the Go loop
Filter ArrowMetal, end to end from a page-aligned arrow.Array 3.22 ms 15× Arrow Go compute
Filter ArrowMetal, end to end with a copying import 2.98 ms 16× Arrow Go compute

Reading this table

arrow-go's compute is single-threaded and ArrowMetal's kernels run on the whole GPU, so the multiples above are not per-core figures.

  • Arrow Go is ahead at Sum end to end. 2.22 ms against Arrow Go's 1.16 ms. A single 76 MB sum is a memory-bandwidth problem that the CPU is already good at, and the ~1.2 ms of import overhead is most of the ArrowMetal number. Only when the array is already on the GPU is ArrowMetal ahead on Sum, 296 µs against 1.16 ms. If your program's shape is "load an arrow-go array, sum it once, throw it away", this binding is the wrong tool.
  • ArrowMetal is ahead at Filter, in every shape. Even paying import and export on every call, 3.22 ms against 48.93 ms is 15×; resident it is 32×. Filter does more work per byte than Sum and the fixed cost stops dominating.
  • The end-to-end rows with a copying import came out faster than the page-aligned ones in this run (1.87 vs 2.22 ms for Sum, 2.98 vs 3.22 ms for Filter), which is the opposite of what the Import rows say. The gap is a few tenths of a millisecond either way, the same size as the copy itself, and it is run-to-run noise from Metal's buffer allocation. The defensible statement is the narrow one: at 76 MB, borrowing saves about 0.3 ms of a 1.4 ms import, and end to end that saving is inside the noise. It is not on its own a reason to change allocators — and it is not a correctness reason either, since Import pins whatever it is given.
  • arrow/math.Int64.Sum ignores nulls, and the data here has none. It is the fastest thing arrow-go has for this and it is what the Sum row compares against, because arrow-go's compute package registers no aggregate function at all — no sum, mean or min_max in its registry. TestArrowGoHasNoAggregates records this and will log a nudge if a later release adds them. Reported as apache/arrow-go#1296; the maintainer asked for the kernels, and a design note with a tested prototype of sum, mean, min_max, min, max, count, any and all is on that issue (the research behind it is Round 10 in Findings against pyarrow). Two documentation pull requests from the same work, #1302 on allocator alignment and #1303 on what the compute registry holds, were merged the day they were opened.
  • The plain Go filter loop writes into a []int64 rather than building an arrow.Array, so it is doing strictly less work than the other two Filter rows. It is included because it is what a Go programmer writes when they have not reached for Arrow yet.

Chunked import against concatenating first

go run ./cmd/amchunks: a column in chunks, each chunk its own allocation from Arrow Go's default allocator, imported with ImportChunks against array.Concatenate then Import, both end to end with the handle (and the concatenation) released inside the timed call. M4 Max, 2026-09-29, Go 1.27.1, arrow-go v18.7.0; three rounds, each row warmed for 100 ms and then timed 10 times; best of the three rounds, and the median of the per-round medians in parentheses; CPU is process CPU time per call. Source: Benchmarks/results/bindings_chunked_import_2026-09-29.csv.

Column Rows Chunks Concatenate + Import ImportChunks CPU ms (concatenate / chunked)
float64, 10% null 10,000,000 153 2.18 (3.79) ms 0.73 (1.04) ms 3.9 / 8.5
float64, 10% null 10,000,000 10 2.21 (4.01) ms 0.63 (0.95) ms 4.1 / 8.6
float64, 10% null 50,000,000 763 8.76 (15.41) ms 3.06 (3.75) ms 15.2 / 37.1
float64, 10% null 50,000,000 50 8.14 (15.57) ms 2.78 (3.20) ms 14.0 / 39.0
int64 10,000,000 153 1.83 (3.40) ms 0.59 (0.67) ms 3.5 / 6.9
int64 10,000,000 10 1.83 (3.28) ms 0.54 (0.58) ms 3.3 / 5.9
int64 50,000,000 763 8.22 (15.60) ms 2.55 (2.79) ms 14.5 / 31.2
int64 50,000,000 50 8.01 (15.27) ms 2.21 (2.69) ms 14.3 / 31.4

The chunked import copies each chunk once, straight into the Metal buffers, on the CPU cores in parallel; the concatenation copies every byte once, and the import then borrows or copies the result by the copy rule above. The parallel copy takes 1.8x to 2.8x the CPU time of the concatenation per call (the last column).

The existing calls, before and after

go run ./cmd/ambench times the calls that existed before the sort options and the chunked import, built from the previous commit and from this one against the same libArrowMetalC.dylib, in four alternating rounds (each row warmed for 100 ms, a 500 ms idle and one call timed on its own, then 30 calls); best of the four rounds, the median of the per-round medians in parentheses. The Go code of these calls is unchanged; the shim resolves seven more entry points at load. At 10M rows:

Call Previous commit This commit
Import + Release, page-aligned int64 0.03 (0.06) ms 0.01 (0.02) ms
Argsort(false), float64 with 10% nulls 7.35 (7.93) ms 7.16 (8.11) ms
Argsort(true), int64 7.95 (8.59) ms 7.84 (8.64) ms
Sort(false), float64 with 10% nulls 6.98 (7.69) ms 6.70 (7.64) ms
Lexsort, int64 then float64 descending 18.28 (18.85) ms 18.32 (18.58) ms

At 1,000 and 1,000,000 rows three rows were slower in both best and median in the four rounds (Argsort(true) at 1,000 rows, Lexsort at 1,000 and 1,000,000 rows); timed again alone, eight alternating rounds of 100 calls, this commit over the previous one is 1.02 / 1.14 (best / median) for Argsort(true) at 1,000 rows (0.39 / 0.50 ms against 0.38 / 0.44 ms) and 0.97 / 1.04 and 1.02 / 1.02 for Lexsort at 1,000 and 1,000,000 rows. Every row, with first-call-after-idle and CPU time: Benchmarks/results/bindings_call_overhead_2026-09-29_summary.csv.


What is covered

include/arrowmetal.h and include/arrow_abi.h in the module are copies of the repository's include/ headers; refresh them (cp include/arrowmetal.h go/arrowmetal/include/) whenever the header changes, or python/tests/test_header_copies.py and TestHeadersMatchRepository fail.

Every item below has at least one test in go/arrowmetal; the oracle is named. 66 test functions and three Examples, 69 runnable; 584 cases counting subtests, all green, plain, under -race and under GOEXPERIMENT=cgocheck2.

Surface Go API Oracle
C Data Interface in and out Import, (*Array).Export value-and-null round trip at lengths 0, 1, 1000, 1,000,001, plain and sliced
Empty input Import, ImportChunks, (*Array).Export zero-row arrays of 12 types (integers, floats, boolean, utf8), built with no rows, all-null and as zero-length slices at offsets 0 and 1: length and type out through Export, an empty ArgsortWith, and ImportChunks with empty chunks alone and next to a non-empty one
Sliced input (offset != 0) the same array.NewSlice at offsets 1, 7, 31, 32, 33, 63, 64, 1000 against the same rows read directly
Sum, Min, Max, Mean (*Array).Sum/Min/Max/Mean plain Go loops (arrow-go has no aggregates); Int64 and Float64, with and without nulls
Null and NaN rules the same all-null and empty arrays report an invalid Scalar; min/max skip NaN; an all-NaN column is null
Compare against a scalar (*Array).CompareScalar compute.CallFunction("equal"/"not_equal"/"less"/"less_equal"/"greater"/"greater_equal"), all six, with nulls
Scalar range checking the same 28 cases: integer overflow at both edges of int8 and uint8, float32 overflow and underflow, integers past 2^24 and 2^53, ordinary rounding still allowed, plus a string and a bool scalar rejected by Arrow format string; the values that do fit agree with a Go loop
Compare two arrays (*Array).CompareArray length-mismatch error path
Filter (*Array).Filter compute.FilterArray with SelectionDropNulls
Take (*Array).Take compute.TakeArray, including null indices
Sort (*Array).Sort compute.SortArray, ascending and descending, nulls at end
Argsort (*Array).Argsort compute.SortIndicesArray, index for index, on data with heavy ties
Lexsort Lexsort sort.SliceStable over the same two columns
Sort options ArgsortWith, SortWith, TopK, TopKWith, LexsortWith index for index against a stable sort.SliceStable reference of the documented order, every direction × null placement × float order (FloatIEEE, FloatTotal, FloatNanLargest), on Float64 and Float32 columns holding NaN of both signs and several payloads, ±0.0, ±Inf and subnormals, with nulls, at 0 to 100,001 rows; the IEEE order also against arrow-go's compute.SortIndicesArray / SortIndicesRecordBatch with SortNullsAtStart; SortWith bit for bit; TopKWith against the head of ArgsortWith at k = 0 to past the length; the zero options against the plain calls; FloatNanLargest on a hand-picked column with NaN of both signs, ±0.0, ±Inf and a null against its expected indices for all four calls; a plan sort key with nulls and float_order, and "float_order": "nan_largest" in each key form and as the sort-level default, with and without a limit
Chunked import ImportChunks, ImportChunked, ImportColumn, NewSourceFromBatches, ChunksSupported array.Equal against the concatenation and against Import of it, over 15 types (integers, floats, boolean, date32, timestamp, decimal128, fixed_size_binary, utf8, large_utf8, binary, utf8_view) and five layouts (empty, one-row, sliced, all-null and no-null chunks mixed; one chunk; all empty; all null; 1,000 small chunks), built with arrow-go's checked allocator so every chunk has to be released exactly once; dictionary chunks through the concatenation; sum and every sort option on a 40-chunk column; a plan over record batches
Group-by NewGroupBy, .Sum/.Count/.CountAll/.Mean/.Min/.Max/.Key/.IDs plain Go maps; one and two key columns, 1 to 1000 groups, null keys, float values, zero rows
JSON plan runner NewSource, RunPlan, ExplainPlan, PlanResult.RecordBatch plain Go; the header's own group-by/sort/limit example, optimized against unoptimized, a type-check failure
Slice on the GPU (*Array).Slice the same rows read from the source
Errors *arrowmetal.Error a length mismatch and a bad plan both carry am_last_error() text
Allocator PageAlignedAllocator alignment at 1M and 10M, allocate/reallocate/free bookkeeping, a full round trip
Allocator alignment the Go heap's shape every Go-heap offset past a page is 0 or 8192 and nothing else, at 1M and 10M, 20 trials each
cgo pointer rules Import the whole suite again under GOEXPERIMENT=cgocheck2; removing the pin makes it die on the first round trip
Handle lifecycle Release repeated import of one arrow.Array; a long-lived handle alongside short-lived ones; a released handle errors rather than crashing
Leaks the whole chain 2,000 import/compare/filter/export/release round trips at 200k rows, in a child process so the baseline is its own; TASK_VM_INFO.phys_footprint has to stay inside 16 MB (it grows a few MB, and leaking just the mask handle grows 35 MB — checked in the test order in 0.1.0)
The loader Init, LibraryPath a child process with ARROWMETAL_LIB pointing at nothing, and a child with nothing set in an empty directory: the error has to name the variable, the paths and the swift build line
Docs the example in this file compiled and run as Example(), so it cannot drift from the API

Sizes are at or below 10M elements throughout.


What is not wrapped

The C ABI has 286 entry points; this binding resolves 40 of them (34 it needs, and 6 newer ones it uses when the loaded library has them: an older library still loads, and the calls that need them return an error naming the missing entry point). Not wrapped, and not tested from Go:

  • Arithmetic and math: am_arith_scalar, am_arith_array, am_unary, am_binary, am_cumulative, am_window, the checked variants.
  • Strings and text: am_str_unary, am_str_match, am_str_transform (all 26 ops), am_str_concat, am_str_dictionary_encode, the regex surface.
  • Temporal: am_temporal_extract, am_temporal_cast_unit, am_round_temporal_ex, am_add_interval.
  • Types: decimals, dictionaries beyond what import/export carries through, nested types (list, struct, map, union), run-end encoding, and the _ex option variants other than the sorts' (am_rank_ex, am_is_in_ex, …).
  • Structural and conditional: am_is_null, am_fill_null, am_drop_null, am_if_else, am_coalesce, am_is_in, am_index_in, the Kleene operators.
  • Aggregates beyond the four: am_reduce_ex (product, variance, stddev), and the grouped aggregates past sum/count/mean/min/max — the Agg method takes any op number from the header's table, but only those five are tested from Go.
  • Batching: am_batch_begin / am_batch_end. A Go binding for these needs the batch and every call inside it pinned to one OS thread, which the current one-call-at-a-time pinning does not give you across calls.
  • Whole subsystems: Parquet (am_parquet_*), streaming (am_stream_*), the device interface (am_import_device / am_export_device), joins (am_join).
  • arrow.Table has no entry point of its own; its columns go in as arrow.Chunked (ImportChunked), and a run of arrow.RecordBatches as a Source (NewSourceFromBatches).

Adding any of these is mechanical: a prototype in amshim.h, a pointer and a forwarder in amshim.c, a method in Go, and a test with an oracle.


Limits

  • macOS on Apple silicon only. The module compiles anywhere cgo does, but the dylib it needs exists only for Apple silicon, so on any other platform Init() returns the loader's error.
  • cgo is required. CGO_ENABLED=0 builds will fail to compile the package.
  • One goroutine at a time per handle. An Array, GroupBy, Source or PlanResult is not safe for concurrent use. Separate handles on separate goroutines are fine.
  • Every call pins its goroutine to an OS thread for the duration. am_last_error() is thread-local, so the failing call and the message read have to happen on the same thread; Go is otherwise free to move a goroutine between calls. The cost is a runtime.LockOSThread pair per call, which is tens of nanoseconds against kernels measured in hundreds of microseconds.
  • Release your handles. A finalizer is set as a backstop, but GPU memory should not wait for the garbage collector, and the finalizer runs at an unpredictable time.
  • Go heap buffers stay pinned for the life of the handle — and only that long. Importing an array built with Arrow Go's default allocator hands C a pointer into the Go heap that ArrowMetal keeps until the handle is released, so Import pins those buffers and Release unpins them (see Go pointers, cgo, and why pinning is not optional). Pinned objects cannot be moved or freed, so a handle you forget to release holds its source buffers as well as its GPU memory. The converse is the open edge: an arrow.Array you exported from a borrowed Go-heap import still names those bytes after Release has unpinned them — they stay alive, through arrow-go's own reference chain, but they are no longer pinned, so the binding is back to relying on Go's collector not moving heap objects for as long as that exported array lives. Pinning does not close that window. Importing from PageAlignedAllocator does, since C memory is never the collector's to move.
  • Scalar comparison covers the primitive types only, and refuses a scalar it cannot represent. CompareScalar accepts a Go value for int8…int64, uint8…uint64, float32, float64 and bool, range-checked against the column's element type. Refused, with an error naming the value and the Arrow type: an integer outside the column's width (int64(1000) against int8 is an error, not a silent −24); a finite float too large for the column (1e300 against float32, which would become +Inf); a non-zero float too small for it (5e-46 against float32, which would become 0 and match every zero in the column); and an integer past the mantissa width, 2^24 for float32 and 2^53 for float64, which would land on a neighbouring value. Ordinary rounding is not refused: 0.1 against a float32 column compares against float32(0.1), as it does in every Arrow implementation. A string, decimal or temporal scalar returns an error naming the Arrow format string rather than guessing.
  • The vendored headers can drift. go/arrowmetal/include/arrowmetal.h and arrow_abi.h are copies of the repository's include/, because a Go module can only see files inside its own directory. TestHeadersMatchRepository compares them when it is run inside a checkout and skips otherwise.

Testing

See Testing. In short, from go/arrowmetal:

ARROWMETAL_LIB=$PWD/../../.build/release/libArrowMetalC.dylib go test ./...

# and once more with the cgo pointer checker armed, which is where an unpinned
# Go pointer handed to C shows up as a hard failure rather than as luck:
ARROWMETAL_LIB=$PWD/../../.build/release/libArrowMetalC.dylib \
    GOEXPERIMENT=cgocheck2 go test -count=1 ./...

The library path is optional inside the repository — ../../.build/release is on the search list — but it is the reliable way to say which build you mean.

docs/TYPESCRIPT.md

TypeScript / Node.js

ArrowMetal from TypeScript: an N-API addon over the one C ABI (include/arrowmetal.h), an Arrow C Data Interface bridge to and from Apache Arrow JS, and a typed API with .d.ts. The package lives in node/; this page is the whole contract.

Every number on this page was measured on 2026-09-07 on an Apple M4 Max, macOS, node v24.9.0, apache-arrow 21.2.0, ArrowMetal 0.1.0 built -c release.

The browser is out of scope. There is no Metal in a browser, and this package loads a .dylib through dlopen. It is macOS on Apple silicon, in Node, or nothing. There is no WASM fallback.

Install

The package is not published; build it from the checkout.

export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
swift build -c release --product ArrowMetalC     # produces .build/release/libArrowMetalC.dylib

cd node
npm install                                      # node-gyp builds the addon during install
npm run build:ts                                 # tsc -> dist/, with .d.ts
npm test

npm install needs a C++ toolchain (Xcode command line tools) and python3 for node-gyp; both come with Xcode. apache-arrow is a peer dependency: everything except fromArrow / toArrow works without it.

Finding the dylib

The addon resolves libArrowMetalC.dylib at runtime, in this order:

  1. $ARROWMETAL_LIB — a full path to the dylib, not a directory
  2. <package>/../.build/release/libArrowMetalC.dylib

If neither exists the error names both paths and tells you the swift build line. There is no bundled binary and no download step.

export ARROWMETAL_LIB=/opt/arrowmetal/libArrowMetalC.dylib

import { info } from 'arrowmetal' reports which one this process bound to, plus the ArrowMetal version, the Metal device name and the page size.

Example

import { MetalArray, groupBy, PlanSource, runPlan } 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   (BigInt: int64 is BigInt everywhere)
amount.mean();                                   // 175    (mean is always a number)
amount.filter(amount.gt(60n)).toArrow();         // Vector<Int64> [100n, 250n, 300n]
amount.sort(true).toArrow();                     // Vector<Int64> [300n, 250n, 100n, 50n]
amount.take(amount.argsort()).toArrow();         // the same as sort()

const gb = groupBy(region);
[...gb.keys(0).toArrow()];                       // ['e', 'w']
[...gb.sum(amount).toArrow()];                   // [400n, 300n]

Sort options

argsort, sort and topK take either the boolean they always took or an options object, and lexsort takes one options object per key:

type NullPlacement = 'last' | 'first';
type FloatOrder = 'ieee' | 'total' | 'nan_largest';
interface SortOptions { descending?: boolean; nulls?: NullPlacement; floatOrder?: FloatOrder }
interface TopKOptions { largest?: boolean; nulls?: NullPlacement; floatOrder?: FloatOrder }

argsort(options?: boolean | SortOptions): MetalArray
sort(options?: boolean | SortOptions): MetalArray
topK(k: number, options?: boolean | TopKOptions): MetalArray   // a boolean is `largest`, default true
lexsort(columns: MetalArray[], keys?: boolean[] | SortOptions[]): MetalArray

A boolean, or nothing, is the order the plain calls have always had: nulls last and NaN after +Infinity in both directions, -0 tied with +0. nulls: 'first' puts the null rows first in either direction. floatOrder: 'ieee' (the default) is Arrow C++'s order: -0 ties +0, every NaN is one value, and the NaN rows sit next to the nulls in both directions. floatOrder: 'total' is IEEE 754 totalOrder, the order arrow-rs and Rust's total_cmp use: -NaN < -Infinity < … < -0 < +0 < … < +Infinity < +NaN, and a descending sort is its exact mirror. floatOrder: 'nan_largest' is Polars' and NumPy's order: -0 ties +0, and every NaN is one value greater than every number, +Infinity included, in both directions (last ascending, first among the values descending); the null placement is independent of it. Integer, string and temporal keys ignore the float order. topK(k, options) answers with the first k indices argsort gives in that direction with the same options. argsort, topK and lexsort return uint32 index arrays (toTypedArray() gives a Uint32Array; row numbers stop at 2^32 - 1 and a longer input throws), and take accepts Int32, BigInt64 and Uint32 indices.

const x = MetalArray.fromArrow(vectorFromArray([2, null, NaN, -0, 7], new Float64()));
[...x.argsort(true).toTypedArray()];                                  // [4, 0, 3, 2, 1]
[...x.argsort({ descending: true, nulls: 'first', floatOrder: 'total' }).toTypedArray()];
                                                                      // [1, 2, 4, 0, 3]
[...x.topK(2, { nulls: 'first', floatOrder: 'total' }).toTypedArray()]; // [1, 2]
[...x.argsort({ descending: true, floatOrder: 'nan_largest' }).toTypedArray()];
                                                                      // [2, 4, 0, 3, 1]
lexsort([region, amount], [{ nulls: 'first' }, { descending: true }]);

A plan's sort key takes the same options as JSON: { column: 'x', descending: true, nulls: 'first', float_order: 'total' } or ['x', true, { float_order: 'nan_largest' }] (Query engine).

Chunked columns

MetalArray.fromChunks(chunks) imports a column held as several chunks of one type — Data chunks, Vectors of any number of chunks, or a mix — as one array of their total length, with no concatenation in JS. fromArrow of a Vector with more than one chunk (a column of a Table built from several record batches) takes the same path.

const table = new Table([batch1, batch2, batch3]);   // or tableFromIPC(bytes)
const k = MetalArray.fromArrow(table.getChild('k')!); // one array, every batch's rows
const c = MetalArray.fromChunks([vectorFromArray([1n, 2n], new Int64()),
                                 vectorFromArray([3n, null, 5n], new Int64())]);
c.length;                                            // 5
c.sum();                                             // 11n

Each chunk crosses the C Data Interface on its own and am_import_chunks copies its buffers straight into the final Metal buffers, on the CPU cores in parallel. Each chunk's offset, length and validity are honoured, and each chunk's V8 buffers stay pinned until ArrowMetal releases that chunk, as for fromArrow. One chunk in total is fromArrow, with its copy rule. Chunks of different types are refused with both types named.

The copy rule

Copy-free out, always. Copy-free in when the producer's buffers are page aligned, one copy otherwise. That is the project's rule and this binding does not add a copy of its own to either side.

In

Arrow JS has no C Data Interface export, so the addon builds the ArrowSchema and ArrowArray structs itself:

  • Wrapped, never copied by this binding. Every buffer pointer is the V8 backing store's own address (ArrayBuffer.Data() + byteOffset). The addon holds a JS reference to each producer buffer in the ArrowArray's own private_data, and drops it when ArrowMetal calls the release callback, and at no other time. That is later than you might expect: on a page-aligned import ArrowMetal wraps the V8 pages with makeBuffer(bytesNoCopy:), and a slice, a group-by or a registered plan source built from that array retains the import, so the pages stay pinned until the last of those is gone. Releasing the handle does not unpin them — see Lifetime below.
  • Then ArrowMetal decides. It wraps those pages when they are page aligned and copies once when they are not. MetalArray#wrappedProducerBuffers reports which happened, per handle.
  • Buffers involved: the values buffer, the validity bitmap when there are nulls, and the int32 offsets buffer for utf8. All three are wrapped by the addon; a copy, when there is one, is ArrowMetal's.

One wrinkle, handled here rather than left to you. Arrow JS's Data.slice advances the numeric values buffer and the utf8 offsets buffer, but leaves the validity bitmap alone and keeps the row offset in Data.offset. The C Data Interface applies one offset to every buffer. So on import this binding rewinds the two advanced buffers to their row-0 origin (a typed-array view at a different byteOffset — no bytes move) and lets the C offset do the work; on export it advances them again. Sliced and doubly sliced vectors, with and without nulls, are tested.

If a hand-built Data has offset > 0 and a buffer that cannot be rewound (byteOffset smaller than offset × BYTES_PER_ELEMENT), the import throws and tells you to materialise the vector. No Arrow-JS-produced Data reaches that path.

Out

am_export hands back an ArrowArray; each of its buffers becomes an external ArrayBuffer sharing one refcount. The release callback runs when the last of them is garbage collected. So toArrow() and toTypedArray() return views over GPU-resident memory and copy nothing. (toArray() copies, by construction — it builds a JS array.)

Lifetime

Who keeps what alive, precisely:

Object Pinned until
The producer's V8 TypedArray (import) ArrowMetal calls the ArrowArray release callback — i.e. 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 garbage collected

The first row is the one with teeth. 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 the JS references must outlive the handle, and they do: they live in the ArrowArray's private_data, not on the handle. col.release() frees the handle and nothing else; the derived slice, group-by or plan source keeps reading the right bytes. Four tests in test/lifetime.test.js drop every JS reference to the source typed array, force two collections, allocate 40 more 1M-element arrays over the freed pages, and then read through the derived object.

Measured: are V8's typed-array buffers page aligned?

Page size 16,384 bytes on this machine. "wrapped" is wrappedProducerBuffers — ArrowMetal kept the buffers instead of handing them 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, both types, the answer is yes — V8 backs a large typed array with its own mmaped pages, so the import is a wrap and not a copy. Small typed arrays are packed into the heap and land wherever they land; the 1,000-element Float64Array above was not aligned and was copied. The test suite does not pin an address: it pins the consequence. A wrapped import sees a later write through the typed array (sum changes); a copied one does not.

A view whose byteOffset is not a multiple of the page size is never page aligned and is always copied. A byteOffset that is a multiple of the page size, into an already-aligned buffer, still lands on a page boundary and is still wrapped.

Covered

Area Surface Backed by
Interop MetalArray.fromArrow, fromTypedArray, toArrow, toTypedArray, toArray am_import / am_export
Types carried Int8/16/32/64, Uint8/16/32/64, Float32, Float64, Bool, Utf8 round-trip test per type, with nulls
Reductions sum, min, max, mean am_reduce
Compare compare(op, scalar), compareWith(op, array), eq ne lt le gt ge am_compare_scalar / am_compare_array
Arithmetic arith('+' | '-' | '*' | '/', scalar) am_arith_scalar
Cast cast(format) am_cast
Selection filter, take, slice am_filter, am_take, am_slice
Sorting argsort, sort, topK, lexsort([...]), each with per-key nulls and floatOrder am_argsort, am_sort, am_top_k, am_lexsort; with options am_argsort_ex2, am_sort_ex2, am_top_k_ex, am_lexsort_ex2
Chunked columns MetalArray.fromChunks, fromArrow of a multi-chunk Vector am_import_chunks, am_import_chunks_supported
Group-by groupBy(keys).sum / mean / min / max / count, .keys(i), .groups am_group_by_keys + am_group_agg_ex
Query engine PlanSource.create, runPlan, explainPlan, PlanResult#column am_plan_source_create, am_plan_run, am_plan_explain
Diagnostics info, bufferAddress, isPageAligned, wrappedProducerBuffers —

Errors from ArrowMetal are thrown as JS Errors carrying the am_last_error() message verbatim, except argument-guard rejections (return code 2), which carry the binding's own message: the C ABI's guards return 2 without setting am_last_error, so reading it there would report the previous call's failure. Handles are freed by the garbage collector; release() frees one now.

Not covered

The C ABI has 286 entry points. This binding wraps the ones above and no others. Not wrapped:

  • strings beyond utf8 import/export — no am_str_unary, am_str_match, am_str_transform, am_regex, am_to_strings, am_parse
  • temporal, decimal128/256, nested (list, struct, map, union), dictionary and run-end types
  • window functions, cumulative ops, am_reduce_ex / am_reduce_ex2 statistics, am_unary / am_binary op tables, Kleene logic and conditionals
  • joins (am_join), IPC, Parquet, the streaming API (am_stream_*), the device C interface (am_import_device / am_export_device), batching (am_batch_begin / am_batch_end)
  • Arrow JS Table and RecordBatch as a whole; their columns import one at a time, through fromArrow / fromChunks.
  • every call is synchronous on the JS thread. There is no worker or async variant, so a long kernel blocks the event loop.

Timing

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. 10,000,000 Int64 rows, no nulls, values i % 1000.

Method: five fresh processes, each building the dataset once, warming each row up 3 times and then timing 5 calls (process.hrtime.bigint() around the call) and keeping its best. The table reports 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.

The spread is the point. A single process's best-of-5 is not a stable measurement here — an earlier one-process run of this same benchmark put filter end to end at 8.62 ms, roughly double the median below — so anything reported as one number would be over-claiming. Two rows whose ranges overlap cannot be ordered by this measurement, and the benchmark prints those pairs itself.

Two ArrowMetal rows: one with the column already on the device, one that re-imports the BigInt64Array on every call (a page-aligned wrap at this size, so no copy) and, for filter, wraps the result back as a typed array.

sum over 10,000,000 Int64 rows (answer 4995000000)

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

filter x >= 500 over 10,000,000 Int64 rows (5,000,000 kept)

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

The only overlapping pair the benchmark found is plain typed-array loop and Arrow JS, vector.toArray() then loop in the sum table, and they overlap because they are the same code: toArray() on an unsliced Int64 vector hands back the same BigInt64Array.

Reading the tables:

  • The two filter rows are not measuring 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 again on the host — whereas the plain loop writes 5,000,000 BigInts into a JS array as it goes. Some of the 2.2x is the GPU and some of it is that the comparison hands the CPU a materialisation the GPU path does not have to repeat.
  • Vector.get(i) is 170–184 ms because Arrow JS allocates a BigInt per row. It is in the table because it is what a naive Arrow JS user writes, not because it is a fair kernel.
  • The 0.27–0.36 ms resident sum is 80 MB read in about 0.3 ms, roughly 265 GB/s, which is in range for an M4 Max. It is not a cached answer: a test mutates the wrapped buffer and watches the sum change.
  • On these two operations at this size ArrowMetal is ahead in every row. The small-array crossover is not measured here.

Numbers are from an M4 Max on 2026-09-07. node bench/spread.mjs re-runs the whole thing; node bench/bench.mjs runs a single process and prints tables.

Chunked columns against concatenating first

node --expose-gc bench/chunks.mjs: a column in chunks, each chunk its own Arrow JS Data, imported with MetalArray.fromChunks against what a program writes without it (copy the chunks into one typed array with TypedArray.set, assemble one validity bitmap bit by bit when there are nulls, then fromTypedArray), both end to end with the handle released inside the timed call. M4 Max, 2026-09-29, node v24.9.0, apache-arrow 21.2.0; three rounds, each row warmed for 100 ms and then timed 10 times; best of the three rounds, the median of the per-round medians in parentheses; CPU is process CPU time per call. Source: Benchmarks/results/bindings_chunked_import_2026-09-29.csv.

Column Rows Chunks Concatenate + import fromChunks CPU ms (concatenate / chunked)
float64, 10% null 10,000,000 153 16.16 (17.41) ms 0.88 (1.16) ms 22.1 / 8.9
float64, 10% null 10,000,000 10 16.99 (17.33) ms 0.77 (1.10) ms 22.6 / 11.0
float64, 10% null 50,000,000 763 81.68 (82.80) ms 3.04 (3.77) ms 86.0 / 39.0
float64, 10% null 50,000,000 50 80.17 (81.26) ms 2.58 (3.27) ms 86.4 / 40.5
int64 10,000,000 153 1.88 (2.54) ms 0.70 (0.83) ms 4.8 / 9.4
int64 10,000,000 10 1.93 (2.38) ms 0.55 (0.59) ms 4.4 / 8.9
int64 50,000,000 763 7.49 (8.03) ms 2.65 (2.98) ms 11.4 / 32.8
int64 50,000,000 50 7.28 (8.14) ms 2.29 (2.75) ms 10.4 / 33.3

The chunked import copies each chunk once, straight into the Metal buffers, on the CPU cores in parallel. For int64 it takes 2.0x to 3.2x the CPU time of the concatenation per call; for the nullable float64 column, where the concatenation assembles the validity bitmap in JS, it takes less.

The existing calls, before and after

node --expose-gc bench/overhead.mjs times the calls that existed before, built from the previous commit and from this one against the same libArrowMetalC.dylib, in four alternating rounds (each row warmed for 100 ms, a 500 ms idle and one call timed on its own, then 30 calls); best of the four rounds, the median of the per-round medians in parentheses. At 10M rows:

Call Previous commit This commit
fromArrow(vector).release(), int64 0.016 (0.049) ms 0.008 (0.015) ms
fromTypedArray(values, { validity }).release(), float64 with 10% nulls 0.014 (0.063) ms 0.021 (0.058) ms
argsort(false), float64 with 10% nulls 5.74 (5.90) ms 5.72 (5.84) ms
argsort(true), int64 7.33 (7.51) ms 7.32 (7.71) ms
sort(false), float64 with 10% nulls 5.69 (6.10) ms 5.60 (5.86) ms
lexsort, int64 then float64 descending 15.72 (16.33) ms 15.98 (16.26) ms

In the four rounds fromArrow took 1.5x to 1.6x the CPU time per call at 1,000 and 1,000,000 rows (a few microseconds), so it was timed again alone, eight alternating rounds of 100 calls: best 0.37x, 1.00x and 0.93x at 1,000, 1,000,000 and 10M rows, median 0.71x, 0.61x and 1.74x, and 1.83x the CPU time at 10M rows (0.016 against 0.029 ms per call). Two further rounds of 1,000 calls give 0.96x best, 0.81x median and 0.72x the CPU time at 10M rows, and 2.7x best at 1,000 rows (0.0032 against 0.0012 ms): in this build's process the 8 KB buffer of the 1,000-row vector was not page aligned, so ArrowMetal copied it (wrappedProducerBuffers false), where the previous build's process had it aligned and borrowed it; the alignment table above shows that this varies at small sizes. Every row, with first-call-after-idle and CPU time: Benchmarks/results/bindings_call_overhead_2026-09-29_summary.csv.

Limits

  • macOS on Apple silicon, in Node. No browser, no WASM, no Intel, no Linux.
  • int64 is BigInt. sum, min, max on an Int64 or Uint64 column return a BigInt; mean always returns a number. Scalars passed to compare / arith on an int64 column should be BigInts (amount.gt(60n), not amount.gt(60)); a number is accepted and truncated toward zero. Arrow JS's Int64 vectors are BigInt64Array-backed, which matches.
  • Column by column. A Table or RecordBatch is imported one column at a time; a chunked column goes through the chunked import.
  • Synchronous. Every call blocks the event loop for the length of the kernel.
  • Handles are GC-managed. A MetalArray holds GPU memory until it is collected; call release() in a loop that makes many of them. release() is safe at any point: it frees the handle only, and anything derived from it keeps working, because the producer's buffers stay pinned until ArrowMetal itself lets go. See Lifetime.
  • No prebuilt binary. npm install compiles the addon locally, and the dylib must already exist.
  • Not published. binding.gyp adds ../include so the addon can include the repository's arrow_abi.h; that path is outside the package, so an npm pack tarball installs and then fails to compile. The package is therefore marked "private": true and has no files, os or cpu fields. main and types are kept, so a local file: or npm link install resolves.

Tests

84 tests, node:test, oracles are Apache Arrow JS and plain JS over the same rows. npm test sets NODE_OPTIONS=--expose-gc, which the lifetime tests need.

cd node
ARROWMETAL_LIB=/path/to/libArrowMetalC.dylib npm test
File Tests What it pins
test/interop.test.js 21 round trip for all 12 carried types with nulls; sliced, doubly sliced, sliced-with-nulls, sliced utf8 and bool; a chunked vector imported as one array, and chunks of two types and an unsupported type rejected by message; the alignment table above; wrapped-vs-copied proved by mutating the source buffer; short-buffer rejection with byte counts for validity, values, utf8 offsets, utf8 values and bool; utf8 offsets that decrease or start below zero, named by index; impossible nullCounts; type-tagged handles rejected across kinds; an argument-guard rejection never reporting a stale message; 10,000 import/compute/export cycles
test/reductions.test.js 10 sum/min/max/mean against plain-JS oracles for Int64 and Float64, with nulls, all-null, empty; 1,000,001 rows; Kahan-summed float oracle to 1e-9 relative; validity bitmaps with a known and an unknown null count
test/compute.test.js 20 all six comparison ops against JS; null masks; take with Int32, BigInt64 and Uint32 indices; Uint32 index arrays; filter on empty and at 1,000,001 rows; sort and argsort with nulls last and stable ties; sort at 1,000,001 rows against Array.prototype.sort; take, slice, arith, cast; groupBy sum/mean/min/max/count against a JS Map, including a null key group and 1,000,001 rows; lexsort
test/plan.test.js 7 a filter → group_by → sort plan against the same steps in JS; optimized vs unoptimized agree; explain; a plan that does not type-check throws the engine's own message; an out-of-range column named by index and by name
test/lifetime.test.js 4 a slice, a plan source and a group-by all still read the right bytes after the parent handle is released, every JS reference to the source array dropped, two collections forced and the freed pages trampled; and an exported Vector after its handle is released
test/sortoptions.test.js 11 argsort / sort with every direction × null placement × float order (ieee, total, nan_largest), index for index and bit for bit against a stable plain-JS reference of the documented order, on Float64 columns holding NaN of both signs and several payloads, ±0, ±Infinity and subnormals, with nulls, at 0 to 100,001 rows; Int64 and Float32 columns; the boolean forms and the defaults equal to the options forms; topK against the head of argsort at k = 0 to past the length; lexsort with per-key options; nan_largest on a hand-picked column with NaN of both signs, ±0, ±Infinity and a null, against its expected indices for argsort, sort, topK and a two-key lexsort; a plan sort key with nulls and float_order, and float_order: 'nan_largest' in each key form and as the sort-level default, with and without a limit; the examples on this page
test/empty.test.js 4 zero-row input: fromTypedArray of every typed-array type (empty, over a zero-byte ArrayBuffer, zero-length views into a non-empty buffer, with an empty validity bitmap), fromArrow of an empty vector, of makeData({ type, length: 0 }) and of a zero-length slice for all 12 types, fromChunks of empty chunks alone, next to a non-empty chunk and from a table with no batches; each back out through toArrow / toTypedArray, and argsort, sort, topK and sum on the empty array; utf8 rows that are all empty strings over a zero-byte values buffer
test/chunks.test.js 5 fromChunks and fromArrow of a multi-chunk Vector against the import of the concatenation, over 9 types and five layouts (empty, one-row, sliced, all-null and no-null chunks mixed; one chunk; all empty; all null; 300 small chunks); sum, max and sorts on a 30-chunk column; a Table of five record batches through a plan; refusals by message; chunk buffers kept pinned across collections
test/workers.test.js 2 four worker_threads workers importing, deriving and releasing concurrently, each answering correctly; and workers that exit with references still parked, so the env cleanup hook has to drain them

Sizes are 0, small, and 1,000,001 — a length that crosses a threadgroup boundary. Nothing above 10,000,000 elements.

docs/R.md

R

r/arrowmetal is an R package that runs ArrowMetal's GPU kernels on columns held by the arrow R package. Columns cross through the Arrow C Data Interface; the package links against nothing at build time and opens libArrowMetalC.dylib with dlopen when it loads.

Every number on this page was measured in one session on an idle Apple M4 Max (16 cores, 64 GB unified memory), macOS 26.6.2, R 4.5.3, arrow 25.0.0, ArrowMetal 0.1.0.

Install

You need the dylib first:

DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
  swift build -c release --product ArrowMetalC   # produces .build/release/libArrowMetalC.dylib

Then either:

R CMD INSTALL r/arrowmetal
remotes::install_local("r/arrowmetal")

The package's configure script looks for ../../.build/release/libArrowMetalC.dylib relative to the package source and compiles the absolute path it finds into the shim, so an in-tree install needs nothing else. Otherwise, point ARROWMETAL_LIB at the dylib:

export ARROWMETAL_LIB=/path/to/libArrowMetalC.dylib

The search order at load is ARROWMETAL_LIB, then the path configure recorded, then ../../.build/release/libArrowMetalC.dylib relative to the working directory. When none of them opens, the package still loads and every compute call raises an error listing all three paths and why each failed; am_available(), am_load_error() and am_lib_path() report the same thing without raising.

A conda-built R names its own compiler in Makeconf (here arm64-apple-darwin20.0.0-clang), which lives in the environment's bin but is only on PATH once the environment is activated. Calling such an R by absolute path without activating it fails with sh: arm64-apple-darwin20.0.0-clang: command not found. Either put that bin on PATH, or set CC = clang in ~/.R/Makevars to use Xcode's clang instead; the shim compiles clean under both (conda LLVM 23.1.0 and Apple clang 21.0.0), and the only non-standard C it uses is __typeof__, which both provide.

Example

library(arrowmetal)
library(arrow)

x  <- Array$create(runif(1e7))                       # an arrow Array
h  <- am_array(x)                                    # onto the GPU
am_sum(h); am_min(h); am_max(h); am_mean(h)          # scalar reductions, nulls skipped

hits <- am_filter(h, am_compare(h, ">", 0.5))        # boolean mask, then gather
sorted <- am_sort(h, descending = TRUE)              # stable radix sort, nulls last
idx <- am_argsort(h)                                 # zero-based uint32 indices
top <- am_take(h, am_slice(idx, 0, 10))

g <- am_group_by(sample(c("north", "south"), 1e7, TRUE))
as.vector(as_arrow_array(g$keys()))                  # one row per group
as.vector(as_arrow_array(g$sum(h)))                  # grouped sum

src <- am_plan_source("sales", list(region = c("a", "b", "a"), amount = c(1, 2, 3)))
res <- am_plan_run('{"op":"limit","count":2,"input":{"op":"scan","source":"sales"}}', src)
as.vector(as_arrow_array(res$amount))                # back to plain R

Sort options

am_argsort(), am_sort(), am_top_k() and am_lexsort() take Arrow's null_placement ("at_end", the default, or "at_start") and a float_order ("ieee", the default, "total" or "nan_largest"):

x <- c(2, NA, NaN, -0, 7)
am_argsort(x, descending = TRUE)                                  # 4 0 3 2 1: NA and NaN last
am_argsort(x, descending = TRUE, null_placement = "at_start",
           float_order = "total")                                 # 1 2 4 0 3
am_top_k(x, 2, null_placement = "at_start", float_order = "total")   # 1 2
am_lexsort(list(c(1, 1, 2), c(3, NA, 1)), descending = c(FALSE, TRUE),
           null_placement = c("at_end", "at_start"))              # 1 0 2

(Each is shown through as.vector(); test-sort-options.R and test-chunks.R run these lines.) With the defaults each function runs the call it always ran: nulls last and NaN after +Inf in both directions, -0 tied with 0. "at_start" puts the nulls first in either direction. float_order = "ieee" is Arrow C++'s order: -0 ties 0, every NaN is one value, and the NaN rows sit next to the nulls in both directions. "total" is IEEE 754 totalOrder, the order arrow-rs and Rust's total_cmp use: -NaN < -Inf < ... < -0 < 0 < ... < Inf < NaN, and a descending sort is its exact mirror. "nan_largest" is Polars' and NumPy's order: every NaN is one value above +Inf in both directions (last ascending, first descending) and -0 ties 0. Integer, string and temporal columns ignore float_order. am_top_k(x, k, largest, ...) is the first k indices of am_argsort(x, descending = largest, ...). In am_lexsort() each option is one value for every key or a vector with one per key. A plan's sort key takes the same options as JSON: {"column": "x", "descending": true, "nulls": "first", "float_order": "total"} (Query engine).

Chunked columns

am_array_chunks() imports a column held in chunks — an arrow::ChunkedArray, or a list of Arrays of one type such as one column of several record batches — as one am_array, with no concatenated copy first. am_array() keeps concatenating a ChunkedArray first, as it always has (measured below, that is the faster path in R at 10M and 50M rows).

ca <- arrow::chunked_array(c(1, NA), numeric(0), c(3, 4, 5))
h <- am_array_chunks(ca)
length(h); am_null_count(h); am_sum(h)    # 5, 1, 13

The column crosses the C Data Interface once, as an ArrowArrayStream of one-column record batches (arrow::as_record_batch_reader()), so the R side does the same work for 10 chunks as for 1,000. The shim reads the stream in C, moves each batch's column into one block of ArrowArray structs it owns and releases the batch, and am_import_chunks copies each chunk's buffers straight into the final GPU buffers, on the CPU cores in parallel, honouring each chunk's offset, length and validity. Each chunk's release callback runs exactly once, by ArrowMetal or, for a chunk it did not take, by the shim. One chunk is am_array(). A list of arrays becomes a ChunkedArray first, which checks that the chunks share one type. A type the chunked import does not take (dictionary, nested, run-end encoded, extension) is concatenated and imported, as am_array() always did.

The copy rule

Out is always copy-free. as_arrow_array() hands arrow a C Data Interface array pointing at the Metal buffers; its release callback keeps them alive, so the am_array handle may go out of scope first.

In is copy-free when the producer's buffers are page aligned, and one copy otherwise. What that means for arrow R, measured with am_buffer_alignment() (page size 16,384 bytes):

Buffer 1M elements 10M elements
Array$create(<R double>) values offset 48, not aligned (20/20 trials) offset 48, not aligned (20/20 trials)
Array$create(<R integer>) values offset 48, not aligned offset 48, not aligned
int64 values (type = int64()) offset 0, page aligned offset 0, page aligned
validity bitmap of a column with nulls offset 0, page aligned offset 0, page aligned
a buffer arrow allocates ($cast(), string offsets and data) offset 0, page aligned not measured
an mmapped Arrow IPC file offset 0, page aligned not measured

arrow R borrows an R double or integer vector instead of copying it — Array$create(x) twice on the same x returns the identical buffer address — and R's vector data starts 48 bytes past a malloc block that is page aligned at these sizes, because R's vector header is 48 bytes on 64-bit. So at 1M and 10M elements a float64 or int32 column that came from an R vector takes the copying path in, while an int64 column, a validity bitmap, a cast result, a string column and anything read from a file take the copy-free path.

That size qualifier matters: a buffer is page aligned only when it is large enough for arrow to give it its own allocation instead of a slice of a pool, so a small column's buffers are not page aligned and take the copying path whatever their type. Where the changeover happens is allocator behaviour — it differs by type and is not monotone in n — so no threshold is quoted here. The table above is what was measured, at 1M and 10M; for anything else, run am_buffer_alignment() on your own data rather than inferring. At small sizes the copy costs nothing worth measuring.

The copy costs 1.39 ms (median) for 80 MB (10M float64; decimal MB, 76 MiB), about 57 GB/s.

Timing

10,000,000 float64, no nulls.

Method. The benchmark is committed at r/arrowmetal/inst/bench/timing.R with its driver run.R, so the table below can be re-derived rather than taken on trust:

ARROWMETAL_LIB=$PWD/.build/release/libArrowMetalC.dylib \
  Rscript r/arrowmetal/inst/bench/run.R 5 2

2 replicates × 5 fresh R processes × microbenchmark(times = 20), each process building its own data. Every expression is timed two ways, because they disagree by enough to move a headline:

  • isolated — each expression in its own microbenchmark() call, after one warm-up call of that expression. The most favourable measurement.
  • interleaved — all expressions in one microbenchmark() call, so the runs are shuffled and each expression meets the cache and buffer-pool state the others leave behind. The conservative measurement, and the right one for a table whose rows are compared with each other.

min is the fastest of all 200 runs, median the median of the 10 per-process medians. "resident" means the column is already an am_array, "import" means the timing starts from an arrow::Array and includes the transfer. All variants were checked to give the same answer in the same script.

sum:

Method isolated min / median interleaved min / median
am_sum(h) resident 0.52 / 0.82 ms 1.37 / 1.70 ms
arrow::call_function("sum", a) 1.09 / 1.16 ms 1.12 / 1.31 ms
am_sum(a) import + sum 3.29 / 6.36 ms 2.75 / 3.20 ms
sum(x) base R 11.15 / 11.60 ms 11.15 / 11.61 ms

Resident sum is not separable from arrow's, and no verdict is claimed for it. It measures between 0.57 and 1.54 ms depending on the process. In the isolated mode: the per-process medians were 0.57, 0.59, 0.60, 0.60, 0.78 in one replicate and 0.86, 1.38, 1.38, 1.39, 1.54 in the next, while arrow stayed tight at 1.14–1.27 across all ten. One replicate makes ArrowMetal look twice as fast, the next makes it look the other way; the two are the same speed to within the noise of this measurement.

sum including the import is a row where arrow is ahead, in every replicate: 3.20 ms against arrow's 1.31 ms interleaved, 2.4×. A sum is one bandwidth-bound pass over 80 MB (decimal MB; 76 MiB) with no arithmetic to hide the transfer behind.

filter (x > 0.5, about 5M rows out):

Method isolated min / median interleaved min / median
am_filter(h, am_compare(h, ">", 0.5)) resident 1.09 / 1.19 ms 1.12 / 1.58 ms
am_filter(a, am_compare(a, ">", 0.5)) import + filter 4.19 / 11.79 ms 3.95 / 4.49 ms
arrow greater then filter 20.80 / 22.27 ms 20.84 / 21.92 ms
x[x > 0.5] base R 36.12 / 37.57 ms 34.79 / 37.88 ms

ArrowMetal is ahead on filter, but the multiple depends on how it is measured: about 14× against arrow resident and about 4.9× including the import, on the conservative interleaved medians. The resident row ranges 1.12–1.64 ms across processes here and independent runs on the same machine have put it above 2 ms, which would make it nearer 9×. Treat it as roughly an order of magnitude, not a precise multiple.

The import rows are the one place isolated timing is worse than interleaved (11.79 ms against 4.49 ms for filter): twenty imports back to back give the buffer pool no chance to recycle.

am_array() alone on the same column: 1.32 ms min, 1.39 ms median interleaved (80 MB, about 57 GB/s).

arrow's kernels are multi-threaded and base R's are not, so a comparison against base R is not a per-core figure. Timings on a loaded machine are noise; these ran on an idle machine.

Chunked columns against concatenating first

Rscript inst/bench/chunks.R: a column in chunks, each chunk its own Array, imported with am_array_chunks() against am_array(do.call(arrow::concat_arrays, chunks)), which is what am_array() of a ChunkedArray does. M4 Max, 2026-09-29, R 4.5.3, arrow 25.0.0; three rounds, each row warmed for 100 ms and then timed 10 times; best of the three rounds, the median of the per-round medians in parentheses; CPU is process CPU time per call. Source: Benchmarks/results/bindings_chunked_import_2026-09-29.csv.

An am_array is freed when R's collector finalizes it. With gc() run before every timed call (outside the timed region), each call starts with the previous call's handle and GPU memory gone, as a program that releases its columns sees it:

Column Rows Chunks concat_arrays + am_array() am_array_chunks() CPU ms (concatenate / chunked)
float64, 10% null 10,000,000 153 1.82 (1.89) ms 1.20 (1.31) ms 2.1 / 8.4
float64, 10% null 10,000,000 10 1.47 (1.51) ms 1.12 (1.24) ms 1.7 / 8.0
float64, 10% null 50,000,000 763 8.36 (9.03) ms 3.68 (4.01) ms 10.5 / 34.6
float64, 10% null 50,000,000 50 6.02 (6.12) ms 3.07 (3.35) ms 7.2 / 37.0
int64 10,000,000 153 1.59 (1.66) ms 1.09 (1.23) ms 1.8 / 6.9
int64 10,000,000 10 1.26 (1.31) ms 0.97 (1.05) ms 1.3 / 6.7
int64 50,000,000 763 7.74 (8.42) ms 3.37 (3.81) ms 8.6 / 32.3
int64 50,000,000 50 5.57 (5.71) ms 2.69 (2.98) ms 5.9 / 30.4

Without the gc() calls, the handles of earlier calls hold their GPU memory until the collector runs, and every chunked import writes into newly allocated GPU memory, while arrow's concatenation is page aligned and borrowed. There the chunked import is behind at 10M rows (see To improve below):

Column Rows Chunks concat_arrays + am_array() am_array_chunks() CPU ms (concatenate / chunked)
float64, 10% null 10,000,000 153 2.87 (3.52) ms 5.15 (5.71) ms 4.2 / 93.8
float64, 10% null 10,000,000 10 2.37 (4.89) ms 4.57 (5.03) ms 5.0 / 81.2
float64, 10% null 50,000,000 763 13.42 (14.34) ms 7.23 (12.44) ms 15.3 / 119.4
float64, 10% null 50,000,000 50 11.31 (15.78) ms 7.70 (86.55) ms 16.2 / 185.4
int64 10,000,000 153 4.51 (5.22) ms 4.36 (5.68) ms 5.6 / 90.8
int64 10,000,000 10 4.32 (5.12) ms 4.85 (5.59) ms 5.6 / 93.9
int64 50,000,000 763 16.96 (21.79) ms 9.73 (10.27) ms 21.3 / 133.8
int64 50,000,000 50 10.76 (11.72) ms 10.12 (10.83) ms 12.0 / 151.0

am_array() of a ChunkedArray therefore keeps concatenating first, as it did.

The existing calls, before and after

Rscript inst/bench/overhead.R times the calls that existed before, installed from the previous commit and from this one against the same libArrowMetalC.dylib, in four alternating rounds (each row warmed for 100 ms, a 500 ms idle and one call timed on its own, then 30 calls); best of the four rounds, the median of the per-round medians in parentheses. At 10M rows:

Call Previous commit This commit
am_array(), int64 Array 0.011 (0.029) ms 0.011 (0.015) ms
am_array(), float64 Array with 10% nulls 4.15 (4.82) ms 4.15 (4.86) ms
am_array(), one-chunk ChunkedArray 3.76 (4.50) ms 3.91 (4.59) ms
am_argsort(x), float64 with 10% nulls 6.47 (7.74) ms 6.48 (7.92) ms
am_argsort(x, TRUE), int64 3.74 (5.46) ms 4.29 (5.28) ms
am_sort(x), float64 with 10% nulls 7.40 (8.07) ms 7.26 (9.50) ms

No row at 1,000, 1,000,000 or 10M rows was slower in both best and median. Every row, with first-call-after-idle and CPU time: Benchmarks/results/bindings_call_overhead_2026-09-29_summary.csv.

To improve

  • am_array_chunks() in a loop that leaves its handles to R's collector: at 10M rows it takes 0.97x to 1.93x the time of concat_arrays + am_array() (best of three rounds; int64 in 153 chunks 4.36 against 4.51 ms, float64 in 10 chunks 4.57 against 2.37 ms), and at 50M rows float64 in 50 chunks has a median of 86.5 ms against 15.8 ms. It uses 81 to 185 CPU-ms per call there, against 7 to 37 with gc() between calls.

Covered

Area Functions
Import / export am_array(), am_array_chunks() (a ChunkedArray or a list of arrays, am_import_chunks), as_arrow_array(), as.vector(), length(), am_null_count(), am_format()
Reductions am_sum(), am_min(), am_max(), am_mean()
Element-wise am_compare() — ==, !=, <, <=, >, >=, against a scalar or a column
Selection am_filter(), am_take(), am_slice()
Sorting am_argsort(), am_sort(), am_top_k(), am_lexsort(), each with null_placement and float_order
Grouping am_group_by() over any number of key columns of any supported type, with $sum $min $max $mean $count $count_all $first $last $product $var $sd $median $quantile and $agg() for the remaining am_group_agg_ex ops
Query engine am_plan_source(), am_plan_run(), am_plan_explain() — the full JSON plan grammar
Environment am_available(), am_load_error(), am_lib_path(), am_version(), am_device_name(), am_buffer_alignment()

Types exercised by the tests: float64, float32, int32, int64, boolean and utf8; sliced views of float64, boolean and utf8 at offsets 0, 1 and 3, plus a slice of a slice and selection on a sliced column; and multi-chunk, single-chunk and empty ChunkedArrays.

Not covered

The binding resolves 42 of the ABI's 286 entry points (36 it needs, and 6 newer ones it uses when the loaded library has them). Not wrapped, and reachable only from Python or Swift for now:

  • arithmetic (am_arith_*, am_unary, am_binary, checked variants), casts (am_cast), boolean logic and Kleene logic;
  • every string kernel (am_str_*, am_string_*, am_regex, am_split), temporal kernels, decimal, list, struct, map and extension types;
  • am_rank, am_unique, am_value_counts, am_is_in, am_cumulative, am_window, am_hash64, am_if_else, am_coalesce, am_fill_null;
  • joins (am_join), the dense-key am_group_by, am_query (the fused expression compiler), Parquet (am_parquet_*), the streaming engine (am_stream_*), C Device interop (am_import_device / am_export_device), batching (am_batch_begin / am_batch_end) and resident mode.

There is also no dplyr backend and no RecordBatch/Table surface: everything is per-column.

Limits

  • int64. R's integer is 32-bit and its double is 64-bit; there is no native 64-bit integer. An int64 Arrow column is carried end to end, but a scalar result comes back as a double, exact only to 2^53. am_sum(x, integer64 = TRUE) returns an exact bit64::integer64 instead, and a plain call warns when the result is outside 2^53. arrow itself follows the same convention: Scalar$as_vector() on an int64 gives an R integer when it fits and a bit64::integer64 when it does not. Note that as.vector() on a bit64::integer64 strips the class and reinterprets the bit pattern as a double (2474723182 becomes 1.22e-314); use as.numeric().
  • The arrow package is a hard dependency. It builds every column and reads every result back; am_array(c(1, 2, 3)) calls arrow::Array$create() for you.
  • Zero-based indices in am_argsort(), am_take() and am_slice(), following Arrow rather than R. am_argsort(x) matches order(x, na.last = TRUE) - 1L. am_take() rejects a fractional index or one outside [0, 2^32) with an error rather than coercing it to NA.
  • Index arrays are uint32 (am_argsort(), am_top_k(), am_lexsort(), the ranks). R has no unsigned 32-bit type, so they read back the way arrow reads a uint32 array: an R integer vector when every value is at most 2^31 - 1, a double vector when one is larger (exact up to 2^32 - 1).
  • as_arrow_array() is arrow's own generic. arrow exports as_arrow_array(x, ..., type = NULL) with eight methods; this package registers a ninth for am_array and re-exports the generic unchanged, rather than defining a second one. A second generic of that name would mask arrow's (breaking its methods) or be masked by it (never dispatching for am_array), depending on the order the packages are attached.
  • An NA scalar in am_compare() returns an all-null boolean mask, matching base R (c(1, 5) > NA) and arrow's greater kernel. The ABI scalar is a raw value with no validity flag, so this case is resolved in R and never reaches the GPU. NaN is a value, not a null, and does go to the kernel.
  • Group order is not first-seen order: ascending by key for numeric, boolean, temporal and decimal columns (nulls last), first-seen for strings and binary, lexicographic in column order for several columns. Label rows with $keys().
  • By default sorts put nulls and NaN last in both directions, so a descending sort is not the exact reverse of an ascending one; null_placement = "at_start" and float_order = "total" change that (Sort options).
  • macOS on Apple silicon only. The dylib is not included in the package.

Tests

src/arrowmetal.h and src/arrow_abi.h are copies of the repository's include/ headers; refresh them (cp include/arrowmetal.h r/arrowmetal/src/) whenever the header changes, or python/tests/test_header_copies.py and test-header-copy.R fail.

88 test_that() blocks in the sources (89 as testthat runs them: the one in test-dispatch.R runs once per attach order), 920 expectations, against base R and against arrow's own kernels on the same data: nulls, all-null and empty columns, sliced input at three offsets, lengths of 1, 33, 1024, 65537 and 1,000,001 (crossing a threadgroup boundary), one group per row and one group for everything, int64 above 2^53, float32 accumulation, and every documented error path.

ARROWMETAL_LIB=$PWD/.build/release/libArrowMetalC.dylib \
  Rscript -e 'testthat::test_local("r/arrowmetal")'
docs/ARROW_FUNCTIONS.md

Apache Arrow compute functions, one row per name

Every Arrow v25 compute function name except pyarrow's two internal meta-functions — 312 of them: the 311 pc.list_functions() reports, less index_in_meta_binary and is_in_meta_binary, plus the three names pyarrow exposes only as Python wrappers (fill_null, top_k_unstable, bottom_k_unstable); 24 are hash_* grouped aggregates — with what ArrowMetal 0.5.0 does about it.

This is the by-name page. Coverage summary is the by-family page: it groups these functions and explains how each family works, with the Arrow type matrix and the interop status alongside. Come here to answer "is <name> covered, and how?"; go there for "how does this family work?".

How the table is produced

python/arrowmetal/functions.py holds a registry with exactly one entry per Arrow name. Each entry carries the status, the Swift file that implements it, the ArrowMetal call that reaches it, a note, a call adapter that runs the function through ArrowMetal under Arrow's own argument and option names, and, for the 65 names where pyarrow's answer needs shaping, an oracle that produces the answer to compare against. The rest compare against getattr(pc, name) directly.

python/tests/test_functions.py then does four things, and the third is the point:

  1. asserts the registry covers every name pyarrow.compute.list_functions() reports apart from two internal meta-functions, so the list cannot drift as Arrow grows, and adds only the three Arrow names pyarrow exposes as Python wrappers;
  2. asserts every row is well formed — a status from the vocabulary below, a note, and, for a row that claims to work, a call, an example input and a named source file;
  3. runs every gpu / cpu / partial row through arrowmetal.functions.call_function and compares the result to pyarrow.compute, value for value, with a second input in a different Arrow type family for the rows whose claim spans several. Float comparisons use the tolerance recorded for that row in arrowmetal.functions.TOLERANCE; where an answer legitimately differs from Arrow's, the row carries an oracle that checks the property Arrow specifies, and the note says what the difference is;
  4. asserts that a missing row raises rather than quietly doing something.

So a status here is a measurement, not an intention. Nothing in this table is reachable-in-principle: if it says gpu, cpu or partial, a test in python/tests/test_functions.py calls it.

What the statuses mean

Status Meaning
GPU A Metal kernel does the work. Host code sets up buffers and reads the answer back, nothing more.
CPU Implemented and reachable through the ArrowMetal API, but the work happens on the host. Every row here says why the host is the right place — a Unicode table, an ICU regex, an output one row wide however long the input, or a metadata-only result that runs no kernel.
Partial Reachable, with a stated limitation. Three different things wear this label and each note says which: (a) an option or an input type Arrow supports and this does not; (b) an answer that deliberately differs from Arrow's — tdigest and hash_tdigest return one q where Arrow returns a list; (c) an evaluation split between the GPU and the host: rank_normal, whose inverse CDF runs on the host, and hash_tdigest, whose centroid merge does.
Missing Not implemented. The note says why.
Pending Reserved; no row carries it.

Precision is recorded, not glossed. Three families of float difference exist and the note on each row names the one that applies. float32 columns run Metal's own library functions, measured at 3-4 ulp against the host libm for the trigonometric and hyperbolic families (expm1 is the outlier, ~4 ulp for small |x| and ~16 at |x| ~ 20, where it inherits MSL exp). float64 columns run software binary64 on the GPU rather than a widened float: sqrt and the four arithmetic operations are correctly rounded, exp / ln / log2 / log10 / power measure 1 ulp over 10^6 inputs each against a 2-ulp bound the tests assert, and the trigonometric family, expm1, log1p, logb and hypot are within 5 ulp over 10^6 random arguments each (the trigonometric assertion is 6 ulp); sin/cos/tan degrade to 7 ulp at |x| ~ 5e13. The grouped moments (hash_variance, hash_stddev, hash_skew, hash_kurtosis) form their deviations about the group mean in binary64 too — worst relative error 1.9e-16 on the adversarial case in docs/DESIGN.md.

Regenerating this file

No Makefile, one command:

PYTHONPATH=python python python/tests/function_table_report.py --page > docs/ARROW_FUNCTIONS.md

Run it from the repository root, with a Python that has pyarrow installed and the ArrowMetal dylib built (DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift build -c release --product ArrowMetalC). Check the numbers first with:

PYTHONPATH=python python -m pytest python/tests/test_functions.py -q

The last section, the differential matrix's cases by section, is read from the matrix's recorded run (docs/data/differential_coverage.json). PYTHONPATH=python python python/tests/coverage_report.py runs the matrix, records it and rewrites this page together with the per-function table in Coverage summary.

Summary by section

Section GPU CPU Partial Missing Pending Rows
Aggregations 16 7 1 0 0 24
Arithmetic 21 0 0 3 0 24
Sorts 8 0 2 1 0 11
Bitwise 8 0 0 0 0 8
Rounding 6 0 0 0 0 6
Logarithmic 10 0 0 0 0 10
Trigonometric 20 0 0 0 0 20
Comparisons 8 0 0 0 0 8
Logical 8 0 0 0 0 8
StringPredicates 19 0 0 0 0 19
StringTransforms 18 2 1 0 0 21
StringPadding 7 0 0 0 0 7
StringTrimming 12 0 0 0 0 12
StringSplitting 3 1 0 0 0 4
StringExtraction 0 2 0 0 0 2
StringJoining 2 0 0 0 0 2
StringSlicing 2 0 0 0 0 2
Containment 8 3 0 0 0 11
Categorizations 6 1 0 0 0 7
Selecting 4 0 0 0 0 4
Conversions 6 0 0 0 0 6
TemporalExtraction 24 0 0 0 0 24
TemporalDifference 11 0 2 0 0 13
Timezone 2 0 0 0 0 2
Random 1 0 0 0 0 1
Associative 4 0 0 0 0 4
Selections 7 0 0 0 0 7
NullFilling 3 0 0 0 0 3
Structural 8 1 0 0 0 9
Pairwise 2 0 0 0 0 2
Cumulative 7 0 0 0 0 7
GroupedAggregations 23 0 1 0 0 24
Total 284 17 7 4 0 312

Totals: 284 gpu, 17 cpu, 7 partial, 4 missing, 0 pending over 312 Arrow function names.

Every Arrow function name

Arrow function Section Status ArrowMetal call Implementation Notes
all Aggregations CPU all() Kernels/Aggregates.swift A word-wise host scan of validity & ~values that stops at the first valid false, so the usual answer costs one load and no dispatch; a large column with no answer in its first mebibit falls back to the counting kernel. Null-only input gives None, as Arrow does.
any Aggregations CPU any() Kernels/Aggregates.swift The mirror of all: a host scan of values & validity that stops at the first true.
approximate_median Aggregations GPU median() Kernels/Aggregates.swift Exact, not approximate: a GPU sort and an interpolated read, not a sketch. Answers are therefore at least as good as Arrow's.
count Aggregations CPU count(mode) Sources/ArrowMetal/MetalArray.swift O(1) metadata: the null count already rides on the column. mode accepts only_valid / only_null / all.
count_all Aggregations CPU count_all() Kernels/Selection.swift The row count, valid or not. O(1) metadata; inside an open batch, reading it forces a sync point.
count_distinct Aggregations GPU count_distinct() Kernels/Unique.swift The length of unique(): one GPU sort and a run scan.
first Aggregations CPU first() Kernels/Aggregates.swift A word-wise host scan of the validity bitmap in shared memory, stopping at the first valid row, then one read of that slot. No dispatch: the cost is the distance to the first valid row, so a mostly-valid column answers in two loads. A column whose first mebibit is all null escalates to the atomic-minimum kernel.
first_last Aggregations GPU first_last() Kernels/Selection.swift first() and last() packaged as the one-row struct Arrow returns. min_count is not implemented.
index Aggregations GPU index(value) Kernels/Aggregates.swift Row of the first occurrence, -1 when absent. The value crosses the C ABI as a double, so an integer above 2^53 cannot be expressed exactly.
kurtosis Aggregations GPU kurtosis(biased) Kernels/AggregatesExtra.swift Excess kurtosis, biased by default as Arrow's is: two GPU passes, the same per-type deviation machinery variance uses, so about 1e-15 relative on a float64 column.
last Aggregations CPU last() Kernels/Aggregates.swift The same host scan as first, run inwards from the end.
max Aggregations GPU max() Kernels/Reductions.swift Threadgroup partials, host finalise, no atomics.
mean Aggregations GPU mean() Kernels/Reductions.swift The sum kernel over a valid-count, divided on the host.
min Aggregations GPU min() Kernels/Reductions.swift Threadgroup partials, host finalise.
min_max Aggregations GPU min_max() Kernels/Aggregates.swift One kernel produces both. Returned as a (min, max) tuple rather than Arrow's struct scalar.
mode Aggregations GPU mode() Kernels/Aggregates.swift A GPU sort plus a run scan. Returned as (value, count); only the single most common value, not Arrow's top-n list.
pivot_wider Aggregations CPU am.pivot_wider(...) Sources/ArrowMetal/PivotWider.swift One host pass over the key column, then one single-row take per field. The output is one row wide however long the input is, so there is no parallel work worth a kernel.
product Aggregations GPU product() Kernels/Aggregates.swift Integers wrap in 64 bits; a float32 product over thousands of factors reassociates.
quantile Aggregations GPU quantile(q) Kernels/Aggregates.swift Exact, with linear interpolation, from a GPU sort. Arrow's other interpolation modes and its multi-q form are not implemented.
skew Aggregations GPU skew(biased) Kernels/AggregatesExtra.swift The third standardised central moment, biased by default as Arrow's is. Same two GPU passes as kurtosis.
stddev Aggregations GPU stddev(ddof) Kernels/Aggregates.swift Compensated squared deviations on the GPU: about 1e-7 relative for float32, 1e-15 for float64.
sum Aggregations GPU sum() Kernels/Reductions.swift Integers accumulate in int64/uint64 and wrap; float64 uses the software binary64 adder.
tdigest Aggregations Partial tdigest(q) Kernels/AggregatesExtra.swift A GPU sort feeding a host centroid merge (delta 100), so the work is mixed: this is a sketch and agrees with Arrow's to within the sketch's error rather than exactly. Returns one q as a float, not Arrow's list; quantile() is the exact answer and needs no sketch.
variance Aggregations GPU variance(ddof) Kernels/Aggregates.swift Same pass as stddev.
abs Arithmetic GPU abs() Kernels/Rounding.swift One thread per element.
add Arithmetic GPU a + b Kernels/Arithmetic.swift Wrapping on integers, which is Arrow's unchecked add.
divide Arithmetic GPU a / b Kernels/Arithmetic.swift Integer division by zero is undefined here rather than an error; that is divide_checked's job.
exp Arithmetic GPU exp() Kernels/Rounding.swift Software binary64 on a float64 column (Kernels/DoublePower.swift): argument reduction against a 107-bit ln 2 pair, then the exp series. Measured 1 ulp against Foundation over 10^6 inputs spanning -745.2 to 709.78, subnormal results and the overflow edge included.
multiply Arithmetic GPU a * b Kernels/Arithmetic.swift Wrapping on integers.
negate Arithmetic GPU negate() Kernels/Rounding.swift Wrapping on integers.
power Arithmetic GPU power(other) Kernels/Rounding.swift Element-wise pow, scalar or column exponent. Integers use repeated squaring and wrap. float64 runs in software binary64 (Kernels/DoublePower.swift): log2 of the base as an unevaluated hi/lo pair against a split exponent, so the 61 bits the product needs survive. Measured 1 ulp over 10^6 random pairs, and the C99 edge table (x^0, 0^y, 1^y, (-1)^int, inf/NaN) matches libm exactly.
sign Arithmetic GPU sign() Kernels/Rounding.swift -1 / 0 / 1.
sqrt Arithmetic GPU sqrt() Kernels/Rounding.swift A negative input gives NaN, as Arrow's unchecked sqrt does. On float64 this is DoubleMath.d_sqrt, an rsqrt seed refined by Newton steps with an exact remainder settling the last bit, and therefore correctly rounded — bit-identical to Foundation over 10^6 random bit patterns, subnormals included.
subtract Arithmetic GPU a - b Kernels/Arithmetic.swift Wrapping on integers.
abs_checked Arithmetic GPU abs_checked() Kernels/Checked.swift The unchecked kernel plus a read-only check pass in the same command buffer, so a checked op costs one GPU round trip. Raises only for INT_MIN on a signed integer column.
add_checked Arithmetic GPU add_checked(other) Kernels/Checked.swift Wrapping is an error rather than a result: an ArrowMetalError naming the Arrow message and the first offending row. Float columns never raise, as in Arrow.
divide_checked Arithmetic GPU divide_checked(other) Kernels/Checked.swift Raises divide by zero for a zero divisor on any type, and overflow for INT_MIN / -1.
multiply_checked Arithmetic GPU multiply_checked(other) Kernels/Checked.swift As add_checked.
remainder Arithmetic GPU modulo(other) Kernels/Rounding.swift Arrow's remainder (26.0.0): the C %, truncated division with the sign of the dividend, which is what modulo() / __mod__ have always computed here. Deliberate difference: Arrow raises divide by zero for an integer zero divisor, here x % 0 is 0.
remainder_checked Arithmetic Missing - - Arrow's checked remainder (26.0.0), which raises on a zero divisor; no checked remainder kernel yet, modulo() defines x % 0 as 0.
modulo Arithmetic Missing - - Arrow's modulo (26.0.0) is Python's %: floored division, the sign of the divisor. Not implemented; modulo() here is the C remainder, Arrow's remainder.
modulo_checked Arithmetic Missing - - Arrow's checked floored modulo (26.0.0). Not implemented.
negate_checked Arithmetic GPU negate_checked() Kernels/Checked.swift Raises for INT_MIN on a signed column and — unlike pyarrow, which has no unsigned kernel at all — for every non-zero value on an unsigned one.
power_checked Arithmetic GPU power_checked(other) Kernels/Checked.swift Raises for a negative integer exponent and for any repeated-squaring step that would wrap. A float column never raises, as in Arrow, and is bit-identical to the unchecked power — so on float64 it is the same 1-ulp software binary64 answer.
sqrt_checked Arithmetic GPU sqrt_checked() Kernels/Checked.swift Raises square root of negative number; NaN, -0.0 and +inf do not raise. Bit-identical to the unchecked sqrt, and so correctly rounded on a float64 column too.
subtract_checked Arithmetic GPU subtract_checked(other) Kernels/Checked.swift As add_checked.
expm1 Arithmetic GPU expm1() Kernels/MathExtra.swift exp(x) - 1, accurate for small x, through the software binary64 routine on a float64 column (measured <= 2 ulp against the host libm over 10^6 inputs). Float columns only, as in Arrow.
hypot Arithmetic GPU hypot(other) Kernels/MathExtra.swift sqrt(x^2 + y^2), scaled so a large or tiny pair neither overflows nor underflows on the way. An infinite operand gives inf even opposite a NaN, as IEEE-754 prescribes.
search_sorted Sorts Missing - - Arrow's search_sorted (26.0.0): the insertion index of each needle in a sorted column, side left or right. Not implemented.
array_sort_indices Sorts GPU array_sort_indices(descending, null_placement, float_order) Kernels/Sort.swift LSD radix sort, stable. Floats order as pyarrow orders them (-0.0 ties +0.0, NaN next to the nulls in both directions); float_order="total" is IEEE 754 totalOrder, arrow-rs's order, and float_order="nan_largest" NaN above +inf in both directions, Polars' order, through the same passes. utf8 and binary columns take the prefix radix sort in Kernels/StringSort.swift, byte-wise lexicographic, index for index with pyarrow. Both of Arrow's null_placement values are implemented, in both directions: the nulls are one block at whichever end, moved there by a stable partition of the index array.
sort_indices Sorts Partial sort_indices() / am.lexsort_indices(cols) Kernels/MultiSort.swift Single key through the radix argsort; multiple keys through lexsort_indices, which is successive stable argsorts from the least significant key upwards. Both null_placement values are implemented, and apply to every key as Arrow's do; lexsort_indices also takes null_placement and float_order per key. utf8 and binary keys sort through Kernels/StringSort.swift; dictionary and nested key columns are still not sortable, which is what keeps this row partial.
partition_nth_indices Sorts GPU partition_nth_indices(pivot, null_placement) Kernels/PartitionNth.swift A real selection, not a sort: an MSB-first GPU radix select finds the pivot value in a fixed four (32-bit keys) or eight (64-bit) histogram passes, and three GPU stream compactions split the row indices around it. O(length). Both null_placement values are implemented. The permutation is not the sorted one, and Arrow does not promise it is — only the partition property, which the check below verifies.
select_k_unstable Sorts GPU select_k_unstable(k, largest) Kernels/TopK.swift For k <= 1024 each threadgroup keeps the best k of its own block and one radix sort orders the survivors; larger k falls back to the full sort. Single key.
top_k_unstable Sorts GPU top_k_unstable(k) Kernels/TopK.swift select_k_unstable with the descending order.
bottom_k_unstable Sorts GPU bottom_k_unstable(k) Kernels/TopK.swift select_k_unstable with the ascending order.
rank Sorts GPU rank(sort_keys, null_placement, tiebreaker) Kernels/Window.swift One argsort, run marks, a scan and a scatter back to the original rows, with Arrow's whole option surface: the sort direction, both null_placement values and all four tiebreakers (min, max, first, dense, which are also spelled rank(), max_rank(), row_number() and dense_rank()). The result never contains nulls.
rank_quantile Sorts GPU rank_quantile(sort_keys, null_placement) Kernels/Selection.swift (average 1-based rank of the tie group - 0.5) / n, computed on the GPU as (s + e) / (2n) over the run's sorted positions with the correctly rounded software binary64 divide. Arrow's sort_keys direction and both null_placement values are implemented; the nulls are one tie group at whichever end.
rank_normal Sorts Partial rank_normal(sort_keys, null_placement, float32=False) Kernels/Selection.swift The normal percent-point function of rank_quantile, with the same sort_keys and null_placement options. float64 evaluates the inverse CDF on the host with Wichura's AS 241 (about 1e-16 relative) because Metal has no double and the software binary64 has no log/exp/erfc — that host step is what keeps this row partial; float32=True runs Acklam plus one Halley refinement entirely on the GPU, within about 1e-6.
winsorize Sorts GPU winsorize(lower_limit, upper_limit) Kernels/Selection.swift One GPU sort for the two nearest quantiles, then a clamp kernel. Nulls stay null and NaNs pass through, taking part in neither the limits nor the comparison.
bit_wise_and Bitwise GPU bitwise_and(other) Kernels/Bitwise.swift One thread per element.
bit_wise_not Bitwise GPU bitwise_not() Kernels/Bitwise.swift One thread per element.
bit_wise_or Bitwise GPU bitwise_or(other) Kernels/Bitwise.swift One thread per element.
bit_wise_xor Bitwise GPU bitwise_xor(other) Kernels/Bitwise.swift One thread per element.
shift_left Bitwise GPU shift_left(other) Kernels/Bitwise.swift A shift at or past the width is undefined here rather than an error.
shift_right Bitwise GPU shift_right(other) Kernels/Bitwise.swift Arithmetic for signed types, logical for unsigned.
shift_left_checked Bitwise GPU shift_left_checked(other) Kernels/Checked.swift Raises when the shift amount is negative or at least the precision of the type (the bit width, less one on a signed column). Bits shifted off the top are not an error, as in Arrow.
shift_right_checked Bitwise GPU shift_right_checked(other) Kernels/Checked.swift The same amount check as shift_left_checked.
ceil Rounding GPU ceil() Kernels/Rounding.swift One thread per element.
floor Rounding GPU floor() Kernels/Rounding.swift One thread per element.
trunc Rounding GPU trunc() Kernels/Rounding.swift One thread per element.
round Rounding GPU round(ndigits, mode) Kernels/MathExtra.swift All ten Arrow round modes and any ndigits, evaluated as round_int(x * 10^ndigits) / 10^ndigits. round() with no argument keeps its historical meaning (halves away from zero); passing either option selects Arrow's kernel, whose defaults are ndigits=0 and half_to_even.
round_binary Rounding GPU round_binary(ndigits, mode) Kernels/MathExtra.swift round with one ndigits per row, from an int32 column. Null wherever either column is.
round_to_multiple Rounding GPU round_to_multiple(multiple, mode) Kernels/MathExtra.swift round_int(x / multiple) * multiple, for any positive scalar multiple and any Arrow round mode.
ln Logarithmic GPU ln() Kernels/Rounding.swift Metal's log on float32; on float64 the software binary64 of Kernels/DoublePower.swift, which carries log2(x) as an unevaluated hi/lo pair and scales it by a split ln 2 so the leading product is exact. Measured 1 ulp against Foundation over 10^6 inputs from 5e-324 to 1.8e308, plus passes concentrated near 1 and over the subnormals.
log10 Logarithmic GPU log10() Kernels/Rounding.swift Metal's log10 on float32; on float64 the same software binary64 reduction as ln, scaled by a split log10 2 instead. Measured 1 ulp over 10^6 inputs.
log2 Logarithmic GPU log2() Kernels/Rounding.swift Metal's log2 on float32; on float64 the software binary64 reduction rounded once, so an exact power of two comes back exactly. Measured 1 ulp over 10^6 inputs.
ln_checked Logarithmic GPU ln_checked() Kernels/Checked.swift Raises logarithm of zero / logarithm of negative number — the boundary is exact, so +5e-324 passes and -5e-324 does not, and NaN and +inf never raise. Bit-identical to the unchecked ln, and so the same 1-ulp software binary64 answer on a float64 column.
log10_checked Logarithmic GPU log10_checked() Kernels/Checked.swift Same domain check and the same 1-ulp software binary64 evaluation as ln_checked.
log2_checked Logarithmic GPU log2_checked() Kernels/Checked.swift Same domain check and the same 1-ulp software binary64 evaluation as ln_checked.
log1p Logarithmic GPU log1p() Kernels/MathExtra.swift ln(1 + x), accurate for small x, through the software binary64 routine — the cancellation ln(1 + x) would suffer near zero is what this one exists to avoid. x == -1 gives -inf and x < -1 gives NaN.
log1p_checked Logarithmic GPU log1p_checked() Kernels/Checked.swift As log1p, raising at the domain boundary: -1 gives logarithm of zero and anything below it logarithm of negative number.
logb Logarithmic GPU logb(base) Kernels/MathExtra.swift ln(x) / ln(base) in software binary64, with a scalar base or a column of bases.
logb_checked Logarithmic GPU logb_checked(base) Kernels/Checked.swift As logb, raising when the value or the base is zero or negative.
acos Trigonometric GPU acos() Kernels/Trig.swift Needs |x| <= 1; outside that the unchecked form gives NaN.
acos_checked Trigonometric GPU acos_checked() Kernels/Trig.swift As acos, raising for |x| > 1 on a non-null row. NaN never raises.
acosh Trigonometric GPU acosh() Kernels/Trig.swift Needs x >= 1.
acosh_checked Trigonometric GPU acosh_checked() Kernels/Trig.swift As acosh, raising for x < 1.
asin Trigonometric GPU asin() Kernels/Trig.swift Needs |x| <= 1.
asin_checked Trigonometric GPU asin_checked() Kernels/Trig.swift As asin, raising for |x| > 1.
asinh Trigonometric GPU asinh() Kernels/Trig.swift Defined on the whole real line, so Arrow publishes no checked twin.
atan Trigonometric GPU atan() Kernels/Trig.swift Whole real line.
atan2 Trigonometric GPU atan2(other) Kernels/Trig.swift The angle of (x, y) in [-pi, pi], following the C99 special-value table including the four +/-0 and four +/-infinity cases. A scalar second argument is broadcast.
atanh Trigonometric GPU atanh() Kernels/Trig.swift Needs |x| < 1.
atanh_checked Trigonometric GPU atanh_checked() Kernels/Trig.swift As atanh, raising for |x| >= 1.
cos Trigonometric GPU cos() Kernels/Trig.swift Whole real line.
cos_checked Trigonometric GPU cos_checked() Kernels/Trig.swift As cos, rejecting +/-infinity.
cosh Trigonometric GPU cosh() Kernels/Trig.swift Written out from a well-conditioned identity rather than Metal's own.
sin Trigonometric GPU sin() Kernels/Trig.swift Whole real line.
sin_checked Trigonometric GPU sin_checked() Kernels/Trig.swift As sin, rejecting +/-infinity.
sinh Trigonometric GPU sinh() Kernels/Trig.swift As cosh.
tan Trigonometric GPU tan() Kernels/Trig.swift Whole real line.
tan_checked Trigonometric GPU tan_checked() Kernels/Trig.swift As tan, rejecting +/-infinity.
tanh Trigonometric GPU tanh() Kernels/Trig.swift As cosh.
equal Comparisons GPU compare('==', other) Kernels/Compare.swift Bit-exact for float32 through an order-preserving integer key; validity bitmaps are ANDed on the GPU.
greater Comparisons GPU compare('>', other) Kernels/Compare.swift One thread per element.
greater_equal Comparisons GPU compare('>=', other) Kernels/Compare.swift One thread per element.
less Comparisons GPU compare('<', other) Kernels/Compare.swift One thread per element.
less_equal Comparisons GPU compare('<=', other) Kernels/Compare.swift One thread per element.
not_equal Comparisons GPU compare('!=', other) Kernels/Compare.swift One thread per element.
max_element_wise Comparisons GPU max_element_wise(other) Kernels/Rounding.swift Arrow's default skip_nulls=True is what this does; skip_nulls=False is not implemented.
min_element_wise Comparisons GPU min_element_wise(other) Kernels/Rounding.swift Same null rule as max_element_wise.
and_ Logical GPU a & b Kernels/Compare.swift Bitmap AND on the GPU, null-propagating (Arrow's and, not and_kleene).
or_ Logical GPU a | b Kernels/Compare.swift Bitmap OR, null-propagating.
invert Logical GPU ~a Kernels/Compare.swift Validity is shared zero-copy with the input.
and_kleene Logical GPU and_kleene(other) Kernels/Structural.swift Three-valued AND: false wins over null.
or_kleene Logical GPU or_kleene(other) Kernels/Structural.swift Three-valued OR: true wins over null.
and_not Logical GPU and_not(other) Kernels/LogicalExtra.swift a AND NOT b, word-wise over the packed bitmaps, one thread per 32-bit output word. Nulls propagate.
and_not_kleene Logical GPU and_not_kleene(other) Kernels/LogicalExtra.swift Three-valued a AND NOT b: a valid false on the left or a valid true on the right gives false even when the other side is null.
xor Logical GPU a ^ b Kernels/LogicalExtra.swift Word-wise bitmap XOR; the output validity is the AND of the inputs'.
ascii_is_alnum StringPredicates GPU is_alnum() Kernels/StringTransforms.swift One thread per row over the bytes. An empty string is false, as in Arrow.
ascii_is_alpha StringPredicates GPU is_alpha() Kernels/StringTransforms.swift One thread per row.
ascii_is_decimal StringPredicates GPU is_digit() Kernels/StringTransforms.swift One thread per row; 0-9 only, which is exactly ascii_is_decimal.
ascii_is_lower StringPredicates GPU is_lower() Kernels/StringTransforms.swift Needs at least one cased character and none of the opposite case; non-ASCII bytes count as uncased.
ascii_is_space StringPredicates GPU is_space() Kernels/StringTransforms.swift Space and \t-\r.
ascii_is_upper StringPredicates GPU is_upper() Kernels/StringTransforms.swift Mirror of ascii_is_lower.
ascii_is_printable StringPredicates GPU ascii_is_printable() Kernels/StringExtra.swift Every byte in 0x20-0x7E. The empty string is true, and every other predicate here is false on it. One thread per row.
ascii_is_title StringPredicates GPU ascii_is_title() Kernels/StringExtra.swift Byte-wise title case over runs of ASCII letters; needs at least one letter.
string_is_ascii StringPredicates GPU string_is_ascii() Kernels/StringExtra.swift Every byte < 0x80. The empty string is true.
utf8_is_alnum StringPredicates GPU utf8_is_alnum() Kernels/StringExtra.swift Non-empty and every code point a letter or a number. GPU for the rows whose bytes are all < 0x80; the rows with a byte >= 0x80 are re-decided on the host, sharded over 4096-row chunks, so an ASCII-only column never leaves the device.
utf8_is_alpha StringPredicates GPU utf8_is_alpha() Kernels/StringExtra.swift Non-empty and every code point in an L* category. Same per-row GPU/host split as utf8_is_alnum.
utf8_is_decimal StringPredicates GPU utf8_is_decimal() Kernels/StringExtra.swift Non-empty and every code point category Nd. Same GPU/host split.
utf8_is_digit StringPredicates GPU utf8_is_digit() Kernels/StringExtra.swift Non-empty and every code point category Nd or No. Same GPU/host split.
utf8_is_lower StringPredicates GPU utf8_is_lower() Kernels/StringExtra.swift At least one cased code point and no upper-case one. Same GPU/host split.
utf8_is_numeric StringPredicates GPU utf8_is_numeric() Kernels/StringExtra.swift Non-empty and every code point category Nd, Nl or No. Same GPU/host split.
utf8_is_printable StringPredicates GPU utf8_is_printable() Kernels/StringExtra.swift No Cc/Cf/Cs/Co/Cn/Zs/Zl/Zp code point, except U+0020; the empty string is true. Same GPU/host split.
utf8_is_space StringPredicates GPU utf8_is_space() Kernels/StringExtra.swift Non-empty and every code point Unicode whitespace (U+200B deliberately is not). Same GPU/host split.
utf8_is_title StringPredicates GPU utf8_is_title() Kernels/StringExtra.swift At least one cased code point, in title case. Same GPU/host split.
utf8_is_upper StringPredicates GPU utf8_is_upper() Kernels/StringExtra.swift At least one cased code point and no lower-case one. Same GPU/host split.
ascii_capitalize StringTransforms GPU capitalize() Kernels/StringTransforms.swift First byte upper-cased, the rest lower-cased, ASCII only.
ascii_lower StringTransforms GPU ascii_lower() Kernels/StringTransforms.swift Byte-wise A-Z to a-z; every other byte is copied through, so the output stays valid UTF-8.
ascii_upper StringTransforms GPU ascii_upper() Kernels/StringTransforms.swift Byte-wise a-z to A-Z.
ascii_swapcase StringTransforms GPU swapcase() Kernels/StringTransforms.swift Byte-wise ASCII case flip.
ascii_reverse StringTransforms GPU ascii_reverse() Kernels/StringBytes.swift Reverses the bytes, which on ASCII input is what ascii_reverse means. Non-ASCII input is refused with non-ASCII sequence in input, exactly as pyarrow refuses it rather than emitting invalid UTF-8.
utf8_reverse StringTransforms GPU str_reverse() Kernels/StringTransforms.swift Reverses code points, walking the UTF-8 lead bytes.
binary_reverse StringTransforms GPU binary_reverse() Kernels/StringBytes.swift Reverses the bytes always, so non-ASCII utf8 content comes back as the invalid UTF-8 Arrow produces — which is why the result is typed binary. Accepts binary and large_binary as well as utf8.
binary_length StringTransforms GPU byte_length() Sources/ArrowMetal/MetalStringArray.swift The offsets difference; no data read at all. Takes binary / large_binary as well as utf8, as Arrow's does.
utf8_length StringTransforms GPU char_length() Sources/ArrowMetal/MetalStringArray.swift Counts non-continuation bytes.
binary_repeat StringTransforms Partial repeat(n) Kernels/StringTransforms.swift Two-pass on the GPU: a length kernel, a scan into offsets, a byte kernel. binary and large_binary are accepted alongside utf8. One limit remains: n is one scalar for the whole column, where Arrow also takes a per-row num_repeats array.
replace_substring StringTransforms GPU replace(pattern, replacement, max_replacements) Kernels/StringTransforms.swift Literal replacement on the GPU, two-pass.
replace_substring_regex StringTransforms CPU replace_substring_regex(pattern, replacement) Kernels/Regex.swift The regex engine is a backtracker on the host; a literal pattern is routed to the GPU kernel instead, and a pattern with a literal core gets the GPU pre-filter — a row that cannot match comes back unchanged without reaching ICU.
utf8_lower StringTransforms GPU lower() Kernels/StringUnicode.swift Unicode's simple (1:1) lower-case mapping over every script, split per row: the GPU table is exact over U+0000-U+017F and any row above that block is mapped on the host, sharded over 4096-row chunks — non-Latin rows on the CPU. İ lower-cases to i alone and Σ to σ in every position, which is what utf8proc (and so Arrow) does.
utf8_upper StringTransforms GPU upper() Kernels/StringUnicode.swift The same per-row split going the other way. ß upper-cases to ẞ and µ to Μ on the GPU; fi and ʼn pass through, because their full mappings are two characters long and the simple mapping Arrow uses leaves them alone.
utf8_swapcase StringTransforms GPU utf8_swapcase() Kernels/StringUnicode.swift The same per-row split. A titlecase letter is both upper and lower case for Arrow and so stays put: Dž swaps to Dž, while DŽ swaps to dž and dž to DŽ.
ascii_title StringTransforms GPU ascii_title() Kernels/StringExtra.swift Byte-wise: the first ASCII letter of every run of ASCII letters is upper-cased and the rest lower-cased. Bytes >= 0x80 are copied through and end a word, so "ünïcödé" becomes "üNïCöDé" — exactly what Arrow does.
utf8_capitalize StringTransforms GPU utf8_capitalize() Kernels/StringUnicode.swift First code point upper-cased, every later one lower-cased, through Unicode's simple 1:1 mappings as utf8proc uses. Same per-row split as utf8_upper: non-Latin rows on the CPU.
utf8_title StringTransforms GPU utf8_title() Kernels/StringUnicode.swift The first cased code point of every word upper-cased and the rest lower-cased, a word being a maximal run of cased code points. Same per-row GPU/host split: non-Latin rows on the CPU.
utf8_normalize StringTransforms CPU utf8_normalize(form) Kernels/StringExtra.swift NFC / NFKC / NFD / NFKD through Foundation, on the host: a full Unicode normalisation table in MSL buys nothing over it. Deliberate difference: pyarrow 25 never composes, so its NFC equals its NFD and its NFKC equals its NFKD; this follows the Unicode standard and agrees with Python's unicodedata.normalize on all four forms. The check below uses NFD, where the two agree.
utf8_replace_slice StringTransforms GPU utf8_replace_slice(start, stop, replacement) Kernels/StringExtra.swift Replaces the code points in [start, stop). Negative indices count from the end, both ends clamp, and a stop below start inserts without deleting. The cut always lands on a code-point boundary.
binary_replace_slice StringTransforms GPU binary_replace_slice(start, stop, replacement) Kernels/StringExtra.swift The same substitution indexed in bytes, which can split a UTF-8 sequence and is why the result is binary rather than utf8.
ascii_lpad StringPadding GPU ascii_lpad(width, padding) Kernels/StringBytes.swift width counts bytes, which is the whole difference between ascii_lpad and utf8_lpad: "héllo" is six bytes but five code points, so the ASCII form pads it twice to width 8 and the utf8 form three times. pad must be one byte; a value already at or over width is returned unchanged.
ascii_rpad StringPadding GPU ascii_rpad(width, padding) Kernels/StringBytes.swift Mirror of ascii_lpad, counting bytes.
utf8_lpad StringPadding GPU pad_left(width, pad) Kernels/StringTransforms.swift The code point form: width counts characters, so an accent costs one, not two.
utf8_rpad StringPadding GPU pad_right(width, pad) Kernels/StringTransforms.swift Mirror of utf8_lpad.
utf8_zero_fill StringPadding GPU utf8_zero_fill(width, padding) Kernels/StringExtra.swift Left-pads to width code points, inserting the padding after a leading + or -. The content need not be numeric.
ascii_center StringPadding GPU ascii_center(width, padding) Kernels/StringBytes.swift Pads on both sides to width bytes, the odd pad byte on the right — the byte-counting twin of utf8_center.
utf8_center StringPadding GPU utf8_center(width, padding) Kernels/StringExtra.swift Pads on both sides to width code points, the odd pad character going on the right ("a" centred in 4 is "*a**"). Strings already at or over width come back unchanged.
ascii_trim_whitespace StringTrimming GPU trim() Kernels/StringTransforms.swift Space and \t-\r from both ends.
ascii_ltrim_whitespace StringTrimming GPU ltrim() Kernels/StringTransforms.swift Leading ASCII whitespace.
ascii_rtrim_whitespace StringTrimming GPU rtrim() Kernels/StringTransforms.swift Trailing ASCII whitespace.
ascii_trim StringTrimming GPU trim(characters) Kernels/StringTransforms.swift Trims any byte in characters from both ends.
ascii_ltrim StringTrimming GPU ltrim(characters) Kernels/StringTransforms.swift Leading bytes only.
ascii_rtrim StringTrimming GPU rtrim(characters) Kernels/StringTransforms.swift Trailing bytes only.
utf8_trim_whitespace StringTrimming GPU utf8_trim() Kernels/StringUnicode.swift Strips the full Unicode whitespace class from both ends (Zs/Zl/Zp plus U+0009-U+000D, U+001C-U+001F and U+0085; U+200B deliberately is not whitespace). Split per row: the GPU takes the rows whose bytes are all < 0x80, where that class is a ten-byte set, and the host takes the rows with a byte >= 0x80 — non-Latin rows on the CPU.
utf8_ltrim_whitespace StringTrimming GPU utf8_ltrim() Kernels/StringUnicode.swift Leading-only form of utf8_trim_whitespace, with the same per-row GPU/host split.
utf8_rtrim_whitespace StringTrimming GPU utf8_rtrim() Kernels/StringUnicode.swift Trailing-only form, with the same per-row split.
utf8_trim StringTrimming GPU utf8_trim(characters) Kernels/StringUnicode.swift Strips leading and trailing code points that appear in characters. An all-ASCII set never leaves the GPU at all — byte-wise trimming cannot split a UTF-8 sequence, since every continuation byte is >= 0x80. A set with a non-ASCII character splits per row: the GPU trims the all-ASCII rows with the set's ASCII part, the host trims the rest.
utf8_ltrim StringTrimming GPU utf8_ltrim(characters) Kernels/StringUnicode.swift Leading-only form of utf8_trim, with the same per-row GPU/host split.
utf8_rtrim StringTrimming GPU utf8_rtrim(characters) Kernels/StringUnicode.swift Trailing-only form, with the same per-row split.
ascii_split_whitespace StringSplitting GPU split_whitespace(unicode=False, max_splits, reverse) Kernels/StringSplit.swift Splits at every maximal run of ASCII whitespace (the space and \t-\r), on the GPU: three passes and two scans — count the pieces of every row, measure them, copy the bytes — returning a real list<utf8>. Every separator makes a boundary, so a leading or trailing run leaves an empty end piece and the empty string splits to one empty piece, as Arrow's does. max_splits and reverse are implemented; the (offsets, values) pair is still available as split_whitespace_pair.
utf8_split_whitespace StringSplitting GPU split_whitespace(unicode=True, max_splits, reverse) Kernels/StringSplit.swift The same kernel over the full Unicode whitespace class — Zs/Zl/Zp plus U+0009-U+000D, U+001C-U+001F and U+0085, and not U+200B — which is small enough to spell out in MSL, so this stays on the GPU too. Deliberate difference: with reverse and a finite max_splits, pyarrow (25.0.1 and 26.0.0) fails to merge a multi-byte whitespace character with the whitespace beside it (" \u3000x" split once in reverse gives it [" ", "x"]); the runs here stay maximal in both directions.
split_pattern StringSplitting GPU split_pattern(pattern, max_splits, reverse) Kernels/StringSplit.swift The same three-pass GPU splitter with a literal byte separator, returning a list<utf8>. max_splits and reverse are implemented; an empty pattern is refused, as in Arrow.
split_pattern_regex StringSplitting CPU split_pattern(pattern, regex=True, max_splits) Kernels/Regex.swift The host backtracking engine, returning the same list<utf8>. max_splits is implemented; reverse is refused, exactly as Arrow refuses it ("cannot split in reverse with regex").
extract_regex StringExtraction CPU extract_regex_struct(pattern) Kernels/StringStructs.swift A struct column with one utf8 field per named capture group, which is Arrow's own shape; the dictionary-of-columns form stays available as extract_regex. A row that does not match and a null row are both a null struct. ICU decides the match on the host, but the GPU literal pre-filter keeps the rows that cannot match away from it. RE2's (?P<name>...) spelling, which pyarrow uses, is rewritten to ICU's (?<name>...) by the Python wrapper.
extract_regex_span StringExtraction CPU extract_regex_span_struct(pattern) Kernels/StringStructs.swift The same struct, each field a fixed_size_list<int32>[2] holding that group's (start, length) in bytes — exactly pyarrow's type. A row that does not match and a null row are a null struct; a group that took part in no alternative is a null list. Host-side (ICU, sharded over 4096-row chunks) behind the same GPU pre-filter. The {group: (start, length)} dictionary form stays available as extract_regex_span.
binary_join StringJoining GPU binary_join(separator) Kernels/StringContainment.swift Joins the child strings of every row of a list<utf8>, two-pass on the GPU. The separator is a scalar or a per-row column. An empty row joins to the empty string; a null row, a null element inside a row and a null separator all give a null output row.
binary_join_element_wise StringJoining GPU binary_join_element_wise(columns, separator, null_handling, null_replacement) Kernels/StringBytes.swift N columns and a scalar separator, GPU throughout: a left fold of one two-pass join step, so N columns cost N-1 passes and the data never leaves the device. All three of Arrow's null_handling modes are implemented — emit_null, skip (a null column contributes nothing, not even its separator, the fold leaves the accumulator null until something has been joined) and replace. Deliberate difference: under skip, a row whose columns are all null joins to the empty string here, where pyarrow (25.0.1 and 26.0.0) drops the row from its output entirely and returns an array shorter than its input.
utf8_slice_codeunits StringSlicing GPU slice_codeunits(start, stop, step) Kernels/StringBytes.swift Code-point slicing with Python's slice rules, two-pass on the GPU: a negative index counts from the end, both ends clamp, and a negative step walks backwards — which the byte kernel does in one forward walk by filling the output from its end. The cut always lands on a code point boundary.
binary_slice StringSlicing GPU binary_slice(start, stop, step) Kernels/StringBytes.swift The same slice indexed in bytes, which can split a UTF-8 sequence and is why Arrow types the result binary and refuses a string input. Arrow's default stop is INT64_MAX; with a negative step pyarrow 25.0.1 overflowed on it and read out of bounds, and 26.0.0 walks to the beginning of the value, which is what an absent stop has always meant here.
count_substring Containment GPU count_substring(pattern) Kernels/StringTransforms.swift Byte-wise search, one thread per row.
find_substring Containment GPU find_substring(pattern) Kernels/StringTransforms.swift First byte offset, -1 when absent.
match_substring Containment GPU str_contains(pattern) Sources/ArrowMetal/MetalStringArray.swift Byte-wise containment. ignore_case is not implemented.
starts_with Containment GPU starts_with(pattern) Sources/ArrowMetal/MetalStringArray.swift Byte-wise prefix test.
ends_with Containment GPU ends_with(pattern) Sources/ArrowMetal/MetalStringArray.swift Byte-wise suffix test.
count_substring_regex Containment CPU count_substring_regex(pattern) Kernels/Regex.swift Host engine; a literal pattern routes to the GPU counter instead, and a pattern with a literal core gets the GPU pre-filter — the rows that cannot contain that literal answer 0 without reaching ICU.
find_substring_regex Containment CPU find_substring_regex(pattern) Kernels/Regex.swift Host engine, behind the same GPU literal pre-filter; a pre-filtered row answers -1.
match_substring_regex Containment CPU match_substring_regex(pattern) Kernels/Regex.swift Host engine; literal and ^literal patterns route to the GPU kernels outright, and a pattern with a literal core gets the GPU pre-filter described in Kernels/StringLike.swift.
match_like Containment GPU match_like(pattern) Kernels/StringLike.swift SQL LIKE for every case-sensitive pattern on the GPU. A pure prefix / suffix / contains / equality pattern takes the byte-wise startsWith / endsWith / contains / equals kernel; anything else — a _ anywhere, an interior %, any mixture — is compiled into a small byte program and matched by one thread per row, where _ and % count code points. Only ignore_case still goes to ICU on the host.
is_in Containment GPU is_in(value_set, null_matching_behavior) Kernels/SetLookup.swift GPU throughout: a sorted set plus a binary search per row for primitive and temporal columns, the GPU string hash table for utf8 and binary ones. All four of Arrow's null_matching_behavior values are implemented — match, skip, emit_null and inconclusive — as a rewrite of the validity bitmap over the kernel's own skip answer, since the four differ only in what a null row reports. ArrowMetal defaults to skip; pyarrow 25 exposes only skip_nulls (default False, i.e. match), so the check below passes match.
index_in Containment GPU index_in(value_set, null_matching_behavior) Kernels/SetLookup.swift The same two paths and the same four behaviours, returning the int32 position of the first occurrence in the set and null where the element is absent. Only match differs from the other three for index_in: it reports the position of the value set's first null for a null element.
is_null Categorizations GPU is_null() Kernels/Structural.swift The validity bitmap inverted on the GPU. Arrow's nan_is_null is not implemented.
is_valid Categorizations GPU is_valid() Kernels/Structural.swift The validity bitmap copied out as a boolean column.
true_unless_null Categorizations CPU true_unless_null() Kernels/Selection.swift A host memset of the values bitmap; the validity bitmap is shared with the input with no copy, so no kernel runs. Union and run-end encoded columns are refused.
indices_nonzero Categorizations GPU indices_nonzero() Kernels/Conditional.swift iota through the existing stream compaction: the uint64 row numbers where the value is valid and not zero. -0.0 counts as zero and every NaN as non-zero, as in Arrow.
is_finite Categorizations GPU is_finite() Kernels/FloatClass.swift A raw bit-pattern test, one thread per 32-bit output word, so float64 needs no software binary64. True everywhere on an integer column, and null where the input is null.
is_inf Categorizations GPU is_inf() Kernels/FloatClass.swift As is_finite; false everywhere on an integer column.
is_nan Categorizations GPU is_nan() Kernels/FloatClass.swift As is_finite; false everywhere on an integer column.
if_else Selecting GPU if_else(left, right) Kernels/Structural.swift One thread per element; a null condition gives a null output.
coalesce Selecting GPU am.coalesce(*arrays) Kernels/Structural.swift First non-null across the inputs, one thread per element.
case_when Selecting GPU am.case_when(conds, values, default) Kernels/Conditional.swift A fold of the existing if_else kernel, one GPU pass per branch. A null condition counts as false and the row falls through, as in Arrow. ArrowMetal takes the conditions as a list of boolean columns where Arrow takes one struct column of them.
choose Selecting GPU am.choose(indices, values) Kernels/Conditional.swift values[indices[i]][i], element-wise, as the same fold. A null index gives a null output and an index outside [0, len(values)) raises, as in Arrow.
cast Conversions GPU cast(target, safe=..., allow_*=...) Sources/ArrowMetal/CastDispatch.swift One entry point for every supported target, taking Arrow's whole CastOptions. Numeric to numeric, bool to and from numeric, numeric and temporal to utf8 and utf8 back to numeric, temporal resolution changes and the date/timestamp conversions, integer to and from decimal128 and a decimal rescale, and list<T> -> list<U> and struct casts that cast the children and share the offsets and bitmaps. safe=True adds one read-only GPU pass that converts each value back and raises on the first row that does not round-trip, which is exactly the set of losses Arrow objects to; each allow_* flag turns one class back off. ArrowMetal defaults to safe=False, where pyarrow defaults to safe=True. Two things remain: with safe=False an out-of-range float -> integer value saturates at 64 bits and truncates where Arrow saturates at the target's width (C leaves it undefined; safe=True refuses the row rather than choosing), and dictionary, union, run-end and interval targets, and utf8 -> temporal (that is strptime), are refused.
ceil_temporal Conversions GPU ceil_temporal(unit, multiple, week_starts_monday, ceil_is_strictly_greater, calendar_based_origin) Kernels/TemporalMath.swift As round_temporal, with the whole RoundTemporalOptions surface. A value already on a boundary is left alone unless ceil_is_strictly_greater — except on month, quarter and year, where Arrow's own ceil always advances a boundary value and the flag makes no difference; that quirk is reproduced deliberately.
floor_temporal Conversions GPU floor_temporal(unit, multiple, week_starts_monday, ceil_is_strictly_greater, calendar_based_origin) Kernels/TemporalMath.swift As ceil_temporal.
round_temporal Conversions GPU round_temporal(unit, multiple, week_starts_monday, ceil_is_strictly_greater, calendar_based_origin) Kernels/TemporalMath.swift The whole of Arrow's RoundTemporalOptions, in integer arithmetic in the value's own resolution plus the civil-date algorithm for the calendar units. An exact half rounds up (toward +infinity), which is what Arrow does. week is a seven-day grid on a Monday or Sunday anchor; calendar_based_origin starts the grid at the beginning of the value's own next-greater calendar unit; the month and quarter grids are anchored at 1970-01 and the year grid at year 0, as Arrow anchors them.
run_end_encode Conversions GPU run_end_encode() Sources/ArrowMetal/RunEndEncoded.swift Bit equality collapses adjacent values; nulls form runs of their own. Run ends are int32.
run_end_decode Conversions GPU run_end_decode() Sources/ArrowMetal/RunEndEncoded.swift Expands a run-end encoded column back to a flat one.
year TemporalExtraction GPU year() Sources/ArrowMetal/Temporal.swift The civil-date algorithm on the GPU, UTC. A timestamp's timezone is carried as metadata and never applied.
month TemporalExtraction GPU month() Sources/ArrowMetal/Temporal.swift UTC.
day TemporalExtraction GPU day() Sources/ArrowMetal/Temporal.swift UTC.
day_of_week TemporalExtraction GPU day_of_week(count_from_zero, week_start) Kernels/TemporalExtra.swift Arrow's full DayOfWeekOptions; week_start uses the ISO numbering (1 = Monday ... 7 = Sunday). The default options keep the int32 result (Monday = 0); any other combination returns int64, as pyarrow does.
hour TemporalExtraction GPU hour() Sources/ArrowMetal/Temporal.swift UTC.
minute TemporalExtraction GPU minute() Sources/ArrowMetal/Temporal.swift UTC.
second TemporalExtraction GPU second() Sources/ArrowMetal/Temporal.swift UTC.
day_of_year TemporalExtraction GPU day_of_year() Kernels/TemporalMath.swift UTC.
quarter TemporalExtraction GPU quarter() Kernels/TemporalMath.swift UTC.
iso_week TemporalExtraction GPU iso_week() Kernels/TemporalMath.swift UTC.
iso_year TemporalExtraction GPU iso_year() Kernels/TemporalMath.swift UTC.
millisecond TemporalExtraction GPU millisecond() Kernels/TemporalMath.swift UTC.
microsecond TemporalExtraction GPU microsecond() Kernels/TemporalMath.swift UTC.
nanosecond TemporalExtraction GPU nanosecond() Kernels/TemporalMath.swift UTC.
is_leap_year TemporalExtraction GPU is_leap_year() Kernels/TemporalMath.swift UTC.
strftime TemporalExtraction GPU strftime(format) Kernels/TemporalFormat.swift A C strftime format string, not a Unicode date pattern. The format is compiled on the host into a small op list uploaded as a constant buffer, so one generic kernel serves every format and none costs a shader recompile; the output width is data dependent, so it is the two-pass shape — measure every row, scan the lengths into the Arrow offsets buffer on the GPU, emit. %Y %m %d %e %H %I %M %S %f %j %y %b %B %h %a %A %p %C %G %V %u %w %z %Z %F %T %D %R %n %t %% and literals run on the GPU; anything else falls back to the C library. A timestamp carrying a timezone formats in that zone, as pyarrow does. Two documented departures from pyarrow, both of them C's behaviour: %S stays two digits (pyarrow folds the fractional second into it) and %f is ArrowMetal's six-digit fraction (pyarrow prints it literally).
strptime TemporalExtraction GPU strptime(format) Kernels/TemporalFormat.swift The inverse of strftime on the same compiled op list, one pass with one thread per 32 rows so each thread owns a whole validity word. The grammar is the C library's: a numeric field takes at least one and at most its own width in digits and must land in its range, whitespace matches any whitespace, and the whole value must be consumed. %Y %m %d %e %H %I %M %S %f %y %b %B %h %a %A %p %z %F %T %D %R %n %t %% run on the GPU; anything else falls back to the C library's strptime. Fields the format omits default to 1970-01-01 00:00:00, which is C's default rather than pyarrow's 1900-01-01. error_is_null is not implemented — an unparseable row is null either way.
is_dst TemporalExtraction GPU is_dst() Kernels/TemporalExtra.swift The one function here that does apply a timestamp's timezone, and so the one that needs the IANA tz database. The zone's transition table is enumerated once on the host, uploaded once and cached (Kernels/TimezoneGPU.swift), and the kernel is a binary search over it. A naive timestamp is an error, as in Arrow. Agrees with pyarrow exactly from 1900 through 2037; past 2038 the host tz database projects each zone's current rule forward while pyarrow's bundled one stops, so the two diverge there — a difference in the timezone data, not in the kernel.
iso_calendar TemporalExtraction GPU iso_calendar() Kernels/TemporalExtra.swift A struct of int64 iso_year, iso_week and iso_day_of_week (1 = Monday), UTC.
subsecond TemporalExtraction GPU subsecond() Kernels/TemporalExtra.swift The fraction of a second in [0, 1) as float64, UTC. date32 and date64 answer 0 (pyarrow has no kernel for them) and duration is rejected.
us_week TemporalExtraction GPU us_week() Kernels/TemporalExtra.swift The US week number: Sunday-start weeks and the majority rule, 1-53. UTC.
us_year TemporalExtraction GPU us_year() Kernels/TemporalExtra.swift The US epidemiological week-numbering year — the year owning the Wednesday of this date's Sunday-start week. UTC.
week TemporalExtraction GPU week(week_starts_monday, count_from_zero, first_week_is_fully_in_year) Kernels/TemporalExtra.swift Arrow's full WeekOptions, int64, UTC. The defaults reproduce iso_week.
year_month_day TemporalExtraction GPU year_month_day() Kernels/TemporalExtra.swift A struct of int64 year, month and day, UTC.
days_between TemporalDifference GPU days_between(other) Kernels/TemporalMath.swift Whole days between two temporal columns, UTC.
day_time_interval_between TemporalDifference Partial day_time_interval_between(other).interval_field(...) Sources/ArrowMetal/IntervalBetween.swift GPU. pyarrow 25 has no Python type or Array class for interval[day_time], so the result cannot be handed to pyarrow at all: read the fields with interval_field('days' | 'nanoseconds'), which is what the check below compares against days_between.
hours_between TemporalDifference GPU hours_between(other) Kernels/TemporalExtra.swift Hour boundaries crossed, int64, UTC.
microseconds_between TemporalDifference GPU microseconds_between(other) Kernels/TemporalExtra.swift Microsecond boundaries crossed.
milliseconds_between TemporalDifference GPU milliseconds_between(other) Kernels/TemporalExtra.swift Millisecond boundaries crossed.
minutes_between TemporalDifference GPU minutes_between(other) Kernels/TemporalExtra.swift Minute boundaries crossed.
month_day_nano_interval_between TemporalDifference GPU month_day_nano_interval_between(other) Sources/ArrowMetal/IntervalBetween.swift The one interval difference pyarrow can express in Python, so this one round-trips as an interval[month_day_nano] column. Every field is the difference of the corresponding truncated field, so the day and sub-day parts may carry the opposite sign.
month_interval_between TemporalDifference Partial month_interval_between(other).interval_field('months') Sources/ArrowMetal/IntervalBetween.swift GPU: month boundaries crossed. Same pyarrow gap as day_time_interval_between — there is no Python type for interval[month], so the months are read with interval_field('months') and checked against the months field of month_day_nano_interval_between.
nanoseconds_between TemporalDifference GPU nanoseconds_between(other) Kernels/TemporalExtra.swift Nanosecond boundaries crossed; wraps in int64 past about 292 years, as Arrow's does.
quarters_between TemporalDifference GPU quarters_between(other) Kernels/TemporalExtra.swift The difference of year * 4 + quarter.
seconds_between TemporalDifference GPU seconds_between(other) Kernels/TemporalExtra.swift Second boundaries crossed.
weeks_between TemporalDifference GPU weeks_between(other, count_from_zero, week_start) Kernels/TemporalExtra.swift Week boundaries crossed, both sides floored to the start of their week first. week_start is 1 = Monday ... 7 = Sunday; count_from_zero is accepted for signature parity and does not change the answer, as in Arrow.
years_between TemporalDifference GPU years_between(other) Kernels/TemporalExtra.swift The difference of the two calendar years.
assume_timezone Timezone GPU assume_timezone(tz, ambiguous, nonexistent) Kernels/TimezoneGPU.swift Reads a naive column as wall-clock times in tz and returns the instants they name. A timezone is a step function over a few hundred instants — America/New_York has 559 UTC-offset transitions between 1800 and 2200 — so the table is enumerated once per zone from the host tz database, uploaded once and cached, and the kernel is a binary search plus an add. The search runs over the local start of each interval, which decides Arrow's ambiguous and nonexistent cases in the same pass: two intervals still running is a fall-back, none running is a spring-forward gap. Raises for either by default; "earliest" / "latest" pick one, as Arrow does. The host implementation stays as the fallback for a zone the tz database will not enumerate and for values outside 1800-2200.
local_timestamp Timezone GPU local_timestamp() Kernels/TimezoneGPU.swift The wall-clock time each instant names in the column's own timezone, as a naive timestamp of the same unit. One pass over the same transition table as assume_timezone; a column with no timezone comes back unchanged.
random Random GPU am.random(n, initializer) Kernels/Selection.swift Philox4x32-10 keyed by the seed, one counter per element, so the stream depends only on the seed. The top 53 bits of each draw become a multiple of 2^-53 in [0, 1). ArrowMetal's own stream: it does not reproduce Arrow C++'s pcg32_fast numbers for the same seed.
unique Associative GPU unique(order) Kernels/UniqueOrder.swift One GPU sort plus a run scan gives the distinct values ascending; order="first_appearance" (the default, and Arrow's own order) then reorders them with a GPU group-min of the row index per distinct value, a stable argsort of those minima and a gather — and, like Arrow, keeps the null as one entry at the position of the first null row. order="sorted" is the ascending pass on its own, nulls dropped, and the cheaper of the two. A utf8 column has no null entry in either order: the GPU string dictionary has no slot for one.
value_counts Associative GPU value_counts(order) Kernels/UniqueOrder.swift The same two orders and the same null handling, returned as a struct of values and counts (int64).
dictionary_encode Associative GPU dictionary_encode(order) Kernels/UniqueOrder.swift GPU hashing for utf8, a GPU sort for primitives, then the same first-appearance reordering unique uses (the default) or the sorted dictionary. Returns (codes, values) rather than Arrow's dictionary-typed array. The dictionary never holds a null and a null row gets a null code, as in Arrow.
dictionary_decode Associative GPU dictionary_decode() Sources/ArrowMetal/DictionaryArray.swift A take of the values through the codes.
filter Selections GPU filter(mask) Kernels/Filter.swift Per-block popcount, GPU scan, scatter and validity pack, all in one command buffer. A null mask entry drops the row (Arrow's null_selection_behavior="drop"); "emit_null" is not implemented.
array_filter Selections GPU array_filter(mask) Kernels/Filter.swift The same kernel under Arrow's array-only name.
take Selections GPU take(indices) Kernels/Take.swift One gather kernel. A null index yields a null row; an out-of-range index sets a GPU error flag raised after the dispatch.
array_take Selections GPU array_take(indices) Kernels/Take.swift The same kernel under Arrow's array-only name.
drop_null Selections GPU drop_null() Kernels/Structural.swift The filter kernel driven by the validity bitmap.
inverse_permutation Selections GPU inverse_permutation(max_index) Kernels/Selection.swift An atomic scatter: for the i-th index the index-th output is i. Unassigned slots are null and duplicates resolve to the last source position, deterministically (the scatter is an atomic maximum). Always int32 — Arrow's output_type is not implemented.
scatter Selections GPU scatter(indices, max_index) Kernels/Selection.swift The inverse permutation used as a take, so it works for every column type. Unassigned positions are null and duplicate indices resolve to the last value.
fill_null NullFilling GPU fill_null(value) Kernels/Structural.swift One thread per element; the result carries no validity bitmap when the fill removes every null.
fill_null_forward NullFilling GPU fill_null_forward() Kernels/Conditional.swift One GPU max-scan over the last valid row index plus a gather. Leading nulls stay null.
fill_null_backward NullFilling GPU fill_null_backward() Kernels/Conditional.swift The same scan run the other way. Trailing nulls stay null.
list_value_length Structural GPU list_value_length() Sources/ArrowMetal/Nested.swift The offsets difference; null lists give null.
list_flatten Structural GPU list_flatten() Sources/ArrowMetal/Nested.swift Concatenates the child ranges of the valid rows.
list_element Structural GPU list_element(index) Sources/ArrowMetal/Nested.swift A gather through offsets + index; a row too short gives null where Arrow raises.
struct_field Structural GPU struct_field(name) Sources/ArrowMetal/Nested.swift The named child with the struct's own nulls propagated into it. Only a single-level field name, not Arrow's nested index path.
make_struct Structural CPU am.make_struct(arrays, names) Sources/ArrowMetal/Nested.swift Metadata only: the children are shared, nothing is copied and no kernel runs.
list_parent_indices Structural GPU list_parent_indices64() / list_parent_indices() Sources/ArrowMetal/NestedExtra.swift GPU, one binary search per child element. list_parent_indices64() returns int64, the width pyarrow returns; list_parent_indices() keeps the int32 form, which is what the list offsets themselves are and what every caller inside this package wants.
list_slice Structural GPU list_slice(start, stop, step) Sources/ArrowMetal/NestedExtra.swift row[start:stop:step] for every row, as a variable-length list. start must be >= 0 and step >= 1, as Arrow requires; a null row stays null and a row shorter than start becomes empty.
map_lookup Structural GPU map_lookup(key, occurrence) Sources/ArrowMetal/NestedExtra.swift One key compare per entry inside each row's range. occurrence is first, last or all (which returns a list of the item type); the result is null where the row is null or the key is absent. Keys may be utf8 / binary or any integer type.
replace_with_mask Structural GPU replace_with_mask(mask, replacements) Kernels/Conditional.swift Rows where the mask is true take the next value from replacements, in order; rows where the mask is null become null; every other row keeps its own value. A GPU scan supplies the replacement index. Fewer replacements than valid trues raises, a surplus is ignored, both as in pyarrow.
pairwise_diff Pairwise GPU pairwise_diff(period) Kernels/Window.swift One thread per element; null where either side is null or outside the array. Integers wrap.
pairwise_diff_checked Pairwise GPU pairwise_diff_checked(period) Kernels/Checked.swift self[i] - self[i - period], raising where that step would wrap.
cumulative_sum Cumulative GPU cumulative_sum() Kernels/Cumulative.swift A two-level scan. Null in, null out, the running value carrying across nulls (Arrow's skip_nulls=True). start is not implemented.
cumulative_prod Cumulative GPU cumulative_prod() Kernels/Window.swift The same scan with a multiply; float products reassociate.
cumulative_max Cumulative GPU cumulative_max() Kernels/Cumulative.swift The same scan with a maximum.
cumulative_min Cumulative GPU cumulative_min() Kernels/Cumulative.swift The same scan with a minimum.
cumulative_mean Cumulative GPU cumulative_mean() Kernels/Window.swift A binary64 running sum over a running count of non-null rows; int64 magnitudes above 2^53 round on the way in.
cumulative_sum_checked Cumulative GPU cumulative_sum_checked() Kernels/Checked.swift The running sum, raising where a step would wrap. Null rows are skipped and the running value carries across them (this package's cumulative_sum behaviour, Arrow's skip_nulls=True); pyarrow's default instead makes every row after a null null.
cumulative_prod_checked Cumulative GPU cumulative_prod_checked() Kernels/Checked.swift The running product, raising where a step would wrap. Same null rule as cumulative_sum_checked.
hash_count GroupedAggregations GPU group_by(keys).count(values) Kernels/GroupByKeys.swift Non-null values per group, for any value type. Arrow's mode option is not implemented — this is always only_valid; hash_count_all is the all mode.
hash_count_all GroupedAggregations GPU group_by(keys).count_all() Kernels/AggregatesExtra.swift Rows per group, null values included. A group with no row is zero, never null.
hash_sum GroupedAggregations GPU group_by(keys).sum(values) Kernels/GroupByKeys.swift A segmented reduction over the dense ids. Integers accumulate in 64 bits and wrap; min_count is not implemented.
hash_mean GroupedAggregations GPU group_by(keys).mean(values) Kernels/GroupByKeys.swift The grouped sum over the grouped valid count.
hash_min GroupedAggregations GPU group_by(keys).min(values) Kernels/GroupByKeys.swift A segmented minimum.
hash_max GroupedAggregations GPU group_by(keys).max(values) Kernels/GroupByKeys.swift A segmented maximum.
hash_min_max GroupedAggregations GPU group_by(keys).min_max(values) Kernels/AggregatesExtra.swift Fused: one segmented kernel produces both extremes from one read of the values, as a struct<min, max> column.
hash_all GroupedAggregations GPU group_by(keys).all(values) Kernels/AggregatesExtra.swift Three-valued AND per group over a boolean column, matching Arrow's null handling.
hash_any GroupedAggregations GPU group_by(keys).any(values) Kernels/AggregatesExtra.swift Three-valued OR per group.
hash_approximate_median GroupedAggregations GPU group_by(keys).approximate_median(values) Kernels/AggregatesExtra.swift Exact, not approximate: a GPU sort by (group, value) and a GPU per-group pick, because sorting on the GPU is cheaper than sketching. Arrow's is a t-digest, so on a group whose size makes the sketch inexact this answers the true median and pyarrow does not.
hash_count_distinct GroupedAggregations GPU group_by(keys).count_distinct(values) Kernels/AggregatesExtra.swift Dictionary-encode, packed unique, then a segmented count.
hash_distinct GroupedAggregations GPU group_by(keys).distinct(values) Kernels/AggregatesExtra.swift The distinct non-null values of each group as a list column, ascending — Arrow returns them in order of first appearance, as it does for the scalar unique.
hash_first GroupedAggregations GPU group_by(keys).first(values) Kernels/AggregatesExtra.swift A group-by extreme over a masked row index plus a gather.
hash_first_last GroupedAggregations GPU group_by(keys).first_last(values) Kernels/AggregatesExtra.swift Both ends in one struct<first, last> column: two group-by extremes over the masked row index plus two gathers.
hash_last GroupedAggregations GPU group_by(keys).last(values) Kernels/AggregatesExtra.swift Mirror of hash_first.
hash_list GroupedAggregations GPU group_by(keys).list(values) Kernels/AggregatesExtra.swift Every value of the group in row order, as a list column: a segmented gather after the stable sort by group id.
hash_one GroupedAggregations GPU group_by(keys).one(values) Kernels/AggregatesExtra.swift One value per group — here always the group's lowest row, null included. Arrow leaves which one unspecified.
hash_product GroupedAggregations GPU group_by(keys).product(values) Kernels/AggregatesExtra.swift A segmented multiply reduction. Integers wrap in 64 bits exactly as the scalar product does, and float products reassociate across the threads of a group.
hash_stddev GroupedAggregations GPU group_by(keys).stddev(values, ddof) Kernels/GroupMoments.swift The square root of hash_variance, and so carries the same binary64 accuracy.
hash_variance GroupedAggregations GPU group_by(keys).variance(values, ddof) Kernels/GroupMoments.swift Two GPU passes over the counting-sort order, both in true binary64: an exact per-group mean, then the sums of the deviations and their squares about it with the shift correction. About 1e-16 relative on a float64 column, which is closer to the exact answer than pyarrow's own.
hash_pivot_wider GroupedAggregations GPU group_by(keys).pivot_wider(pivot_keys, values, names) Kernels/AggregatesExtra.swift One masked hash_one per pivot key, giving a struct with one field per name. A (group, key) pair carrying more than one non-null value takes the lowest row here where pyarrow raises.
hash_kurtosis GroupedAggregations GPU group_by(keys).kurtosis(values) Kernels/AggregatesExtra.swift Excess kurtosis, biased, from two GPU passes over the per-group means, in binary64 like hash_variance. A group with too few values is null here where pyarrow returns NaN.
hash_skew GroupedAggregations GPU group_by(keys).skew(values) Kernels/AggregatesExtra.swift The third standardised central moment, biased. Same passes, precision and null-on-degenerate group as hash_kurtosis.
hash_tdigest GroupedAggregations Partial group_by(keys).tdigest(values, q) Kernels/AggregatesExtra.swift Mixed: a GPU sort by (group, value) and a host merge of each group's centroids. Returns one q per group as a scalar column where Arrow returns a list, and being a sketch it agrees with Arrow's to within the sketch's error. group_by(keys).quantile(values, q) is the exact answer.

Differential matrix cases, by section

Separately from the row-by-row calls above, python/tests/test_differential.py runs every ArrowMetal operation over generated columns of every type it accepts: 41,688 cases in the recorded run, 38,898 pass, 1,643 documented divergences, 1,147 skipped, 0 unclassified. Coverage summary has the count for each name; this is how many names in each section the matrix reaches.

Section Names With matrix cases Names without
Aggregations 24 21 count, count_all, pivot_wider
Arithmetic 24 21 remainder_checked, modulo, modulo_checked
Sorts 11 10 search_sorted
Bitwise 8 8 -
Rounding 6 6 -
Logarithmic 10 10 -
Trigonometric 20 20 -
Comparisons 8 8 -
Logical 8 8 -
StringPredicates 19 19 -
StringTransforms 21 21 -
StringPadding 7 7 -
StringTrimming 12 8 ascii_trim_whitespace, ascii_trim, ascii_ltrim, ascii_rtrim
StringSplitting 4 4 -
StringExtraction 2 2 -
StringJoining 2 2 -
StringSlicing 2 2 -
Containment 11 11 -
Categorizations 7 7 -
Selecting 4 4 -
Conversions 6 6 -
TemporalExtraction 24 24 -
TemporalDifference 13 13 -
Timezone 2 2 -
Random 1 0 random
Associative 4 4 -
Selections 7 7 -
NullFilling 3 3 -
Structural 9 8 make_struct
Pairwise 2 2 -
Cumulative 7 7 -
GroupedAggregations 24 6 hash_min_max, hash_all, hash_any, hash_approximate_median, hash_count_distinct, hash_distinct, hash_first, hash_first_last, hash_last, hash_list, hash_one, hash_product, hash_stddev, hash_variance, hash_pivot_wider, hash_kurtosis, hash_skew, hash_tdigest
Total 312 281

Version 0.5.0. Read alongside Coverage summary, ROADMAP.md and Design.

docs/COVERAGE.md

Apache Arrow compute coverage

ArrowMetal 0.4.0 measured against the Apache Arrow C++ compute function list and the Arrow columnar type list.

Every row below was decided by reading the source in this repository, not by intent. If a row says GPU there is a Metal kernel behind a public API call; if it says CPU the work happens on the host but the call exists; if it says GPU / CPU the evaluation is split between the two and the note says where the seam is; if it says anything else, the note says what is missing. Rows carry the file that decides them so a claim can be checked in one jump.

This file groups Arrow's functions into families and explains each one. The companion page Arrow functions, one by one does the complementary thing: one row per exact Arrow function name, all 312 of them (Arrow v26; 307 in v25), generated from a registry that the test suite executes name by name against pyarrow.compute. Go there for "is <name> covered?"; stay here for "how does this family work?".

Summary

Arrow function category GPU GPU / CPU CPU Partial Not implemented Rows
Aggregations — scalar 15 1 6 0 0 22
Aggregations — grouped (hash_*) 14 1 0 0 0 15
Element-wise arithmetic 18 0 0 1 0 19
Bit-wise and shifts 4 0 0 2 0 6
Comparisons 8 0 0 0 0 8
Logical 7 0 0 0 0 7
String predicates 3 1 0 0 0 4
String transforms 14 5 3 0 0 22
String containment and matching 8 1 1 0 0 10
Temporal 9 0 0 0 0 9
Conversions and casts 3 0 2 1 0 6
Selections 6 0 1 0 0 7
Containment / set lookup 3 0 0 0 0 3
Sorts and partitions 9 0 0 2 0 11
Structural and conditional 14 0 2 0 0 16
Associative transforms 6 0 0 1 0 7
Pairwise and cumulative 6 0 0 0 0 6
Hashing 2 0 0 0 0 2
Total (compute functions) 149 9 15 7 0 180
Arrow types (matrix below) 21 0 0 7 0 28

Interop uses a separate vocabulary and is counted apart: 9 in 0.1.0 (the IPC row among them, extended in 0.2.0), 9 in 0.2.0, 1 partial, 2 not implemented (21 rows). The "Hash join (Acero, not a compute function)" row in the grouped aggregations section below is likewise outside the compute-function total.

A row here covers a family, so these are not function counts. The by-name numbers are in Arrow functions, one by one: of Arrow v25's 307 compute function names, all 307 are reachable — 284 entirely on the GPU, 17 on the host, 7 with a stated limitation, and none missing. Of the five names Arrow v26 added, remainder runs on the GPU (it is modulo()'s C %) and the floored modulo, the two checked variants and search_sorted are recorded as missing.

The scope ArrowMetal 0.4.0 answers to, name by name: every Arrow compute function name — 7 of them with a stated limitation — over int8/16/32/64, uint8/16/32/64, float16/32/64, bool, utf8, binary, fixed_size_binary, decimal32/64/128, the six temporal types, the three interval layouts, list / struct / map / dictionary / run_end_encoded and extension types — null-aware with Arrow semantics, and checked value for value against pyarrow.compute in python/tests/test_functions.py. Concretely that is: the scalar and grouped aggregate families including skew / kurtosis / tdigest and hash_* over arbitrary key columns; unchecked and checked (overflow-raising) arithmetic; the full transcendental set — the twelve trigonometric and hyperbolic functions, their checked twins, atan2, expm1, log1p, logb, hypot — in software binary64 on the GPU; all ten Arrow round modes with ndigits; the complete Unicode string surface (the utf8_is_* predicates, case and title mapping, centring, slicing, trimming, normalisation, regex, LIKE, splitting, joining); the whole temporal surface — every extractor, every *_between difference, the interval differences, week with all its options, timezones; window, rank, rolling and lexicographic-sort functions; case_when / choose / replace_with_mask / the forward and backward null fills; set lookup over strings and binary as well as numerics; nested access, list_slice, map_lookup, list_parent_indices; and Arrow C Data, C Device and C Stream interop for all of it.

The scope it does not claim: the work that runs on the host and the differences that remain differences, both listed by name below and in each row's note.

  • Host-side by design, because the data lives there — Unicode normalisation (utf8_normalize), the ICU regular-expression engine (extract_regex, extract_regex_span, match_substring_regex, count_substring_regex, find_substring_regex, replace_substring_regex, split_pattern_regex), the t-digest centroid merge in tdigest and hash_tdigest, and pivot_wider, whose output is one row wide however long the input is. count, count_all, true_unless_null and make_struct are "CPU" only in the sense that they read metadata or share buffers and run no kernel at all.
  • Split between the two, per row — the ten utf8_is_* predicates answer every row on the GPU and re-decide only the rows carrying a byte ≥ 0x80 on the host. The five case transforms (utf8_upper, utf8_lower, utf8_swapcase, utf8_capitalize, utf8_title) map every row whose code points are all at or below U+017F with an exact GPU table and hand the rest to the host, so Latin text of any accent stays on the device and Greek, Cyrillic, CJK and emoji do not. The utf8_trim* family splits the same way: an all-ASCII character set never leaves the GPU at all, and a set with a non-ASCII member sends only the rows carrying a byte ≥ 0x80 to the host. The seam is always the two-pass transform's length kernel, which declines a row it cannot answer exactly.
  • Precision — Metal has no double at all, so every float64 transcendental is software binary64 on the GPU. sqrt is correctly rounded; exp, ln, log10, log2 and power (and their checked twins) are within 1 ulp, measured over 10^6 inputs per function. The grouped moments (hash_variance, hash_stddev, hash_skew, hash_kurtosis) form their deviations in software binary64 about a float64 mean and are asserted against Arrow to 1e-5 relative, the tolerance recorded in arrowmetal.functions.TOLERANCE, not to the last bit. Every tolerance is recorded there and asserted.
  • Deliberate differences from Arrow — hash_distinct returns values ascending rather than in order of first appearance, and the group-by order is deterministic but is not pyarrow's first-seen order; unique / value_counts / dictionary_encode now default to Arrow's first-appearance order and keep order="sorted" as the cheaper option, but over a utf8 column they still drop the null, because the GPU string dictionary has no slot for one; cumulative_* carries the running value across nulls (Arrow's skip_nulls=True) where pyarrow's default nulls the rest of the column; approximate_median and hash_approximate_median are exact rather than sketches; pyarrow.compute.utf8_normalize never composes, so its NFC and NFKC differ from this one's. Two places where Arrow itself is the outlier are reproduced on purpose and pinned by a test: ceil on a calendar unit always advances a value already on a boundary, and rounding to a unit finer than the column's own resolution floors whatever mode was asked for. One is not reproduced: floor_temporal(unit="week", multiple>1, calendar_based_origin=True) in Arrow returns a value greater than its input for a fifth of all days, so this floors instead (test_options.py asserts both halves).
  • Options and inputs still unimplemented — per-row num_repeats on binary_repeat; utf8 / binary / dictionary and nested key columns in sort_indices and lexsort_indices (utf8 and binary keys sort on the GPU through Kernels/StringSort.swift); and rank_normal's float64 inverse CDF, which runs on the host. null_placement, rank's four tiebreakers, null_matching_behavior, the distinct-value order, CastOptions, RoundTemporalOptions, max_splits / reverse on the splits and N-column binary_join_element_wise are all implemented; python/tests/test_options.py and test_strings_extra.py walk their cross product against pyarrow.
  • Not implemented at all — nothing. binary_slice was the last Arrow compute name without an answer; it is now a GPU kernel in Kernels/StringBytes.swift.

The long form is at the bottom of this file.

Legend

Status Meaning
GPU A Metal kernel, reachable from the public Swift API, the C ABI, or both.
GPU / CPU Split evaluation: part of the work is a Metal kernel and part runs on the host. The note says where the seam is and what decides it.
CPU Implemented and reachable through the same ArrowMetal API, but the work runs on the host.
Partial Available with a stated limitation; the note says exactly what is missing.
Not implemented No ArrowMetal API for it.
In 0.2.0 Added in 0.2.0. Used in the interop table below, where In 0.1.0 marks what 0.1.0 already had and extended in 0.3.0 what 0.3.0 added to a row.

A note that starts with Routed marks a row whose operation also has a single-threaded CPU loop with byte-identical Arrow output, which the CPU/GPU router runs below its crossover table's row count (Design). The status still names the GPU kernel: it runs at and above the crossover, inside a batch, for the types the loop does not cover, and always under ARROWMETAL_ROUTER=gpu.

Counts in the summary are counts of rows. A row covers one Arrow function unless it names several (for example the eighteen ascii_is_* / utf8_is_* predicates share four rows). For counts of names, use Arrow functions, one by one, which has exactly one row per Arrow function name.

Aggregations — scalar

Arrow function Status Notes
sum GPU Routed (all ten primitives). Kernels/Reductions.swift. Threadgroup partials, host finalise, no atomics. Integers accumulate in Int64/UInt64 and wrap; Float32 values are widened exactly to binary64 and, like Float64, accumulated with the software IEEE-754 binary64 adder on the GPU (Kernels/DoubleMath.swift); the host adds the threadgroup partials in order. The summation order is the kernel's tree, so the last ulp can differ from a strictly sequential double sum. Returns nil when there is no valid value, matching Arrow.
product GPU Kernels/Aggregates.swift, the same threadgroup-partial shape as sum. Integers accumulate in Int64/UInt64 and wrap, as Arrow's does; Float32 accumulates in float per thread and combines in double, so thousands of factors reassociate (about 1e-5 relative); Float64 multiplies through the software binary64 routine on the GPU. Nil when there is no valid value. product() in Swift, am_reduce_ex op 0 in C, product() in Python.
mean GPU Routed (all ten primitives). GPU sum divided by the valid count on the host (Reductions.swift).
min GPU Routed (integers and float64; float32 stays on the GPU). NaN is skipped; all-NaN returns null, matching Arrow's min_max. Float64 reduces on order-preserving 64-bit keys.
max GPU Routed (integers and float64; float32 stays on the GPU). Same as min.
min_max GPU Kernels/Aggregates.swift: one kernel produces both partials from a single read of the values, and the host combines them. NaN is skipped, as in min/max. minMax() in Swift, am_reduce_ex ops 14 and 15 in C, min_max() in Python.
count (valid values) CPU validCount = length - nullCount; the null count comes from a host popcount over the validity bitmap (MetalArray.swift, Bitmap.popcount). O(1) once the count is known.
count_all (rows) CPU length, O(1) metadata. Inside an open batch, reading it forces a sync point. AnyMetalArray.countAll in Swift, am_count_all in C, count_all() in Python.
count_distinct GPU unique().length (Kernels/Aggregates.swift over Kernels/Unique.swift): sort, mark run boundaries, scan. Non-null values only, Arrow's mode = "only_valid". countDistinct() in Swift, am_reduce_ex op 8 in C, count_distinct() in Python.
any CPU MetalBooleanArray.anyTrue() (Kernels/Aggregates.swift) scans values & validity 64 bits at a time in shared memory and stops at the first true bit, so the usual answer is two loads and no dispatch — a GPU pass cannot beat a short circuit, which is why the CPU libraries were faster here. A column longer than 8 Mi rows whose first mebibit gives no answer escalates to the word-wise counting kernel (agg_bool_counts), which is the old path. am_reduce_ex op 12 in C, any() in Python.
all CPU allTrue(), the mirror of any: a host scan that stops at the first word with a bit set in validity & ~values. An empty or all-null column is true, matching Arrow's all with skip_nulls. Same escalation to the counting kernel. am_reduce_ex op 13 in C, all() in Python.
index GPU index(of:) (Kernels/Aggregates.swift): every matching row lowers one device-wide atomic minimum, so the answer is the first row holding the value and -1 when it is absent. Float equality is Arrow value equality (-0.0 equals 0.0, NaN equals nothing). am_reduce_ex op 11 in C (the value travels as a double), index() in Python.
first / last / first_last CPU first(skipNulls:) / last(skipNulls:) (Kernels/Aggregates.swift): a host scan of the validity bitmap in shared memory, 64 bits at a time inwards from each end, stopping at the first (last) valid row, then one read of that slot. The cost is the distance to that row, not the length of the column, so a mostly-valid column answers in a handful of loads with no dispatch at all; a column whose first mebibit is entirely null falls back to the atomic minimum / maximum kernel. With skipNulls: false the first (or last) row is returned as it is, null included. first_last is the fused pair firstLast(skipNulls:) (Kernels/Selection.swift), which packages the two reads as the one-row struct<first, last> Arrow returns; Arrow's min_count option is not implemented. am_reduce_ex ops 9 and 10 and am_first_last in C, first() / last() / first_last() in Python.
mode GPU mode() (Kernels/Aggregates.swift) is GPU value_counts (sort, run marks, scan) plus a host argmax over the distinct values, returning the value and its count. Ties go to the smallest value, as Arrow does. Only the single most common value: Arrow's n option is not implemented. am_reduce_ex ops 7 and 16 in C, mode() in Python.
quantile GPU quantile(_:) (Kernels/Aggregates.swift): the GPU radix sort orders the values and the result is read at the interpolated position, so the answer is exact. Linear interpolation only (Arrow's default); the lower / higher / nearest / midpoint options are not implemented, and only one q per call. q is clamped to [0, 1]. am_reduce_ex op 5 in C, quantile() in Python.
approximate_median GPU approximateMedian() is quantile(0.5) — exact, not a sketch, because sorting on the GPU is cheap enough that approximating would buy nothing. am_reduce_ex op 6 in C, median() in Python.
tdigest GPU / CPU tdigest(_:delta:bufferSize:) (Kernels/AggregatesExtra.swift): the GPU radix sort orders the values and one host pass merges them into centroids with the standard k1 scale function delta * (asin(2q - 1) / pi + 0.5) — GPU sort, CPU merge, documented as such. Because the values arrive fully sorted there is nothing to buffer, so Arrow's buffer_size option has no counterpart and is accepted only for signature compatibility; the digest a single global merge builds is at least as accurate as Arrow's incrementally buffered one. Measured against pyarrow.compute.tdigest on 50k uniform values over a range of 20, the worst disagreement across q = 0.01 to 0.99 was 0.0158 (0.08% of the range), and both were within 0.0003 of the exact quantile; q = 0 and q = 1 are the exact minimum and maximum. am_reduce_ex2 op 2 in C, tdigest() in Python. Use quantile() when you want the exact answer — here it is cheaper as well as exact.
stddev GPU The square root of variance, same two passes. stddev(ddof:) in Swift, am_reduce_ex ops 3 and 4 in C, stddev() in Python.
variance GPU variance(ddof:) (Kernels/Aggregates.swift), Welford-free and two-pass: sum() gives the mean, then a GPU pass sums the squared deviations from it. Integer and Float32 columns accumulate those in compensated (Neumaier) float pairs, and integer deviations subtract the integer part of the mean in 64-bit first, so large integers keep their precision; Float64 columns accumulate in software binary64. Both are combined on the host in Double. Expect about 1e-7 relative error for Float32 and integers, 1e-15 for Float64. ddof 0 is the population variance, 1 the sample one; nil when there are fewer than ddof + 1 valid values. am_reduce_ex ops 1 and 2 in C, variance() in Python.
pivot_wider CPU PivotWider.pivot(keys:values:keyNames:unexpectedKey:) (Sources/ArrowMetal/PivotWider.swift): one host pass over the key column deciding which output field each row belongs to, then one single-row take per field on the GPU. Deliberately host-side — the output is one row wide however long the input is, so there is no parallel work a kernel would win. Key columns may be utf8, binary, dictionary or any integer type (integers match by their decimal rendering). A key that never appears, or appears only with a null value, gives a null field; a key carrying more than one non-null value raises, as Arrow does. am_pivot_wider in C, pivot_wider() in Python.
skew GPU skew(biased:minCount:) (Kernels/AggregatesExtra.swift), the same two passes as variance: sum() gives the mean, then one kernel accumulates the second, third and fourth central deviations about it — compensated (Neumaier) float pairs for integer and Float32 columns, software binary64 for Float64 ones. Arrow's default is the biased (population) form m3 / m2^1.5; biased: false gives the sample-corrected G1. Null when every value is equal (the denominator is zero) or fewer than minCount values are valid. Matches pyarrow.compute.skew to 1e-5 relative. am_reduce_ex2 ops 0 and 3 in C, skew() in Python.
kurtosis GPU Excess kurtosis m4 / m2^2 - 3, biased by default as Arrow's is, from the same kernel and the same pass as skew — asking for both costs one pass, not two. biased: false gives the sample-corrected G2. Matches pyarrow.compute.kurtosis to 1e-5 relative. am_reduce_ex2 ops 1 and 4 in C, kurtosis() in Python.

Aggregations — grouped (hash_*)

All grouped aggregates go through GroupBy, which takes dense integer keys in [0, keyCount) — the shape a dictionary encoding produces. Keys outside the range and null keys are skipped. There are two implementations behind it.

The atomic path (Kernels/GroupBy.swift) uses privatised threadgroup tables up to 1024 keys and device atomics beyond; 64-bit sums use split 32-bit atomics with carry because MSL has no 64-bit atomics. That is also its ceiling: no 64-bit min/max, no Float64 values.

The segmented path (Kernels/Segmented.swift) removes atomics from the aggregation. It argsorts the keys once, which makes each group a contiguous run of the sorted order, then reduces one run per threadgroup, at any group count (past 2^24 groups the grid of threadgroups is folded into rows, see Findings against pyarrow, round 13). segments() returns the sorted order so several aggregates share the one sort. It covers exactly what the atomic path could not: Float64 sums and means (through the software binary64 adder in Kernels/DoubleMath.swift), Float32 sums and means accumulated in Float64, and 64-bit min/max.

Arrow function Status Notes
hash_sum GPU Routed for integer values (uint64 through sumUnsigned too) when there are 1,024 groups or fewer. All value types, and now any key type: GroupByKeys (Kernels/GroupByKeys.swift) maps arbitrary keys to dense ids first. Integers go through the atomic sum; Float32 through sumFloat (Float32 accumulation, host finalise) or sumFloatAsDouble (Float64 accumulation, GPU); Float64 through sumDouble, which adds with the software binary64 adder on the GPU. am_group_agg_ex op 0 in C, group_by([...]).sum() in Python.
hash_mean GPU Any key type. Integer values through mean (GPU sum + GPU count, host division); Float32 and Float64 through meanFloat / meanDouble, which sum and divide entirely on the GPU. am_group_agg_ex op 3.
hash_min GPU Any key type, all ten primitive value types. 32-bit and narrower use the atomic min; Int64, UInt64 and Float64 use min64 on the segmented path, since MSL has no 64-bit atomic min/max. min64 forwards narrower types to min, so it is safe to call for any type — as is the fused minMax, which covers every width in one kernel. am_group_agg_ex op 4.
hash_max GPU As hash_min (max / max64). am_group_agg_ex op 5.
hash_count (valid values per key) GPU Any key type. countValid(_:) counts through the validity bitmap alone, so it works for Float64 value columns too. am_group_agg_ex op 2.
hash_count_all (rows per key) GPU Any key type. countAll(); am_group_agg_ex op 1 (the only op that needs no value column).
hash_min_max GPU GroupBy.minMax(_:) / minMaxStruct(_:) (Kernels/AggregatesExtra.swift): one segmented kernel carries both extremes through a single read of each value, for every width including 64-bit. NaN is skipped, as in min/max. minMaxStruct returns the Arrow struct<min, max> shape. am_group_agg_ex op 6, min_max() in Python.
hash_any / hash_all GPU Any key type: GroupBy.any(_:) / all(_:) (Kernels/Aggregates.swift) unpack the boolean bitmap into bytes and run the existing group-by maximum / minimum, so a key with no valid value is null. Boolean values only. am_group_agg_ex ops 14 and 15.
hash_product GPU GroupBy.product(_:) is now a segmented multiply reduction on the GPU (Kernels/AggregatesExtra.swift): Metal has no 64-bit atomic multiply, so the keys are argsorted once and one threadgroup multiplies one key's run. Integers accumulate in Int64 / UInt64 and wrap, as the scalar product does; productFloat(_:) covers Float32 (multiplied in float) and Float64 (through the software binary64 routine), both reassociated across the threads of a group. am_group_agg_ex op 16, product() in Python.
hash_stddev / hash_variance GPU Any key type: per-key means from the existing sum/count, a take by key gathers each row's mean, and the squared deviations are summed per key with sumFloat. Deviations are computed in Float32, so expect about 1e-6 relative error; a Float64 value column throws in Swift (cast to Float32 first) and is narrowed for you through the C ABI and Python. ddof as in the scalar form. am_group_agg_ex ops 17-20. hash_skew and hash_kurtosis are in the same family and have no rows of their own: GroupBy.skew(_:) / kurtosis(_:) (Kernels/AggregatesExtra.swift) take two GPU passes — the per-key means through the segmented meanFloat, then one kernel accumulating the second, third and fourth central deviations about them in compensated float pairs — and match pyarrow's hash_skew / hash_kurtosis to 2e-3 relative, biased by default as Arrow's are (am_group_agg_ex ops 23 and 24, skew() / kurtosis() in Python).
hash_count_distinct / hash_distinct GPU Both, any key type. The values are dictionary encoded, each row becomes the packed key key * uniqueCount + code and unique() collapses the repeats; countDistinct(_:) counts what is left per key, and distinct(_:) (Kernels/AggregatesExtra.swift) rebuilds the surviving values into a MetalListArray — the packed keys come out of unique() already grouped by key and ascending by value inside each key, so the list only needs an exclusive scan of the per-key counts. Non-null values only, matching Arrow. am_group_agg_ex ops 13 and 12.
hash_first / hash_last / hash_first_last GPU Any key type: a group-by minimum / maximum over a row-index array that carries the values' validity, then one take. Any value type. GroupBy.first(_:) / last(_:), and firstLast(_:) for the Arrow struct<first, last> shape (two passes, not fused). am_group_agg_ex ops 7, 8 and 9.
hash_one / hash_list GPU GroupBy.one(_:) returns the first non-null value of the key, which is what pyarrow's hash_one returns on the same input and is reproducible run to run (Arrow itself does not promise which row); oneIncludingNull(_:) is the other reading, the lowest row null included. list(_:) builds a MetalListArray of every value of the key in row order: the stable sort by key that segments() already produces puts each key's rows together and in order, a GPU scan of the per-key counts gives the offsets, and one gather builds the child. am_group_agg_ex ops 10 and 11, one() / list() in Python. hash_pivot_wider sits alongside them and has no row of its own: GroupBy.pivotWider(pivotKeys:values:names:) over a utf8 pivot-key column turns each name into a string-equality mask, intersects it with the values' validity and lets hash_first pick the survivor, returning a struct with one field per name (am_group_pivot_wider in C, pivot_wider() in Python). Arrow raises on duplicate pivot keys within a group; this picks the first.
hash_approximate_median / hash_quantile / hash_tdigest GPU / CPU The segmented sort now exists (Kernels/AggregatesExtra.swift): two stable GPU radix argsorts — by value, then by key — leave every key's rows contiguous and ascending by value with its nulls last, and gx_seg_pick reads the two values bracketing the requested position. approximateMedian(_:) is quantile(_:0.5) and is exact, not a sketch, so it disagrees with pyarrow's hash_approximate_median by pyarrow's own sketch error (measured up to 18.7 on values spanning 1000); it matches an exact per-group median oracle bit for bit. quantile(_:_:) takes any q with linear interpolation. hash_tdigest is GPU sort + a host centroid merge per key, the grouped form of tdigest. am_group_agg_ex ops 21, 22 and 25.
Group-by over arbitrary (non-dense) keys GPU GroupByKeys (Kernels/GroupByKeys.swift) maps any key column to dense ids 0 ..< groupCount on the GPU and hands back the key values per group (groupKeys()). Supported key types: int8-int64, uint8-uint64, float32/float64 (-0.0 == 0.0, all NaNs one group), bool, temporal and date, utf8, binary, dictionary-encoded (its codes are re-encoded, which also drops unused dictionary entries) and decimal128/decimal256 (folded limb by limb). Several key columns fold pairwise into the injective 64-bit key a * Kb + b, re-encoded after every fold so the cardinality stays bounded by the row count; three and four columns are tested. A null key forms its own group, as in Arrow. Two mappings, cheaper first: a range path for integer, boolean, temporal and dictionary columns whose values span at most 2^24 and at most max(2^16, 4 * rows) — mark the occupied values, GPU-scan the marks, read each row's rank, no sort at all — and the dictionaryEncode sort path (argsort, run marks, prefix scan) for floats, decimals, strings and wide-range integers. A fold's composite has a known range, so multi-column keys usually take the range path as well. The hashing alternative to the injective fold was measured at 50M rows and ~100k groups, both composites re-encoded by the same sort: 160.0 ms for the radix combine against 163.9 ms for a 64-bit hash that had not yet paid for its verification pass — and only the injective key has a range known in advance, which is what lets the fold take the range path instead. Group order is deterministic but is not pyarrow's first-seen order. GroupBy(keys: [...]) in Swift with denseKeyCount: keeping the old fast path, am_group_by_keys / am_group_by_keys_result in C, am.group_by([...]) in Python.
Hash join (Acero, not a compute function) Partial Kernels/Join.swift: hashJoin(left:right:kind:) builds a GPU hash table over the right key column and probes it with the left, returning the index pairs of every match; MetalRecordBatch.join(_:on:rightKey:kind:) turns those into a joined batch with one take per column of both sides, with the duplicated right key column dropped. Inner and left joins, many-to-many, null keys never matching. The key columns must be int32 or int64 — a temporal column joins on its storage integer and a dictionary column on its codes; dictionary-encode or hash any other key type first. Right, full outer, semi and anti joins are in Kernels/JoinExtra.swift (Query engine). Reachable from every binding since 0.1.0: am_join(left_keys, right_keys, join_type, out_left_idx, out_right_idx) in C and am.join(left, right, how) in Python, both returning the index pairs for the caller to apply with take.

Element-wise arithmetic

modulo (%, C remainder semantics, x % 0 defined as 0) lives beside power in Kernels/Rounding.swift and is reachable as am_binary(op 5) and .modulo() / __mod__ in Python. Since Arrow 26.0.0 it has a function name of its own, remainder (the C %); Arrow's floored modulo, the checked pair and search_sorted, new in 26.0.0, are not implemented and carry missing rows in the registry.

Arrow function Status Notes
add GPU Routed (integers and float64; float32 stays on the GPU). Scalar and array forms, vectorised 4-wide (Kernels/Arithmetic.swift). Integer overflow wraps, like Arrow's unchecked add. Float64 runs a software IEEE-754 binary64 adder on the GPU, bit-exact against Swift's Double.
subtract GPU Routed (integers and float64; float32 stays on the GPU). As add.
multiply GPU Routed (integers and float64; float32 stays on the GPU). As add.
divide Partial GPU, but integer division by zero is defined as 0 here (KernelSource.swift, matched by the CPU oracle in ArrowPrimitive.swift) rather than raising. Check this against Arrow's divide before relying on it. Float division follows IEEE.
add_checked / subtract_checked / multiply_checked / divide_checked GPU Kernels/Checked.swift and Kernels/CheckedSource.swift, scalar and array forms over all ten primitives. Each checked op is the unchecked kernel plus one read-only check pass, both encoded into a single command buffer, so the values are bit-identical to the unchecked op and the cost is one GPU round trip. The check pass writes into an eight-word flag buffer only from a failing element (one atomic_or for the kind, one atomic_min for the row), and the wrapper raises ArrowMetalError.overflow(op:index:detail:) naming the Arrow message and the first offending row. Nulls are never checked, on either side. divide_checked raises "divide by zero" on every type and "overflow" for INT_MIN / -1. On float columns only divide_checked can raise: an overflow to infinity and a NaN are ordinary results, as in Arrow. Inside MetalContext.batch the check joins the open buffer and the error surfaces from flush(). am_binary_checked in C, add_checked() … in Python.
negate / negate_checked GPU negate() is GPU over all ten primitives (Kernels/Rounding.swift); integers wrap, so negate(int8 -128) is -128, and unsigned negation is modular. Float64 flips the sign bit, exactly. negate_checked() (Kernels/Checked.swift) raises for T.min on a signed column and — going beyond Arrow, which has no unsigned kernel at all — for every non-zero value on an unsigned one.
abs / abs_checked GPU abs() is GPU over all ten primitives; abs(int8 -128) wraps to -128, unsigned is the identity, Float64 clears the sign bit exactly. absChecked() raises only for T.min on a signed integer column; on floats and unsigned columns it never raises.
sign GPU sign() over all ten primitives: -1/0/1 in the input's own type (0 or 1 for unsigned). Floats keep NaN and both signed zeros, matching Arrow.
power / power_checked GPU power(), scalar and array forms. Integers use repeated squaring and wrap; a negative exponent is defined as 0 here (Arrow raises), and 0^0 is 1. Float32 uses MSL pow. Float64 is software binary64 (Kernels/DoublePower.swift): x^y needs the product y·log2 x — up to 1024 in magnitude — good to 2⁻⁶¹ absolutely for a 1-ulp result, more than a double holds, so log2 x is carried as an unevaluated hi/lo pair and multiplied by a split y whose leading product y₁·t₁ is exact. Measured 1 ulp over 10⁶ random pairs and over 5·10⁵ negative bases with integer exponents; the C99 edge table (x^0, 0^y, 1^y, (-1)^int, inf/NaN) matches libm bit for bit. powerChecked() (Kernels/Checked.swift) raises "integers to negative integer powers are not allowed" for a negative integer exponent and "overflow" for any repeated-squaring step that would wrap, flagging exactly the multiplies the unchecked kernel wraps; float columns never raise, as in Arrow.
sqrt / sqrt_checked GPU sqrt() on float columns; an integer column throws rather than being promoted to float64 as Arrow does — cast first. Float64 is correctly rounded: DoubleMath.d_sqrt refines a hardware rsqrt seed with Newton steps in fixed point and settles the last bit with the exact remainder N - q², so the result is the correctly rounded root, not an approximation — bit-identical to Foundation.sqrt over 10⁶ random bit patterns, subnormals and both extremes included. sqrtChecked() raises "square root of negative number"; NaN, -0.0 and +inf pass through, as they do in Arrow, and the boundary is exact (+5e-324 passes, -5e-324 raises).
exp GPU exp() on float columns only. Float64 is software binary64: reduce x = k·ln2 + r against the 107-bit ln 2 pair, sum r·Σ rⁿ/(n+1)!, then scale by 2^k — split as (s·2^(k-1))·2 above k = 1023 so a finite result just under DBL_MAX is not turned into infinity on the way, and stepped through 2^-1022 below it so a subnormal result rounds exactly once. Measured 1 ulp over 10⁶ inputs spanning -745.2 to 709.78, with separate passes over the subnormal-result and near-overflow edges.
expm1 GPU expm1() (Kernels/MathExtra.swift), float columns only. Float32 is a Taylor series below |x| = 0.5 and exp(x) - 1 above it — the usual Kahan repair is unusable because this Metal front end folds log(exp(x)) back to x; measured ≤ 4 ulp for small |x| and ≤ 16 ulp at |x| ≈ 20, where it inherits MSL exp's accuracy. Float64 runs entirely in software binary64 (Kernels/DoubleTranscendental.swift): argument reduction against a 107-bit ln 2 plus a Taylor series, measured ≤ 2 ulp against Foundation over 10^6 inputs.
ln / log2 / log10 / log1p / logb (and _checked) GPU All five on float columns. Float64 ln/log2/log10 share one reduction (Kernels/DoublePower.swift): log2(x) comes back as an unevaluated pair t₁ + t₂ where t₁ holds 21 significant bits, so t₁ · ln2_hi (32 significant bits) is an exact product and only the tiny correction rounds — log2 is t₁ + t₂ rounded once, ln and log10 scale it by a split ln 2 / log10 2. Measured 1 ulp each over 10⁶ inputs from 5e-324 to 1.8e308, plus passes concentrated near 1 and over the subnormals; an exact power of two comes back exactly from log2. log1p() and logb(base) (scalar and array base) are in Kernels/MathExtra.swift and measured at ≤ 1 ulp and ≤ 2 ulp. lnChecked(), log2Checked(), log10Checked(), log1pChecked() and logbChecked() raise "logarithm of zero" / "logarithm of negative number" at exactly the right boundary (for log1p it is -1, and logb checks the base as well as the value).
hypot GPU hypot() (Kernels/MathExtra.swift), scalar and array forms, float columns only. Scales by a power of two before squaring, so a pair near the top or bottom of the range neither overflows nor underflows on the way; an infinite operand gives inf even opposite a NaN, as IEEE-754 prescribes. Float64 is software binary64 over the correctly rounded d_sqrt, measured ≤ 1 ulp against Foundation over 10^6 inputs.
sin / cos / tan / asin / acos / atan / atan2 GPU Kernels/Trig.swift + Kernels/TrigSource.swift, float32 and float64, scalar and array forms of atan2. float32 uses the MSL library (precise::sin/cos/tan, the accurate default asin/acos/atan), with atan2's ±0 and ±∞ cases fixed here because Metal's own does not follow C99. float64 is a real software binary64 implementation on the GPU, not a widened float: Cody-Waite reduction against π/2 split into sixteen 8-bit chunks (128 bits) plus Taylor series over the correctly rounded d_add/d_mul/d_div of DoubleMath.swift, with asin/acos folded through atan and √ so no branch cancels. Measured against Foundation over 1,000,003 random arguments per function (TrigTests.testFloat64UlpBudget / testFloat32UlpBudget, which print the table): float64 max 2 ulp sin, 3 cos, 5 tan, 4 asin, 4 acos, 2 atan; float32 max 3 sin, 3 cos, 4 tan, 3 asin, 3 acos, 3 atan; atan2 ≤ 4 ulp on both. sin/cos/tan reduce exactly for |x| ≤ 2⁴⁵·π/2 ≈ 5.5e13 (measured ≤ 7 ulp at 5e13, tan worst); above that the reduction degrades in step with the argument's own ulp, and |x| ≥ 2⁶² returns NaN rather than a meaningless number — the one documented difference from libm. An integer column throws rather than being promoted to float64, as with sqrt/exp/ln. trig(_:) and sin()…atan2(_:) in Swift, am_trig ops 0-12 in C, sin()…atan2() in Python.
sin_checked / cos_checked / tan_checked / asin_checked / acos_checked / acosh_checked / atanh_checked GPU Kernels/Trig.swift, the seven _checked twins Arrow publishes (there is no checked atan, sinh, cosh, tanh or asinh — those are total). Same values as the unchecked op, but a domain violation on a non-null row raises: asin/acos need |x| ≤ 1, acosh needs x ≥ 1, atanh needs |x| < 1, and sin/cos/tan reject ±∞. A NaN input never raises and a null row is never inspected, both verified against pyarrow.compute. The check rides inside the same kernel — one relaxed atomic flag plus an atomic_fetch_min of the offending row — so a clean column costs nothing beyond the unchecked op and the thrown error names the first bad index and its value. trigChecked(_:) / sinChecked()… in Swift, am_trig ops 13-19 in C, sin_checked()… in Python.
sinh / cosh / tanh / asinh / acosh / atanh GPU Same files and the same two flavours. Metal's own float32 hyperbolics are the naive (e^x ± e^-x)/2 and log(x + √(x²+1)) forms — measured at ~12 ulp on sinh(30), tens of thousands of ulp on asinh(-710), and NaN for tanh(±∞) and asinh(±∞) — so all six are written out here from well-conditioned identities (expm1/log1p series near zero, which MSL does not provide either), the same ones the float64 path uses. Max error over 1,000,003 random arguments: float64 4 ulp sinh, 2 cosh, 3 tanh, 3 asinh, 3 acosh, 4 atanh; float32 4, 3, 4, 3, 3, 4. sinh/cosh fold the halving into the exponential's scale, so they stay finite up to their true overflow point instead of overflowing near 710.
ceil / floor / trunc GPU Kernels/Rounding.swift, all ten primitives. On an integer column they are the identity and keep its type (Arrow promotes to float64 instead). Float64 clears the fractional mantissa bits on the GPU, exactly, signed zeros and infinities included.
round / round_to_multiple / round_binary GPU All three, in all ten Arrow RoundModes, over all ten primitives (Kernels/MathExtra.swift, Kernels/MathExtraSource.swift). round(ndigits:mode:) evaluates round_int(x · 10^ndigits) / 10^ndigits — the expression Arrow evaluates, so it inherits the same scaling rounding and lands on 123.46 for round(123.456, 2); roundToMultiple(_:mode:) is round_int(x / multiple) · multiple and refuses a non-positive multiple as Arrow does; roundBinary(_:mode:) takes an int32 ndigits column and is null wherever either column is. On an integer column the work is done on the quotient and remainder, so an int64 above 2^53 rounds exactly and a non-negative ndigits is the identity. Float64 rounding is exact (bit-pattern kernels plus the correctly rounded software binary64 multiply and divide). The no-argument round() keeps its old meaning, halves away from zero. Two extremes are defined rather than raised as Arrow raises them: a float ndigits past the type's decimal range is the identity, and an integer ndigits whose multiple does not fit the column type gives 0.

Bit-wise and shifts

Arrow function Status Notes
bit_wise_and GPU Kernels/Bitwise.swift, scalar and array forms, over the eight integer types. Note the two different things called "and": boolean and/or/not over packed bitmaps are the Logical section below; these are value-level ops on integer columns. A float column throws.
bit_wise_or GPU As bit_wise_and.
bit_wise_xor GPU As bit_wise_and.
bit_wise_not GPU bitwiseNot(); validity is shared zero-copy with the input.
shift_left / shift_left_checked Partial shiftLeft() is GPU, scalar and array forms. Arrow raises on a shift count that is negative or at least the bit width and C leaves it undefined; ArrowMetal defines it as 0 instead (Kernels/BitwiseSource.swift, matched by the test oracle) — that difference is why this row is still Partial. shiftLeftChecked() is GPU (Kernels/Checked.swift) and does raise, on Arrow's rule exactly: the amount must lie in [0, precision), where precision is the bit width on an unsigned column and one less on a signed one, so shift_left_checked(int64 1, 63) raises. Bits shifted off the top are not an error, in Arrow or here.
shift_right / shift_right_checked Partial shiftRight() is arithmetic on signed columns and logical on unsigned ones. An out-of-range count is defined as the sign fill: 0 for a non-negative value or an unsigned column, -1 for a negative one. shiftRightChecked() is GPU and applies the same amount check as shift_left_checked.

Comparisons

Arrow function Status Notes
equal GPU Routed (all ten primitives). Kernels/Compare.swift, scalar and array forms, output is a packed Arrow boolean bitmap written one 32-bit word per thread. Float64 compares on order-preserving bit patterns; IEEE semantics for NaN.
not_equal GPU As equal.
less GPU As equal.
less_equal GPU As equal.
greater GPU As equal.
greater_equal GPU As equal.
max_element_wise GPU maxElementWise(_:) (Kernels/Rounding.swift), two columns of the same type. Nulls are skipped, which is Arrow's skip_nulls default: a null on one side yields the other side's value and only two nulls make a null, so the output validity is the OR of the inputs', not the AND. NaN loses, as it does in the min/max reductions. Float64 compares on order-preserving bit patterns, exactly.
min_element_wise GPU As max_element_wise.

Logical

Arrow function Status Notes
and GPU Word-wise bitmap AND (Kernels/BitmapOps.swift). Nulls propagate — this is Arrow's and, not and_kleene.
or GPU As and.
invert (not) GPU Validity is shared zero-copy with the input.
xor GPU Kernels/LogicalExtra.swift, word-wise over the packed bitmaps, one thread per 32-bit output word. Nulls propagate (output validity is the AND of both inputs), matching Arrow's xor rather than a Kleene form. xor(_:) in Swift, am_logical op 0 in C, xor() / ^ in Python.
and_not GPU Kernels/LogicalExtra.swift: a AND NOT b, reusing the existing bitmap_and_not word kernel. Nulls propagate. andNot(_:) in Swift, am_logical op 1 in C, and_not() in Python.
and_kleene / or_kleene GPU Kernels/Structural.swift, one thread per 32-bit word: the value words are a & b / a | b and the validity word is computed from both operands' validity, so false AND null is false and true OR null is true. With no nulls on either side the call falls through to the plain and / or kernel. andKleene / orKleene in Swift, am_and_kleene / am_or_kleene in C, and_kleene / or_kleene in Python.
and_not_kleene GPU Kernels/LogicalExtra.swift, its own word kernel because a.andKleene(b.not()) is only correct when b has no nulls: the value word is a & ~b and the validity word (a.valid & ~a) | (b.valid & b) | (a.valid & b.valid), so a valid false on the left or a valid true on the right gives false even when the other side is null. With no nulls anywhere the call falls through to the plain and_not kernel. The nine-row truth table is asserted in ConditionalTests.testAndNotKleeneTruthTable. andNotKleene(_:) in Swift, am_logical op 2 in C, and_not_kleene() in Python.

String predicates

Arrow function Status Notes
ascii_is_alnum / _alpha / _decimal / _space / _lower / _upper GPU Kernels/StringTransforms.swift: asciiIsAlnum(), asciiIsAlpha(), asciiIsDigit() (Arrow's _decimal), asciiIsSpace(), asciiIsLower(), asciiIsUpper() → packed boolean bitmap, one 32-bit word per thread. Python/Arrow semantics: the empty string is false everywhere, and _lower/_upper need at least one cased ASCII character and none of the opposite case, treating bytes ≥ 0x80 as uncased. Nulls propagate.
ascii_is_printable / ascii_is_title GPU Kernels/StringExtra.swift: asciiIsPrintable() (every byte in 0x20–0x7E) and asciiIsTitle() (byte-wise title case over runs of ASCII letters, at least one letter). One bitmap word per thread, like the row above. ascii_is_printable is the one predicate here that is true on the empty string, matching Arrow.
utf8_is_alnum / _alpha / _decimal / _digit / _lower / _numeric / _printable / _space / _title / _upper GPU / CPU Kernels/StringExtra.swift, split per row: the sx_pred kernel answers every row byte-wise and reports, in a second bitmap, which rows carry a byte ≥ 0x80. A string of bytes < 0x80 is classified identically by the byte rules and by the Unicode tables, so only the marked rows are re-decided on the host with UnicodeClass (Swift's Unicode.Scalar.Properties), sharded over 4096-row chunks — an all-ASCII column never leaves the device. Categories: alpha L*, decimal Nd, digit Nd∪No, numeric Nd∪Nl∪No, alnum letters ∪ numbers, space Zs/Zl/Zp plus U+0009–U+000D, U+001C–U+001F and U+0085 (U+200B is not whitespace), printable everything but Cc/Cf/Cs/Co/Cn/Zs/Zl/Zp with U+0020 added back. utf8_is_printable is true on the empty string; the rest are false. Arrow's cased rule is reconstructed from utf8proc: upper = the simple lower-case mapping changes it or category Lt; lower = the simple upper-case mapping changes it or category Ll, minus the Roman numerals U+2160–U+216F — not Unicode's Uppercase/Lowercase derived properties, which would call modifier letters such as U+02B0 (ʰ) lower case. A titlecase letter is therefore both, which is why utf8_is_upper("Dž") and utf8_is_lower("Dž") are both false. Checked row-for-row against pyarrow.compute on a mixed 5 000-row column.
string_is_ascii GPU Kernels/StringExtra.swift: stringIsAscii(), every byte < 0x80, true on the empty string. Shares the sx_pred kernel with the rows above.

String transforms

MetalStringArray (Sources/ArrowMetal/MetalStringArray.swift) is Arrow utf8: validity bitmap, int32 offsets, data bytes; or, imported from utf8_view / binary_view (Polars' String layout), validity bitmap, 16-byte views and the variadic data buffers, which most string kernels read directly and the rest convert once (Design lists which). Everything below is byte-wise and case-sensitive except where a row says otherwise: the utf8_* case, slice, pad and reverse rows work in UTF-8 code points.

The transforms in Kernels/StringTransforms.swift produce new string arrays whose bytes are data-dependent, so each runs the same two-pass shape: one kernel writes the output byte length of every row, exclusiveScanToOffsets scans those into the Arrow offsets buffer on the GPU, and a second kernel writes the bytes. Both passes call one MSL routine (tf_apply in Kernels/StringTransformSource.swift), so a length and the bytes that fill it cannot disagree. The validity bitmap is shared with the input zero-copy and a null row emits no bytes. All of them are reachable from Swift, from the C ABI (am_str_transform, op table in include/arrowmetal.h) and from Python.

Arrow function Status Notes
binary_length GPU byteLength() → Int32, the offsets difference, null in / null out. Takes binary and large_binary as well as utf8, as Arrow's does.
utf8_length GPU charLength() counts UTF-8 code points.
ascii_lower / ascii_upper / ascii_swapcase / ascii_capitalize GPU asciiLower(), asciiUpper(), asciiSwapcase(), asciiCapitalize(). Byte-wise over a–z / A–Z; every other byte, UTF-8 continuation bytes included, is copied through, so the output is always valid UTF-8 and the same length as the input.
utf8_lower / utf8_upper GPU / CPU utf8Lower() / utf8Upper() (Kernels/StringUnicode.swift), Unicode's simple (1:1 code point) mapping over every script, split per row. The GPU table is exact over U+0000–U+017F — Basic Latin, the Latin-1 Supplement and Latin Extended-A, including the four length-changing entries U+00DF (ß → ẞ, two bytes to three), U+0130 (İ → i), U+0131 (ı → I) and U+017F (ſ → S), and U+00B5 (µ → U+039C), whose image leaves the block. Any row holding a code point above U+017F is mapped on the host with Swift's Unicode tables, sharded over 4096-row chunks, so an all-Latin column never touches the CPU and a Greek or Cyrillic one is answered correctly rather than passed through. Simple, not full: fi and ʼn stay put because their full mappings are two characters long, which is what utf8proc — and therefore Arrow — does too. Σ lowercases to σ in every position; the contextual final-sigma rule is not part of a simple mapping and Arrow does not apply it either.
utf8_swapcase GPU / CPU utf8Swapcase(), the same per-row split as utf8_upper / utf8_lower and the same two-pass shape. Inside the Latin blocks a code point with a lowercase mapping is lowered and everything else raised, which is exactly Arrow's answer there; above them the host applies Arrow's rule that a titlecase letter is both upper and lower case and so stays put — Dž swaps to Dž, while DŽ swaps to dž and dž to DŽ. am_str_extra op 0 in C, utf8_swapcase() in Python.
utf8_zero_fill GPU utf8ZeroFill(width:padding:) (Kernels/StringAliases.swift): left-pads to width code points, inserting the padding after a leading + or - so a signed number keeps its sign in front. A string already at or over width is unchanged, an empty string becomes width padding characters, and the content need not be numeric ("abc" at width 5 becomes "00abc", as Arrow does). padding must be exactly one character. am_str_extra op 1 in C, utf8_zero_fill() in Python.
utf8_capitalize / ascii_title / utf8_title GPU / CPU asciiTitle() (Kernels/StringExtra.swift) is always GPU (byte-wise: the first ASCII letter of every run of ASCII letters is upper-cased and the rest lower-cased, so "ünïcödé" → "üNïCöDé", exactly as Arrow does). utf8Capitalize() (first code point up, the rest down) and utf8Title() (the first cased code point of every maximal run of cased code points up, the rest down) take the same per-row split as utf8_upper (Kernels/StringUnicode.swift): a row inside U+0000–U+017F is done by the GPU table, anything above it on the host, sharded over 4096-row chunks. Case mapping is Unicode's simple 1:1 mapping, as utf8proc's is, reconstructed from Swift's full mappings: a full mapping of exactly one scalar is the simple mapping, a longer one leaves the code point alone (U+0149 ʼn, U+01F0 ǰ, U+1E96 ẖ, U+0587 և …), and U+00DF (ß → ẞ), U+0130 (İ → i) and the Greek iota-subscript blocks U+1F80–U+1F87 / U+1F90–U+1F97 / U+1FA0–U+1FA7 (which map +8) are the explicit exceptions where the two would otherwise disagree. Checked against pyarrow.compute on a mixed 5 000-row column.
replace_substring_regex / extract_regex CPU Kernels/Regex.swift. A backtracking engine is a poor fit for SIMT, so matching runs on the host through NSRegularExpression (ICU), sharded over DispatchQueue.concurrentPerform chunks of 4096 rows. replaceSubstringRegex(_:with:maxReplacements:) falls through to the GPU replaceSubstring kernel when the pattern has no metacharacter and the template has no $. Every host path also sits behind the GPU literal pre-filter (Kernels/StringLike.swift): requiredLiteral(_:) reads the pattern conservatively for a literal run every match must contain — \d{4}-cust-\d+ must contain -cust- — the byte-wise contains kernel marks the rows that hold it, and only those reach ICU. Rows it clears take the answer a non-match implies (false, 0, -1, unchanged, null), so the result is identical to running the engine everywhere. The analysis collects literals only at paren depth 0 and gives up entirely on a top-level |, an inline (?i) flag or \Q; a *, ? or {…} quantifier drops the character it binds to. extractRegexStruct(_:) returns Arrow's own shape, a struct<group: utf8, …> (Kernels/StringStructs.swift), with a non-matching or null row a null struct; extractRegex(_:) still returns the [String: MetalStringArray] dictionary, which is the convenient thing to hold in Swift. One documented difference from pyarrow, which uses RE2: the replacement template is ICU's ($1, not \1), and named groups are spelled (?<name>…) — the Python wrapper rewrites RE2's (?P<name>…).
extract_regex_span CPU extractRegexSpanStruct(_:ignoreCase:) (Kernels/StringStructs.swift) returns Arrow's own shape: a struct with one fixed_size_list<int32>[2] field per named capture group, holding that group's (start, length). extractRegexSpan(_:ignoreCase:) (Kernels/StringExtra.swift) still returns the [String: (start, length)] pair of int32 arrays. Offsets and lengths count bytes, as Arrow's do. A row that does not match, a null row and a group that took part in no alternative are null in both arrays. Same ICU engine and 4096-row sharding as extractRegex, so the same RE2-vs-ICU syntax differences apply — notably ICU's \d matches every Unicode decimal digit where RE2's is ASCII only.
ascii_reverse / binary_reverse / utf8_reverse GPU Three functions, two kernels. reverse() is utf8_reverse: it reverses code points, not grapheme clusters, so a combining mark or a ZWJ emoji sequence comes back in reverse code point order. binaryReverse() (Kernels/StringBytes.swift) reverses bytes always and types its result binary, because on non-ASCII content that is the invalid UTF-8 Arrow produces; it accepts binary and large_binary as well as utf8. asciiReverse() is the same byte kernel with pyarrow's guard in front of it — non-ASCII input is refused with non-ASCII sequence in input rather than mangled — and on ASCII the two reversals are the same answer.
replace_substring GPU replaceSubstring(_:with:maxReplacements:), non-overlapping and left to right, maxReplacements < 0 meaning all. Byte-wise, so a multi-byte pattern works. An empty pattern is the identity, matching Foundation's replacingOccurrences(of: "", with:) rather than Python's insert-everywhere.
binary_replace_slice / utf8_replace_slice GPU Kernels/StringExtra.swift: replaceSlice(start:stop:with:) indexes code points and replaceSliceBytes(start:stop:with:) bytes (returning binary, since a byte cut can split a UTF-8 sequence). Negative indices count from the end, both ends clamp into range, and a stop below start inserts without deleting — all three checked against pyarrow. Two-pass like every other transform, so the length and the bytes cannot disagree.
binary_slice / utf8_slice_codeunits GPU Kernels/StringBytes.swift: binarySlice(start:stop:step:) in bytes and sliceCodeunits(start:stop:step:) in code points, both with Python's slice rules — a negative index counts from the end, both ends clamp, and a negative step walks backwards, which the kernel does in one forward walk by filling the output from its end. A code point slice always lands on a UTF-8 boundary; a byte slice can split a sequence, which is why Arrow types that result binary and refuses a string input. One deliberate difference: Arrow's default stop is INT64_MAX, and with a negative step pyarrow 25.0.1 overflows it and reads out of bounds; an absent stop here means the beginning of the value, which is what the option intends.
ascii_trim* / ascii_ltrim* / ascii_rtrim* (whitespace and character set) GPU trim()/ltrim()/rtrim() strip ASCII whitespace (space, \t, \n, \v, \f, \r); trim(characters:)/ltrim(characters:)/rtrim(characters:) strip any byte in an ASCII set, and reject a non-ASCII set rather than splitting a UTF-8 sequence. Bytes ≥ 0x80 are never trimmed.
utf8_trim* (Unicode whitespace / character set) GPU / CPU Kernels/StringUnicode.swift: utf8Trim(characters:) / utf8Ltrim / utf8Rtrim and utf8TrimWhitespace() / utf8LtrimWhitespace() / utf8RtrimWhitespace(), split per row. A character set that is itself ASCII never leaves the GPU at all — byte-wise trimming can never split a UTF-8 sequence, since every continuation byte is ≥ 0x80 — and a set with a non-ASCII character has the GPU trim the rows whose bytes are all < 0x80 with the set's ASCII part (the only part such a row could match) and the host trim the rest. The whitespace family works the same way, the GPU taking the all-ASCII rows against a ten-byte set (\t, \n, \v, \f, \r, U+001C–U+001F and the space); the Unicode set is Zs/Zl/Zp plus U+0009–U+000D, U+001C–U+001F and U+0085, so U+00A0 and U+2003 are trimmed and U+200B is not. An empty character set is the identity, as in Arrow.
ascii_lpad / ascii_rpad, utf8_lpad / utf8_rpad GPU Two kernels, because Arrow means two different things. padLeft(width:pad:) / padRight(width:pad:) are the utf8_* forms: width counts code points. asciiLpad(width:pad:) / asciiRpad(width:pad:) (Kernels/StringBytes.swift) are the ascii_* forms: width counts bytes, so "héllo" — six bytes, five code points — gains two pad characters to width 8 where the utf8 form gives it three. pad is one character (one byte for the ASCII forms); a value already at or over width is returned unchanged.
ascii_center / utf8_center GPU center(width:pad:) (Kernels/StringExtra.swift) counts code points and asciiCenter(width:pad:) (Kernels/StringBytes.swift) counts bytes, the same pairing as the padding row above. Both put the odd pad character on the right ("a" centred in 4 is "*a**"), which is what Arrow does, and both return a value already at or over width unchanged.
binary_repeat GPU repeat(_ n:), n == 0 giving empty strings and n < 0 raising.
binary_join_element_wise GPU MetalStringArray.joinElementWise(_:separator:nullHandling:nullReplacement:) (Kernels/StringBytes.swift) over N equal-length columns and one scalar separator: a left fold of one two-pass join step, so N columns cost N-1 GPU passes and the data never leaves the device. All three of Arrow's null_handling modes are implemented — emit_null (a null anywhere makes the row null), skip (a null column contributes nothing, not even its separator; the fold leaves the accumulator null until a column has been joined) and replace. concat(_:separator:) remains as the two-column convenience. One deliberate difference: under skip, a row whose columns are all null joins to the empty string here, where pyarrow 25.0.1 emits no offset for it and returns an array shorter than its input.
binary_join (list of strings) GPU Kernels/StringContainment.swift: MetalListArray.binaryJoin(separator:) over a list<utf8> (MetalListArray lives in Sources/ArrowMetal/Nested.swift), with a scalar separator or a per-row utf8 column. Two passes: one kernel sums the child byte lengths plus count - 1 separators into a per-row output length, the host scans those into the offsets buffer, and a second kernel copies the bytes. An empty row joins to the empty string; a null row, any null element inside a row, and a null separator all give a null output row — Arrow's EMIT_NULL; the REPLACE / SKIP options are not implemented. am_binary_join in C, binary_join() in Python.
split_pattern / split_pattern_regex / ascii_split_whitespace / utf8_split_whitespace GPU / CPU Kernels/StringSplit.swift returns a real Arrow list<utf8> — the child of a row is values[offsets[i] ..< offsets[i+1]], and a null input row is a null list row owning no pieces; splitPatternPair / splitWhitespacePair still hand back the flat (offsets, values) pair over the same buffers. Splitting is the one string result that is two levels deep, so the GPU form is three passes over two scans: count the pieces of every row, measure them, copy the bytes. A literal separator and both whitespace classes run there — the Unicode class is eighteen code points plus three control ranges, small enough to spell out in MSL — leaving only split_pattern_regex on the host. Every separator makes a boundary, so a leading or trailing separator leaves an empty end piece and the empty string splits to one empty piece, which is Arrow's behaviour rather than Python's no-argument str.split(). max_splits and reverse are implemented; split_pattern_regex refuses reverse, exactly as Arrow does. One deliberate difference: pyarrow 25.0.1's utf8_split_whitespace splits the whitespace run that reaches the far end of its scan into two separators, so "a " comes back from it as three pieces; the runs here stay maximal.
utf8_normalize CPU Kernels/StringExtra.swift: utf8Normalize(_:) for NFC, NFKC, NFD and NFKD through Foundation, sharded over 4096-row chunks — full Unicode normalisation tables in MSL buy nothing over the host. Difference from pyarrow: pyarrow.compute.utf8_normalize (checked against 25.0.1) never composes, so its NFC output equals its NFD and its NFKC equals its NFKD ("é" comes back as U+0065 U+0301); this follows the Unicode standard and agrees with Python's unicodedata.normalize on all four forms.

String containment and matching

Arrow function Status Notes
equal (string vs. string scalar) GPU equals(_ s: String) → boolean bitmap.
equal (string vs. string array) GPU equals(_ other: MetalStringArray); validities are AND-ed on the GPU.
match_substring GPU contains(_:), byte-wise, case-sensitive, no ignore_case option.
starts_with GPU startsWith(_:).
ends_with GPU endsWith(_:).
match_substring_regex / match_like GPU / CPU match_like is now entirely GPU for every case-sensitive pattern (Kernels/StringLike.swift): a pure prefix, suffix, contains or equality pattern still routes to startsWith / endsWith / contains / equals, which is exact because SQL LIKE anchors to the whole value, and everything else — a _ anywhere, an interior %, any mixture — is compiled into a small byte program and run by one thread per row with the classic greedy-plus-backtrack wildcard algorithm, where _ and % count code points rather than bytes. Only ignoreCase still goes to ICU. matchSubstringRegex(_:ignoreCase:) is a real regex and stays on the host, with two things in front of it: a pattern with no metacharacter routes to the contains kernel and ^literal to startsWith (ICU's ^ is exactly "start of input"; a trailing $ deliberately does not, because ICU also matches it before a final line terminator), and any pattern with a required literal run gets the GPU pre-filter described below.
count_substring_regex / find_substring_regex CPU Same file and the same sharding. A literal pattern routes to the existing GPU countSubstring / findSubstring kernels. findSubstringRegex reports the byte offset of the first match, or -1, matching find_substring.
count_substring GPU countSubstring(_:) → Int32, non-overlapping occurrences, byte-wise and case-sensitive (no ignore_case). An empty pattern counts the code point boundaries, charLength() + 1, matching Arrow. Nulls propagate.
find_substring GPU findSubstring(_:) → Int32, the byte offset of the first occurrence or -1 when absent; an empty pattern finds 0. Byte-wise and case-sensitive. Nulls propagate.
index_in / is_in (strings) GPU Kernels/StringContainment.swift. The value set is hashed with the same 64-bit key dictionaryEncode builds (two independently seeded MurmurHash3 x86_32 passes side by side) and inserted into an open-addressing table of row indices with linear probing; every probe confirms its candidate by comparing the full bytes, so a hash collision costs one extra probe and can never give a wrong answer, and duplicates in the set collapse onto the lowest row index — exactly the first occurrence index_in reports. Nulls in the value set are ignored, so the kernel itself computes Arrow's null_matching_behavior = "skip"; Kernels/SetLookup.swift turns that answer into any of the four behaviours — match (pyarrow's default), skip (this package's), emit_null and inconclusive — with one boolean or, one if_else or a validity-bitmap rewrite, because the four differ only in what a null probe reports and, for inconclusive, what a miss reports when the value set holds a null. isIn(_:) / indexIn(_:) in Swift, am_string_is_in / am_string_index_in in C, is_in() / index_in() in Python.

Temporal

Arrow function group Status Notes
Component extraction: year, month, day, day_of_week, hour, minute, second, day_of_year, quarter, iso_week, iso_year, is_leap_year, millisecond, microsecond, nanosecond GPU Temporal.swift decomposes the calendar with Howard Hinnant's civil_from_days; Kernels/TemporalMath.swift extends it in its own source file with dayOfYear(), quarter(), isoWeek(), isoYear(), isLeapYear() (a packed boolean bitmap, one 32-bit word per thread) and the three subsecond components. Arrow's nesting for those: millisecond counts from the last full second, microsecond from the last full millisecond, nanosecond from the last full microsecond. UTC only — a timestamp's timezone is metadata and is never applied. subsecond, week/us_week/us_year, iso_calendar, year_month_day, the day_of_week options and is_dst are in the two rows below.
Component extraction with options, and the struct-valued extractors: week(week_starts_monday, count_from_zero, first_week_is_fully_in_year), us_week, us_year, iso_calendar, year_month_day, day_of_week(count_from_zero, week_start), subsecond GPU Kernels/TemporalExtra.swift (+ TemporalExtraSource.swift), which carries its own copy of the civil-calendar algorithms so Temporal.swift and TemporalMath.swift are untouched. All int64, as pyarrow's are. Every WeekOptions combination is one formula: the week belongs to the year owning its pivot — its first day when first_week_is_fully_in_year is set, its fourth day (the ISO majority rule) otherwise — and count_from_zero numbers the weeks against the value's own calendar year instead, which is what makes a leading partial week come out as 0. us_week is the Sunday-start majority rule and us_year the year owning that week's Wednesday. iso_calendar and year_month_day write their three int64 children in one pass and come back as a MetalStructArray (Nested.swift), a null row making the struct row null with the children left valid, exactly as Arrow shapes it. subsecond is float64 and Metal has no double, so the quotient goes through Kernels/DoubleMath.swift's software binary64 — d_div is correctly rounded, so the result is bit-for-bit the one Arrow computes on the host; date32 and date64 answer 0 where pyarrow has no kernel at all, and duration is rejected. dayOfWeek(countFromZero:weekStart:) is a new overload beside the existing no-argument int32 dayOfWeek(). am_temporal_extra ops 0-4, 6 and 7 in C; week(), us_week(), us_year(), iso_calendar(), year_month_day(), day_of_week(...) and subsecond() in Python.
is_dst GPU Kernels/TemporalExtra.swift, on the transition table Kernels/TimezoneGPU.swift uploads: one binary search per row over the zone's DST flags. Needs a timestamp whose format string carries a timezone; a naive one is an error, as in Arrow. A fixed offset ("+02:00") never observes DST. Agrees with pyarrow exactly from 1900 through 2037; past the 2038 cliff Foundation projects each zone's current rule forward while pyarrow's bundled tz database stops, so the two diverge there — a difference in the timezone data, not in the kernel. 50M rows, America/New_York: 14.4 ms against pyarrow's 595 ms.
Differences and arithmetic: days_between, subtract / add over temporal types, hours_between, minutes_between, seconds_between, milliseconds_between, microseconds_between, nanoseconds_between, weeks_between, months_between, quarters_between, years_between, *_interval_between GPU Kernels/TemporalMath.swift: daysBetween(_:) floors both sides to their UTC day and returns the int64 day difference; subtractTemporal(_:) gives timestamp − timestamp (or duration − duration, date32 − date32, date64 − date64) as a duration in the finer of the two resolutions; addDuration(_:) adds a duration column (rescaled to the receiver's unit) or a scalar of the receiver's own ticks, and is rejected on date32, whose tick is a whole day. Kernels/TemporalExtra.swift adds the rest as int64: yearsBetween (the difference of the calendar years), quartersBetween (of year * 4 + quarter), monthsBetween (of year * 12 + month — Arrow spells the same quantity month_interval_between and returns an interval), weeksBetween(_:countFromZero:weekStart:) (week boundaries crossed, both sides floored to their week start first) and hoursBetween … nanosecondsBetween. All of them count boundaries crossed, Arrow's definition: each side is truncated to the unit first and the difference taken afterwards, so it is not the truncated difference. One kernel maps both sides onto the op's own ruler with floor(v * num / den), so the two columns may differ in unit and in type (a date32 against a timestamp[ns]), which is more permissive than Arrow, where both arguments must have the same type. nanoseconds_between wraps in int64 past about 292 years, as Arrow's does. time32 / time64 are accepted by the fixed-unit ops (ticks since midnight) and rejected by the calendar ones; duration is rejected by both. Checked against pyarrow.compute over 100k random pairs spanning 1900-2200 in python/tests/test_arrowmetal.py. am_temporal_extra ops 8-17 in C.
Interval differences: month_interval_between, day_time_interval_between, month_day_nano_interval_between GPU Sources/ArrowMetal/IntervalBetween.swift, one kernel behind all three (Kernels/TypesExtraSource.swift), over date32 / date64 / timestamp pairs brought to a common resolution first. Every field is the difference of the corresponding truncated field, which is how Arrow defines these: months are month boundaries crossed ((y2 - y1) * 12 + (m2 - m1), so 2020-01-31 → 2020-02-01 is one month and 2020-01-01 → 2020-01-31 is zero), the day field is the difference of the day-of-month fields (month_day_nano) or of the whole days (day_time), and the sub-day field is the difference of the two times of day — so the day and sub-day fields may have the opposite sign to the month count. Null in / null out on either side. am_interval_between in C, month_interval_between() / day_time_interval_between() / month_day_nano_interval_between() in Python. month_day_nano_interval_between is compared value-for-value against pyarrow.compute over 100k random pairs spanning 1900–2200 in both directions; the other two have no pyarrow oracle reachable from Python (pyarrow 25 cannot wrap interval[month] or interval[day_time] arrays), so they are checked against a host civil-calendar oracle and against the corresponding field of pyarrow's month_day_nano result.
Rounding: ceil_temporal, floor_temporal, round_temporal GPU Kernels/TemporalMath.swift, with the whole of Arrow's RoundTemporalOptions in one RoundTemporalOptions struct: multiple, all eleven unit values, week_starts_monday, ceil_is_strictly_greater and calendar_based_origin. nanosecond … day are integer arithmetic in the value's own resolution; week is a seven-day grid anchored on a Monday (1969-12-29) or a Sunday (1969-12-28); month / quarter / year go through the civil algorithm and its inverse days_from_civil, so they need a column that carries a date. The month and quarter grids count from 1970-01 and the year grid from year 0, which is how Arrow anchors them and only shows up at multiple > 1. calendar_based_origin starts the grid at the beginning of the value's own next-greater calendar unit — the containing day for hours, month for days, year for weeks and months — which the kernel resolves per element. round sends an exact half up, toward +infinity, which is what Arrow does (this file used to claim Arrow rounds half to even; it does not). Two Arrow quirks are reproduced deliberately: ceil on month / quarter / year always advances a value already on a boundary, and a unit finer than the column's own resolution floors in that finer unit and truncates back, whatever mode was asked for. Checked against Foundation's Calendar in UTC over 100k random timestamps in TextTests, and against pyarrow.compute over the whole option cross product in python/tests/test_options.py.
Timezones: assume_timezone, local_timestamp, utc_offset, to_timezone GPU Kernels/TimezoneGPU.swift. A timezone is a step function over a few hundred instants — America/New_York has 559 UTC-offset transitions between 1800 and 2200, Europe/Berlin 467, Asia/Kolkata 7 — so the table is enumerated once per zone from Foundation's TimeZone, verified against it at both ends of every interval, uploaded once and cached for the process. Each function is then one pass whose per-row work is a ten-step binary search and an add. assumeTimezone(_:ambiguous:nonexistent:) searches a second sorted key, the local start of each interval, which decides Arrow's ambiguous and nonexistent cases without a second lookup: two intervals still running is a fall-back, none running is a spring-forward gap. Either throws by default (ambiguous = "raise" / nonexistent = "raise"); earliest / latest pick the earlier / later instant, and for a gap the last instant before / the first instant after it. localTimestamp() is the inverse of assumeTimezone; utcOffset() exposes the lookup on its own as int32 seconds; toTimezone(_:) retags the type and changes no value, which is what Arrow's cast between timezones does. Neither changes the unit, and the sub-second part rides through untouched. IANA names and fixed offsets ("+02:00") are both accepted; a zone with no transitions short-circuits to an add. Sources/ArrowMetal/Timezone.swift keeps the host implementation as the fallback for a zone Foundation will not enumerate, for values outside 1800-2200 and on a virtual GPU (ARROWMETAL_TZ_HOST=1 forces it). am_assume_timezone / am_local_timestamp / am_utc_offset / am_to_timezone in C, the same names in Python. Checked against pyarrow.compute over 1M random instants per zone per unit for America/New_York, Europe/Berlin, Australia/Sydney, Asia/Kolkata, America/Sao_Paulo and UTC across 1900-2037 and over every ambiguous and nonexistent minute around every DST change from 2000 to 2037, and against the host path over the full 1900-2100 window (72M values, no mismatches). Past 2038 both ArrowMetal paths report the rules Foundation projects forward while pyarrow's bundled tz data stops, the same divergence the is_dst row records. 50M rows, America/New_York: assume_timezone 16.8 ms against pyarrow's 1191 ms, local_timestamp 16.1 ms against 558 ms.
strftime / strptime GPU Kernels/TemporalFormat.swift. The format string is host data but tiny and the same for every row, so it is compiled once into a flat list of uint4 operations and uploaded as a small buffer: one generic kernel serves every format and no format costs a shader recompile. It is a C strftime/strptime format, which is what Arrow takes, not a DateFormatter Unicode pattern. strftime is the two-pass shape the integer→string cast uses — measure every row, scan the lengths into the Arrow offsets buffer on the GPU, emit — because %B, %Z and a wide %Y make the output width data dependent; both passes run the same fmt_row, so a length and its bytes cannot disagree. Month and day names are C-locale constants in the shader. On the GPU: %Y %m %d %e %H %I %M %S %f %j %y %b %B %h %a %A %p %C %G %V %u %w %z %Z %F %T %D %R %n %t %% and literals for strftime, and the same minus %j %C %G %V %u %w %Z for strptime; anything else falls back to the C library (gmtime_r + strftime, strptime + timegm), which is still the reference. %f is an ArrowMetal extension expanding to the six-digit fractional second, and %S stays two digits — both are C's reading and both differ from pyarrow, which folds the fraction into %S and prints %f literally. A timestamp carrying a timezone formats in that zone on the GPU path through the same transition table, as pyarrow does, which is what gives %z and %Z an answer; the host fallback is UTC only. strptime must consume the whole value; a row that does not parse comes back null, or throws with strict: true. Byte-exact against pyarrow.compute over 200k random timestamps per unit in all four units from 1900 to 2100, for date32, and for five timezones; byte-exact against the host path over the same corpus except the two documented pyarrow differences and %f in strptime, which the C library rejects. ARROWMETAL_FORMAT_HOST=1 forces the host path. 50M rows: strftime("%Y-%m-%d") 176 ms against Polars' 5264 ms, strptime("%Y-%m-%d %H:%M:%S") 17 ms against pyarrow's 2076 ms.
Temporal types (date32, date64, time32, time64, timestamp, duration) GPU Sources/ArrowMetal/Temporal.swift: all six import and export through the C Data Interface and run on the existing fixed-width integer kernels, with the Arrow unit and timezone carried alongside as metadata. See the Type matrix below for the per-type detail.

Conversions and casts

Arrow function Status Notes
cast (numeric → numeric) GPU Kernels/Cast.swift, GPU, across all ten primitives, with Arrow's CastOptions in Sources/ArrowMetal/CastOptions.swift. safe=false (this package's default) is the unchecked C-style conversion: integer narrowing wraps and float → int truncates toward zero and saturates the way Arrow's does. safe=true adds one read-only GPU pass that converts each value back and raises ArrowMetalError.overflow naming the first row that loses something — integer overflow, a float's fractional part, a float out of the integer range, an infinity or a NaN. Two rules are not the round trip and are spelled out at the predicate: an equal-width sign crossing (int64(-1) → uint64) round-trips bit for bit and is still refused, and int → float is refused outside the float's contiguous integer range (2^24, 2^53) even when the value happens to be representable. allow_int_overflow and allow_float_truncate turn those back off individually.
cast involving Float64 CPU Dispatch.runsOnGPU excludes Double, so any cast with Float64 on either side runs a host loop. Note that Float64 arithmetic and reductions do run on the GPU — the cast is the exception.
cast boolean ↔ integer GPU Both directions through cast(to:): MetalBooleanArray.toUInt8Array() is the GPU unpack (bitmap → uint8) and a numeric column becomes a boolean with the GPU != 0 compare, which is Arrow's rule.
cast string ↔ numeric / temporal Partial Kernels/StringCast.swift. Integer → string is GPU (str_itoa_*, the same two-pass length/bytes shape as the string transforms), exact for Int64.min and UInt64.max; string → integer is GPU (str_parse_int, one thread per 32 rows so the validity word needs no atomics), the whole value matching [+-]?[0-9]+ with leading zeros allowed and everything else — empty, malformed, out of range, a - on an unsigned target — becoming null, which is Arrow's safe=false; strict: true throws instead. Float → string is CPU: the shortest decimal string that round-trips, which differs from Arrow in keeping a .0 on a whole value and using Swift's exponent form. String → float is GPU (str_parse_f64 / str_parse_f32, Eisel-Lemire in integer arithmetic, Kernels/CSVFloatSource.swift), bit-identical to Swift's Double/Float initialiser, which still parses the rows the GPU cannot decide exactly (hex floats, NaN payloads, a 20+ digit mantissa on a rounding boundary); CSVFloatParseTests compares the two bit for bit. Boolean conversions are CPU; booleans are "true"/"false" out and "true"/"false"/"1"/"0" case-insensitively in. Temporal ↔ string goes through the strftime / strptime row above. Reachable as am_to_strings / am_parse in C and to_strings() / cast("string") / parse(type) in Python. The checked (safe=true) forms of the numeric casts are implemented (see the numeric row); a string that does not parse still becomes null rather than raising, which is strict: true's job.
cast to/from decimal CPU Sources/ArrowMetal/Decimal.swift, decimal128 only, one host pass each. toFloat64() divides the unscaled 128-bit value by 10^scale; MetalDecimalArray.fromFloat64(_:type:) multiplies by 10^scale and rounds halves away from zero, turning a non-finite or out-of-range value into null; fromInt64(_:type:) multiplies exactly (wrapping past 128 bits). Both directions carry Double's 53 bits of precision, so a cast through float64 is lossy above 2^53 — deliberately host code, since a kernel would buy nothing over the PCIe-free unified memory. am_decimal_op ops 16 and 17, to_float64() in Python. cast(to:options:) now reaches all of this by format string: integer → decimal128, float → decimal128 (scaled before it becomes an integer, so the fractional digits survive), and decimal → decimal, which rescales and honours allow_decimal_truncate — a rescale that would drop a digit raises without it. Decimal ↔ string is still not implemented.
cast dictionary GPU Both directions, under their own names rather than through cast: dictionaryEncoded() (Kernels/DictionaryCompute.swift) covers every column type this package has, and decode() materialises a dictionary column with one take. am_str_dictionary_encode / am_dictionary_decode in C, dictionary_encode() / dictionary_decode() in Python.

Selections

Arrow function Status Notes
filter / array_filter GPU Routed (primitive columns). Kernels/Filter.swift: per-block popcount, GPU scan, scatter, validity pack — all in one command buffer. Null mask entries drop the element (Arrow's null_selection_behavior = "drop"); the "emit_null" option is not implemented. Works for primitives, booleans and utf8.
filter(where:) (fused predicate + compaction) GPU Routed (integer columns; float64 runs a routed compare and a routed filter; float32 stays on the GPU). ArrowMetal extension, not an Arrow function: the comparison is evaluated inside the counting pass so no boolean array is materialised.
take / array_take GPU Int32/Int64/UInt32 index arrays (the index arrays ArrowMetal returns are UInt32). A null index yields a null output element; out-of-range indices set a GPU error flag that is raised after the dispatch. Strings gather through offsets + a GPU byte copy.
drop_null GPU Kernels/Structural.swift: is_valid followed by the existing filter compaction, so it is one command buffer with no host round trip. An array with no validity bitmap is returned unchanged. dropNull() in Swift (primitive and boolean), am_drop_null in C, drop_null() in Python.
inverse_permutation GPU inversePermutation(maxIndex:) (Kernels/Selection.swift): one atomic scatter kernel writes, for the i-th index, the value i into slot index. The output has max_index + 1 elements (the input's length when max_index is negative); a slot no index names comes back null, and when several positions name the same slot the last one wins — Arrow's rule, and deterministic here, because "last wins" is an atomic_fetch_max over the source positions and a maximum does not depend on thread order. Null indices are skipped; an index outside [0, max_index] raises through the usual GPU error flag. The result is always int32 — Arrow's output_type option is not implemented. am_inverse_permutation in C, inverse_permutation() in Python.
scatter GPU scattered(to:maxIndex:) (Kernels/Selection.swift) is that inverse permutation used as a take, so one kernel plus the existing gather covers every column type, nested ones included: take already turns a null index into a null output row, which is exactly what an unassigned slot must produce. Same null and duplicate rules as inverse_permutation. am_scatter in C, scatter() in Python.
slice (array method, not a compute function) CPU Zero-copy at every offset and O(1) in the length (Sources/ArrowMetal/Slice.swift). An offset that is a multiple of 32 becomes a pair of exact MetalArrowBuffer views — bitmap words stay 4-byte aligned and the values stay aligned for the kernels' 4-wide vector loads — and the slice carries no offset, so every kernel runs on it unchanged. Any other offset is carried as Arrow's element offset on the array: host reads (subscript, first, last), the null count (a popcount over the bit range, deferred until someone asks for it) and export read that offset directly, and export hands the parent's buffers over with ArrowArray.offset attached, so exporting a slice back to pyarrow still moves no bytes. Import accepts a producer's non-zero offset the same way. Only an actual kernel dispatch normalises the offset away, once, cached — a memcpy of the values plus a word-wise bit shift of the bitmap. Slicing a slice adds the offsets, so a chain never costs more than one slice. utf8 and binary slice by viewing the offsets buffer and sharing the data buffer, so the characters are never touched.

Containment / set lookup

Arrow function Status Notes
is_in GPU Kernels/Structural.swift, all ten primitive types. The value set is reduced to its sorted distinct non-null values with the existing unique(), and each element binary-searches it on the GPU (no hash table). Nulls in the set are ignored and a null element never matches, so the result never has nulls — Arrow's null_matching_behavior = "skip". Float equality is Arrow value equality, as in unique(): every NaN is one value and -0.0 equals 0.0. isIn(_:) in Swift (a [T] or a MetalArray<T>), am_is_in in C, is_in() in Python. utf8 columns take a different route — a GPU hash table over the string bytes; see the index_in / is_in (strings) row under String containment — and is_in() / index_in() in Python dispatch on the column's type.
index_in GPU Same search, returning the int32 position in the caller's set array of each element's first occurrence there, and null where the element is null or absent. The unique-rank-to-first-row map is a group-by min over the set's dictionary codes, so it too runs on the GPU. indexIn(_:) in Swift, am_index_in in C, index_in() in Python.
indices_nonzero GPU Kernels/Conditional.swift: a GPU iota put through the existing stream compaction with value != 0 as the mask, so it is the filter kernel's own scan-and-scatter and no new scan. Returns uint64 row numbers, as Arrow does; null rows are dropped and the result never has nulls. -0.0 counts as zero and every NaN as non-zero (the IEEE != 0 test), matching Arrow. Primitive and boolean columns. indicesNonzero() in Swift, am_indices_nonzero in C, indices_nonzero() in Python.

Sorts and partitions

Arrow function Status Notes
array_sort_indices GPU Returns uint32 row numbers (pyarrow returns uint64, with the same values; a row number past 2^32 - 1 is an error), as do sort_indices, select_k_unstable, partition_nth_indices, rank and the window ranks below. Kernels/Sort.swift: LSD radix sort, 4 passes for 32-bit keys and 8 for 64-bit, stable. Ascending or descending. Total order for floats (NaN after +inf). utf8 and binary take Kernels/StringSort.swift: an LSD radix over 7-byte prefix chunks, each packed into a 63-bit key as nine bits per byte holding byte + 1, so "this row ended here" sorts strictly below every real byte — including NUL, which a zero-padded key would confuse with the end of a shorter row ("ab" must precede "ab\0", and does). The column is sorted once per chunk from the last chunk to the first with the same stable radix argsort, so ceil(longest row / 7) passes give full byte-wise lexicographic order: the order Arrow defines for these types, index for index with pyarrow.compute.array_sort_indices, not Unicode collation. Nulls last and stable in both directions.
sort_indices (multiple sort keys) Partial Kernels/MultiSort.swift: successive stable radix argsorts from the least significant key upwards, the keys reordered with take between passes, so k keys cost k argsorts and no new kernel. Ascending or descending per key; nulls last in every key in both directions. lexsortIndices(_:descending:) and MetalRecordBatch.sorted(by: [(column:descending:)]) in Swift, am_lexsort in C, lexsort_indices() in Python. utf8 and binary key columns sort through Kernels/StringSort.swift and may be mixed freely with numeric keys; dictionary and nested key columns throw — there is no order-preserving GPU key for them.
Sorted copy (sorted()) and MetalRecordBatch.sorted(by:) GPU Argsort then take, single key or several. Not an Arrow compute function name, but it is what callers use.
null_placement in the sorted index array GPU The null rows never enter the sort: a stable three-way partition (part_count / part_scan / part_scatter in Kernels/Sort.swift) puts the values, the NaNs when at_start separates them, and the nulls into their blocks first, and the radix sort runs over the value block alone. The partition is stable, so the nulls keep their input order, which is what Arrow's stable sort promises. NaN travels with the nulls, as it does in Arrow: at_start puts the nulls first, then the NaNs, then the values. A utf8 or binary sort partitions its nulls the same way, as a bit in the sort's own prefix keys (Kernels/StringSort.swift).
select_k_unstable (top-k) GPU Kernels/TopK.swift: for k ≤ 1024 each threadgroup keeps the best k of its own block in threadgroup memory (threshold plus a bitonic compaction), and one radix sort over the blocks * k candidates orders the winners. Same total order as argsort — value key, ties by row — so the result is index-for-index what the full sort would give. Larger k goes through a GPU radix select (Kernels/RadixSelect.swift: a digit histogram finds the bin holding the k-th key, one compaction keeps the candidates); only the case where fewer than k rows are non-null falls back to the argsort-and-slice. Single key. 50M Int64, k=100: 2.67 ms in the 2026-09-07 matrix, against 32 ms for the full sort.
partition_nth_indices GPU partitionNthIndices(_:nullPlacement:) (Kernels/PartitionNth.swift) is a real selection. Every value maps to the same order-preserving unsigned key the radix argsort uses, and an MSB-first radix select finds the order statistic in a fixed four (32-bit keys) or eight (64-bit) passes: each pass histograms 256 digit values over only the keys whose higher bytes match the prefix found so far, and the host walks those 256 counts to pick the next byte. Three GPU stream compactions (< key, == key, > key) then split the row indices around it, concatenated by a copy kernel. O(length), against the sort's histogram + scan + scatter per pass and its two extra buffers. Both null_placement values; when the pivot falls inside the null block the select is skipped entirely. am_partition_nth_ex in C, partition_nth_indices(pivot, null_placement) in Python.
rank GPU Kernels/Window.swift: one argsort, run marks over the sorted order, a two-level scan of those marks, and a scatter back to the original rows. Arrow's whole option surface — the sort_keys direction, both null_placement values and all four tiebreakers. The four are also spelled under their SQL names: rank() is min, maxRank() is max, rowNumber() is first and denseRank() is dense; max reads the run-end bound the kernel already recorded for cume_dist. The nulls are one tie group in a contiguous block at whichever end, which is all the marks kernel needs to know. am_rank_ex in C, rank(sort_keys, null_placement, tiebreaker) in Python.
rank_quantile GPU rankQuantile() (Kernels/Selection.swift), GPU: the same argsort-marks-scan shape, but a row's value is (average 1-based rank of its tie group - 0.5) / n — pyarrow's definition — computed as (s + e) / (2n) over the run's sorted positions [s, e) with the correctly rounded software binary64 divide, so it matches a host Double division bit for bit. The nulls are one tie group at whichever end null_placement names, NaN is one value ordered after +inf (and, like a null, moved to the front by at_start), and no result is itself null. Arrow's sort_keys direction and both null_placement values are implemented. am_rank_quantile_ex op 0 in C, rank_quantile(sort_keys, null_placement) in Python.
rank_normal Partial The normal percent-point function of rank_quantile. In float64 (rankNormal()) the ranks come off the GPU and the inverse CDF runs on the host through Wichura's AS 241 (NormalQuantile.ppf, about 1e-16 relative): Metal has no double, and the software binary64 in DoubleMath has no log/exp/erfc to build one on. In float32 (rankNormalFloat32()) the whole thing runs on the GPU — Acklam's rational approximation plus one Halley refinement through a Chebyshev erfc, with the upper tail evaluated on the exact integer complement so a float32 quantile near 1 cannot lose the digits the PPF needs — and lands within about 1e-6 of the float64 answer (checked at sizes up to 1,000,003 rows in SelectionExtraTests). Arrow's option set is not implemented. am_rank ops 1 and 2 in C, rank_normal(float32=) in Python.
winsorize GPU winsorize(lowerLimit:upperLimit:) (Kernels/Selection.swift): one GPU sort for the two bounds, then a clamp kernel that shares the input's validity bitmap. The limits are Arrow's nearest quantiles, not interpolated ones — with m non-null, non-NaN values sorted ascending, a limit q picks sorted[round(q * (m - 1))] with the halfway case rounding to the even index, so both bounds are values that actually occur (verified against pyarrow.compute.winsorize over 6,000 random shapes). Nulls stay null and NaNs pass through, taking part in neither the limits nor the comparison; an all-null or all-NaN column comes back unchanged. Float64 clamps through the order-preserving d_key map rather than software arithmetic. am_winsorize in C, winsorize() in Python.
SQL window ranking: row_number, dense_rank, percent_rank, cume_dist GPU An ArrowMetal extension, not Arrow compute function names. Same argsort-plus-scan as rank, so all five cost one sort. Nulls follow ORDER BY x NULLS LAST: they sort after every value and form one tie group, so no ranking result is itself null. Float ties use Arrow value equality (every NaN is one value, ordered after +inf; -0.0 equals 0.0). percentRank() and cumeDist() come back as float64 through the correctly rounded software binary64 divide. am_window ops 0-4 in C, row_number() / rank() / dense_rank() / percent_rank() / cume_dist() in Python.

Structural and conditional transforms

Arrow function Status Notes
fill_null GPU Kernels/Structural.swift, one thread per element, all ten primitive types plus bool; the result drops the validity bitmap. Float64 moves as a raw 64-bit value, so no software binary64 is involved. Spelled fillingNull(_:) in Swift because the internal host-side MetalArray.fillNull used by string gather still exists (MetalStringArray.swift); am_fill_null in C, fill_null() in Python.
fill_null_forward / fill_null_backward GPU Kernels/Conditional.swift + ConditionalSource.swift. A seed kernel writes p + 1 at every valid slot and 0 at every null one, a two-level max-scan (the one CumulativeSource already generates, instantiated over uint) turns that into "one more than the index of the last valid row so far", and a gather reads it back; the backward form walks the scan from the far end, so one direction flag covers both. Leading (respectively trailing) nulls stay null, as in Arrow. All ten primitive types plus bool; Float64 moves as a raw 64-bit value, so NaN payloads and -0.0 survive bit-identically. An array with no validity bitmap is returned unchanged. fillNullForward() / fillNullBackward() in Swift, am_fill_null_direction in C, fill_null_forward() / fill_null_backward() in Python.
if_else GPU Kernels/Structural.swift. Array/array, array/scalar, scalar/array and scalar/scalar branches, all ten primitive types plus bool (booleans go through the existing unpack/repack). A null condition yields a null output; otherwise the chosen branch's value and null-ness are copied through. MetalArray.ifElse(_:_:_:) and cond.ifElse(_:_:) in Swift, am_if_else in C, if_else() in Python.
case_when GPU Kernels/Conditional.swift, as a right-to-left fold of the GPU if_else kernel — the same shape coalesce uses, so k branches cost k passes and no new kernel. Arrow takes the conditions as a struct of booleans; here they are a plain array, one per branch, plus an optional default. A null condition counts as false and the row falls through to the next one (verified against pyarrow.compute), so each condition is fill_null(false)-ed before the three-valued if_else; a null in the chosen branch's values does make the output null. With no default a row that matches nothing is null. MetalArray.caseWhen(conds:values:else:) in Swift, am_case_when in C, module-level case_when() in Python.
coalesce GPU Kernels/Structural.swift: a left fold of a two-input kernel, stopping early once the accumulator has no validity bitmap left. Any number of same-typed, same-length inputs. MetalArray.coalesce(_:) in Swift, am_coalesce in C, module-level coalesce() in Python.
choose GPU Kernels/Conditional.swift: values[indices[i]][i], as a fold of if_else over indices == j, one pass per candidate column. Indices are int32, int64 or uint32; a null index gives a null output. An index outside [0, count) raises, as in Arrow — the range check is one GPU min and one GPU max over the index column, both of which skip nulls, so a null sitting next to an out-of-range value slot never trips it. MetalArray.choose(_:_:) in Swift, am_choose in C, module-level choose() in Python.
replace_with_mask GPU Kernels/Conditional.swift + ConditionalSource.swift: a seed kernel marks the valid trues, the same two-level scan as the null fills (instantiated with +) turns that into each selected row's position in replacements, and one gather assembles the result. Rows where the mask is null become null and consume no replacement, which is what pyarrow does. replacements must hold at least as many elements as the mask has valid trues — fewer raises, a surplus is ignored, both matching pyarrow. All ten primitive types plus bool. replaceWithMask(_:_:) in Swift, am_replace_with_mask in C, replace_with_mask() in Python.
is_null / is_valid GPU Kernels/Structural.swift, bitmap word kernels over the validity bitmap: is_null is a word-wise NOT of it, is_valid shares it zero-copy, and an array with no bitmap gets a constant word fill. Primitive and boolean arrays; the result never has nulls itself. isNull() / isValid() in Swift, am_is_null / am_is_valid in C, is_null() / is_valid() in Python.
true_unless_null CPU AnyMetalArray.trueUnlessNull() (Kernels/Selection.swift): the values bitmap is a host memset — every bit 1 — and the validity bitmap is shared with the input with no copy, so no kernel runs and nothing is read. Works for every column type that carries a top-level validity bitmap; union and run-end encoded columns throw, since neither does. am_true_unless_null in C, true_unless_null() in Python.
random (Arrow's Random category) GPU ArrowRandom.uniform(count:seed:) (Kernels/Selection.swift): Philox4x32-10 (Salmon, Moraes, Dror & Shaw, SC'11), a counter-based generator, so element i is derived from the counter (i, 0, 0, 0) and the 64-bit key alone — the stream depends only on the seed, never on the device, the threadgroup size or the scheduling, and a prefix of a long draw equals a short draw with the same seed. Words 0 and 1 of each 128-bit output form a 64-bit integer whose top 53 bits are packed straight into a binary64 exponent and mantissa, so every value is an exact multiple of 2^-53 in [0, 1) and none is ever 1.0. The stream is ArrowMetal's own: it does not reproduce the numbers Arrow C++ produces for the same seed (Arrow uses pcg32_fast on the host). Checked for mean, variance and a chi-square over 100 bins at 10M samples in SelectionExtraTests. am_random in C, random(n, initializer) in Python.
is_nan / is_finite / is_inf GPU Kernels/FloatClass.swift, one thread per 32-bit output word over the raw bit patterns, so Float64 needs no software binary64 and Float32 is unaffected by the GPU's flush-to-zero of subnormal arithmetic (nothing here does arithmetic). Arrow defines all three over every numeric type, so an integer column answers the constant it must — is_finite true everywhere, the other two false — as a bitmap fill rather than throwing. Nulls propagate: the result shares the input's validity bitmap zero-copy, so a null element gives a null predicate, matching pyarrow.compute.is_nan. isNan() / isFinite() / isInf() in Swift, am_float_class in C, is_nan() / is_finite() / is_inf() in Python.
make_struct CPU MetalStructArray(names:children:valid:) composes equal-length columns into a struct-typed column, and MetalRecordBatch(names:columns:) does the record-batch form of the same thing (Sources/ArrowMetal/Nested.swift). Metadata only — the children are shared, nothing is copied and no kernel runs. am_make_struct in C, make_struct(arrays, names) in Python.
struct_field GPU MetalStructArray.structField(_:) (Nested.swift), am_struct_field in C, struct_field() in Python. The struct's own nulls are propagated into the field, matching Arrow: a field of a null row is null, which is one GPU gather when the struct has a validity bitmap and free when it has none. batch[name] / selecting(_:) still project columns out of a record batch.
list_element / list_flatten / list_value_length GPU Kernels/NestedSource.swift, over list, large_list, fixed_size_list and map, with a child of any supported type including another nested array. listValueLength() → int32, null in / null out; listFlatten() is the child restricted to offsets[0] ..< offsets[length]; listElement(_:) builds child indices on the GPU and gathers through the child's own take, giving null where the row is null or shorter than the index (Arrow raises instead). am_list_value_length / am_list_flatten / am_list_element in C, list_value_length() / list_flatten() / list_element() in Python.
list_parent_indices / list_slice GPU Sources/ArrowMetal/NestedExtra.swift and Kernels/NestedExtraSource.swift, over list, large_list, fixed_size_list and map. listParentIndices() gives, for every child element the list references (the same range listFlatten() returns), the row that covers it — one binary search over the offsets per output element, so empty and null rows cost nothing and the result never has nulls. listParentIndices64() returns int64, the width pyarrow returns; listParentIndices() keeps the int32 form, which is what the list offsets themselves are and what every caller inside this package wants. The values are identical. listSlice(start:stop:step:) is row[start:stop:step] for every row: one kernel computes the new row lengths, the shared GPU scan turns them into offsets, and a second kernel expands the per-row index ranges for the child's own take. stop: nil slices to the end of each row; start must be >= 0 and step >= 1, as Arrow requires; a null row stays null and a row shorter than start becomes empty. The output is always a variable-length list (+l), never a fixed-size one. am_list_parent_indices / am_list_parent_indices64 / am_list_slice in C, list_parent_indices() / list_parent_indices64() / list_slice() in Python; both compared against pyarrow.compute.
map_lookup GPU MetalMapArray.mapLookup(_:occurrence:) (NestedExtra.swift). One kernel scans each row's entry range and reports the first match, the last match and the match count; occurrence: .all then scans the counts into offsets and a second kernel writes each row's matching entry indices, which the item array's own take gathers. first / last return the map's item type and all returns a list of it; all three are null where the row is null or the key is absent, so an empty list never stands for "not found" — the same convention pyarrow uses. Keys may be utf8 / binary (a byte compare inside the kernel) or any integer type (widened to int64 with the existing GPU cast); float and nested keys are rejected. am_map_lookup in C, map_lookup() in Python; compared against pyarrow.compute.map_lookup for all three occurrences.

Associative transforms

Arrow function Status Notes
dictionary_encode (utf8) GPU Kernels/StringDictionary.swift. Hash each string, argsort the hashes, mark run boundaries by comparing the full bytes of adjacent sorted strings, rank the marks with the same GPU scan unique() uses, and gather the dictionary with the string gather. Codes are relabelled into first-seen order, so the result is identical to the host version this replaced — same codes, same dictionary order. Reachable from Swift, the C ABI (am_str_dictionary_encode) and Python. 10M strings, 200k distinct: 43 ms against 1.3 s for the host path.
dictionary_encode (utf8) collision handling GPU Rows are grouped by a 64-bit key (two independent murmur3 seeds) and the boundary kernel counts content runs against key runs; equal totals prove every bucket holds one distinct string. A bucket that does not is re-hashed under new seeds, and MetalStringArray.dictionaryEncodeCPU() remains as the final fallback, so the result is correct rather than probably correct.
dictionary_encode (numeric) GPU Kernels/Unique.swift behind Kernels/DictionaryCompute.swift: one GPU radix argsort, run marks over the sorted values, a scan into ranks and a scatter back to the rows. Temporal and boolean columns take the same path.
unique GPU Kernels/Unique.swift sorts and run-scans to the distinct values ascending; Kernels/UniqueOrder.swift turns that into Arrow's order of first appearance, which is now the default. The permutation costs a GPU group-min of the row index per distinct value (GroupBy.min, which already skips null keys), a stable argsort of those minima — over the distinct values, not the rows — and a gather. Gathering the representatives straight out of the input reproduces the null for free, so, like Arrow, the null appears once at the position of the first null row. order="sorted" keeps the older ascending answer with the nulls dropped and skips the extra pass. A utf8 column has no null entry in either order: the GPU string dictionary has no slot for one. am_unique_ex in C, unique(order) in Python.
value_counts GPU The same two orders and the same null handling, returned as a struct<values, counts> column with int64 counts. am_value_counts_ex in C, value_counts(order) in Python.
dictionary_encode (temporal, boolean, binary) Partial AnyMetalArray.dictionaryEncoded() (Kernels/DictionaryCompute.swift) covers every column type this package has: temporal and boolean columns go through the GPU numeric encoder (the temporal type is carried over to the values), utf8 and binary through the existing host hash map. Returns .dictionary(codes:values:) rather than a loose pair.
Dictionary compute without decoding (compare, filter, take, slice, unique, value_counts, group_by) GPU Kernels/DictionaryCompute.swift. dictionaryCompare(_:_:) compares the dictionary once and gathers the booleans by the codes (values.length comparisons plus one gather, not one per row); filter/take/slice touch the codes only and share the values array; dictionaryUnique() / dictionaryValueCounts() run over the codes and gather once; dictionaryGroupBy() hands the codes straight to GroupBy as dense keys. String dictionaries compare with == and != only.

Pairwise and cumulative

Arrow function Status Notes
cumulative_sum / cumulative_sum_checked GPU cumulativeSum() is GPU (Kernels/Cumulative.swift): a two-level inclusive scan — block scan, exclusive scan of the block totals, add back. Nulls are skipped in Arrow's sense: the output is null exactly where the input is and the running value carries across unchanged. Integers wrap and are exact; Float32 and Float64 reassociate the additions, so the last ulp can differ from a strictly sequential sum (Float64 accumulates through the software binary64 adder). cumulativeSumChecked() is GPU too (Kernels/Checked.swift). The scan reassociates, so checking inside it would flag sums of interior ranges a sequential scan never forms; instead a second pass verifies the finished running values against the sequential recurrence — out[i] must be out[i-1] plus vals[i] in range — which is exactly what Arrow evaluates, so the row it reports is the row Arrow would stop at. Note the null rule differs from pyarrow's default: ArrowMetal skips nulls and carries the running value across them, where pc.cumulative_sum makes every row after a null null. The scan needs an exact count, so a pending batched input is materialised first.
cumulative_prod GPU cumulativeProd() (Kernels/Window.swift) runs the scan from CumulativeSource with a multiply, so it is the same three passes as cumulative_sum and skips nulls the same way. Integer products wrap and are exact; Float32 and Float64 reassociate, and a Float32 product that drifts into the subnormals comes back as zero (Apple GPUs flush Float32 denormals — the Float64 path uses the software multiplier and keeps them). cumulativeProdChecked() is GPU, verified the same way cumulative_sum_checked is, with the multiplication check.
cumulative_max / cumulative_min GPU Same two-level scan, exact on every type including Float64 (bit-pattern ordering, NaN skipped).
cumulative_mean GPU cumulativeMean() returns float64 for every input type: a binary64 running sum over an int32 running count of non-null rows, then the correctly rounded software divide. Output null exactly where the input is. Values widen to binary64 first, so int64 magnitudes above 2^53 round on the way in, and the sum reassociates as cumulative_sum does.
pairwise_diff / pairwise_diff_checked GPU pairwiseDiff(period:) is GPU, one thread per element: out[i] = a[i] - a[i - period], null where either side is null or falls outside the array, and a negative period differences forwards. Integers wrap; Float32 subtracts in float and Float64 through the correctly rounded software binary64 subtract, so both are exact. pairwiseDiffChecked(period:) is GPU (Kernels/Checked.swift): the same values, raising where the subtraction would wrap. A row whose partner falls outside the array, or where either side is null, is null in the output and is never checked.
shift (lag / lead) and trailing rolling sum / min / max / mean GPU ArrowMetal extensions, not Arrow compute function names (Kernels/Window.swift). shift(by:fill:) moves rows forwards or backwards, filling with a scalar or a null. The rolling calls take window and minPeriods (how many non-null rows the trailing window needs before it produces a value; fewer gives a null). Min and max scan the window, one thread per output — O(n · window), the right shape up to a few thousand rows per window — with NaN skipped as the reductions skip it. Sum and mean are the difference of two prefix sums, so they are O(n); the price is that a float window sum loses cancellation digits, and one NaN or infinity in a float column poisons every later window. rollingMean returns float64. am_window ops 5 and 9-12 in C, shift() / rolling_sum() / rolling_min() / rolling_max() / rolling_mean() in Python.

Hashing

Arrow C++ exposes no public element-wise hash compute function; the hash_* names in its catalogue are grouped aggregates, covered above. The row below is an ArrowMetal extension.

Function Status Notes
hash32 over utf8 (MurmurHash3 x86_32, seed 0) GPU Kernels/StringSource.swift. Nulls hash to 0 and stay null. Reachable as am_str_unary(kind: 2) and .hash32() in Python. A seeded variant is internal to Kernels/StringDictionary.swift, which needs two independent hashes.
Hash of primitive values (hash64) GPU Kernels/Hash64.swift, all ten primitive types plus bool, uint64 out. Defined so it is reproducible from the specification alone: hash64(v) = fmix64(normalise(v) ^ 0x9E3779B97F4A7C15), with fmix64 MurmurHash3's 128-bit finaliser (k ^= k >> 33; k *= 0xFF51AFD7ED558CCD; k ^= k >> 33; k *= 0xC4CEB9FE1A85EC53; k ^= k >> 33) and normalise the value's own bytes read as the unsigned type of the same width and zero-extended. Floats are normalised first — -0.0 becomes +0.0 and every NaN the canonical quiet NaN — which is exactly the value equality unique(), is_in and the group-by keys use, so Arrow-equal values always hash equal, the property a hash join needs. Booleans hash 0 or 1. The golden-ratio seed keeps the value 0 from hashing to 0 (fmix64(0) is 0), leaving 0 free for nulls: a null hashes to 0 and stays null, as the utf8 hash32 above does. Deterministic, and identical for a column and any slice of it (asserted in ConditionalTests.testHash64Properties, which also checks 1,000,003 distinct int64 values hash without a collision). hash64() in Swift, am_hash64 in C, hash64() in Python.

Differential cases per Arrow function

The differential matrix (python/tests/test_differential.py, Testing) runs 217 operations over 46 column types: 1,544 (operation, type) cells, each over 27 datasets, 41,688 cases. The run recorded in docs/data/differential_coverage.json (ArrowMetal 0.5.0, pyarrow 26.0.0, Apple M4 Max, router gpu): 38,898 pass, 1,643 documented divergences, 1,147 skipped, 0 unclassified.

A case counts toward an Arrow function when the ArrowMetal call it runs is that function's implementation (the call column of Arrow functions, one by one) or, for an ArrowMetal call with no Arrow name of its own (sort, argsort, to_strings), when its answer is compared with that function's result. One case can check several functions — a compare_scalar case runs all six comparisons — so the rows below add up to more than the case total. A documented divergence is a failing case that matches an open finding in Evaluation method; a skipped case is an operation that does not implement that column type.

281 of the 312 Arrow function names have matrix cases. Every name, with or without them, is also run through call_function against pyarrow.compute in python/tests/test_functions.py.

Arrow function Section Matrix operations Cases Pass Documented Skipped
all Aggregations any_all 27 27 0 0
any Aggregations any_all 27 27 0 0
approximate_median Aggregations quantile 270 270 0 0
count_distinct Aggregations mode_and_count_distinct 297 262 8 27
first Aggregations first_last_index_min_max 297 270 0 27
first_last Aggregations first_last 297 270 0 27
index Aggregations first_last_index_min_max 297 270 0 27
kurtosis Aggregations skew_kurtosis 270 256 14 0
last Aggregations first_last_index_min_max 297 270 0 27
max Aggregations decimal_reduce, float16_reduce, min_max 378 378 0 0
mean Aggregations mean 270 270 0 0
min Aggregations decimal_reduce, float16_reduce, min_max 378 378 0 0
min_max Aggregations first_last_index_min_max 297 270 0 27
mode Aggregations mode_and_count_distinct 297 262 8 27
product Aggregations product 270 270 0 0
quantile Aggregations quantile 270 270 0 0
skew Aggregations skew_kurtosis 270 256 14 0
stddev Aggregations variance_and_stddev 270 262 8 0
sum Aggregations decimal_reduce, sum 351 351 0 0
tdigest Aggregations tdigest 270 270 0 0
variance Aggregations variance_and_stddev 270 262 8 0
abs Arithmetic abs, decimal_unary, float16_compute 378 378 0 0
add Arithmetic add_duration, add_interval, arith_array, arith_scalar, decimal_arith 1,269 1,261 8 0
divide Arithmetic arith_array, arith_scalar 540 532 8 0
exp Arithmetic exp 54 54 0 0
multiply Arithmetic arith_array, arith_scalar, decimal_arith 621 613 8 0
negate Arithmetic decimal_unary, float16_compute, negate 270 270 0 0
power Arithmetic power 216 216 0 0
sign Arithmetic decimal_unary, sign 351 343 8 0
sqrt Arithmetic sqrt 54 54 0 0
subtract Arithmetic arith_array, arith_scalar, decimal_arith, subtract_temporal 1,107 991 8 108
abs_checked Arithmetic unary_checked 270 270 0 0
add_checked Arithmetic arith_checked 270 266 4 0
divide_checked Arithmetic arith_checked 270 266 4 0
multiply_checked Arithmetic arith_checked 270 266 4 0
remainder Arithmetic modulo 216 216 0 0
negate_checked Arithmetic unary_checked 270 270 0 0
power_checked Arithmetic power_checked 216 216 0 0
sqrt_checked Arithmetic log_checked 54 54 0 0
subtract_checked Arithmetic arith_checked 270 266 4 0
expm1 Arithmetic math_extra 54 46 8 0
hypot Arithmetic math_extra 54 46 8 0
array_sort_indices Sorts argsort, argsort_nulls_first, argsort_total_order, sort, sort_indices, top_k, top_k_nulls_first 1,944 1,944 0 0
sort_indices Sorts lexsort, sort_indices 594 594 0 0
partition_nth_indices Sorts partition_nth_indices 270 270 0 0
select_k_unstable Sorts select_k_unstable 270 270 0 0
top_k_unstable Sorts select_k_unstable 270 270 0 0
bottom_k_unstable Sorts select_k_unstable 270 270 0 0
rank Sorts ranking 270 270 0 0
rank_quantile Sorts rank_quantile_and_normal 270 270 0 0
rank_normal Sorts rank_quantile_and_normal 270 270 0 0
winsorize Sorts winsorize 270 268 2 0
bit_wise_and Bitwise bitwise_and 216 216 0 0
bit_wise_not Bitwise bitwise_not 216 216 0 0
bit_wise_or Bitwise bitwise_or 216 216 0 0
bit_wise_xor Bitwise bitwise_xor 216 216 0 0
shift_left Bitwise shift_left 216 216 0 0
shift_right Bitwise shift_right 216 216 0 0
shift_left_checked Bitwise shift_checked 216 216 0 0
shift_right_checked Bitwise shift_checked 216 216 0 0
ceil Rounding rounding 270 270 0 0
floor Rounding rounding 270 270 0 0
trunc Rounding rounding 270 270 0 0
round Rounding decimal_round, round_extra, rounding 405 374 31 0
round_binary Rounding round_extra 54 35 19 0
round_to_multiple Rounding round_extra 54 35 19 0
ln Logarithmic logarithm 54 54 0 0
log10 Logarithmic logarithm 54 54 0 0
log2 Logarithmic logarithm 54 54 0 0
ln_checked Logarithmic log_checked 54 54 0 0
log10_checked Logarithmic log_checked 54 54 0 0
log2_checked Logarithmic log_checked 54 54 0 0
log1p Logarithmic math_extra 54 46 8 0
log1p_checked Logarithmic log_checked 54 54 0 0
logb Logarithmic math_extra 54 46 8 0
logb_checked Logarithmic math_extra 54 46 8 0
acos Trigonometric trig 54 46 8 0
acos_checked Trigonometric trig_checked 54 46 8 0
acosh Trigonometric trig 54 46 8 0
acosh_checked Trigonometric trig_checked 54 46 8 0
asin Trigonometric trig 54 46 8 0
asin_checked Trigonometric trig_checked 54 46 8 0
asinh Trigonometric trig 54 46 8 0
atan Trigonometric trig 54 46 8 0
atan2 Trigonometric trig 54 46 8 0
atanh Trigonometric trig 54 46 8 0
atanh_checked Trigonometric trig_checked 54 46 8 0
cos Trigonometric trig 54 46 8 0
cos_checked Trigonometric trig_checked 54 46 8 0
cosh Trigonometric trig 54 46 8 0
sin Trigonometric trig 54 46 8 0
sin_checked Trigonometric trig_checked 54 46 8 0
sinh Trigonometric trig 54 46 8 0
tan Trigonometric trig 54 46 8 0
tan_checked Trigonometric trig_checked 54 46 8 0
tanh Trigonometric trig 54 46 8 0
equal Comparisons compare_array, compare_scalar, decimal_compare, filter_where, fixed_binary_compare, str_equals_array, str_match 1,026 1,026 0 0
greater Comparisons compare_array, compare_scalar, decimal_compare, filter_where 891 891 0 0
greater_equal Comparisons compare_array, compare_scalar, decimal_compare, filter_where 891 891 0 0
less Comparisons compare_array, compare_scalar, decimal_compare, filter_where 891 891 0 0
less_equal Comparisons compare_array, compare_scalar, decimal_compare, filter_where 891 891 0 0
not_equal Comparisons compare_array, compare_scalar, decimal_compare, filter_where, fixed_binary_compare 918 918 0 0
max_element_wise Comparisons element_wise_min_max 270 270 0 0
min_element_wise Comparisons element_wise_min_max 270 270 0 0
and_ Logical bool_logic 27 27 0 0
or_ Logical bool_logic 27 27 0 0
invert Logical bool_logic, invert 54 54 0 0
and_kleene Logical kleene 27 27 0 0
or_kleene Logical kleene 27 27 0 0
and_not Logical logical_extras 27 27 0 0
and_not_kleene Logical logical_extras 27 27 0 0
xor Logical logical_extras 27 27 0 0
ascii_is_alnum StringPredicates is_alnum 54 54 0 0
ascii_is_alpha StringPredicates is_alpha 54 54 0 0
ascii_is_decimal StringPredicates is_digit 54 54 0 0
ascii_is_lower StringPredicates is_lower 54 54 0 0
ascii_is_space StringPredicates is_space 54 54 0 0
ascii_is_upper StringPredicates is_upper 54 54 0 0
ascii_is_printable StringPredicates string_predicates 54 54 0 0
ascii_is_title StringPredicates string_predicates 54 54 0 0
string_is_ascii StringPredicates string_predicates 54 54 0 0
utf8_is_alnum StringPredicates string_predicates 54 54 0 0
utf8_is_alpha StringPredicates string_predicates 54 54 0 0
utf8_is_decimal StringPredicates string_predicates 54 54 0 0
utf8_is_digit StringPredicates string_predicates 54 54 0 0
utf8_is_lower StringPredicates string_predicates 54 54 0 0
utf8_is_numeric StringPredicates string_predicates 54 54 0 0
utf8_is_printable StringPredicates string_predicates 54 54 0 0
utf8_is_space StringPredicates string_predicates 54 54 0 0
utf8_is_title StringPredicates string_predicates 54 54 0 0
utf8_is_upper StringPredicates string_predicates 54 54 0 0
ascii_capitalize StringTransforms capitalize 54 54 0 0
ascii_lower StringTransforms ascii_lower 54 54 0 0
ascii_upper StringTransforms ascii_upper 54 54 0 0
ascii_swapcase StringTransforms swapcase, swapcase_and_zero_fill 108 108 0 0
ascii_reverse StringTransforms byte_transforms 54 54 0 0
utf8_reverse StringTransforms str_reverse 54 54 0 0
binary_reverse StringTransforms byte_transforms 54 54 0 0
binary_length StringTransforms str_length 54 54 0 0
utf8_length StringTransforms str_length 54 54 0 0
binary_repeat StringTransforms repeat 54 54 0 0
replace_substring StringTransforms replace 54 54 0 0
replace_substring_regex StringTransforms regex_replace 54 28 26 0
utf8_lower StringTransforms lower 54 54 0 0
utf8_upper StringTransforms upper 54 54 0 0
utf8_swapcase StringTransforms swapcase_and_zero_fill 54 54 0 0
ascii_title StringTransforms string_case_transforms 54 54 0 0
utf8_capitalize StringTransforms string_case_transforms 54 54 0 0
utf8_title StringTransforms string_case_transforms 54 54 0 0
utf8_normalize StringTransforms string_normalize 54 54 0 0
utf8_replace_slice StringTransforms string_pad_and_slice 54 54 0 0
binary_replace_slice StringTransforms string_pad_and_slice 54 54 0 0
ascii_lpad StringPadding ascii_pad 54 54 0 0
ascii_rpad StringPadding ascii_pad 54 54 0 0
utf8_lpad StringPadding ascii_pad, pad 108 108 0 0
utf8_rpad StringPadding ascii_pad, pad 108 108 0 0
utf8_zero_fill StringPadding swapcase_and_zero_fill 54 54 0 0
ascii_center StringPadding ascii_pad 54 54 0 0
utf8_center StringPadding string_pad_and_slice 54 54 0 0
ascii_ltrim_whitespace StringTrimming ltrim 54 54 0 0
ascii_rtrim_whitespace StringTrimming rtrim 54 54 0 0
utf8_trim_whitespace StringTrimming string_trim, trim 108 108 0 0
utf8_ltrim_whitespace StringTrimming string_trim 54 54 0 0
utf8_rtrim_whitespace StringTrimming string_trim 54 54 0 0
utf8_trim StringTrimming string_trim 54 54 0 0
utf8_ltrim StringTrimming string_trim 54 54 0 0
utf8_rtrim StringTrimming string_trim 54 54 0 0
ascii_split_whitespace StringSplitting ascii_split_whitespace 54 54 0 0
utf8_split_whitespace StringSplitting split_whitespace 54 26 28 0
split_pattern StringSplitting regex_split 54 54 0 0
split_pattern_regex StringSplitting regex_split 54 54 0 0
extract_regex StringExtraction extract_regex_structs, regex_extract 108 108 0 0
extract_regex_span StringExtraction extract_regex_structs, regex_extract_span 108 64 0 44
binary_join StringJoining binary_join 27 27 0 0
binary_join_element_wise StringJoining str_concat 54 54 0 0
utf8_slice_codeunits StringSlicing slice_codeunits 54 54 0 0
binary_slice StringSlicing byte_transforms 54 54 0 0
count_substring Containment substring_search 54 54 0 0
find_substring Containment substring_search 54 54 0 0
match_substring Containment str_match 54 54 0 0
starts_with Containment str_match 54 54 0 0
ends_with Containment str_match 54 54 0 0
count_substring_regex Containment regex_match 54 24 30 0
find_substring_regex Containment regex_match 54 24 30 0
match_substring_regex Containment regex_match 54 24 30 0
match_like Containment match_like 54 54 0 0
is_in Containment is_in, string_set_lookup 405 375 3 27
index_in Containment index_in, string_set_lookup 405 375 3 27
is_null Categorizations is_null 351 297 0 54
is_valid Categorizations is_valid 351 297 0 54
true_unless_null Categorizations true_unless_null 351 351 0 0
indices_nonzero Categorizations indices_nonzero 297 297 0 0
is_finite Categorizations float_class 270 270 0 0
is_inf Categorizations float_class 270 270 0 0
is_nan Categorizations float_class, is_nan 324 324 0 0
if_else Selecting if_else 351 297 0 54
coalesce Selecting coalesce 297 270 0 27
case_when Selecting case_when 297 270 0 27
choose Selecting choose 297 270 0 27
cast Conversions cast, cast_to_string, cast_unit, decimal_to_float64, decimal_widen_narrow, float16_casts, float16_compute, parse_numbers, timezone_metadata, to_strings, to_strings_text, to_strings_value 2,052 1,904 121 27
ceil_temporal Conversions temporal_ceil_calendar, temporal_round, temporal_round_duration, temporal_round_finer, temporal_round_unaligned 1,404 1,057 266 81
floor_temporal Conversions temporal_round, temporal_round_calendar, temporal_round_duration, temporal_round_finer, temporal_round_unaligned, temporal_utc_semantics 1,512 1,165 266 81
round_temporal Conversions temporal_round, temporal_round_calendar, temporal_round_duration, temporal_round_finer, temporal_round_unaligned 1,404 1,057 266 81
run_end_encode Conversions run_end 27 20 0 7
run_end_decode Conversions run_end 27 20 0 7
year TemporalExtraction temporal_calendar, temporal_utc_semantics 378 312 66 0
month TemporalExtraction temporal_calendar, temporal_utc_semantics 378 312 66 0
day TemporalExtraction temporal_calendar, temporal_utc_semantics 378 312 66 0
day_of_week TemporalExtraction temporal_week_options 270 204 66 0
hour TemporalExtraction temporal_clock, temporal_clock_on_a_date, temporal_utc_semantics 486 413 73 0
minute TemporalExtraction temporal_clock, temporal_clock_on_a_date, temporal_utc_semantics 486 413 73 0
second TemporalExtraction temporal_clock, temporal_clock_on_a_date, temporal_utc_semantics 486 413 73 0
day_of_year TemporalExtraction temporal_calendar, temporal_utc_semantics 378 312 66 0
quarter TemporalExtraction temporal_calendar, temporal_utc_semantics 378 312 66 0
iso_week TemporalExtraction temporal_calendar, temporal_utc_semantics 378 312 66 0
iso_year TemporalExtraction temporal_calendar, temporal_utc_semantics 378 312 66 0
millisecond TemporalExtraction temporal_clock, temporal_clock_on_a_date, temporal_utc_semantics 486 413 73 0
microsecond TemporalExtraction temporal_clock, temporal_clock_on_a_date, temporal_utc_semantics 486 413 73 0
nanosecond TemporalExtraction temporal_clock, temporal_clock_on_a_date, temporal_utc_semantics 486 413 73 0
is_leap_year TemporalExtraction temporal_calendar, temporal_utc_semantics 378 312 66 0
strftime TemporalExtraction strftime, strftime_seconds 540 337 203 0
strptime TemporalExtraction strptime, strptime_roundtrip 162 162 0 0
is_dst TemporalExtraction temporal_timezone 108 60 48 0
iso_calendar TemporalExtraction temporal_struct 270 204 66 0
subsecond TemporalExtraction temporal_clock, temporal_clock_on_a_date, temporal_utc_semantics 486 413 73 0
us_week TemporalExtraction temporal_calendar, temporal_utc_semantics 378 312 66 0
us_year TemporalExtraction temporal_calendar, temporal_utc_semantics 378 312 66 0
week TemporalExtraction temporal_week_options 270 204 66 0
year_month_day TemporalExtraction temporal_struct 270 204 66 0
days_between TemporalDifference temporal_between 270 204 66 0
day_time_interval_between TemporalDifference interval_layouts 270 206 64 0
hours_between TemporalDifference temporal_between, temporal_between_clock 378 312 66 0
microseconds_between TemporalDifference temporal_between, temporal_between_clock 378 312 66 0
milliseconds_between TemporalDifference temporal_between, temporal_between_clock 378 312 66 0
minutes_between TemporalDifference temporal_between, temporal_between_clock 378 312 66 0
month_day_nano_interval_between TemporalDifference interval_between 270 204 66 0
month_interval_between TemporalDifference interval_layouts 270 206 64 0
nanoseconds_between TemporalDifference temporal_between, temporal_between_clock 378 312 66 0
quarters_between TemporalDifference temporal_between 270 204 66 0
seconds_between TemporalDifference temporal_between, temporal_between_clock 378 312 66 0
weeks_between TemporalDifference weeks_between 270 204 66 0
years_between TemporalDifference temporal_between 270 204 66 0
assume_timezone Timezone assume_timezone 108 60 48 0
local_timestamp Timezone temporal_timezone 108 60 48 0
unique Associative unique 351 292 32 27
value_counts Associative value_counts 351 292 32 27
dictionary_encode Associative dictionary_encode 54 54 0 0
dictionary_decode Associative dictionary_decode, dictionary_ops 81 81 0 0
filter Selections filter, filter_ext, filter_where, null_column, run_end 1,539 1,532 0 7
array_filter Selections array_selection 351 351 0 0
take Selections null_column, run_end, take, take_ext 1,269 1,262 0 7
array_take Selections array_selection 351 351 0 0
drop_null Selections drop_null 351 297 0 54
inverse_permutation Selections permutation 270 270 0 0
scatter Selections permutation, scatter 621 621 0 0
fill_null NullFilling fill_null 351 297 0 54
fill_null_forward NullFilling fill_null_direction 297 297 0 0
fill_null_backward NullFilling fill_null_direction 297 297 0 0
list_value_length Structural list_length 81 81 0 0
list_flatten Structural list_flatten 54 54 0 0
list_element Structural list_element 54 54 0 0
struct_field Structural struct_field 27 27 0 0
list_parent_indices Structural list_parent_indices, list_parent_indices64 108 108 0 0
list_slice Structural list_slice 54 54 0 0
map_lookup Structural map_lookup 27 27 0 0
replace_with_mask Structural replace_with_mask 297 297 0 0
pairwise_diff Pairwise pairwise_diff 270 267 3 0
pairwise_diff_checked Pairwise cumulative_checked 216 216 0 0
cumulative_sum Cumulative cumulative_sum 270 270 0 0
cumulative_prod Cumulative cumulative_prod 270 258 12 0
cumulative_max Cumulative cumulative_max 270 270 0 0
cumulative_min Cumulative cumulative_min 270 270 0 0
cumulative_mean Cumulative cumulative_mean 270 270 0 0
cumulative_sum_checked Cumulative cumulative_checked 216 216 0 0
cumulative_prod_checked Cumulative cumulative_checked 216 216 0 0
hash_count GroupedAggregations group_by_count_values 270 270 0 0
hash_count_all GroupedAggregations group_by_count 270 270 0 0
hash_sum GroupedAggregations group_by_sum, group_by_sum_correctly_rounded 297 270 0 27
hash_mean GroupedAggregations group_by_mean, group_by_mean_correctly_rounded 297 243 0 54
hash_min GroupedAggregations group_by_min 270 189 0 81
hash_max GroupedAggregations group_by_max 270 189 0 81

The 16 operations that check no Arrow compute function, with what they check:

Matrix operation What it checks Cases Pass Documented Skipped
extension_type extension name, metadata and storage through the C Data Interface 216 216 0 0
hash64 ArrowMetal's 64-bit hash: null positions kept, equal values hash equal, no collisions 324 324 0 0
months_between whole months between two dates, against Arrow's year and month arithmetic 270 224 46 0
nulls_constructor am.nulls(n) against pa.nulls(n) 27 27 0 0
percent_rank_and_cume_dist window functions, against a reference written in the harness 270 270 0 0
rolling_mean rolling windows, against Arrow's mean over each window 270 240 0 30
rolling_min_max rolling windows, against Arrow's min / max over each window 270 235 5 30
rolling_sum rolling windows, against Arrow's sum over each window 270 240 0 30
roundtrip import and export through the Arrow C Data Interface 351 351 0 0
roundtrip_ext import and export of the extended types through the Arrow C Data Interface 891 884 0 7
shift window shift with and without a fill value, against a reference written in the harness 270 270 0 0
slice zero-copy slicing (Array.slice, not a compute function) 351 351 0 0
slice_ext zero-copy slicing of the extended types 864 864 0 0
split_pairs the offsets-and-values form of the splits against their list form 54 30 24 0
str_hash32 ArrowMetal's 32-bit string hash: null positions kept, no collisions over the data 54 54 0 0
temporal_field_types the result type of every temporal extractor 270 270 0 0

Regenerate with PYTHONPATH=python python python/tests/coverage_report.py (runs the matrix); --check compares a fresh run with the record and writes nothing.

Type matrix

"Compute" means the kernels in this document run on the type. "Interop" means the C Data Interface importer (Sources/ArrowMetal/CInterop.swift) accepts it: today that is exactly the format strings c C s S i I l L f g b u U, plus +s for record batches. Anything else raises ArrowMetalError.unsupportedType, and arrays carrying a dictionary pointer or any children are rejected outright.

Index arrays (the output of argsort, top-k, partition_nth, the lexsort, the join and the ranks) are uint32; take reads int32, int64 and uint32 indices.

Arrow type Status Notes
null GPU MetalNullArray (Sources/ArrowMetal/TypesExtra.swift): a length and nothing else, so C Data import and export cost no memory and filter / take / slice only work out the new length (the filter's length comes from the GPU mask's popcount). The importer accepts 0 or 1 buffers, since producers differ on whether they still declare a null validity pointer; the exporter writes n_buffers = 0, which is what the Arrow spec prescribes. nullCount == length by definition. am_null_array in C, am.nulls(n) in Python.
bool GPU Packed bitmap values. and/or/not, filter, take, slice are GPU; count/any/all are host popcounts.
int8 / int16 / int32 / int64 GPU Full kernel set.
uint8 / uint16 / uint32 / uint64 GPU Full kernel set.
float16 (halffloat) GPU MetalFloat16Array (TypesExtra.swift): the binary16 bit patterns in a MetalArray<UInt16>, so filter / take / slice are the existing 16-bit kernels. Compute goes through float32: toFloat32() is one GPU pass through Metal's native half (exact) and MetalArray<Float>.toFloat16() rounds back to nearest-even, overflowing to +/-infinity. compare, sum, min and max widen and run the Float32 kernels, so arithmetic results are rounded back to half only on an explicit toFloat16() — a sum of many halves is a Float32 sum, not a half one. C Data import and export are zero-copy on a page-aligned producer (the importer reuses the primitive path by re-describing the buffer as uint16). am_cast_float16 in C, to_float32() / to_float16() in Python; the widening is compared against pyarrow's cast(float32).
float32 GPU Full kernel set. NaN skipped by min/max, propagated by sum.
float64 Partial Metal has no double. Compare/min/max/filter/take/slice/sort run on the GPU over order-preserving bit patterns; sum and add/sub/mul/div run a software IEEE-754 binary64 implementation on the GPU that is correctly rounded and bit-exact against Swift's Double. negate/abs/sign/floor/ceil/round/trunc, element-wise min/max and the cumulative functions are exact too (bit-pattern kernels); sqrt is correctly rounded and exp/ln/log10/log2/power are software binary64 within 1 ulp (Kernels/DoublePower.swift). modulo is the one binary op with no float64 kernel and says so. Only cast falls back to the host, and group-by min/max/sum do not accept it. Partial is now about throughput, not accuracy: add, multiply and divide run at or near the ~390-400 GB/s these single-pass rows reach, but a logarithm costs forty-odd software binary64 operations per element and lands around 10 GB/s — see docs/BENCHMARKS.md.
decimal32 / decimal64 GPU ArrowSmallDecimalType / MetalSmallDecimalArray (TypesExtra.swift): the unscaled value in an int32 or int64 column, so filter / take / slice are the existing integer kernels and C Data import / export are zero-copy on a page-aligned producer. There are no narrow-decimal kernels by design: toDecimal128() is a GPU widening cast (sign extension into two limbs, exact for every value) into MetalDecimalArray, where the whole decimal kernel set already lives, and MetalDecimalArray.narrowed(to:) is the GPU narrowing cast back, keeping the scale and wrapping when a value does not fit (Arrow's unchecked cast). am_decimal_widen / am_decimal_narrow in C, to_decimal128() / to_small_decimal() in Python; round trips checked against real pa.decimal32 / pa.decimal64 arrays.
decimal128 GPU Sources/ArrowMetal/Decimal.swift and Kernels/DecimalSource.swift. Elements are Arrow's raw 16-byte little-endian two's-complement unscaled values; every kernel works on two 64-bit limbs, with carries from unsigned compares and mulhi/* for the products. GPU: the six comparisons (scalar and array, signed 128-bit ordering), sum/min/max (per-thread 128-bit accumulate, threadgroup tree, host combine), add/subtract (array and scalar), negate/abs/sign, multiply by an int64 scalar and element-wise multiply (Arrow's rule: precision p1+p2+1, scale s1+s2, rejected when the precision does not fit 38), round/ceil/floor/truncate to a target scale (scaling up multiplies, scaling down is a restoring 128-bit long division with the rounding mode applied to the magnitude; halves go away from zero, as this package's float round does), and filter/take/slice (the int32 filter compacts an index vector, then a byte-width-generic gather moves the values). Arithmetic wraps modulo 2^128, matching Arrow's unchecked kernels; the two sides of a binary op must share a scale or the call raises rather than rescaling silently. Casts are CPU (see Conversions above). C Data Interface import (zero-copy, offsets and foreign producers handled) and export both work, am_format reports d:p,s, and MetalRecordBatch carries decimal columns through nullCount/filter/take/slice/+s export. am_decimal_op in C (op table in include/arrowmetal.h), am_compare_scalar/am_compare_array for the comparisons, and decimal_add/decimal_sub/decimal_mul/decimal_round/to_float64 plus the ==/</… operators in Python. sum(), min() and max() on a Python MetalArray detect a decimal column and route to am_decimal_op ops 18-20, which return the 128-bit answer as a length-1 decimal column (an int64 out-parameter cannot hold it); Python reads it back through the C Data Interface and hands you a decimal.Decimal. Arrow IPC reads and writes decimal columns (precision, scale and bit width in the schema; checked against pa.decimal128 / pa.decimal256). Not implemented: divide, cast between decimal precisions, group-by, sort and is_in over decimal columns.
decimal256 Partial Same files, four limbs instead of two: C Data Interface import and export, the six comparisons (a 16-byte scalar is sign-extended to 256 bits), filter, take, slice and sum run on 32-byte elements. min/max, all arithmetic, sign, the rounding family and the casts throw unsupportedType naming decimal128 rather than computing something wrong.
date32 / date64 GPU MetalTemporalArray (Sources/ArrowMetal/Temporal.swift): tdD as int32 days and tdm as int64 milliseconds since the epoch, C Data import and export, and every fixed-width kernel (compare, arithmetic, filter, take, slice, sort, group-by) over the underlying integer. The calendar functions are in the Temporal section above.
time32 / time64 GPU Same file: tts / ttm as int32 and ttu / ttn as int64 ticks since midnight, with the same kernel set. The fixed-unit *_between differences accept them; the calendar-based ones reject them, since they carry no date.
timestamp GPU Same file: tss / tsm / tsu / tsn as int64 ticks since the epoch, with the unit and any timezone carried as metadata. Every calendar function reads it as UTC — the timezone is never applied — except is_dst, assume_timezone, local_timestamp, utc_offset and strftime, which read the zone's GPU-resident transition table (Kernels/TimezoneGPU.swift).
duration GPU Same file: tDs / tDm / tDu / tDn as int64 ticks, produced by subtractTemporal(_:) and consumed by addDuration(_:). Rejected by the calendar extractors and by subsecond.
interval (month, day_time, month_day_nano) GPU ArrowIntervalUnit / MetalIntervalArray (TypesExtra.swift): all three layouts — tiM (int32 months), tiD (int32 days + int32 milliseconds) and tin (int32 months + int32 days + int64 nanoseconds) — as raw fixed-width records, with C Data import and export (zero-copy on a page-aligned producer) and filter / take / slice through one record gather parameterised by the byte width. Arithmetic is MetalTemporalArray.addInterval(_:): month arithmetic goes through the civil calendar and clamps the day to the target month's length (2024-01-31 + 1 month = 2024-02-29), as Arrow does; days are whole UTC days; the sub-day field is converted to the column's own resolution, truncating toward zero when the column is coarser; and date32, whose tick is a whole day, rejects an interval that carries a sub-day part rather than dropping it. pyarrow has no add(timestamp, interval) kernel, so this one has no pyarrow oracle and is checked against a host civil-calendar oracle instead. pyarrow 25 also cannot wrap interval[month] or interval[day_time] arrays in Python at all (no Array class for those type ids), so to_arrow() raises for those two formats and interval_field("months" / "days" / "nanoseconds") — am_interval_field in C — is the way to read their values; month_day_nano round trips through pyarrow normally. am_add_interval in C, add_interval() in Python.
binary / large_binary GPU z and Z import and export (64-bit offsets narrowed to int32 on import, as large_utf8's are), and every byte operation Arrow defines on them runs over them unchanged: filter / take / slice / is_in / index_in, binary_length, binary_repeat, binary_reverse, binary_slice, binary_replace_slice and binary_join_element_wise. A binary input keeps its type through the transform, so the result is binary and not utf8.
fixed_size_binary GPU MetalFixedBinaryArray (TypesExtra.swift): w:N as N raw bytes per element, with C Data import and export (zero-copy on a page-aligned producer) and filter / take / slice through the same record gather the interval types use. compare(.eq / .ne, …) against a scalar record or another array of the same width is a GPU byte compare, null in / null out, with the array form ANDing the two validity bitmaps; ordering comparisons are not defined for the type and throw. hash64() is FNV-1a 64 over each element's bytes — an ArrowMetal extension, not Arrow's hash64. am_fixed_binary_compare / am_fixed_binary_hash64 in C, fixed_binary_compare() / hash64() in Python; the comparison is checked against pyarrow.compute.equal.
utf8 GPU Byte/char length, equals/starts_with/ends_with/contains, count_substring/find_substring, murmur3 hash, dictionary_encode (GPU), filter, take, C Data import/export, and the transforms that build new string arrays: ASCII and Latin case mapping, trim/ltrim/rtrim, pad, slice, repeat, replace, reverse, element-wise join and the ascii_is_* predicates, plus GPU integer↔string casts. The regex functions, SQL LIKE, splitting and the float/boolean casts are CPU.
large_utf8 Partial Import only, and only when the data is under 2 GB: 64-bit offsets are narrowed to int32 in one pass. Exports come back out as utf8.
utf8_view / binary_view GPU Imported as views (the view buffer and the data buffers as Metal shared buffers, copy-free at any alignment) and read directly by the string kernels (Design, "Strings in two layouts"); the kernels that still convert once to offsets + bytes are listed in am.STRING_VIEW_CONVERTS. Exported back as views.
list / large_list / fixed_size_list Partial MetalListArray (Sources/ArrowMetal/Nested.swift): C Data import and export of +l, +L and +w:N, list_value_length / list_flatten / list_element, and filter / take / slice. The child is an AnyMetalArray, so it may be any supported type including another list, a struct or a map, recursively. Offsets are always int32 in Metal memory: large_list offsets are narrowed on import (and come back out as +l, as large_utf8 comes back out as utf8), and a fixed_size_list materialises the i * N offsets its layout implies, so one set of kernels covers all three. take recomputes the offsets with the existing GPU scan and expands the selected rows' source ranges into one child index array that the child's own take gathers; a slice of a variable-length list shares both the offsets buffer and the child. list_parent_indices and list_slice are in NestedExtra.swift (see the compute table above). Not implemented: aggregates or arithmetic over list values.
list_view / large_list_view Partial Import only (Sources/ArrowMetal/NestedExtra.swift). +vl and +vL arrive as three buffers — validity, per-row offsets and per-row sizes — with rows that may point anywhere in the child, in any order, overlapping. The importer walks the rows once on the host: when they already lie back to back the contiguous offsets MetalListArray needs are the view's own and the producer's child is shared untouched; otherwise the sizes are prefix-summed into fresh offsets and the child is materialised in that order with one GPU gather (list_view_gather plus the child's own take, so the child may be any supported type). +vL's int64 offsets and sizes are narrowed as large_list's are. A null row references nothing, whatever its slots say. Everything therefore exports as a plain list (+l): ArrowMetal has no view-shaped column, so a round trip through this package flattens a view.
struct GPU MetalStructArray (Nested.swift) is +s as a column, not only as the record-batch container: named children of any supported type, its own validity bitmap, arbitrary nesting in either direction (a struct of lists, a list of structs), structField(_:), and filter / take / slice by delegating to the children and gathering the struct's own validity. importArrowRecordBatch keeps its top-level meaning and now accepts nested children. No aggregate takes a struct column.
map Partial MetalMapArray (Nested.swift): +m import and export — a list of non-nullable struct<key, value> entries, with keys_sorted carried through — plus filter / take / slice and the list_* functions on the entries. map_lookup (first / last / all, utf8 or integer keys) is in NestedExtra.swift — see the compute table above. There is no other compute over keys or values.
union (dense and sparse) Partial MetalUnionArray (Nested.swift): +ud: and +us: import and export with the type ids, the dense offsets and one child per variant, plus filter / take / slice (dense selects type ids and offsets, sparse moves the children with the selection). No kernel reads a union's values: a type-id-dispatched layout defeats the uniform-thread model the compute kernels rely on. Unions carry no validity bitmap, per Arrow 1.0.
dictionary GPU Int32 codes plus a type-erased values array (DictionaryArray.swift), with C Data import/export and IPC read/write of DictionaryBatch messages (the writer emits one complete dictionary per column; the reader also takes deltas and, in a stream, replacements). Compute runs on the codes without decoding — compare, filter, take, slice, unique, value_counts and group-by (Kernels/DictionaryCompute.swift) — and decode() materialises with take when a caller wants the flat column. Int64 indices are narrowed to int32 on import.
run_end_encoded GPU RunEndEncoded.swift: "+r" import and export (run ends of int16/int32/int64 are narrowed to int32), runEndEncode() on the GPU (boundary marks, bitmap pack, compaction of an iota, one gather) for primitive, boolean and temporal columns, and runEndDecode() on the GPU (one binary search of the run ends per output row, then a gather). Runs are maximal stretches of bit-equal, equally-null neighbours, so -0.0 and 0.0 start different runs. take / filter / slice decode first. Arrow IPC reads and writes a run-end encoded column as the spec defines it — no buffers of its own, a non-nullable run_ends child and a values child — and pyarrow 25 reads it back encoded. am_run_end_encode / am_run_end_decode in C, run_end_encode() / run_end_decode() in Python.
Extension types GPU Sources/ArrowMetal/ExtensionType.swift. An extension type is a storage type plus two schema metadata keys, so the importer decodes the C Data Interface metadata blob (int32 count, then length-prefixed key/value pairs, native endian — ArrowSchemaMetadata.decode / .encoded()), imports the storage array with the ordinary importer and keeps ARROW:extension:name / ARROW:extension:metadata beside it in MetalExtensionArray. Every kernel runs on the storage; filter / take / slice re-wrap the result so the tag survives a selection, and arrowFormat reports the storage format because an extension type has none of its own. The exporter writes both keys back — together with every unrelated metadata key the field carried, such as PARQUET:field_id — so pyarrow reconstructs the extension type when it is registered and sees plain storage when it is not. extensionName / extensionMetadata / storageArray / asExtensionType(name:metadata:) in Swift, am_extension_name / am_extension_metadata / am_extension_storage / am_extension_wrap in C, the same names in Python. Round-tripped against pa.uuid(). Storage types that are themselves nested work, since the storage schema is written by the storage array.

Interop (not compute — separate vocabulary)

Capability Status Notes
C Data Interface import In 0.1.0 Zero-copy when the producer's buffers are page aligned and the offset is 0, otherwise one copy; the result reports which (ImportResult.zeroCopy). Moves the array per spec.
C Data Interface export In 0.1.0 Every type in the matrix above.
C Device Data Interface import / export In 0.1.0 ARROW_DEVICE_METAL; a sync_event on import is waited on with an empty command buffer.
C Stream Interface import In 0.1.0 importArrowArrayStream drains a stream into record batches.
C Stream Interface export In 0.1.0 am_stream_export_c (include/arrowmetal.h) hands a streaming query out as an ArrowArrayStream, over StreamQuery.exportArrowArrayStream. The C Device Stream (ArrowDeviceArrayStream) is not implemented.
Record batch as +s struct array Partial Import and export both work; import rejects struct-level nulls, a non-zero offset, and nested children.
MTLBuffer recovery from our own exports In 0.1.0 metalBuffers(of:) for device arrays this process produced.
Arrow IPC (file and stream) read / write In 0.1.0, extended in 0.2.0 IPC/IPCReader.swift and IPC/IPCWriter.swift, no dependencies: both encapsulations, random access through the file footer, and cross-checked against pyarrow in both directions. Columns keep their logical type — temporal columns read and write as .temporal, binary / large_binary as .binary, and dictionary-encoded columns as .dictionary through DictionaryBatch messages. Every type the engine can hold is written and read, nested children recursively: decimals (all four widths), float16, fixed_size_binary, the three interval units, null, list / large_list / fixed_size_list, struct, map, dense and sparse unions, run_end_encoded, and extension types (whose ARROW:extension:* keys travel in the field's custom_metadata); pyarrow 25 reads each one back and to_pylist() matches value for value, and each one pyarrow writes reads back with pyarrow's values, in both encapsulations (IPCTests). A sliced column writes only the bytes and child elements its own rows cover; 64-bit offsets are narrowed to the 32-bit ones the engine stores, with a clear error above 2 GB. The reader also takes LZ4_FRAME and ZSTD body compression, per buffer, including the -1 marker for a buffer left uncompressed (ZSTD needs libzstd, dlopened as the Parquet reader does, and says so when it is missing); the writer emits uncompressed bodies only. Dictionaries follow message order: a dictionary applies to the batches after it, so a stream may replace one part way through or extend it with a delta, while the file format — which indexes every dictionary in its footer — still refuses a replacement. A batch whose field nodes or buffers are not all consumed is rejected as malformed. The view types are read: utf8_view / binary_view (variadic data buffers as the batch's variadicBufferCounts gives them) materialise to the utf8 / binary layout in a CPU pass sharded over the cores (offsets from the views' lengths, then one copy of the bytes), and list_view / large_list_view to a list: offsets derived and the child used as it is when the rows are consecutive and in order, otherwise the child gathered with the engine's take (a GPU gather for primitive and string children). Views nested in structs, lists and dictionaries, sliced batches and LZ4-compressed bodies are covered; reader.schema keeps the view types, and the writer writes their classic counterparts (utf8, binary, list, large_list) even when handed a schema that names them. Big-endian sources are read: every buffer is byte swapped on read by the width of what it holds (validity and boolean bitmaps untouched; offsets, sizes, dictionary indices and fixed-width values swapped; decimal128/256 limbs swapped and reordered; each part of an interval; a view's length, buffer index and offset), children and dictionary batches included, and reader.isBigEndian says so; Arrow's 1.0.0 big-endian integration files read to the values of their little-endian twins and of pyarrow (Tests/Fixtures/ipc/). The writer writes little-endian only. The arrow.fixed_shape_tensor extension is read and written: the reader keeps each field's custom_metadata on reader.schema and returns an arrow.fixed_shape_tensor column (over fixed_size_list) as .extended; it round-trips through ArrowIPCReader and ArrowIPCWriter with pyarrow's FixedShapeTensorArray, metadata byte for byte, and its metadata is checked on read (integer dimensions whose product fits in an Int and equals the storage's list size, else a malformed-data error); compute runs on the storage, a fixed-size list whose child holds every element. sink_ipc and sort_to_ipc write a tensor column with its extension keys, as ArrowIPCWriter does, so pyarrow reads it back as a FixedShapeTensorArray (test_sinks_keep_an_extension_column_as_its_extension_type). A column whose metadata names any other extension type reads as its storage column, so every operator takes it as it takes the plain type; its keys stay on reader.schema. IPC Tensor and SparseTensor messages are refused with an error that names them. Offsets past 2 GB (large binary, large list, large list view, or views adding up past 2 GB) are a clear error: the engine's arrays hold 32-bit offsets (IPCViewTests, test_ipc_views.py). Read timings are in Benchmarks/results/ipc_views_2026-09-24.csv (Benchmarks/ipc_views_bench.py, 10M rows, best of 5; run conditions in Benchmarks/results/bench_conditions_2026-09-24.txt). pyarrow's read_all, which keeps views as views, is ahead on every layout there, so IPC reads are to improve: list_view<int64> out of order reads in 51.11 ms against pyarrow's 17.48 ms, list_view<int64> in order in 30.0 ms against 16.99 ms, list<int64> in 20.51 ms against 15.12 ms, string_view in 17.51 ms against 16.82 ms and string in 15.76 ms against 11.51 ms.
Parquet read: nested columns In 0.2.0 Parquet/ParquetNested.swift: structs, maps and lists reassembled from their leaves at any depth (list<list<T>>, list<struct<...>>, struct<list<...>>, map<K, list<V>>, list<map<K, V>>), one flag kernel, one prefix sum and one scatter per field. Checked value for value and type for type against pyarrow.parquet.read_table on files from pyarrow, DuckDB and Polars (ParquetNestedTests, test_parquet_nested.py). Struct members and list elements are exported nullable, and 64-bit offsets and view layouts come back 32-bit; Parquet on the GPU.
Parquet read: stored Arrow schema In 0.2.0 Parquet/ParquetArrowSchema.swift decodes ARROW:schema and restores time zones, durations, decimal32 / decimal64, fixed-size lists, string and binary dictionary columns and extension types, matching fields by position as pyarrow does; the UNKNOWN annotation reads as null; field and schema metadata reach read_parquet_table's Table. Absent metadata and a stored schema of another width are ignored as in pyarrow; malformed metadata is ignored where pyarrow refuses the file (ParquetArrowSchemaTests, Parquet on the GPU).
Parquet read: page index and bloom filters In 0.2.0 Parquet/ParquetPageIndex.swift: statistics filters skip the data pages the column index rules out, flat columns decode only the pages the offset index says overlap the candidate rows, every column is trimmed to the same rows, and the exact matches are identical with and without the index (ParquetPageIndexTests). Parquet/ParquetBloomFilter.swift: == filters drop the row groups whose split-block bloom filter rules the literal out (ParquetBloomFilterTests).
Delta Lake table read In 0.2.0, partial Lakehouse/DeltaTable.swift, am.read_delta / am_delta_read: log replay with single and multi-part checkpoints (read by the GPU Parquet reader), time travel, partition values, column mapping none and name, file pruning by partition values and statistics, filters applied to the rows; data files decoded by the GPU Parquet reader. Deletion vectors, column mapping id and every other unimplemented reader feature are refused by name, and nested columns are not read. Checked against deltalake (Delta Lake and Iceberg).
Apache Iceberg table read In 0.2.0, partial Lakehouse/IcebergTable.swift, am.read_iceberg / am_iceberg_read: format v1 and v2, any snapshot, Avro manifest lists and manifests (null, deflate, snappy), columns by field id (renames, added columns, int -> long), name mapping for files without ids, manifest and file pruning (identity, year/month/day/hour, truncate), filters applied to the rows. Position and equality delete files are refused by name, and nested columns are not read. Checked against pyiceberg (Delta Lake and Iceberg).
CSV read In 0.2.0 Sources/ArrowMetal/CSV/ (CSV on the GPU): structure scan, pyarrow's type inference and the conversions are kernels; the host parses bytes only for a BOM, skip_rows and skip_rows_after_names, the header, error messages and the floats the GPU parser defers. Same tables as pyarrow.csv.read_csv (floats bit for bit) or the same error message over python/tests/test_csv.py. escape_char, timestamp_parsers, auto_dict_encode, ignore_empty_lines=False, other encodings and column_types outside the list in CSV.md (decimal, dictionary, date64, duration, the large types among them) are not implemented.
Newline-delimited JSON read In 0.2.0 Sources/ArrowMetal/JSON/, Kernels/JSONSource.swift: JSONReader (Swift), am_json_open / am_json_read (C), am.read_json / am.read_json_table (Python), with the semantics of pyarrow.json.read_json — the same type inference (null, bool, int64, double, timestamp[s] from ISO-8601 strings, string, struct, list), field order, explicit schema, unexpected_field_behavior and error texts, compared input by input in python/tests/test_json.py. Record boundaries, grammar validation, key matching, string unescaping and timestamp parsing run on the GPU; number text is parsed by MetalStringArray.parse, integers and floats on the GPU (Eisel-Lemire, with the CPU for the rare values it cannot decide exactly). Explicit types: null, bool, the integers, float, double, string, timestamp, list, struct. Inputs below 4 GiB, nesting up to 1024 levels; the differences from pyarrow are listed in JSON on the GPU.
Python: PyCapsule __arrow_c_array__ In 0.1.0 python/arrowmetal/__init__.py over the C ABI.
Python: wheel with the dylib inside In 0.1.0 scripts/build_wheel.sh copies libArrowMetalC.dylib into the package and builds a macosx_*_arm64 wheel; 0.1.0 is on PyPI (pip install arrowmetal).
Polars engine: lf.collect(engine=am.MetalEngine()) In 0.2.0, extended in 0.3.0 and 0.4.0 python/arrowmetal/polars_engine.py, tier 4 of Polars: translates the subtrees of Polars' optimised plan that read in-memory frames (filter, projection, slice, sort, group-by, aggregate, inner/left/semi/anti join, unique) into ArrowMetal plans through Polars' post-optimisation callback and leaves every other node to Polars; results checked against Polars by python/tests/test_polars_engine.py. By default (shapes="measured") it takes a subtree when its input rows are at or above the measured crossover of every shape class in it, per dtype class and input (POLARS.md, "Which translatable subtrees it runs: the defaults"; fitted from Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv, which holds the 2026-09-29 sort sweep and the 2026-09-30 group-by sweep; a shape is ahead when its time plus 15%, or 35% with a String column, is at most the faster Polars engine's, the default takes it from 1.5 times that fitted crossover, a String shape from 5M rows at the earliest, and a group-count bucket in which a case the default took was behind in the default benchmark only above the size it was behind at): sorts from 1,000,000 rows (the sort kernels' crossover), numeric-key left joins from 1,875,000 input rows, inner joins from 2,029,827 and anti joins from 3,727,959, unique from 5,494,090, sorts of a Parquet file from 1,500,000, sorts and unique with a String column from 5,000,000. A group-by is judged at the bucket of its number of groups, which a fixed-seed sample of its key columns estimates at plan time (bias-corrected Chao1, from 512 sampled rows until the decision is settled; cached per frame and keys): over 10,000 and 100,000 groups every numeric group-by class is taken, from 802,938 to 6,041,361 rows by class and bucket (the two-key mean from 50,000,000), over 1,000,000 groups every class from 7,500,000, and over 200 and 1,000 groups the two-key count, mean and min/max, the one-key min/max and the two-key sum at 1,000, from 5,580,594 to 19,740,807 rows; the one-key sum, count and mean over a few hundred or thousand groups and group-bys over a quarter of the rows or more are not taken; the (String, int32) sum over about 1,000,000 groups is taken from 9,744,372. Whole-frame aggregates, top-k, semi joins, row-wise shapes and the other String shapes are not taken. In Benchmarks/results/polars_engine_bench_2026-10-02.csv (220 case-size pairs, 2M and 50M rows, a 60-case group-by grid and Parquet included, the table in force) the default takes 75 pairs, 55 of them group-bys, every one ahead of the faster Polars engine, cold, best of 5 (1.52x to 11.54x), and every result is equal to Polars'; in Benchmarks/results/polars_engine_default_groupby_2026-09-30.csv (75 group-by cases at 2M and 50M rows) the table in force takes 54 of the 150 case-size pairs, each 1.12x to 10.69x of the faster Polars engine on the best run (1.01x to 10.1x on the median); in Benchmarks/results/polars_engine_bench_2026-09-26-final3.csv (194 case-size pairs, the 2026-09-26 table) the 0.3.0 default took 62 pairs (1.21x to 9.42x); shapes="all" takes everything it can translate. Right, full, cross and as-of joins, windows and file scans other than one local Parquet file stay with Polars; a scan_parquet of one local file (0.3.0) is read on the GPU, with Polars' projection and the comparisons of its predicate handed to the reader (POLARS.md, "Parquet scans").
Python: __arrow_c_device_array__ Not implemented Only __arrow_c_array__ is defined.
arrow-swift and MLX bridges, DuckDB/DataFusion UDF Not implemented No bridge or UDF exists.
DuckDB: aggregates of unchanged SQL on the GPU In 0.2.0, built from source duckdb-extension/src/arrowmetal_rewrite.cpp, a C++ optimizer extension for DuckDB 1.5.6 (the C extension API has no optimizer hook): sum/avg over integer columns, min/max over integer, DATE and TIMESTAMP columns, count, with no key or one integer, DATE, TIMESTAMP or VARCHAR key, over projections and filters of a table scan, replaced by ARROWMETAL_AGGREGATE with DuckDB's exact answers (HUGEINT sums, avg arithmetic, NULL groups). Floating-point sum/avg/min/max, DECIMAL, DISTINCT, several keys and joins below the aggregate stay DuckDB's. SET arrowmetal_rewrite = 'auto' / 'off' / 'force'; arrowmetal_rewrites() logs every decision. DuckDB §4b.

Engines

The rows above are checked against pyarrow.compute. The two engines built on them are checked against their own hosts: python/tests/engine_report.py runs a generated grid through each engine and through the host's CPU engine and compares the answers bit for bit. Every difference is either a documented divergence, with the line of the docs that states it, or unclassified, and the script exits 1 on an unclassified one. A case the engine did not run itself (Polars' plan had nothing to run, or the DuckDB extension left the aggregate to DuckDB) is compared as well and counted as not taken.

Engine Against Cases Pass Documented Unclassified Not taken
Polars MetalEngine(shapes="all", min_rows=0) lf.collect(), polars 1.44.1 15,376 15,096 33 0 247
DuckDB optimizer extension, arrowmetal_rewrite = 'force' 'off', DuckDB 1.5.6 33,376 21,844 0 0 11,532

The Polars row from Benchmarks/results/engine_conformance_2026-10-02.csv, the DuckDB row from Benchmarks/results/engine_conformance_2026-10-04.csv; one row per shape is in the _shapes.csv file of each.

  • Polars — every shape the engine translates (filter, select and with_columns expressions, slice, sort with and without a limit, group-by with each aggregate over the column as key and as value, whole-frame aggregates, inner, left, semi and anti joins, unique) over the eight integer widths, Float32, Float64, Boolean, String, Date, Datetime (ms, us, ns and with a time zone), Duration (ms, us, ns) and Time, with no, 5%, 70% or all nulls, at 0, 1, 7, 1,000 and 100,000 rows, plus special values. By family: filter 1,112 cases, select 1,526, with_columns 397, slice 397, sort 1,985 (105 not taken), sort with a limit 794 (68 not taken), group-by 3,172 (18 documented), whole-frame aggregate 1,084 (14 documented), join 1,420, unique 710. The 173 not taken are sorts over 0 or 1 row, which Polars' optimised plan leaves out.
  • DuckDB — sum, avg, min, max, count(v), count(*), all of them together and a GROUP BY without aggregates, over the ten value types the extension rewrites and six it leaves to DuckDB, with no key or one key of each kind it takes, no filter or one of two, the same null patterns and sizes (the 100,000-row table also in 2,048-row blocks), plus integer extremes. Without a key 6,394 cases (3,838 not taken), with one 26,982 (7,694 not taken). Of the 11,532 not taken, 1,746 are ungrouped queries of counts only, 1,030 filters DuckDB's statistics prove empty, and the rest aggregates over types or casts the extension does not take (DuckDB §4b, "What is rewritten").

Documented divergences found by the grid:

  • Float sums and means add in another order (Polars, 32 cases: Float32 and Float64 sum, Float64 mean). The largest difference was 0.216 u·Σ|x| for a Float32 sum (2.81e-5 of the answer) and 0.372 u·Σ|x| for Float64 (8.96e-14 of the answer), against a bound of 2(n - 1) u·Σ|x|; stated in Polars, Tier 4, "Where the answers would differ", Float sums and means.

The DuckDB half has none: every rewritten query returned DuckDB's types and values bit for bit, avg included.

What ArrowMetal 0.4.0 claims, and what it does not

The claim. ArrowMetal 0.5.0 answers to all 307 of the Apache Arrow v25 compute function names (and to remainder of the five v26 added; the other four are recorded as missing) — the 283 in the C++ docs plus the 24 hash_* grouped aggregates — over int8/16/32/64, uint8/16/32/64, float16/32/64, bool, utf8, binary, fixed_size_binary, decimal32/64/128, date32/64, time32/64, timestamp, duration, the three interval layouts, list / large_list / fixed_size_list, struct, map, dictionary, run_end_encoded and extension types. Every one of those names is a row in Arrow functions, one by one carrying the Swift file behind it, the ArrowMetal call that reaches it and the status the test suite measured: 283 gpu, 17 cpu, 7 partial, 0 missing. "Measured" is literal — python/tests/test_functions.py calls every runnable row through arrowmetal.functions.call_function and compares the answer to pyarrow.compute, with a second input in a different type family for the rows whose claim spans several.

Underneath that: reductions and the whole scalar aggregate family (sum … quantile, mode, count_distinct, first/last, index, skew, kurtosis, tdigest); grouped aggregation over arbitrary key columns, several folded together, with all 24 hash_* names; unchecked and checked arithmetic; every transcendental Arrow defines, in software binary64 on the GPU; all ten Arrow round modes; the complete Unicode string surface; the complete temporal surface including intervals and timezones; sorts, top-k, ranks, windows and rolling aggregates; the conditional and null-filling transforms; nested access; and Arrow C Data, C Device and C Stream interop for all of it.

What it does not claim.

  • No function is missing. binary_slice was the last name without an answer; Kernels/StringBytes.swift now has a byte-indexed slicing kernel that understands Arrow's step, and every one of Arrow v25's 307 compute function names is reachable.
  • Four families of functions are host-side because their data is. Unicode normalisation (utf8_normalize), the ICU regular-expression engine and everything built on it (extract_regex, extract_regex_span, the four *_regex string functions, split_pattern_regex), the centroid merge in tdigest and hash_tdigest, and pivot_wider. None of these is a missing kernel: a Unicode table would have to be uploaded per call, and pivot_wider's output is one row wide however long the input is. Where a fast path exists it is taken — a regex that is really a literal routes to the GPU kernel. The IANA timezone database used to be on this list; it is now a few-hundred-entry transition table uploaded once per zone and cached, which is what moved assume_timezone, local_timestamp, is_dst, strftime and strptime onto the GPU.
  • The Unicode predicates and case transforms are split, per row. The ten utf8_is_* predicates answer every row on the GPU and re-decide on the host only the rows carrying a byte ≥ 0x80, so an all-ASCII column never leaves the device. The five case transforms and the utf8_trim* family draw the seam the same way, one row at a time: an exact GPU table covers everything at or below U+017F, and only the rows above it — Greek, Cyrillic, CJK, emoji — are redone on the host.
  • Every float64 transcendental is software binary64 on the GPU. Metal has no double at all, so exp, ln, log10, log2, sqrt and power — and their _checked twins — are written out over ulong bit patterns (Kernels/DoublePower.swift) rather than narrowed to float and widened back: sqrt is correctly rounded and the other five are within 1 ulp, measured over 10^6 inputs each. The trigonometric family, expm1, log1p, logb and hypot are the same machinery, within 5 ulp of the host libm. The price is throughput, and it is real: see docs/BENCHMARKS.md.
  • A handful of answers deliberately differ from Arrow's, each stated on its row: ascending hash_distinct rather than first-appearance order and a deterministic but not first-seen group order; unique / value_counts over a utf8 column dropping the null (they keep it on every other type, as Arrow does, and their order option now defaults to Arrow's); an out-of-range safe=false float -> integer cast wrapping rather than saturating at the target's width (pyarrow 26.0.0 wraps the same way on the values the test pins; 25.0.1 saturated) — C leaves it undefined and safe=true refuses the row instead; cumulative_* carrying the running value across nulls; exact approximate_median where Arrow sketches; and NFC / NFKC composing, where pyarrow.compute.utf8_normalize did not until 26.0.0. Two Arrow quirks are reproduced rather than corrected — ceil on a calendar unit always advancing a boundary value, and rounding to a unit finer than the column's resolution flooring whatever mode was asked for — and one is not: Arrow's floor_temporal(unit="week", multiple>1, calendar_based_origin=True) can exceed its own input, and this floors.
  • Some options are still unimplemented, and the note on each row lists them: per-row num_repeats, dictionary and nested key columns for the sorts (utf8 and binary keys sort on the GPU through Kernels/StringSort.swift), and rank_normal's host-side inverse CDF. null_placement, rank's four tiebreakers, null_matching_behavior, the distinct-value order, CastOptions, RoundTemporalOptions, the split options and N-column binary_join_element_wise are all implemented, and python/tests/test_options.py walks their cross product against pyarrow.compute.
  • Type-level gaps remain, in the matrix above rather than in the function list: decimal256 arithmetic, compute over union values, and aggregates over list values.
  • It is still not a query planner, a SQL engine or a tensor library. The one Acero-shaped thing here is a GPU hash join over int32 / int64 keys.

Version 0.4.0. Read alongside Arrow functions, one by one, Roadmap, Design and Benchmark method.

docs/EVALUATION.md

Differential evaluation against pyarrow.compute

ArrowMetal 0.1.0 checked kernel by kernel against Apache Arrow's own C++ compute functions, through pyarrow.compute. The point is not to confirm the hand-written tests in python/tests/test_arrowmetal.py but to find the cases nobody thought to write down: the harness generates the input, runs both engines on it and compares, so a kernel that is wrong in an unanticipated way still fails.

swift build -c release --product ArrowMetalC
PYTHONPATH=python python python/tests/differential_report.py       # the matrix, as a table
PYTHONPATH=python python -m pytest python/tests/test_differential.py -q
What Detail
Files python/tests/test_differential.py (the harness), python/tests/differential_report.py (the runner)
Oracle pyarrow.compute 26.0.0 since 2026-10-15 (25.0.1 before; four of its bugs the matrix worked around are fixed in 26.0.0, see below), plus a reference written in the harness for the 16 operations Arrow has no function for (Coverage summary)
Cases in the default matrix 41,688 — 217 operations over 46 column types (45 types plus utf8_view, the utf8 values imported as views), 1,544 applicable (operation, type) cells x 27 datasets — 268 s on an M4 Max on 2026-10-01; the per-function table is generated from the recorded run (docs/data/differential_coverage.json, docs/COVERAGE.md); more with DIFF_LARGE=1
Result at 0.1.0 36,486 pass, 1,566 fail across 22 documented divergences, 1,017 skip (an operation that does not apply to a type), 0 unclassified
Result on 2026-09-25, with utf8_view 38,006 pass, 1,644 fail across the documented divergences, 1,174 skip, 0 unclassified; the 1,755 utf8_view cases are the 65 utf8 operations again, with the same pass, fail and skip counts as their utf8 cells
Result on 2026-10-01 38,897 pass, 1,644 fail across the documented divergences, 1,147 skip, 0 unclassified
Result on 2026-10-15, pyarrow 26.0.0 (the recorded run) 38,898 pass, 1,643 fail across the documented divergences, 1,147 skip, 0 unclassified; the case gained is winsorize/float64 on the negative-zero dataset, which had been pyarrow's offset bug counted under finding 22

Method

One oracle, never a literal. Every expected value comes from a pyarrow.compute call on the same array: pc.sum, pc.min/pc.max, pc.equal…, pc.add/pc.subtract/pc.multiply/pc.divide, Array.cast(safe=False), pc.and_/pc.or_/pc.invert, Array.filter, Array.take, Array.slice, pc.array_sort_indices, pc.binary_length, pc.utf8_length, pc.starts_with, pc.ends_with, pc.match_substring, and pa.Table.group_by(...).aggregate(...) for the grouped aggregates. Every option is spelled out rather than defaulted, so the oracle says what the kernel implements: pc.rank gets its tiebreaker, pc.week its three WeekOptions flags, pc.round its round_mode, pc.quantile its interpolation, pc.assume_timezone its ambiguous/nonexistent, pc.is_in its skip_nulls, pc.list_slice its return_fixed_size_list.

Where Arrow has no function. Seventeen operations have no pyarrow.compute counterpart at all. Sixteen name themselves in _NO_ORACLE and str_hash32 states it in its own @op note; test_no_oracle_operations_are_reported prints the _NO_ORACLE list on every run, and each is compared against a reference written out in the harness rather than against nothing:

Operation Reference
hash32, hash64 no value to compare: null propagation, equal-values-hash-equal, and injectivity over the distinct values
percent_rank_and_cume_dist the SQL definitions, computed from the same ascending-nulls-last order pc.rank uses
shift the shifted Python list
cumulative_mean the exact running sum over the running count, on the values a double can hold exactly
rolling_sum, rolling_mean, rolling_min_max each window taken as an Arrow slice and reduced with pc.sum / pc.mean / pc.min / pc.max, so only the windowing is the harness's
run_end the decoded column, put through pyarrow's own kernels — pyarrow has no filter/take/slice for a run-end array
temporal_round_duration integer arithmetic on the tick count, which is what the kernel documents
temporal_clock_on_a_date the clock of the midnight the date names: zero where valid, null where not
temporal_field_types the recorded result-type contract (_TEMPORAL_RESULT_TYPES)
interval_layouts the month_day_nano result, which pyarrow does compute and the matrix compares against it
add_interval pc.add of the equivalent duration for the day/time half, and a Python calendar reference for the month half
extension_type the round trip through pyarrow's own extension registry
nulls_constructor pa.nulls(n)

Generated input, not fixtures. The generator covers every type ArrowMetal imports — 45 columns, plus utf8_view: the utf8 values again, handed to ArrowMetal as Arrow string_view (cast from the unsliced parent, so a sliced dataset keeps its Arrow offset) while the oracle computes on the utf8 array, and a string_view result compared as string:

  • Flat primitives int8 int16 int32 int64, uint8 uint16 uint32 uint64, float32, float64, bool, utf8.
  • Temporal timestamp in all four units, each with and without a timezone (America/New_York, for its DST rule); date32, date64; time32[s], time32[ms], time64[us], time64[ns]; duration in all four units. Values are drawn from 1900-01-01 to 2100-01-01, and the special flavor adds the epoch and the second either side of it, both US DST transitions of 2024, the leap days of 1900, 2000 and 2024, the ISO-week corners of 2021 and 2025, and the ends of the window.
  • Decimal decimal128(9,2), decimal128(18,6) and decimal128(38,10) — Arrow's maximum precision, generated with full 38-digit values, negative values, and the extremes ±(10^p − 1) in the special flavor — plus decimal32(9,2) and decimal64(18,4) for the widening and narrowing casts.
  • Nested list<int64>, list<utf8> (rows of 0 to 4 elements, with nulls inside the rows), struct<a: int64, b: utf8> and map<utf8, int64> (with duplicate and empty keys).
  • The rest of the type matrix float16, fixed_size_binary(8), dictionary<int32, utf8>, run_end_encoded<int32, int64> (runs of 1 to 6), null, and month_day_nano_interval.

For every one of them the generator produces:

  • Sizes 0, 1, 33, 1000, 100,003; add 5,000,000 with DIFF_LARGE=1, drop 100k with DIFF_QUICK=1. 33 and 100,003 are deliberately not multiples of a threadgroup or SIMD width.
  • Null ratios 0, 0.3 and 1.0. Nulls are applied with pa.array(values, mask=...), which leaves the original numbers under the validity bitmap — what a real column looks like after a filter, and what a kernel is required to ignore. That choice is what surfaced the argsort null-order bug.
  • Special values (the special flavor): each type's min and max, 0, 1, -1, half-range; for floats -0.0, NaN, ±inf, the smallest normal, the smallest subnormal and their negatives, FLT_MAX/DBL_MAX and machine epsilon; for strings the empty string, multi-byte UTF-8 (héllo, 日本語, Ωμέγα, an emoji pair, a full-width digit run and a title-case code point), embedded tabs and newlines, and 300- and 4096-byte strings. The two long strings drop out of the pool above 200,000 rows, where they would otherwise make a gigabyte-scale array per case. The temporal, decimal, float16 and fixed_size_binary generators have their own special pools, listed above.
  • Sliced arrays (the sliced flavor): the array is built two offsets longer and then pa.Array.slice(offset, size) at offset 3 or 5 — neither byte- nor 8-bit-aligned, so the import path has to honour ArrowArray.offset for the values buffer, the validity bitmap and the utf8 offset buffer alike.

That is 27 datasets per (operation, type) by default.

Every method that exists is in scope. Operations are a registry in test_differential.py, and test_every_public_operation_has_a_differential_case fails if a method is added to MetalArray or GroupBy without one, so the harness cannot silently fall behind the library; test_every_module_level_function_has_a_differential_case does the same for coalesce, case_when, choose, lexsort_indices and nulls. Optional operations register themselves the moment the method appears on MetalArray, so a new kernel joins the matrix with no edit here; the ones still missing (reverse at 0.1.0) are listed by the report as absent. _NO_MATRIX_TYPE — the list of methods the generator could not reach — is empty: it used to hold the temporal kernels, which now have their own columns. The only members outside the matrix are the six accessors and the six generic dispatchers (unary, binary, cumulative, window, string_predicate, string_transform), every op of which is reached through a named form that is in the matrix.

Comparison strictness. Results are compared as pyarrow.Arrays, not as Python lists, so the type is part of the comparison: a kernel that returns the right numbers as int64 instead of uint64 fails, which is how the unsigned group-by total turned up. Integers, booleans, strings and index vectors are compared exactly. Float arithmetic (+ - * /, scalar and array, Float32 and Float64) is compared bit-exact — Array.equals is deliberately not used for float types, because Arrow calls -0.0 and 0.0 equal and NaN and NaN unequal, and this harness needs the opposite of both. Reductions get a tolerance, because the two engines legitimately accumulate in a different order: a relative 1e-6 for Float32 and 1e-12 for Float64, plus the standard roundoff bound 8·eps·sqrt(n)·Σ|x|, without which a 100k-row Float32 sum cannot be compared at all (sqrt(100000)·2^-24 ≈ 2e-5 on its own).

Not-implemented versus wrong. An ArrowMetalError whose message matches a known gap ("Unsupported Arrow type", "group-by min/max needs a 32-bit or narrower type", …) is a skip; any other error, or a wrong answer, is a failure. The 1,017 skips in the default run are:

  • the group-by combinations the kernels do not cover — min/max on 64-bit values, mean on Float32, and any aggregate over Float64, the same set test_arrowmetal.py pins as expected errors;
  • the primitive-only kernels (is_null, is_valid, fill_null, drop_null, if_else, is_in, index_in, case_when, choose, coalesce) on utf8 and bool;
  • mode and first/last/index on a boolean column (am_reduce_ex does not define them there);
  • subtract_temporal on a time-of-day column (two times of day do not make a duration here);
  • run-end encoded arrays with a non-zero offset ("decode on the producer side first"), which is the sliced flavor of roundtrip_ext;
  • the cells where the operation has nothing to do: temporal_round_finer on a nanosecond column, because no unit is finer than its own tick.

The report prints all of them, grouped by the message the kernel gave.

Tolerances, and where each comes from. Exactness is the default; every departure is named:

Comparison Tolerance Why
integers, booleans, strings, index vectors, decimals, temporal values, nested values exact nothing accumulates
float + - * /, scalar and array bit-exact the two engines do the same operation in the same order
sum, mean, cumulative_sum, rolling_sum, rolling_mean, the grouped aggregates relative 1e-6 (float32) / 1e-12 (float64), plus the roundoff bound 8·eps·sqrt(n)·Σ|x| the two engines accumulate in a different order; without a bound that scales with the input a 100k-row float32 sum cannot be compared at all
variance, stddev relative 1e-6 (float32) / 1e-7 the kernel accumulates the two moments and subtracts them, which costs about six digits against pyarrow's exact two-pass algorithm — measured at 6e-10 over the matrix and 2e-8 on a float32 column
sqrt, exp, ln, log10, log2 relative 1e-6 (plus an absolute 1e-6 for the logarithms, and a |x|-scaled bound for exp) they evaluate in float and widen back
sin…atanh, atan2 relative 1e-6 (float32) / 1e-14 (float64) the header measures the worst case at 4 ulp and 5 ulp against the host libm; every special value (NaN, ±inf, ±0, the domain errors) is compared exactly by a pinned test instead
quantile, median, cumulative_mean relative 1e-12 a double division at the end of an exact selection

Nothing else is given a tolerance. In particular the temporal, decimal, string and nested families are compared exactly, type included.

Deliberate and documented divergences

These are not bugs. Each has a named test in test_differential.py that asserts both behaviours, so the suite notices if either engine changes its mind.

Case ArrowMetal pyarrow Test
Integer division by zero 0 raises ArrowInvalid test_integer_division_by_zero_returns_zero_where_pyarrow_raises
Integer overflow wraps pc.add wraps, pc.add_checked raises — the harness uses the unchecked variants test_integer_overflow_wraps_where_checked_pyarrow_raises
INT_MIN / -1 (undefined in C) INT_MIN (the hardware answer) 0 test_int_min_divided_by_minus_one_wraps_where_pyarrow_yields_zero
Out-of-range float → int cast (undefined in C) wraps 26.0.0 wraps the same way on every value the test holds; 25.0.1 saturated to INT32_MAX test_out_of_range_float_to_int_cast_agrees_since_pyarrow_26
Float division by zero ±inf / NaN ±inf / NaN — identical test_float_division_by_zero_is_ieee_754_in_both
& / | with nulls null-propagating, like pc.and_/pc.or_ — not and_kleene/or_kleene both available test_boolean_and_or_are_not_kleene
NaN in min/max skipped skipped — identical test_nan_is_skipped_by_min_and_max_in_both
NaN in sum propagates propagates — identical test_nan_propagates_through_sum_in_both
Sort order of ties stable stable — identical test_ties_sort_stably_in_both
NaN's position in a sort after +inf after +inf — identical test_sort_places_nan_after_positive_infinity_in_both
Group-by keys outside [0, key_count) dropped given their own group test_group_by_ignores_keys_outside_the_declared_range
Integer mean when the total overflows divides the wrapped 64-bit sum, matching its own sum() accumulates in double test_integer_mean_wraps_where_pyarrow_widens
min/max of an all-NaN column null NaN test_all_nan_min_max_is_null_in_arrowmetal_and_nan_in_pyarrow
sign of an integer column keeps the column's type narrows to int8 test_sign_keeps_the_column_type_where_pyarrow_narrows_to_int8
floor/ceil/trunc of an integer column the identity, keeps the type at any magnitude widens to double, and refuses the column past 2^53 test_floor_and_ceil_keep_the_integer_type_where_pyarrow_widens_to_double
Shift count outside [0, bit width) shifts the bits out: 0, or the sign fill for a signed shift_right unchecked returns the operand untouched; checked raises. Arrow's range also excludes the sign bit, so int32 << 31 already differs test_out_of_range_shift_counts_shift_the_bits_out_where_pyarrow_returns_the_operand
Nulls in cumulative_sum/_min/_max the running value carries across a null, output null where input null — Arrow's skip_nulls=True the same with skip_nulls=True; its default propagates the first null to the end. ArrowMetal has no such mode test_cumulative_functions_skip_nulls_where_arrow_propagates_them
A null in is_in's value set ignored; a null element never matches, so the result has no nulls — Arrow's skip_nulls=True the same with skip_nulls=True; its default matches null to null test_is_in_never_matches_a_null_where_arrow_matches_null_to_null
Empty pattern in replace the identity pc.replace_substring does not terminate on an empty pattern — the harness must never call it with one test_empty_replace_pattern_is_the_identity
Empty pattern in count_substring code points + 1 bytes + 1 test_empty_pattern_counts_code_points_where_arrow_counts_bytes
float64 sqrt/exp/ln/log10/log2/power software IEEE-754 binary64 on the GPU: sqrt correctly rounded, the others measured at a worst case of 1 ulp of libm over 10^6 inputs per function and asserted within 2 (was: evaluated in float, fixed 2026-09-06) evaluated in double test_float64_transcendentals_are_true_binary64
list_element on a row shorter than the index null raises ArrowInvalid for the whole column test_list_element_of_a_short_row_is_null_where_pyarrow_raises
parse of a string that is not a number null cast raises, even with safe=False test_parse_returns_null_where_pyarrow_raises
adding a duration to a time of day past midnight wraps inside the day raises: the result is outside [0, 86400) test_time_of_day_addition_wraps_where_pyarrow_raises
array_sort_indices, sort_indices, select_k_unstable, partition_nth_indices, rank uint32 row numbers (arrow-rs's and Polars' index width); the same values uint64 test_index_arrays_are_uint32_where_pyarrow_returns_uint64
list_parent_indices int32 — list offsets are int32 throughout this package int64 test_list_parent_indices_are_int32_where_pyarrow_returns_int64
the temporal extractors (year…nanosecond, day_of_week with the default options) int32 int64 (us_week, us_year, week and the option-carrying day_of_week are int64 in both) test_temporal_extractors_return_int32_where_pyarrow_returns_int64
null_count of a run-end encoded column the logical count always 0 — the nulls live in the values child test_run_end_null_count_is_logical
filter/take/slice of a run-end encoded column decodes to a flat column no kernel for the type at all test_run_end_selection_decodes
the replacement template in replace_substring_regex ICU's: $1, $2 RE2's: \1, \2 test_regex_replacement_template_is_icu_not_re2
index's needle on a uint64 value above 2^63 not found: the needle crosses the C ABI as a double found test_index_of_a_large_unsigned_value_is_not_found
min/max of an all-NaN rolling window the scan's identity, ±inf NaN, as pc.min does test_rolling_min_of_an_all_nan_window
negate_checked on an unsigned column raises for every non-zero value — the negation does not fit no unsigned kernel at all: ArrowNotImplementedError test_negate_checked_refuses_an_unsigned_column_pyarrow_has_no_kernel_for
an overflow, a zero divisor or a negative integer power in the _checked family raises ArrowMetalError, naming the first offending row raises ArrowInvalid — identical, and the matrix compares the objections as well as the answers test_checked_arithmetic_raises_where_the_unchecked_form_wraps
unique/value_counts of a null string row dropped: the GPU string dictionary has no slot for a null (every other type keeps it, as Arrow does) kept, as one entry test_unique_drops_the_null_row_of_a_string_column
decimal +/-/* past the column's precision wraps modulo 2^128, the storage width pc.add refuses precision 39 outright; its unchecked cast wraps modulo 10^precision test_decimal_arithmetic_wraps_modulo_two_to_the_128

Five of the divergences the harness turned up are pyarrow's, not ArrowMetal's. They are recorded here because the matrix had to work around them, each with a test that fails when pyarrow fixes it. Four are fixed in pyarrow 26.0.0 (Upstream tracker): on 2026-10-15 the matrix moved to 26.0.0, the four pinning tests failed as designed and were retired with their workarounds, and the oracle is pyarrow.compute on the slice itself again. One remains:

Case pyarrow 25.0.1 Correct Test
pc.utf8_normalize decomposed whatever form said — its NFC was NFD fixed in 26.0.0 by our apache/arrow#51237; the oracle was Python's unicodedata until then retired
pc.pairwise_diff on a sliced column read the values buffer without ArrowArray.offset fixed in 26.0.0; the oracle was given a materialised copy until then retired
pc.fill_null_forward/_backward/replace_with_mask on a sliced boolean column the same offset bug fixed in 26.0.0 retired
pc.winsorize on a sliced column the same offset bug — it answered with the wrong rows null, and put values where the input has nulls fixed in 26.0.0 retired
pc.binary_slice with its own default stop sys.maxsize overflows the output-size arithmetic: ArrowInvalid: Negative buffer resize (still so in 26.0.0) the matrix names an explicit stop past every generated value test_pyarrow_binary_slice_overflows_on_its_own_default_stop

And one was a crash rather than a wrong answer: in the first run of this matrix pc.year_month_day and pc.iso_calendar corrupted the heap in pyarrow 25.0.1 and the process segfaulted a couple of allocations later. A deliberate attempt to reproduce it on 2026-09-07 (the same recipe, thousands of iterations, every timestamp unit, under guard malloc) did not crash, so it is recorded as observed once and unexplained, not as a confirmed bug, and the matrix still never calls them — temporal_struct compares the two struct-valued kernels field by field against pc.year/pc.month/pc.day and pc.iso_year/pc.iso_week/pc.day_of_week instead, which is a stronger check anyway. See docs/FINDINGS.md.

Two of Arrow's own defaults would have made the matrix meaningless if the harness accepted them, so the oracle states the option instead and a test records why. pc.cumulative_max's default start in pyarrow 25.0.1 was numeric_limits<T>::min(), which on a float column is the smallest positive normal — so the default clamped every negative running maximum to 1.18e-38; 26.0.0 answers the running maximum, and the matrix still passes ∓inf explicitly, the same answer either way (test_arrow_cumulative_max_default_start_takes_negative_floats_since_pyarrow_26). And pc.round defaults to half-to-even where ArrowMetal rounds halves away from zero, so the oracle asks for half_towards_infinity, the mode the kernel documents.

Because min/max skip NaN in both engines but disagree on what is left when every value is NaN, the matrix compares min/max/mean on NaN-free input and the NaN behaviour is pinned in tests of its own. For the same reason the float cumulative_sum comparison drops the values that can overflow a partial sum (±inf, and anything above type_max / n): past that point one association reaches ±inf where the other does not, and inf - inf is NaN, which is not a roundoff difference any bound can express. NaN itself stays in the input — it poisons every later element in both engines (test_cumulative_sum_propagates_nan_and_reassociates_infinities).

Open findings

Twenty-two divergences the harness found that are not bugs but are not free choices either: each is a place where a kernel's own consistency was preferred to Arrow's answer, or where a documented limit of the GPU path shows through. Together they account for all 1,566 failing cases; a cell matched by two findings appears under both, so the Cases column below sums to more than 1,566. Each has an entry in FINDINGS in test_differential.py, so the matrix groups the affected cells under the finding instead of burying them, and an xfail(strict=True) reproduction, so the suite turns red the moment a kernel changes its mind. Eighteen of them are classified by the data — a data_check that looks at the generated values — so a dataset that does not contain the triggering value still has to agree exactly.

# Finding Cases Cells
1 float32-subnormal-ftz 42 8
2 sign-of-negative-zero 8 2
3 negative-zero-set-lookup 30 8
4 cumulative-prod-reassociation 12 2
5 decimal-to-float64-divides 38 3
6 decimal-round-carry-past-the-precision 12 3
7 regex-icu-unicode-classes 28 2
8 regex-anchor-in-a-repeated-search 15 1
9 split-loses-the-null-row 26 2
10 split-whitespace-trailing-run 14 1
11 float-text-swift-format 35 2
12 temporal-extract-in-utc 1,064 63
13 strftime-seconds-carry-the-fraction 57 3
14 timezone-after-2038 144 12
15 trig-argument-reduction 8 2
16 variance-accumulator-overflow 8 2
17 rolling-min-max-zero-and-subnormal 5 2
18 unique-drops-the-null-string 24 2
19 logb-of-zero-against-a-small-base 4 1
20 round-scales-before-it-rounds 19 2
21 high-moment-accumulator-overflow 14 4
22 winsorize-negative-zero-limit 2 1
Total 1,609 128

The counts are the full differential_report.py run of 2026-09-07. The column totals exceed the headline 1,566 cases and the 126 distinct cells because differential_report.py counts a cell whole under every finding that claims it, and three cells are claimed twice: round_extra/float32 (findings 1 and 20), regex_match/utf8 (7 and 8) and split_whitespace/utf8 (9 and 10). That double counting is the 44-case, 3-cell difference.

1. Float32 arithmetic flushes subnormals to zero

42 failing cases across 8 cells: arith_array/float32, arith_checked/float32, arith_scalar/float32, math_extra/float32, pairwise_diff/float32, round_extra/float32, trig/float32, trig_checked/float32, the special flavor.

Metal's default math mode is flush-to-zero and denormals-are-zero. The Float32 arithmetic kernels inherit it, so a subnormal result becomes 0.0 and a subnormal operand contributes nothing — while import/export round-trips the same values perfectly, which makes the loss look like a data bug rather than a math-mode one.

tiny = 1.1754943508222875e-38                      # smallest normal float32
a = pa.array([tiny, 1.0], pa.float32())
am.array(a).arith("*", 0.5).to_arrow()             # [0.0, 0.5]
pc.multiply(a, pa.scalar(0.5, pa.float32()))       # [5.877471754111438e-39, 0.5]   (so does numpy)

Float64 is unaffected — it runs through the software IEEE-754 path and returns the subnormal, which test_float64_arithmetic_and_comparison_keep_subnormals asserts. The kernels that do not add or multiply were taken off the flush: compare builds bit keys, and sign, floor, ceil, trunc, round and the element-wise min/max decide the subnormal and signed-zero cases from the bit pattern, so sign(1.4e-45) is 1 and ceil(1.4e-45) is 1.0, as in Arrow (test_float32_comparison_distinguishes_subnormals_from_zero, test_sign_of_a_float32_subnormal_is_one, testFloat32SignAndRoundingCorners). Only + - * / still flush.

Reproduction: test_float32_arithmetic_keeps_subnormal_results.

2. sign keeps the sign of -0.0

8 failing cases: sign/float32, sign/float64, the datasets containing a negative zero.

am_unary's sign returns -1/0/1 and leaves NaN and both signed zeros alone, so sign(-0.0) is -0.0. pyarrow normalises it:

a = pa.array([-0.0, 0.0, math.nan], pa.float64())
am.array(a).sign().to_arrow()      # [-0.0, 0.0, nan]
pc.sign(a)                         # [ 0.0, 0.0, nan]

Keeping the operand is the documented behaviour (include/arrowmetal.h) and the one that loses no information; the two engines agree on every other value, NaN included. Reproduction: test_sign_of_negative_zero_matches_pyarrow.

3. is_in and index_in treat -0.0 and 0.0 as one value

30 failing cases across 8 cells: is_in/float64, index_in/float64, unique, value_counts and mode_and_count_distinct on float32 and float64, the datasets containing a negative zero.

The set lookup is a binary search over unique(), which orders values by the same total order the radix sort uses: every NaN is one value, and -0.0 is 0.0. Arrow's hash lookup keeps the two zeros apart (it agrees with us on NaN).

a = pa.array([0.0, -0.0], pa.float64())
s = pa.array([-0.0], pa.float64())
am.array(a).is_in(am.array(s)).to_arrow()               # [True, True]
pc.is_in(a, value_set=s, skip_nulls=True)               # [False, True]

Matching Arrow here would mean is_in disagreeing with unique, dictionary_encode and sort about how many distinct values a column has, which is the worse of the two inconsistencies. Reproductions: test_is_in_separates_negative_zero_from_zero (xfail) and test_is_in_matches_nan_to_nan_in_both.

4. cumulative_prod reassociates, so overflow and underflow land differently

cumulative_prod/float32 and cumulative_prod/float64, the datasets whose running product leaves the normal range.

The running product is a two-level parallel scan (Kernels/Window.swift), so the multiplications happen in a different order from Arrow's left-to-right loop. Inside the normal range that only moves the last ulp, which the float tolerance absorbs (test_cumulative_prod_matches_arrow_inside_range). Once an intermediate overflows or underflows the order decides the answer: Arrow's sequential product turns to inf (or 0) and stays there, while the scan can pair an overflowed partial with an underflowed one and produce inf * 0 = NaN, or skip the overflow altogether.

a = pa.array([1e30, 1e30, 1e-30, 1e-30] * 8, pa.float32())
pc.cumulative_prod(a)                        # [1e30, inf, inf, inf, ...]
am.array(a).cumulative_prod().to_arrow()     # [1e30, inf, 1e30, nan, ...]

Matching Arrow exactly would need a sequential pass, which is the one thing the GPU should not do. The finding is classified by the data (_prefix_product_leaves_safe_range): a dataset counts only when its sequential running product gets within 2^40 of overflow or of the smallest normal. Reproduction: test_cumulative_prod_matches_arrow_past_overflow (xfail).

5. decimal → float64 divides where Arrow multiplies by the reciprocal

38 failing cases: decimal_to_float64 on all three decimal128 columns.

am_decimal_op op 16 computes unscaled / 10^scale in double, which is correctly rounded — the nearest double to the exact decimal value. Arrow's cast multiplies by the precomputed reciprocal 10^-scale, which is a ulp out on most values.

a = pa.array([decimal.Decimal("99.99")], pa.decimal128(9, 2))
am.array(a).to_float64().to_arrow()   # [99.99]                <- 9999 / 100, exactly
a.cast(pa.float64())                  # [99.99000000000001]

The kernel's answer is the better one, and changing it to reproduce Arrow's rounding would mean introducing an error on purpose. Reproductions: test_decimal_to_float64_matches_arrows_cast (xfail) and test_decimal_to_float64_is_the_correctly_rounded_quotient.

6. Rounding a decimal down narrows the precision, and a carry can outgrow it

12 failing cases: decimal_round on the three decimal128 columns, the special flavor.

decimal_round(target) rescales: the result type is decimal128(precision − (scale − target), target) when rounding down and decimal128(precision + target − scale, target) when rounding up. Scaling up is exact. Scaling down is not always: a value whose rounding carries needs one digit more than the narrowed precision, and the kernel keeps the value rather than wrapping it.

a = pa.array([decimal.Decimal("9999999.99")], pa.decimal128(9, 2))
am.array(a).decimal_round(0).to_arrow()          # decimal128(7, 0): [10000000]  <- 8 digits
pc.round(a, ndigits=0, round_mode="half_towards_infinity")   # decimal128(9, 2): [10000000.00]

Arrow keeps the input scale instead and never has to narrow, so the two only part company on the type and on that carry; casting Arrow's answer into the narrowed type wraps it modulo 10^precision, which is not a number anyone wants. Classified by the data (_decimal_rounding_carries). Reproductions: test_decimal_round_carry_wraps_like_arrows_cast (xfail) and test_decimal_round_narrows_the_precision_by_the_digits_it_drops.

7. ICU's \d, \w and \s are Unicode-aware; RE2's are ASCII

28 failing cases: regex_match and regex_replace on utf8, the datasets containing a full-width digit.

ArrowMetal matches with ICU (NSRegularExpression) and pyarrow with RE2. The syntaxes agree on everything the matrix uses except the character classes: ICU's \d is Unicode category Nd, RE2's is [0-9].

a = pa.array(["2024"], pa.string())
am.array(a).match_substring_regex(r"\d").to_arrow()   # [true]
pc.match_substring_regex(a, r"\d")                     # [false]

Which is right is a matter of which engine you came from; neither is a bug. The finding is classified by the data — a column has to contain a non-ASCII code point of category Nd to count under it — so a column of plain ASCII still has to agree, which test_regex_digit_class_agrees_on_ascii asserts. Reproduction: test_regex_digit_class_is_ascii_only (xfail).

15 failing cases: regex_match on utf8, the datasets with a row an anchored pattern can match twice.

count_substring_regex and replace_substring_regex search repeatedly. ICU anchors ^ at the start of the input and keeps it there; RE2 re-anchors it at the start of each search, so a second match can appear where there is no second line.

a = pa.array(["aa"], pa.string())
am.array(a).count_substring_regex("^a").to_arrow()   # [1]
pc.count_substring_regex(a, "^a")                     # [2]

Reproduction: test_anchored_pattern_counts_once_per_input (xfail).

9. split has nowhere to put a null row

26 failing cases: split_pairs and split_whitespace on utf8, every dataset with a null.

ArrowMetal has no list type, so split_pattern and split_whitespace return the (offsets, values) pair of an Arrow list<utf8> instead — and a pair of flat arrays has no validity bitmap for the rows. A null value splits to an empty row where Arrow's list<utf8> keeps the null.

a = pa.array(["a b", None], pa.string())
offsets, values = am.array(a).split_pattern(" ")
pa.ListArray.from_arrays(offsets.to_arrow(), values.to_arrow())   # [["a", "b"], []]
pc.split_pattern(a, " ")                                          # [["a", "b"], null]

Giving the pair a third array for the validity would be a list type in all but name; the caller that needs the distinction has is_null(). Classified by the data (the column has to have a null). Reproduction: test_split_keeps_the_null_row (xfail).

10. split_whitespace gives one empty piece for a trailing run, Arrow gives two

14 failing cases: split_whitespace (the unicode=True path) on utf8, on the datasets with a value whose trailing whitespace run is two or more characters long.

Both engines treat a run of whitespace as one separator and keep the empty pieces at either end: " padded" is ['', 'padded'] in both, " " is ['', ''] in both. Arrow's utf8_split_whitespace is asymmetric on its own — a trailing run of two or more characters produces two empty pieces — while Arrow's ascii_split_whitespace produces one, and so does ArrowMetal in both modes. The Unicode flag lines up with Arrow's pair: split_whitespace(unicode=True) is compared with utf8_split_whitespace (U+3000 and U+00A0 separate) and the default with ascii_split_whitespace, whose cell passes.

a = pa.array(["padded  "], pa.string())
am.array(a).split_whitespace(unicode=True).to_arrow()   # [['padded', '']]
pc.utf8_split_whitespace(a)                             # [['padded', '', '']]
pc.ascii_split_whitespace(a)                            # [['padded', '']]   <- Arrow's own ASCII variant agrees

Classified by the data (_ends_with_a_whitespace_run). Reproductions: test_split_whitespace_trailing_run_matches_arrow (xfail), test_ascii_split_whitespace_trailing_run_matches_arrows_ascii_variant and test_split_whitespace_agrees_with_arrow_away_from_a_trailing_run.

11. float → utf8 uses Swift's formatting

35 failing cases: to_strings_text on float32 and float64.

am_to_strings formats a float on the CPU with Swift's own description, which is a shortest round-tripping decimal like Arrow's — but shaped differently: an integral value keeps its .0, the exponent has two digits, and the switch to scientific notation happens at a different magnitude.

a = pa.array([1.0, -0.0, 1.1786107e-06, 7.5e-07], pa.float64())
am.array(a).to_strings().to_arrow()   # ['1.0', '-0.0', '1.1786107e-06', '7.5e-07']
a.cast(pa.string())                   # ['1',   '-0',   '0.0000011786107', '7.5e-7']

Both texts name the same number, and that is the property the matrix gates on: to_strings_value reads ArrowMetal's own digits back with Python's float and compares them with the input bit for bit, -0.0, the subnormals and DBL_MAX included, and it passes everywhere. Only the text is a finding, classified by the data (_float_text_differs). Reproductions: test_float_to_text_matches_arrows_formatter (xfail) and test_float_to_text_names_the_same_number.

12. The temporal kernels read a zoned timestamp in UTC

1,064 failing cases across 63 cells: every temporal operation on the four timestamp[…, tz] columns.

am_temporal_extract, am_temporal_math and am_temporal_extra all work on the instant, in UTC, whatever timezone the column's type carries — which is what the class documents ("extracted in UTC"). pyarrow reads a zoned timestamp in its own zone, so every field, rounding and difference differs by the offset.

a = pa.array([0], pa.timestamp("s", "America/New_York"))
am.array(a).hour().to_arrow()   # [0]     <- 1970-01-01T00:00:00Z
pc.hour(a)                       # [19]    <- 1969-12-31T19:00:00-05:00

Reading in UTC is the only thing a GPU kernel can do without the tz database, and it is what the two functions that do consult the database (assume_timezone, local_timestamp) are for: convert first, extract after. The finding is the largest in the matrix, so it gets a companion operation rather than a blanket exemption — temporal_utc_semantics runs every extractor and floor_temporal on the zoned column and compares them with Arrow's own answer for the same instants written without a zone, and it passes everywhere. The zone is therefore the whole of the difference, which is what test_temporal_extract_is_arrows_answer_for_the_same_naive_instant asserts. Reproduction: test_temporal_extract_uses_the_columns_timezone (xfail).

13. %S prints whole seconds

57 failing cases: strftime_seconds on the sub-second timestamp columns.

am_parse's strftime path is C's, where %S is the two-digit second. Arrow's appends the sub-second digits of the column's own resolution to it.

a = pa.array([1_700_000_000_123_456], pa.timestamp("us"))
am.array(a).strftime("%S").to_arrow()      # ['20']
pc.strftime(a, format="%S")                # ['20.123456']

%f is the ArrowMetal extension for the fractional second, so the information is reachable; a %S that silently changes width with the column's unit is not something a C format string should do. Every other field agrees (test_strftime_agrees_on_every_other_field), which is why the %S formats are an operation of their own. Reproduction: test_strftime_seconds_carry_the_fraction (xfail).

14. pyarrow's timezone database stops transitioning at 2038

144 failing cases across 12 cells: assume_timezone, temporal_timezone and timezone_metadata on the datasets reaching past 2038.

This one is pyarrow's, not ours, but it is a divergence the matrix has to carry: Arrow's bundled tz database stops applying a DST rule once the 32-bit epoch runs out, so every summer instant after January 2038 comes back with the winter offset. Foundation keeps applying the rule.

a = pa.array([_seconds_of(2050, 7, 15, 12)], pa.timestamp("s"))   # July, so EDT = UTC-4
am.array(a).assume_timezone("America/New_York", "earliest", "earliest")   # offset -14400
pc.assume_timezone(a, "America/New_York", ambiguous="earliest", nonexistent="earliest")  # -18000

Before 2038 the two agree everywhere in the matrix, which test_assume_timezone_agrees_before_2038_and_keeps_the_rule_after asserts along with the one-hour gap after it. Classified by the data (_after_the_2038_cutoff). Reproduction: test_assume_timezone_agrees_past_2038 (xfail).

15. The software binary64 trigonometry's argument reduction drifts past 2^49

8 failing cases: trig and trig_checked on float64, the datasets containing a value at or above 2^49.

sin, cos and tan on a Float64 column run the software binary64 implementation on the GPU, whose argument reduction carries a fixed number of bits of π. Below 2^49 the results are bit-comparable with the host libm; above it they drift, and past about 2^61 the reduction gives up and returns NaN.

a = pa.array([2.0 ** 52 + 0.5], pa.float64())
am.array(a).sin().to_arrow()   # [0.797567…]
pc.sin(a)                       # [0.874217…]

The header states the limit. Float32 is unaffected — it runs Metal's own functions, which reduce correctly across the whole range. Classified by the data. Reproductions: test_trig_reduces_a_large_argument (xfail) and test_trig_agrees_below_the_reduction_limit.

16. variance and stddev accumulate the moments in 64 bits

8 failing cases: variance_and_stddev on int64 and uint64, the special flavor.

The two moments are accumulated on the GPU in a 64-bit accumulator. For a column of 64-bit integers past about 2^40 the sum of squares leaves it, and the answer stops meaning anything — a wrapped number, or NaN.

a = pa.array([2 ** 62] * 3, pa.int64())
am.array(a).variance(0)            # 1.3611295640251995e+37
pc.variance(a, ddof=0).as_py()     # 0.0                     <- three equal values

Inside the accumulator the kernel is accurate to about ten significant digits, which is the tolerance the matrix gives it (see Tolerances above) and what test_variance_is_accurate_inside_the_accumulator asserts. Classified by the data (_moments_leave_the_accumulator). Reproduction: test_variance_of_a_large_int64_column (xfail).

17. The rolling min/max scan compares raw values

5 failing cases: rolling_min_max on the float columns containing a negative zero or a float32 subnormal.

The scalar min/max, the sort and the element-wise min/max all decide their ±0 and subnormal cases from the bit pattern — that was one of the fixes in the first round. The rolling window's scan kept the plain comparison, so a ±0 tie is broken by position rather than the way fmin does it.

a = pa.array([0.0, -0.0], pa.float64())
am.array(a).rolling_min(2).to_arrow()   # [null, 0.0]
pc.min(a).as_py()                        # -0.0

Small, and the same shape as the bugs that were fixed in the selection kernels; it is recorded here as a known divergence. Classified by the data. Reproduction: test_rolling_min_breaks_a_zero_tie_like_fmin (xfail).

18. unique and value_counts drop the null row of a string column

24 failing cases: unique/utf8, value_counts/utf8, the datasets containing a null.

Both orders of unique() and value_counts() go through the GPU string dictionary, which has one slot per distinct string and none for a null, so a null row leaves no entry behind. Arrow keeps the null as an entry of its own, at the position of the first null row. Every other type agrees with Arrow exactly — this is a property of the string path, not of the operation.

a = pa.array(["a", None, "a"], pa.string())
am.array(a).unique().to_arrow()          # ["a"]
pc.unique(a)                             # ["a", null]

n = pa.array([1, None, 1], pa.int32())
am.array(n).unique().to_arrow()          # [1, null]   -- the same as pc.unique

The counts of the values that are there are exact, and so is their order. Classified by the data (a column with no null still has to agree). Reproduction: test_unique_drops_the_null_row_of_a_string_column.

19. logb of zero against a base below one is +inf

4 failing cases: math_extra/float64, the datasets containing a zero.

logb(x, base) is ln(x) / ln(base) throughout, which is what the header documents. With a base below one, ln(base) is negative, so ln(0) / ln(base) is -inf / -something = +inf — the mathematically correct limit of log_b(0) for b < 1. Arrow special-cases a zero argument and answers -inf whatever the base:

values = pa.array([0.0], pa.float64())
bases  = pa.array([0.5], pa.float64())
am.array(values).logb(am.array(bases)).to_arrow()   # [inf]
pc.logb(values, bases)                              # [-inf]

am.array(values).logb(am.array(pa.array([2.0])))    # [-inf]   -- above one the two agree

Only the zero argument is affected, and only against a base in (0, 1); every other pair agrees to the family's 5-ulp bound. Classified by the data. Reproduction: test_logb_of_zero_against_a_small_base_is_positive_infinity.

20. round with ndigits scales before it rounds

19 failing cases: round_extra/float32, round_extra/float64, the datasets holding a value the scaling cannot carry.

round(x, n), round_to_multiple(x, m) and round_binary(x, n) are all round_int(x / step) * step, evaluated in the column's own precision — which is what include/arrowmetal.h documents. Two kinds of argument do not survive that: one whose scaled product needs more significant digits than the type holds, and one so small that the scaling underflows. Arrow reaches the same answers a different way and returns such a value unchanged.

a = pa.array([-421591.21875], pa.float32())          # 8 significant digits after x100
am.array(a).round(2, "down").to_arrow()              # [-421591.1875]
pc.round(a, ndigits=2, round_mode="down")            # [-421591.21875]

Inside the digits the type can carry — which is every value the random flavor generates — the ten round modes agree exactly, round_binary's per-row ndigits included. Classified by the data. Reproduction: test_round_with_ndigits_scales_before_it_rounds.

21. skew and kurtosis share the variance kernel's moment accumulator

14 failing cases: skew_kurtosis on int32, int64, uint32 and uint64, the special flavor.

The third and fourth standardised moments are built on the same GPU accumulator finding 16 describes for variance/stddev, and they raise their input to the third and fourth power on the way. A column holding a value at the top of a 32- or 64-bit integer type leaves that accumulator, and the kernel answers null — no answer — rather than a wrapped number. pyarrow accumulates in double and answers.

a = pa.array([2 ** 63 - 1, -(2 ** 63), 1, 0, 7], pa.int64())
am.array(a).skew()          # None
pc.skew(a).as_py()          # 1.74e-55

Answering null is the safer of the two failure modes, and every column inside the range agrees with pyarrow to 1e-5 absolute (measured worst deviation over the matrix: 4.6e-7). Classified by the data. Reproduction: test_skew_and_kurtosis_share_the_variance_accumulator.

22. winsorize clamps to the -0.0 of a zero tie

2 failing cases: winsorize/float32, the datasets containing a negative zero. (On pyarrow 25.0.1 a winsorize/float64 case was counted here too; it was pyarrow's offset bug, gone on 26.0.0.)

winsorize takes its two limits out of the sorted values — Arrow's nearest quantile, not an interpolated one. -0.0 and 0.0 are one tie group in every sort order both engines use, so which of the pair becomes the limit is not defined by either; ArrowMetal picks the -0.0 and Arrow the 0.0. The clamped values are therefore equal as numbers and differ only in the sign bit.

a = pa.array([1.0, -0.0, 0.0, -1.0, 2.0, -2.0], pa.float32())
am.array(a).winsorize(0.5, 0.5).to_arrow()               # [-0.0, -0.0, 0.0, -0.0, -0.0, -0.0]
pc.winsorize(a, lower_limit=0.5, upper_limit=0.5)        # [ 0.0, -0.0, 0.0,  0.0,  0.0,  0.0]

The harness compares floats bit-exactly, which is why this shows at all. Classified by the data. Reproduction: test_winsorize_clamps_to_the_negative_zero_of_a_zero_tie.

Findings that were fixed

The five bugs the first run of this matrix reported are closed, and four of the original twenty-one findings were closed by later kernel work (full-Unicode case mapping, and the three calendar-rounding corners); three more bugs were found and fixed while closing them (the top_k relaxed-atomic race, the winsorize pipeline-cache collision and the order="sorted" string order), which is why the table below has twelve rows. The reproductions stayed, as plain assertions, so the suite notices a relapse.

Was Now Test
argsort/top_k ordered null indices by the bytes under the validity bitmap null rows keep their input order test_argsort_null_block_is_stable, test_top_k_null_block_is_stable
sort/argsort/top_k split the -0.0 / 0.0 tie the sort keys canonicalise the sign of zero, so the tie holds input order test_negative_zero_is_a_sort_tie
sum/mean over Float32 accumulated in Float32 they accumulate in double, as Arrow's do test_float32_sum_does_not_overflow_before_arrow_does
Grouped sum over UInt64 came back as Int64 the aggregate keeps the value type test_group_by_sum_over_uint64_stays_unsigned
Float32 comparison treated subnormal operands as zero comparisons are exact, on bit keys; only arithmetic still flushes test_float32_comparison_distinguishes_subnormals_from_zero
top_k put row 0 first about once in 900 calls: every thread read the candidate count from a relaxed threadgroup atomic, one simdgroup could see a newer value, and the sentinel padding then left holes that sorted ahead of the real candidates the count is broadcast through a plain threadgroup variable between barriers, and the buffer starts full of sentinels TopKTests.testStressAgainstCPUOracle (ARROWMETAL_STRESS=1 for the long run)
upper/lower mapped Basic Latin, Latin-1 and Latin Extended-A only; everything above U+017F passed through the GPU table covers U+0000–U+017F and any row holding a code point above it is mapped on the host with Unicode's simple mapping, so Greek, Cyrillic, Turkish dotted I and astral scripts agree with pyarrow test_case_mapping_covers_all_of_unicode
ceil_temporal left a value already on a month, quarter or year boundary alone; Arrow advances it a whole unit the calendar units advance, the fixed-length units keep the value, as Arrow's do test_ceil_temporal_advances_a_value_on_a_month_boundary
a multiple of months or quarters counted from year 0; Arrow counts from 1970-01 months and quarters count from 1970-01 (years from year 0, as Arrow's do) test_calendar_multiples_share_an_origin
rounding to a unit finer than the column's resolution was the identity; Arrow converts, rounds and truncates back the value is converted to the finer unit, rounded there and truncated back test_rounding_to_a_finer_unit_converts_like_arrow
a uint64 winsorize poisoned every later float64 one in the process: the clamp pipeline was cached under its MSL type alone, and both types travel as ulong the cache key carries the value kind as well as the type test_winsorize_on_float64_survives_a_uint64_call
unique/value_counts/dictionary_encode with order="sorted" put every non-ASCII string after every ASCII one (Swift's canonical String <), while sort() orders by UTF-8 bytes the sorted string pass orders by UTF-8 bytes test_sorted_unique_uses_the_columns_own_sort_order

Four more turned up while closing those, and were fixed here rather than written down:

  • top_k did not canonicalise floats at all. The selection kernel carries its own key mapping, which the -0.0 and NaN fix had never reached, so top_k could order a column differently from argsort. It now uses the same mapping (testNaNAndNegativeZeroPlacement).
  • A descending sort mirrored NaN to the front. Arrow's null placement covers NaN, so a reversed order keeps NaN at the end, next to the nulls; the key mapping now sends NaN to the maximum key when the order is inverted (test_nan_sorts_last_in_both_directions_in_both).
  • Element-wise min/max broke a ±0 tie by position, so min(0.0, -0.0) and min(-0.0, 0.0) disagreed. Both now answer the way fmin/fmax do — min keeps -0.0, max keeps 0.0 (test_element_wise_min_max_break_a_zero_tie_like_fmin_and_fmax_in_both).
  • Float32 sign, ceil, floor, trunc and round inherited the arithmetic flush, so sign(1.4e-45) returned the subnormal itself, ceil(1.4e-45) was 0.0, and round(-0.4) lost the sign of its zero. All five decide those cases on the bit pattern now, which is what the exact float64 kernels already did (testFloat32SignAndRoundingCorners).

What passed

Everything else — 36,486 of the 39,069 cases — on all 27 datasets per cell:

  • Import/export round trip for all 45 types, including every sliced offset, all-null and empty arrays, 4096-byte strings and multi-byte UTF-8, 38-digit decimals, nested lists with nulls inside the rows, maps with duplicate keys, a dictionary, a run-end encoded column and the null type — type, length and null_count preserved. filter, take and slice follow on all 45 as well.
  • Reductions sum, min, max, mean on all 10 numeric types, including UInt64 totals above 2^63 and Int64 totals that wrap.
  • Comparisons all six operators, scalar and array, on all numeric types — including UInt64 against 2^63, where a signed comparison would flip.
  • Arithmetic + - * /, scalar and array, bit-exact on both float types (outside finding 1) and exact under wrapping on all eight integer types; modulo and power on all eight, against a truncating-division oracle and with the exponent folded into [0, 8).
  • Casts all 100 numeric source/target pairs, matching Array.cast(safe=False) including narrowing wraps and float→int truncation toward zero.
  • Boolean and, or, not with nulls.
  • Selection filter (null mask entries drop the row, as in Arrow), filter_where for all six predicates, take with repeated and null indices, slice.
  • sort, argsort, top_k ascending and descending on all numeric types, at every size, including -0.0, NaN and null placement -- the three now agree with Arrow everywhere in the matrix. With null_placement="at_start" against pyarrow's own; with float_order="total" against IEEE 754 totalOrder written out in numpy.
  • Element-wise math abs, negate, sign, floor, ceil, round, trunc, sqrt, exp, ln, log10, log2, element-wise min/max, the four bit-wise ops and both shifts.
  • Cumulative sum, min, max on all ten numeric types, nulls included.
  • Structural is_null, is_valid, fill_null, drop_null, if_else, is_in, index_in and the Kleene and/or.
  • Strings byte_length, char_length, starts_with, ends_with, str_contains, str_equals (scalar and array) against 11 patterns including the empty string, a multi-byte prefix, a tab and a 300-byte needle; the ASCII case and trim transforms, replace, repeat, slice_codeunits, the two pads, str_reverse, str_concat, count_substring, find_substring and the six ASCII predicates; dictionary_encode round trip, first-seen dictionary order and decode; hash32 null propagation and injectivity.
  • Group-by count, count_values, sum, min, max, mean on every value type the kernels cover.
  • Decimals the six comparisons scalar and array, add/subtract against the operand's own type and multiply against Arrow's widened one (in decimal256 where 38 digits overflow Arrow's own precision), negate/abs/sign, round/ceil/floor/truncate to five target scales each, sum/min/max, and the decimal32/64 widening and narrowing casts — all exact, at 9, 18 and 38 digits of precision.
  • Nested list_value_length, list_flatten, list_element at three indices, list_parent_indices, list_slice with six start/stop/step triples, struct_field by name and child by position, map_lookup for three keys × first/last/all, and binary_join with a scalar and a per-row separator.
  • Regular expressions twelve patterns × match/count/find, case-insensitive matching, five replacements, seven splits, three named-group extractions and their byte spans, and twelve LIKE patterns — against ICU-vs-RE2, with only the two documented syntax differences.
  • The Arrow string surface all thirteen character-class predicates including the Unicode ones, ascii_title/utf8_capitalize/utf8_title, utf8_center, utf8_replace_slice and its binary twin with negative and reversed bounds, the six trims with and without a character set, all four normalisation forms, and string is_in/index_in through the GPU hash table.
  • utf8 ↔ number to_strings exact on all eight integer types and bool; the float text's value bit-exact; cast("string") identical to to_strings() on all ten; and parse back from Arrow's own rendering on all ten plus bool.
  • Temporal every calendar and clock field on all fourteen date- or time-carrying columns; week with all eight WeekOptions combinations and day_of_week with six; iso_calendar and year_month_day field by field; floor/ceil/round_temporal for every unit at multiples 1, 2, 3 and 7; all nine *_between differences plus weeks_between at every week_start; months_between, month_day_nano_interval_between and the two narrow interval layouts; add_duration, subtract_temporal, add_interval, cast_unit between all four resolutions, strftime and strptime; is_dst, local_timestamp and assume_timezone in four zones across both DST modes.
  • Window and ranking row_number, rank and dense_rank against pc.rank's three tiebreakers; percent_rank, cume_dist, shift (five offsets, with and without a fill), pairwise_diff at three periods, cumulative_prod/cumulative_mean, the rolling sum, mean, min and max over five windows, and lexsort_indices against pc.sort_indices on a two-column table in all four directions.
  • Statistical aggregates product, variance/stddev at ddof 0 and 1, quantile at seven quantiles with linear interpolation, median, mode with its count, count_distinct, first, last, index, min_max, any and all — and the run-end encode/decode round trip.
  • The remaining type rows the float16 casts both ways and computation through float32, fixed_size_binary equality against an array and a scalar, hash64's determinism, canonicalisation and injectivity, the dictionary decode, the null column, the interval fields and the extension-type metadata round trip.
  • The _checked family add, subtract, multiply, divide, power, shift_left, shift_right, logb, negate, abs, sqrt, ln, log10, log2 and log1p, plus the two cumulative forms and pairwise_diff_checked — compared both ways: the answers on the rows that fit the column's type, and the objection itself on the rows that do not, which both engines have to raise.
  • The remaining element-wise math expm1, log1p, logb (scalar and column base), hypot, round_to_multiple, round_binary and round(ndigits, mode) over all ten Arrow round modes.
  • The Arrow-named selection kernels array_filter, array_take, array_sort_indices and sort_indices in both directions with the nulls at either end, invert, scatter on all twelve flat types, inverse_permutation with duplicates, nulls and an explicit max_index, and the three unstable selections (top_k_unstable, bottom_k_unstable, select_k_unstable) and partition_nth_indices compared as value multisets, since neither engine defines which of a tied pair it picks.
  • The associative transforms unique and value_counts in both orders, count_all, true_unless_null, first_last with skip_nulls both ways and list_parent_indices64.
  • The statistical aggregates Arrow names skew and kurtosis biased and unbiased, tdigest at five quantiles (against pyarrow's own sketch, to 5% of the column's range), winsorize at four limit pairs, and rank_quantile/rank_normal in both directions with the nulls at either end.
  • The byte-indexed string transforms ascii_lpad/ascii_rpad/ascii_center (which count bytes) against utf8_lpad/utf8_rpad (which count code points), utf8_swapcase, ascii_swapcase, utf8_zero_fill, binary_slice at eight start/stop/step triples, binary_reverse, and ascii_reverse — which both engines refuse on non-ASCII input. The struct-valued extract_regex_struct and extract_regex_span_struct, and the flat (offsets, values) split pairs.
  • Timezone metadata to_timezone to UTC, to a named zone and to none, against Arrow's own cast, and utc_offset against the seconds pc.local_timestamp moves the instant by.
  • Trigonometry all twelve functions plus atan2 on both float types, the seven _checked twins agreeing in-domain and both raising out of it, xor/and_not/and_not_kleene, is_nan/is_inf/is_finite on integers as well as floats, fill_null_forward/_backward, case_when, choose, replace_with_mask, indices_nonzero and coalesce.

What each family compares

One row per family: the operations in it, the columns it runs on, the oracle, and the tolerance. "exact" means the values and the Arrow type have to match.

Family Operations Types Oracle Tolerance
Interop roundtrip, roundtrip_ext, filter_ext, take_ext, slice_ext, null_column all 45 the input itself; Array.filter/take/slice exact
Reductions sum, min_max, mean, product, variance_and_stddev, quantile, mode_and_count_distinct, first_last_index_min_max, any_all the 10 numeric, bool pc.sum/min/max/mean/product/variance/stddev/quantile(linear)/mode/count_distinct/first/last/index/min_max/any/all, options spelled out exact for the positional ones; see Tolerances for the rest
Element-wise compare_*, arith_*, cast, rounding, abs, negate, sign, bitwise_*, shift_*, modulo, power, element_wise_min_max, float_class the 10 numeric the matching pc.*, unchecked variants bit-exact (floats), exact (integers)
Transcendental sqrt, exp, logarithm, trig, trig_checked float32, float64 pc.sqrt/exp/ln/log10/log2/sin…atanh/atan2 and the _checked twins relative, per Tolerances; the special values pinned exactly
Selection & sort filter, filter_where, take, slice, sort, argsort, top_k, lexsort, argsort_nulls_first, top_k_nulls_first, argsort_total_order, indices_nonzero, drop_null the 10 numeric, bool, utf8 Array.filter/take/slice, pc.array_sort_indices (with null_placement), pc.sort_indices, pc.indices_nonzero, pc.drop_null; for float_order="total" on floats, IEEE 754 totalOrder by its definition (numpy), which pyarrow has no function for exact
Structural & conditional is_null, is_valid, fill_null, fill_null_direction, if_else, case_when, choose, replace_with_mask, coalesce, is_in, index_in, kleene, logical_extras the 10 numeric, bool, utf8 the matching pc.*; skip_nulls=True for the set lookups exact
Cumulative & window cumulative_sum/_min/_max/_prod/_mean, ranking, percent_rank_and_cume_dist, shift, pairwise_diff, rolling_sum, rolling_mean, rolling_min_max the 10 numeric pc.cumulative_* with skip_nulls=True and an explicit start, pc.rank with a tiebreaker, pc.pairwise_diff; a reference in the harness for the six Arrow has no function for exact for the integer and min/max forms; the reductions' bound for the float sums
Strings str_length, str_match, str_equals_array, str_hash32, the ASCII transforms and predicates, replace, repeat, slice_codeunits, pad, substring_search, str_concat, string_predicates, string_case_transforms, string_pad_and_slice, string_trim, string_normalize, string_set_lookup utf8 the matching pc.*; unicodedata for utf8_normalize exact
Regex & LIKE regex_match, regex_replace, regex_split, split_whitespace, regex_extract, regex_extract_span, match_like utf8 pc.match_substring_regex, pc.count_substring_regex, pc.find_substring_regex, pc.replace_substring_regex, pc.split_pattern[_regex], pc.utf8_split_whitespace, pc.extract_regex[_span], pc.match_like exact
utf8 ↔ number to_strings, to_strings_text, to_strings_value, cast_to_string, parse_numbers the 10 numeric, bool Array.cast(utf8) and back; the text read with Python's float for the value check exact
Decimal decimal_compare, decimal_arith, decimal_unary, decimal_round, decimal_to_float64, decimal_reduce, decimal_widen_narrow the 5 decimal columns pc.equal…, pc.add/subtract/multiply (in decimal256 where 38 digits overflow Arrow's own precision), pc.negate/abs/sign, pc.round, Array.cast, pc.sum/min/max exact
Nested list_length, list_flatten, list_element, list_parent_indices, list_slice, struct_field, map_lookup, binary_join list<int64>, list<utf8>, struct, map pc.list_value_length/list_flatten/list_element/list_parent_indices/list_slice/struct_field/map_lookup/binary_join exact
Temporal fields temporal_calendar, temporal_clock, temporal_clock_on_a_date, temporal_week_options, temporal_struct, temporal_field_types, temporal_utc_semantics the 8 timestamp, 2 date, 4 time columns pc.year…pc.subsecond, pc.week with all eight option combinations, pc.day_of_week with six exact, after the documented int32/int64 width cast
Temporal rounding temporal_round, temporal_round_finer, temporal_round_calendar, temporal_ceil_calendar, temporal_round_unaligned, temporal_round_duration the 8 timestamp, 2 date, 4 time, 4 duration columns pc.floor_temporal/ceil_temporal/round_temporal, every unit at multiples 1, 2, 3 and 7 exact
Temporal arithmetic temporal_between, temporal_between_clock, weeks_between, months_between, interval_between, interval_layouts, add_duration, subtract_temporal, add_interval, cast_unit, strftime, strftime_seconds, strptime, strptime_roundtrip the temporal columns, utf8 the nine pc.*_between, pc.weeks_between with week_start 1–7, pc.month_day_nano_interval_between, pc.add, pc.subtract, Array.cast, pc.strftime, pc.strptime(error_is_null=True) exact
Timezones temporal_timezone, assume_timezone the 4 zoned and 4 naive timestamp columns pc.is_dst, pc.local_timestamp, pc.assume_timezone in four zones x two ambiguity modes exact
The rest of the type matrix float16_casts, float16_compute, float16_reduce, fixed_binary_compare, hash64, dictionary_ops, dictionary_encode, dictionary_decode, run_end, extension_type, nulls_constructor float16, fixed_size_binary, dict<utf8>, run-end, null, the numerics Array.cast, pc.equal/not_equal, pc.run_end_encode/decode; the hash by its properties exact
Checked arithmetic arith_checked, power_checked, shift_checked, unary_checked, log_checked, cumulative_checked the 10 numeric (integers only for power, shift and the cumulative forms) pc.add_checked…pc.log1p_checked, on the rows that fit; the objection on the rows that do not, which both engines have to raise exact, except the logarithms' 1e-6
The rest of the element-wise math math_extra, round_extra float32, float64 pc.expm1, pc.log1p, pc.logb, pc.hypot, pc.round_to_multiple, pc.round_binary, pc.round(ndigits, round_mode) over all ten modes relative 1e-6 (float32) / 1e-13 (float64) — the header's 5-ulp bound for this family
Associative transforms unique, value_counts, true_unless_null, first_last, list_parent_indices64 all 12 flat types, the two list columns pc.unique, pc.value_counts, pc.true_unless_null, pc.first_last, pc.list_parent_indices; the sorted order against the same distinct values sorted exact
Arrow-named selection sort_indices, array_selection, invert, scatter, permutation, select_k_unstable, partition_nth_indices all 12 flat types (numeric for the permutations) pc.array_sort_indices/sort_indices with null_placement, Array.filter/take, pc.invert, pc.scatter, pc.inverse_permutation, pc.select_k_unstable, pc.partition_nth_indices exact — except the unstable selections, compared as sorted value multisets, which is what both engines actually define
Arrow-named aggregates skew_kurtosis, tdigest, winsorize, rank_quantile_and_normal the 10 numeric pc.skew/kurtosis biased and unbiased, pc.tdigest, pc.winsorize, pc.rank_quantile/rank_normal in both directions and both null placements 1e-5 for the moments; 5% of the column's range for the t-digest sketch; 1e-12 for the ranks
Byte-indexed strings ascii_pad, swapcase_and_zero_fill, byte_transforms, extract_regex_structs, split_pairs utf8 pc.ascii_lpad/ascii_rpad/ascii_center/utf8_lpad/utf8_rpad, pc.utf8_swapcase/ascii_swapcase/utf8_zero_fill, pc.binary_slice/binary_reverse/ascii_reverse, pc.extract_regex[_span]; the split pairs against the list-valued forms exact
Timezone metadata timezone_metadata the 8 timestamp columns Array.cast between timezones; utc_offset against the difference pc.local_timestamp leaves behind exact
Group-by group_by_count, _count_values, _sum, _min, _max, _mean the 10 numeric pa.Table.group_by(...).aggregate(...) exact for the integers, the reductions' bound for the floats

Reading the report

differential_report.py prints one row per operation and one column per type — in blocks of ten types, because the matrix is 45 columns wide, and a block leaves out the operations that do not apply to any of its types. A cell says ok N when all N datasets agree, known n/N when n of them hit one of the open findings above, NEW n/N for a divergence that is not in this document, skip N where the kernel does not exist for that type, and - where the operation does not apply.

It exits on the unclassified count, not on the failure count. A documented divergence has a FINDINGS entry, a strict-xfail reproduction and a paragraph above it: it is a decision on the record, and holding CI red on it would only teach everyone to ignore the gate. An unclassified divergence is one nobody has looked at, and that is what fails the build -- 0 at 0.1.0, and it should stay there. The totals line prints both numbers, and the last line reads PASS (n documented divergence(s)) so the count cannot drift unnoticed.

--ops and --types narrow the matrix while chasing one cell:

PYTHONPATH=python python python/tests/differential_report.py --ops argsort,top_k --types int32
docs/FINDINGS.md

Findings and gotchas

Things learned the hard way. Add to this whenever something surprises you.

Round 22 (2026-10-15): the matrix moves to pyarrow 26.0.0

What. Apache Arrow 26.0.0 shipped on 2026-10-09 with four of the pyarrow bugs the differential matrix had been working around since Round 8b: utf8_normalize composing for NFC and NFKC (our apache/arrow#51237), and pairwise_diff, the boolean fill_null_forward / _backward / replace_with_mask kernels and winsorize honouring ArrowArray.offset on a sliced array (apache/arrow#50858, #51240, #51251). Each workaround had a pinning test written to fail on the day pyarrow changed the behaviour it pinned. The venv and the CI pin moved from 25.0.1 to 26.0.0.

  • The pinning tests did their job. On 26.0.0 the four failed, every one on the != or == that asserted pyarrow's wrong answer, and nothing else in the suites moved: test_json.py and test_csv.py, which pin the documented differences from pyarrow, 979 passed unchanged. The fifth, test_pyarrow_binary_slice_overflows_on_its_own_default_stop, still passes: the binary_slice default-stop overflow is not in 26.0.0 (apache/arrow#51222, a contributor's fix #52451 under review).
  • One documented divergence was pyarrow's. The matrix on 26.0.0 (264.3 s): 38,898 pass, 1,643 fail, all documented, 1,147 skip, 0 unclassified, against 38,897 / 1,644 / 1,147 on 25.0.1. The one case gained is winsorize/float64 on the negative-zero dataset, which the classifier had counted under finding 22 (winsorize-negative-zero-limit); it was the offset bug. The finding keeps its two float32 cases.
  • Four more pinning tests moved, in the gate. pc.cumulative_max's default start no longer clamps negative floats to 1.18e-38 (the matrix still names the identity, the same answer either way); an out-of-range safe=false float-to-int cast wraps as the GPU does on every value the test holds, where 25.0.1 saturated; pyarrow reads the fixed-size-list-with-null-rows Parquet file its own writer produced, which 25.0.1 rejected, and agrees with ArrowMetal's rows; and five compute function names are new, remainder / remainder_checked (the C %, which modulo() here has always been, so remainder gets a GPU registry row), modulo / modulo_checked (Python's floored %) and search_sorted, the last four missing in the registry. The matrix's modulo operation checks against pc.remainder now instead of a divide / multiply / subtract construction.
  • binary_slice with a negative step and no stop. The function note recorded that pyarrow 25.0.1 overflowed on its INT64_MAX default and read out of bounds; 26.0.0 walks to the beginning of the value, the answer ArrowMetal has always given for an absent stop.

Result. The four pinning tests and the _materialised copies are retired; the oracle for string_normalize is pc.utf8_normalize again and pairwise_diff, the fill-null kernels, replace_with_mask and winsorize hand pyarrow the slice itself; the operations Arrow has no function for keep their references written in the harness, as before. The tracker rows in Upstream tracker read "fixed in 26.0.0" and stay as history.

Round 19 (2026-10-07): Polars 2.0.0

What. Polars 2.0.0 (released 2026-10-06) changed five things the engine stands on. collect() runs the streaming engine by default (1.44: in-memory); engine="in-memory" and "streaming" are still accepted. The plan's IR version is (15, 2) (1.44: (14, 7)), and MetalEngine fails closed on a major it has not been tested on, so on 2.0.0 the engine as shipped in 0.4.0 left every plan to Polars. The post-optimisation callback is called with one argument (the plan; 1.44 also passed the time since the start). LazyFrame.profile and LazyFrame.with_context are gone, and with them the ExtContext node kind, the only IR node kind that changed. Series._get_buffer_info() is gone too: the engine named a key column by its buffer address to cache its group-count estimate between calls, and on 2.0.0 every probe sampled again. Polars 2.0.0 itself rejects 63 plans that 1.44.1 accepted (sum, min, max and mean over Date, Datetime, Time, Categorical and Enum), and keeps the input NaN under x * -1.0 where the in-memory engine of both versions flips its sign: the one bit-level difference the suites found was the test baseline moving to the streaming engine, not the arithmetic.

Measured. With the version gate opened, 50,000,000 rows, Apple M4 Max on AC power, the faster of Polars' two engines in each run (Benchmarks/results/polars_engine_bench_2026-10-02.csv on 1.44.1, polars_engine_bench_2026-10-07-polars2.csv on 2.0.0): over the 62 cases the default takes on both, Polars 2.0.0's own time is 0.72x of 1.44.1's at the median, down to 0.25x on unique over two keys (153.1 ms to 38.5 ms), and the streaming engine is the faster one in 56 of the 62. ArrowMetal's time is unchanged (median ratio 1.03). So the same code that was 1.52x to 11.54x ahead on 1.44.1 was 0.86x to 7.72x on 2.0.0, with four cases behind: two-key mean at 200 and 1,000 groups (0.88x, 0.97x), mean + max over two String keys (0.98x) and the sort with a String column (0.86x).

What shipped. TESTED_IR_VERSION holds one tested version per IR major ((14, 7) and (15, 2)), TESTED_POLARS adds 2.0.0, the extra installs polars>=1.44,<2.1, MetalEngine.profile raises NotImplementedError on 2.0, a single-chunk key column is named through PySeries.as_single_ptr() so the estimate cache holds on 2.0 as on 1.44, and the capability table generated on 2.0.0 is docs/ENGINE_CAPABILITIES_POLARS2.md: the same 1,326 of 2,523 plans run on Metal. The 1.44-built expression plugin loads on 2.0.0 and passes its 151 tests.

Round 20 (2026-10-07): the integer mean paid for a Float64 column, not for atomics

What. The first explanation for the four cases behind was the fused expression group-by kernel (ExprGroupBy.swift: privatised 32-bit atomic tables per threadgroup, 64-bit sums as lo/hi pairs), which the planner picks whenever every aggregate is of a fusable type, against GroupBy's own per-aggregate kernels. Measured through the plan executor on 50,000,000 rows (Benchmarks/results/groupby_fused_matrix_2026-10-07.csv: one and two Int32 keys, 200 to 1,000,000 groups, sum, mean, count, sum + min + max, a Float32 sum, a sum over an expression), the two paths are equal on every integer shape, within 0.3 ms; the fused kernel is 2x to 3x faster on the Float32 sums. The hypothesis was wrong. What the matrix did show is that mean costs twice sum on every shape (14 to 28 ms against 6.6 to 18 ms): the translator plans a mean over an integer column as a mean over the column cast to Float64, so that extreme values average as Polars averages them, and the executor materialised that cast, a 400 MB column for 50,000,000 Int64 values, before the group-by.

Fix. Executor.integerMeanIfExact: one min/max pass over the integer column; when every |v| is below 2^53 and rows times the largest |v| is below 2^63, the mean runs from the integer column in one pass (GroupBy.meanInteger), otherwise the Float64 path as before. The two give the same bits; a test covers ordinary values, values near 2^62 and values above 2^53 on both Polars versions. Before and after on Polars 2.0.0, 50,000,000 rows, AC (polars_engine_bench_2026-10-07-polars2.csv and polars_engine_bench_2026-10-08-polars2-after.csv, the faster Polars engine in each run):

case Polars 2.0.0 ArrowMetal before after before after
two String keys, mean + max (c) 32.0 ms 32.7 ms 22.7 ms 0.98x 1.41x
2 keys, 200 groups, mean 18.0 20.7 14.3 0.88x 1.26x
2 keys, 1,000 groups, mean 20.9 20.9 14.8 0.97x 1.41x
2 keys, 10,000 groups, mean 34.6 26.5 18.4 1.27x 1.88x
2 keys, 100,000 groups, mean 101.2 28.2 18.9 3.56x 5.34x
2 keys, 1,000,000 groups, mean 128.9 36.6 25.5 3.72x 5.05x
1 key, 10,000 groups, mean 24.9 23.0 15.8 1.07x 1.58x
1 key, 100,000 groups, mean 58.4 24.5 16.7 2.02x 3.50x
1 key, 1,000,000 groups, mean 56.5 34.6 22.1 1.46x 2.56x
two String keys, Float64 sum + mean (l) 34.4 29.9 29.8 1.04x 1.15x
sort with a String column (o) 217.9 249.7 244.8 0.86x 0.89x

The Float64 case is unchanged, as expected; the String-column sort is sort-kernel time and stays behind. Every other case in the run is within noise of before.

Round 21 (2026-10-08): fitting the default's table on Polars 2.0.0

What. The default's crossover table is fitted from a sweep (every case at 250,000 to 50,000,000 rows, Polars against the engine with everything taken) and then charged with an idle benchmark: the default as it would run, at a few sizes, and any case it takes and loses moves its class up to the next benchmarked size. Two things about that procedure showed up on 2.0.0 (Benchmarks/results/polars_engine_crossover_2026-10-08-polars2.csv, polars_engine_default_groupby_raw_2026-10-08-polars2.csv, conditions files beside them).

  • The idle benchmark can only measure what the table in force takes. The first fit, charged from runs at 2,000,000 and 50,000,000 rows, moved the numeric sort class from 2,000,000 to 50,000,000 rows, since the sort was 0.94x at 2,000,000. Runs at 5,000,000, 10,000,000 and 20,000,000 rows with that table in force then recorded the sort as left to Polars at every one of them, and the fit could not see where it is ahead. Those sizes were run again with the sweep-only table in force; the sort is taken from 10,000,000 rows on 2.0.0 (1.44.1: 947,836).
  • A plan the default leaves runs on Polars' in-memory engine. The callback hands the plan back to the engine Polars named, and MetalEngine registers as the in-memory engine on both versions. On 2.0.0 a plain collect() runs the streaming engine, the faster one for sorts: a three-column sort by an Int64 key, Polars streaming against in-memory, 5.95 against 12.13 ms at 2,000,000 rows, 13.55 against 32.88 at 5,000,000, 52.88 against 149.27 at 20,000,000, 157.84 against 399.22 at 50,000,000. A MetalEngine() user on 2.0.0 pays the in-memory time on every plan the default leaves.

Result. One table per Polars major (_engine_policy.table_for, _engine_crossovers_pl2.py). Against Polars 2.0.0's faster hash tables the table gives up the one- and two-key sum and mean at 200 to 10,000 groups, the String-column sort, the String-key join, semi join and top-k; unique is taken from 2,943,034 rows (1.44.1: 5,494,090). Measured with it in force (polars_engine_bench_2026-10-08-polars2-refit.csv, 50,000,000 rows, AC): 52 of 107 in-memory cases taken, every one 1.35x to 7.94x faster than the faster Polars 2.0.0 engine, none behind; of the 12 cases it stopped taking, 11 were ahead by 1.15x to 2.75x in the run before, and one (the String-column sort) was 0.89x.

Round 18 (2026-10-01): 32-bit loop steps, block ends and byte positions near 2^32

What. Round 17 made the grid itself safe up to 2^32 - 1 elements. Inside the kernels, four kinds of 32-bit arithmetic still reached 2^32 for arrays of at most 2^32 - 1 elements:

  • Grid-stride loops, for (i = gid; i < n; i += gridSize): once n > 2^32 - gridSize a thread's i + gridSize wraps to a value below n again and the loop never ends. The whole-column aggregates run 2^19 threads (reduce_*, whose four-wide loop steps 2^21 and so wraps from 4,292,870,148 elements; agg_product, agg_minmax, agg_moment, gx_moment34, dec_sum: from 4,294,443,009), the partition's select and the utf8_view byte total 2^18 (pn_hist, sv_total: from 4,294,705,153).
  • Block ends, end = min(n, start + chunk): on the last block start + chunk passes 2^32, wraps to a small number, and the block reads nothing. The group-by passes split the rows into at most 1,024 or 4,096 blocks (GroupBySource, GroupByExtremaSource, GroupSumExactSource, the atomic group order: from 4,294,966,273 rows with 1,024 blocks, 4,294,963,201 with 4,096), the chunked group order into 2,048 sub-blocks (from 4,294,965,249), the radix sort and the partition into blocks of up to 2^25 rows (radix_histogram, part_count, part_scatter, radix_scatter, pn_count, pn_scatter: from 4,294,934,529), top-k into at most 2,048 blocks (from 4,294,965,249) and the radix select into sub-blocks of about 2^19 rows (from 4,294,705,153). Inside a block the loops step by 256 or 32 and wrap the same way from 2^32 - 255, as do the per-group loops that walk a segment of the group order up to its end (gx_seg_*, gm_*, seg_reduce, the streaming Float64 group sum).
  • Word counts, (n + 31) / 32: 0 for n > 2^32 - 32, so the bitmap kernels (bitmap_and, bitmap_or, bitmap_not, bitmap_and_not, lx_xor, the Kleene forms, st_fill_words), the filter's block scan and the fused expression reduce (am_reduce, am_scan) did no work.
  • Byte positions, row * width in 32 bits: the Parquet fixed-width decode wrote row r at byte r * width mod 2^32, over the start of the column, once the decoded column passed 4 GB: from 2^29 rows of Int64 or Float64, 357,913,942 of INT96, and 2^28 rows of a decimal widened to 16 bytes. A bit-packed run's bit position, index * bitWidth, wraps the same way in a run of more than 512 MiB. The group-by key gather of a decimal column (gk_limb) indexed row * limbs, which wraps from 2^31 rows of decimal128.

Measured on 2^32 - 1 rows with the build before (Benchmarks/results/index_wrap_limit_2026-10-01.txt): a group-by count over 1,024 blocks counted 4,290,772,992 rows (the last 4,194,303 went uncounted), the chunked group order placed 4,292,870,144, the sort's digit histogram counted 4,261,412,864, and, or, xor and not of two boolean columns wrote no word, a filter kept 4,096 rows of 2,147,483,648, and the fused reduce returned a count of 0, all with no error. A dictionary-encoded Int64 Parquet column of 541,065,216 rows and an INT96 column of 358,962,517 rows came back with their first rows overwritten. The grid-stride loops were not run on that build: a kernel that does not end holds the GPU.

Fix. A first version saturated every step at the end, i = (end - i > step) ? i + step : end (the form the fused group-by got in round 17), and took every block end in 64 bits. Timed against the build before, alternating (Benchmarks/results/index_wrap_forms_2026-10-01.csv), that cost more than 1% on three calls at 10M rows: Int32 min_max (best 5.0% and median 6.3% slower), a top-k of 5,000 (3.5% / 2.5%) and a group-by min_max over 1,000 keys (6.7% / 3.1%). The plain loop is the one the compiler can unroll. So the shipped forms keep it wherever it cannot wrap:

  • Block loops walk the block by offset: len = start < n ? min(chunk, n - start) : 0, then for (off = lid; off < len; off += 256) with the row at start + off. A block holds at most 2^25 rows in the sort and the partition, and at most half the array in a group-by over more than 4,096 rows, so off + 256 never wraps, and start + len never passes n. The same goes for the radix select's sub-blocks, the top-k blocks, the sort's and the partition's chunk loops (a lane is in range when off + lid < len) and the group order's.
  • Grid-stride loops are two copies of the loop (AM_GRID_STRIDE, in the kernel prelude): the plain i += step while n <= 2^32 - 1 - step, which holds for every array of up to 4,292,870,143 elements, and the saturating step past that.
  • Segment loops keep the saturating step: one group can hold every row, so a segment can end within 256 rows of 2^32.

The word count is (n >> 5) + ((n & 31) != 0). The Parquet byte positions, the bit position inside a run and the limb index are 64-bit. The four-wide aggregate loop's scalar tail (at most 3 elements, on a grid of at least 256 threads) is one if per thread. Below the wrap every kernel visits the same elements in the same order, so the results are bit-identical.

Testing at the limit. A column of 2^32 - 1 Float64 values is 32 GiB. IndexWrapTests maps one 1 GiB region back to back (mach_vm_remap) and wraps the whole range in one Metal buffer (makeBuffer(bytesNoCopy:)): a periodic column of any length up to the GPU's largest buffer (38.88 GiB on this machine) for 1 GiB of memory, with a host reference of whole periods plus a remainder. With ARROWMETAL_BIG_TESTS=1 each family runs once over 2^32 - 1 rows, and all pass; the largest real allocation is a 16 GiB Int32 output (the group order, the partition). test_parquet_4gb_columns.py reads the two Parquet columns above and a 4 GB decimal column against the values written. Not run at the limit: the decimal sum and the utf8_view byte total (a column of 16-byte values that long is 64 GiB, past this GPU's largest buffer), the radix sort's scatter passes (four 16 GiB buffers), the decimal key gather (2^31 decimal128 rows) and the Parquet bit position (a bit-packed run of more than 512 MiB).

Cost. The build before and this one alternated in one process: per call an untimed warm-up of at least 100 ms, the first call after a 500 ms idle on its own, then 8 rounds of up to 30 calls each (Benchmarks/index_wrap_bench.py, Benchmarks/results/index_wrap_2026-10-01.csv). One call per kernel family touched, at 10M and 50M rows. The rows where both best and median came out more than 1% slower were timed again alone, 20 rounds of 100 calls (marked *); none stays slower on both:

Call (kernel family) 10M best (ms) 10M median (ms) 50M best (ms) 50M median (ms)
sum int32 (reduce) 0.180 → 0.189 0.272 → 0.267 0.680 → 0.668 0.917 → 0.893
and boolean (bitmap words) 0.074 → 0.074* 0.099 → 0.099* 0.127 → 0.132 0.173 → 0.166
min_max int32 (agg) 1.234 → 1.237 1.294 → 1.331 1.870 → 1.802 2.445 → 2.092
variance float64 (agg) 1.597 → 1.590 2.010 → 2.065 5.256 → 5.282 5.395 → 5.401
skew float32 (moments) 1.650 → 1.780 2.447 → 2.426 2.206 → 2.165 2.389 → 2.406
sum decimal128 0.879 → 0.914 1.123 → 1.108 1.731 → 1.717 1.821 → 1.798
expr sum int32 (fused reduce) 0.184 → 0.200 0.278 → 0.235 0.662 → 0.671 0.911 → 0.885
group_by 64 keys sum int32 (atomic) 0.765 → 0.828 0.996 → 0.988 1.370 → 1.368 1.482 → 1.470
group_by 1000 keys min_max int32 (extrema) 1.102 → 1.997* 2.335 → 2.341* 3.608 → 3.637 4.864 → 4.264
group_by 1000 keys sum float64 (exact) 2.046 → 2.091* 2.888 → 2.880* 5.521 → 5.532 5.671 → 5.667
group_by 1000 keys variance int64 (group order, moments) 6.927 → 6.941 7.101 → 7.146 34.844 → 34.899 35.195 → 35.149
group_by 1000 keys product int64 (segmented) 1.265 → 1.228 1.326 → 1.330 7.492 → 7.479 7.567 → 7.569
group_by 1000 keys mean float32 (segmented reduce) 1.230 → 1.201 1.771 → 1.937 6.704 → 6.689 6.835 → 6.846
group_by decimal keys count (limb gather) 6.401 → 7.562 11.381 → 11.184 17.784 → 17.703 18.114 → 18.095
argsort int32 (radix) 2.977 → 2.962 3.058 → 3.069 14.380 → 14.381 15.187 → 15.187
argsort int32 with nulls (partition) 3.391 → 3.320 3.543 → 3.527 16.769 → 17.129 17.929 → 17.925
partition_nth_indices int32 3.178 → 3.164* 3.523 → 3.584* 5.361 → 5.360 5.764 → 5.757
top_k 10 float64 3.185 → 2.996 3.690 → 3.606 4.332 → 4.324 4.804 → 4.817
top_k 5000 float64 3.832 → 3.847 4.652 → 4.509 4.787 → 4.841 6.542 → 6.317
utf8_view import (byte total) 3.376 → 3.309 3.669 → 3.682 16.254 → 16.162 17.646 → 17.604
stream group_by 1M keys sum float64 71.448 → 72.618 74.536 → 75.216 472.742 → 475.619 480.280 → 483.785
read_parquet int64 dictionary 1.254 → 1.248* 1.728 → 1.711* 4.938 → 4.940 5.178 → 5.180
read_parquet int64 plain 1.628 → 1.651 1.795 → 1.803 6.452 → 6.334 7.298 → 7.231

A best and a median can move apart by several percent on calls of 1-2 ms from one run to the next (the group-by min_max before's best of 1.102 ms against medians of 2.335 and 2.341 ms); a loss shows in both.

Round 17 (2026-10-01): the 2^32-thread wrap on row-wise kernels, and a totalOrder argsort re-timed

What. Round 13 folded the per-group grids; the row-wise ones were still (groups, 1, 1). A kernel with one thread per element launches ceil(n / 256) threadgroups of 256 threads, which is 2^24 threadgroups and 2^32 threads from n = 2^32 - 255, the width the GPU wraps. Measured on a UInt8 array of 2^32 - 1 elements (Benchmarks/results/grid_fold_limit_2026-10-01.txt): bitwise xor on that grid got 0 of 4,294,967,295 bytes right, with no error. Arrays are limited to 2^32 - 1 elements, so the affected lengths were 2^32 - 255 to 2^32 - 1: every dispatch1D kernel, and the two-level scans that size their own grids (string offsets, hash-join offsets, unique runs, cumulative and forward-fill scans, window scans), whose add pass launches one more threadgroup and reached the width from 2^32 - 511.

Fix. Dispatch.dispatch1D and Dispatch.dispatchRows fold a grid that would reach 2^32 threads into rows of 65,536 threadgroups, as perGroup does. At the limit that is 2^24 threadgroups in exactly 256 rows, so the folded grid launches the same threadgroups. Rather than edit some 350 kernels, MetalContext.pipeline rewrites each kernel's text before compiling it (Dispatch.foldGridPositions): uint i [[thread_position_in_grid]] becomes a uint2 and the body starts with uint i = (p.y << 24) + p.x;, and threadgroup_position_in_grid the same with a shift of 16. On a one-row grid .y is 0, so every kernel dispatched any other way computes the same index as before. Metal compiles a kernel only when its position inputs are all scalar or all vectors of one width ("expecting input declarations with either all scalar types or all vector types with the same number of elements"), so the kernel's other scalar position inputs (thread_position_in_threadgroup, threads_per_threadgroup, threads_per_grid) become uint2 read as .x. A parameter inside a #define argument list gets its declaration in every kernel that names the macro. A grid past 2^32 threads cannot be indexed by a uint: dispatch1D stops with a message there and dispatchRows throws, which is where a string scan over more than 2^32 - 256 rows now ends. The fused expression group-by kernels computed each chunk's end as start + chunk in 32 bits, which wraps on the last chunk near 2^32 rows; the end is now taken in 64 bits and the row loop cannot step past it.

Cost. Old and new builds alternated in one process, 8 rounds of 100 calls after a 100 ms warm-up (Benchmarks/results/grid_fold_2026-10-01.csv):

Case Rows Best before → after (ms) Median before → after (ms)
add float64 + float64 10M 0.625 → 0.626 0.781 → 0.788
bitwise_and int32 & scalar 10M 0.551 → 0.538 0.690 → 0.714
abs int64 10M 0.585 → 0.579 0.819 → 0.830
fused group-by sum int32, 64 keys 10M 0.874 → 0.836 1.236 → 1.117
add float64 + float64 50M 2.689 → 2.690 2.755 → 2.751
bitwise_and int32 & scalar 50M 0.981 → 0.987 1.061 → 1.057
abs int64 50M 1.847 → 1.848 1.913 → 1.913
fused group-by sum int32, 64 keys 50M 1.663 → 1.663 1.768 → 1.784

Tests. GridRowFoldTests checks the rewritten text, runs the rewritten kernels on a three-row folded grid at 40,000,003 elements (every element and threadgroup position, through a parameter and through a macro), runs xor, not, add, take, cumulative sum, forward fill and a fused expression with the fold forced on at 40M elements against the plain grid, and with ARROWMETAL_BIG_TESTS=1 runs xor and not over 2^32 - 1 elements. The whole Swift suite (1,030 tests) also passes with the fold forced on from 2^20 threads.

The totalOrder argsort row. The sort-options benchmark had a float64 argsort at 1M rows at 1.41 ms in totalOrder against 0.97 ms in the default order, from a best of five after one call (Benchmarks/results/sort_options_2026-09-28.csv: 1.411 and 0.968 ms). With a 100 ms warm-up, the first call after a 500 ms idle kept apart, and 8 alternating rounds of 100 calls (Benchmarks/results/argsort_total_order_2026-10-01.csv), the two orders run in the same time:

Rows Default best / median (ms) totalOrder best / median (ms) First call after idle, default / totalOrder (ms)
1M 1.119 / 1.876 1.096 / 1.607 5.112 / 5.188
10M 7.492 / 7.593 7.531 / 7.588 16.914 / 19.404
50M 37.911 / 38.109 37.934 / 38.068 45.869 / 51.189

The first call after an idle gap takes 4.6x (default) and 4.7x (totalOrder) the best at 1M rows (first_after_idle_ms ÷ best_ms in the same file), so a best of five taken with only one call before it can carry part of that state; the key map, the only difference between the two orders, costs nothing measurable.

Round 16 (2026-09-30): Float64 group sums without a group order

What. Float64 group-by aggregates were the slowest ones ArrowMetal ran. Over 50M rows a Float64 sum took 10.62 ms at 200 groups, 39.78 ms at 1M groups and 227.11 ms at 25M groups (rows / 2), where an Int64 sum took 1.73, 7.94 and 52.16 ms and count 1.00, 1.66 and 15.29 ms (Benchmarks/results/groupby_float64_profile_2026-09-30.csv, the Swift API, best of 5).

Where the time went. A Float64 sum put each group's rows together first (the counting sort by group id, GroupBy.segments(): 7.02 ms at 200 groups, 29.20 ms at 1M, 92.60 ms at 25M) and then gave a 256-thread threadgroup to each group, which added its run in a fixed tree order (3.64, 10.38 and 134.15 ms). The order was the reason for the sort: the tree is what made the answer reproducible. At 200 groups that reduction ran 200 threadgroups over 250,000 rows each; at 25M groups it launched 25M threadgroups of 256 threads, 6.4 billion threads for 50M additions. min and max took two passes of 32-bit atomics, with three atomic updates per row in the first (the high word's minimum and maximum, and a count) spread over four arrays, and a plan with a min and a max of one column ran both passes twice.

Change. A correctly rounded sum does not depend on the order of its terms, so Float64 sum and mean no longer build a group order (Kernels/GroupSumExact.swift, DESIGN.md "Group-by"):

  • Pass one takes each group's largest exponent with an atomic max of the 11-bit field, counts its values, and records NaN, ±inf and whether a +0.0 was seen.
  • Pass two writes each value as an integer in a per-group fixed-point window, G = 74 - bitlength(rows) bits below the last bit of the group's largest exponent, and adds it to a 128-bit two's-complement accumulator with 32-bit atomics that carry (or borrow) word by word. Each word's carry comes from its own read-modify-write, so the four words hold the exact sum modulo 2^128 in any interleaving, and the headroom keeps every sum inside it. A value whose last bits fall below the window is truncated and counted.
  • A thread per group rounds once to nearest-even. With nothing truncated that is the correctly rounded sum, and the mean is the 128-bit sum divided by the count in a long division, rounded once (a power-of-two count is a shift). With d truncated values the exact sum lies strictly between W - d and W + d units, and the result stands when both ends round to the same double. A group whose rounding that cannot settle, or that holds a NaN other than 0x7FF8000000000000, is summed on the host from its own rows with an exact 2,240-bit accumulator.
  • Up to 1,024 groups each threadgroup accumulates in threadgroup memory and merges once.

min and max keep their two passes and change their table: a group's four words sit in one 16-byte entry, a row in pass one reads them and issues an atomic only when it improves on what it read, pass two reads the finished high words through a plain view of the table, and no count is kept (a group has a value exactly when its minimum key is at or below its maximum key). A sum and a mean, or a min and a max, of one column on one grouping share one call.

Behaviour change. Grouped Float64 sum is now correctly rounded: the binary64 value nearest the exact sum of the group's non-null values, ties to even, equal to math.fsum of those values. It no longer depends on row order, it differs from the earlier ordered sum in the last bits of some groups, and it is never further from the exact sum than that sum. mean is the exact sum divided by the count, rounded once. A group whose exact sum is finite returns it where a running sum overflowed ([max, max, -max] gives max); one whose exact sum is beyond the largest double gives ±inf. NaN in a group gives NaN: the canonical quiet NaN when every NaN of the group is that one, else the group's first NaN in row order, quieted. +inf with -inf gives the canonical quiet NaN; an exact zero is -0.0 only when every value is -0.0. min and max return the same bits as before. The streaming group-by keeps the ordered sum, so its per-batch, resident and host paths stay bit-identical to each other.

Measured. The builds before and after, timed alternately in three rounds, with a warm-up of at least 100 ms before each case and the first run after 500 ms of idle recorded apart (Benchmarks/results/groupby_float64_2026-09-30.csv; every round in …_raw_…, the conditions in …_conditions.txt). The array API is one am.group_by(keys) and one aggregate per call; the plan runner is a warm collect(). At 50M rows, one int32 key, no nulls:

aggregate groups array API plan runner
sum 200 12.73 → 5.62 ms (2.27x) 14.87 → 7.77 ms (1.91x)
sum 1,000,000 43.50 → 17.69 ms (2.46x) 47.75 → 22.11 ms (2.16x)
sum 25,000,000 283.67 → 197.05 ms (1.44x) 328.73 → 242.34 ms (1.36x)
min 1,000,000 14.59 → 9.68 ms (1.51x) 18.31 → 13.59 ms (1.35x)
min + max 1,000,000 14.04 → 9.57 ms (1.47x) 28.86 → 13.63 ms (2.12x)
min + max 25,000,000 176.05 → 130.96 ms (1.34x) 300.75 → 175.30 ms (1.72x)

Over every 50M-row case (one and two keys, 0 and 10% nulls, 200 to 25M groups), best of the rounds (the speedup_best column of Benchmarks/results/groupby_float64_2026-09-30.csv): sum and mean 1.41x-2.66x (array API) and 1.34x-2.33x (plan runner); min + max 0.98x-1.49x and 1.29x-2.12x; min or max alone 0.96x-1.51x and 0.95x-1.35x. count, an Int64 sum and a Float32 sum in the same runs are 0.96x-1.04x. The kernels alone at 50M rows (Benchmarks/results/groupby_float64_profile_2026-09-30.csv, the Swift API, best of 5): sum 10.62 → 3.48 ms (200 groups), 23.78 → 9.02 ms (10,000), 39.78 → 14.17 ms (1M), 227.11 → 110.43 ms (25M); min + max 10.64 → 5.91 ms (1M) and 82.82 → 35.65 ms (25M).

Polars MetalEngine() (Benchmarks/results/polars_engine_groupby_float64_2026-09-30.csv, three alternating rounds, every result equal to Polars'): at 50M rows the grid's min + max cases (over an int64 column) the default runs on the GPU are 1.19x-2.59x faster (1M groups, one key: 48.36 → 18.67 ms) and (l) Float64 sum + mean is 46.12 → 29.68 ms. At 2M rows six case-engine pairs were slower in that run; timed again alone, eight alternating rounds of 100 runs (Benchmarks/results/polars_engine_groupby_float64_retime_2026-09-30.csv), none is slower in both best and median: the Float64 min + max cases are 1.18x-1.38x faster cold (g1minmax10k 1.90 → 1.43 ms, g2minmax10k 2.28 → 1.82 ms, t7 2.18 → 1.58 ms), the Int64 g1sum1M 1.04x, and the default's g1minmax200 and t4, which it does not run on the GPU at 2M rows, 0.98x-1.02x. Through the DataFusion rule forced on at 50M rows (datafusion/results/datafusion_float64_groupby_2026-09-30.csv, one run of each build): Float64 sum and avg 1.33x-1.82x faster than before (1M groups: 58.03 → 31.95 ms against DataFusion alone's 55.51 ms), MIN + MAX 1.06x-1.19x.

The 10M-row, 10,000-group min/max rows. In the alternating run and in eight further rounds of 100 calls each (Benchmarks/results/groupby_float64_retime_2026-09-30.csv), ten Float64 min/max rows at 10M rows were slower in both best and median, nine of them at 10,000 groups (best 0.60x-0.92x, median 0.71x-0.88x). Their first run after 500 ms of idle was faster than before (groupby_float64_2026-09-30.csv; one key, max: 8.60 → 7.09 ms). In fresh processes that time only am.group_by(keys).max(values) (Benchmarks/results/groupby_float64_minmax_state_2026-09-30.csv, six rounds per build; median: the median of the rounds' medians), at 5M, 10M and 20M rows and 3,000, 10,000 and 30,000 groups, in a warm loop and with a 50M-row Int64 group sum run before every timed call, the new build was faster in 17 of 18 cells in best and 17 of 18 in median (10M rows, 10,000 groups, warm loop: 1.85 → 1.72 ms best, 3.21 → 2.38 ms median); no cell was slower in both.

To improve. Through the DataFusion rule, a Float64 MIN/MAX over a column holding a NaN or both zero signs carries four helper aggregates per extreme (the NaN, value, zero and negative-zero counts, each a sum over an if_else; when this was measured every Float64 column did, and since 2026-10-01 the others run on the plan runner's own min + max, DataFusion), which take most of the plan: 36.88 ms at 200 groups from 50M rows (datafusion/results/datafusion_float64_groupby_2026-09-30.csv, kernel_new_ms), where the plan runner's own min + max takes 7.34 ms (groupby_float64_2026-09-30.csv). At 25M groups from 50M rows the grouping itself (am.group_by(keys) with a count) is 110.12 ms of the 197.05 ms sum (the same file, array API, best).

Tests. GroupSumExactTests (Swift) checks sums and means against an independent correctly rounded reference (CPython's msum partials with its half-even correction, and a residual check of each mean against its two neighbours) over 1 to 100,000 groups on both sides of the private-table limit, with null and out-of-range keys, null values, magnitudes spread over 2^-300 to 2^300, hand-built groups of cancellation, signed zeros, subnormals, overflow, ties and every special value on both paths, cancellation-heavy groups of 10^15 to 10^17 terms that cancel, 2^24 + 3 groups, and min/max over Float64 (NaN, both zeros, inf), Int64 near its maximum, Float32 and UInt32 against a host reference. python/tests/test_group_sum_exact.py checks the array API and the plan runner against the exact Fraction sum and mean of each group, and two differential rows, group_by_sum_correctly_rounded and group_by_mean_correctly_rounded, check every shape of the matrix bit for bit against the exact value rounded once.

Round 15 (2026-09-27): a count that depended on its neighbours

What. count(expr) in a plan's group_by gave three different answers for one column depending on the other aggregates of the same group_by. Alone, or next to aggregates the one-kernel group-by takes, a count of a Float64 column was right and a count of a utf8 column was rejected ("group_by count of a utf8 expression"). Next to a sum, mean, min or max over Float64, the count of the Float64 column failed ("group-by over Float64 values: cast to Float32 first"), and the count of the utf8 column returned the group's row count with its nulls: 4 for ["a", null, "b", null], where SQL's count is 2. No error was raised for the second. Found by a differential grid of group-by plans.

Cause. One aggregate the one-kernel group-by cannot take (its sums finish with 32-bit atomics) sends the whole group_by through GroupBy's per-aggregate kernels (Executor.aggregateOne). That path's count switched on the column's type: the numeric types went to GroupBy.count(_:), whose shared accumulate kernel refuses a Float64 MetalArray before it looks at the kind, although a count reads only the validity bitmap; every type the switch did not list fell to default: and to GroupBy.count(), the row count. The one-kernel path reads numeric expressions only and rejected the rest. The same default: counted rows in the streaming group-by's per-batch count, for boolean, decimal, dictionary and nested columns (667 per group where 500 rows were non-null), and the dense-key streaming result left out a key whose rows were all null in the first counted or summed column, because it chose its groups by that aggregate's count of values instead of rows.

Fix. A count is computed from validity and nothing else, for every type. GroupBy.count(validity:) runs the existing count kernel with a validity bitmap and the key column's buffer standing in for the values, which the count kind never loads; GroupBy.countValid(_: AnyMetalArray) takes a column of any type through AnyMetalArray.logicalValidity(), which is the column's own bitmap, or, where Arrow's nulls live elsewhere, one built on the host: all null for the null type, the code's and the dictionary entry's validity for a dictionary, the run's for run-end encoded, the selected child slot's for a union. The per-aggregate path, the streaming group-by, the C ABI's hash_count and the dense am_group_by count all call it. A count no longer decides the group-by path; in the one-kernel path a count of an expression the kernel does not read as a number is taken the same way next to the kernel, and the whole-table aggregate does the same. The dense-key streaming result keeps a row count per key, taken from a count the batch already has when its column holds no null.

Cost. The one-kernel group-by shapes run the same kernel as before. On the per-aggregate kernels a count reads only the validity bitmap. The dense-key streaming group-by counts a column once per batch where sum and count share it; a sum alone over a column with nulls adds one row count per batch.

Tests. GroupByCountTests (Swift) checks 27 column types, the ten numeric types, boolean, utf8, utf8 views, binary, date32, timestamp, decimal128, list, struct, two dictionaries (null codes, null entries), fixed-size binary, float16, null, run-end encoded, a sparse union and an extension type, at null fractions 0, 0.1 and 1.0, alone, next to a Float64 sum, mean, min and max, and next to an int32 sum and a row count, against a count of the validity each column was built from; and the same types through the whole-table aggregate with and without a filter, the dense GroupBy API at 13 and 5,000 keys, computed expressions with nulls, empty input, the streaming group-by with and without a dense key count, and the fused stream join aggregate on both sides. python/tests/test_group_count.py checks the plan runner, am.group_by(...).count(...), the dense count_values and the streaming group-by against pyarrow's is_valid per key, and rust/arrowmetal/tests/plan.rs checks the two minimal cases above.

Round 14 (2026-09-26): what the 0.3.0 reviews and sweeps found

TL;DR

  • The Polars engine imported NumPy for one reciprocal. NumPy comes with neither the wheel, pyarrow nor Polars, so on a clean install the engine raised ModuleNotFoundError.
  • One 790 KB Snappy dictionary page took about 94 ms on the GPU, most of a 1,000,000-row read.
  • A grid of 2^32 threads or more wraps on the GPU, which made group-bys over 2^24 groups or more return nulls: round 13.
  • The engine conformance grid found three answers of the Polars engine different from Polars'. All three now match.
  • The engine's first crossover table was fitted from a sweep that started at a load average of 50.8. The sweeps rerun from a quiet start moved it: String shapes from 5,000,000 rows, the rows/2 group-count bucket never taken, and every shape taken from 1.5 times its fitted crossover.

Each change below is in 0.3.0.

1. The Polars engine needed NumPy

Polars divides a column by a scalar as x * (1 / c), with the reciprocal rounded in the result type, and the engine emits the same multiply so the bits match (Polars). It computed that reciprocal with NumPy. NumPy is installed by none of the wheel, pyarrow and Polars, so in a fresh virtualenv holding the wheel and its polars extra, tier 4 raised ModuleNotFoundError. Found by installing the wheel alone (scripts/check_wheel.sh). The reciprocal is now computed with Python floats, identical to the NumPy result on 800,046 checked values, zeros, infinities, NaN and subnormals among them, and scripts/check_wheel.sh runs all four tiers with NumPy not installed (Polars).

2. One Snappy page took 94 ms on the GPU

A 1,000,000-row file with an int64 and a float64 column, written with pyarrow's defaults, read in 102-106 ms, 94 ms of it one 790 KB dictionary page. Every Snappy page was decompressed by one GPU SIMD group, and one worker walks a page's token stream from start to end however the page is decoded. A token-dense page is tens of thousands of tokens of a few bytes: on the GPU it costs 130 ns per output byte on a SIMD group, on one host core 0.42 ns (Parquet on the GPU). The first fix decoded dictionary pages, and dispatches of at most 16 pages, on the host (20-36 ms for that file). The reader now splits the Snappy and LZ4 pages of a read between the host and the GPU by how token-dense each page header says it is, from measured costs on each side, and runs both at once: the 10,000,000-row, 7-column pyarrow-default Snappy file reads in 25.4 ms against 67.5 ms, its token-dense pages, the 790 KB dictionary pages among them, on the host.

3. A grid of 2^32 threads

Round 13.

4. Three engine answers that differed from Polars'

The engine conformance grid (python/tests/engine_report.py) compares the Polars engine with lf.collect() bit for bit over generated shapes, dtypes, null patterns and sizes from 0 to 100,000 rows (Coverage summary). It found three answers that differed, each now the same as Polars' (Polars):

  • A column of one row. Polars divides by a scalar as a reciprocal multiply and multiplies a float column by -1 as a negation, except over a column of one row, where the scalar and the column have the same length and it computes element-wise. The engine now chooses by the input's row count: from the in-memory frame, or counted when the plan runs if a filter or join decides it.
  • -0.0 and 0.0. Polars' min and max order -0.0 below 0.0; ArrowMetal treats the two as equal. The engine counts the zeros of the sign Polars prefers and takes the sign from that count.
  • A Float32 mean. Polars accumulates it in Float64. The engine casts the column to Float64 first, and the two match bit for bit.

The recorded run: 12,597 Polars cases, 12,392 pass, 32 documented (float summation order), 0 unclassified (Benchmarks/results/engine_conformance_2026-09-25.csv).

5. Crossovers fitted on a busy machine

The default MetalEngine() takes a subtree from its measured crossover against the faster Polars engine (Polars). The first table was fitted from Benchmarks/results/polars_engine_crossover_2026-09-26.csv, a sweep that started at a load average of 50.8 and ran between 13.6 and 58.2 (polars_engine_crossover_2026-09-26_conditions.txt). There the String sort (o) was 1.66x the faster Polars engine at 1,000,000 rows, 0.69x at 2,000,000 and 0.78x at 5,000,000. The final sweep started at 3.2 (bench_conditions_2026-09-26-final.txt), and there (polars_engine_crossover_2026-09-26-final.csv) the same case is 0.98x, 1.20x and 1.37x, growing with size. The table in 0.3.0 is fitted from the final sweep, with three rules on the fit:

  • String shapes from 5,000,000 rows (STRING_FLOOR), with a 35% margin where a numeric shape has 15% (MARGIN). A String shape's advantage grows slowly with size, and below 5,000,000 rows the String sorts, (o) 1.2x and (p) 1.38x at 2,000,000, sit inside the benchmark's noise band of 0.62x to 1.31x.
  • The rows/2 bucket is never taken (UNTAKEN_BUCKETS). The sweep is not monotone for group-bys over a quarter of the rows or more: the one-key count over 0.43 times as many groups as rows is 0.78x, 1.9x, 4.04x, 2.21x and 1.18x the faster Polars engine from 2,000,000 to 50,000,000 rows.
  • 1.5 times the fit (HEADROOM). The fit interpolates between sizes measured 2 to 2.5 times apart, and shapes just past their crossover were within run-to-run noise of Polars.

In the default benchmark on that table (Benchmarks/results/polars_engine_bench_2026-09-26-final3.csv, 194 case-size pairs) the default took 62 pairs, 42 of them group-bys, every one ahead of the faster Polars engine, 1.21x to 9.42x. On the 0.4.0 table (fitted from Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv), in Benchmarks/results/polars_engine_bench_2026-10-02.csv (220 case-size pairs at 2,000,000 and 50,000,000 rows), the default takes 75 pairs, 55 of them group-bys, every one ahead of the faster Polars engine, 1.52x to 11.54x, and every result is equal to Polars'.

Round 13 (2026-09-26): a grid of 2^32 threads runs almost none of them

What. am.group_by([key]).sum(values) and .mean(values) over Float64 values, and over Float32 values (summed in Float64), came back null for 2^24 groups and more; 2^24 - 1 groups were right. At exactly 2^24 groups every group was null, at 2^24 + 1 every group but one, at 2^24 + 2^20 the first 2^20 groups were right and the other 2^24 null. No error was raised. product and list were wrong for the same groups. Count, min and max over every type, and the Int64 sum and mean, were right. Found by the Polars engine's crossover sweep.

Cause. Those aggregates reduce one group per 256-thread threadgroup and dispatched a (groups, 1, 1) grid of threadgroups (seg_reduce, Kernels/Segmented.swift). On the M4 Max the thread count of a grid dimension, threadgroups times threads per threadgroup, is held in 32 bits: at 2^24 threadgroups of 256 threads it is 2^32 and wraps, and the dispatch runs groups mod 2^24 threadgroups. A standalone kernel shows it: 2^24 threadgroups of 32 threads all run, 2^24 of 256 run none, 2^23 of 512 run none, 2^25 + 3 of 128 run 3. The groups that no threadgroup reached kept a count of zero and came back null. The atomic path (count, 32-bit min/max, integer sum and mean) and the two-pass 64-bit min/max dispatch over rows, not groups, and never reach that width. The same one-threadgroup-per-group grid served the segmented 64-bit min/max, product, the list gather, the variance passes over groups of 32 rows or more on average, and the per-group run sort of the counting sort.

Fix. Dispatch.perGroup dispatches every one-threadgroup-per-group kernel. Below 2^32 threads the grid is the same (groups, 1, 1); from there it is folded into rows of 65,536 threadgroups, and the kernels read their group as (tgid.y << 16) + tgid.x. Reading the row width from threadgroups_per_grid instead nearly doubled the time of a light per-group kernel (a gather over 10M groups of 5 rows: 122.6 ms against 64.5 ms), so the width is a power of two known to the kernel. The 50M-row group-by rows run in the same time as before.

Tests. GroupByGridFoldTests (Swift) checks sum, mean, count, min and max over Float64, Float32 and Int64 at 2^24 - 1, 2^24, 2^24 + 1 and 2^24 + 2^20 groups against a host reference, and runs every per-group kernel with the fold forced on at 150,000 groups against the plain grid. python/tests/test_group_by_2_24.py checks the same aggregates, product and list at the same four group counts against pyarrow's Table.group_by, and the lazy-plan Float64 sum and mean at 2^24 groups.

Round 12 (2026-09-24): what the 0.2.0 reviews found

TL;DR

  • The 0.2.0 work (the CPU/GPU router, the Polars engine, DuckDB plan rewriting, the GPU CSV and NDJSON readers, the Parquet reader's nested and page-index gaps, Delta Lake and Iceberg tables, and IPC view types, big-endian files and the tensor extension) was reviewed before it merged.
  • The reviews found seven bugs older than this work and one in pyarrow, filed as apache/arrow#51491 (Upstream tracker). Everything below is fixed on main with a test.
  • The Python suites had been loading a library built on 2026-09-07, left in the checkout by a wheel build. The test run now pins the library it loads.

1. The Python suites tested a stale library

The Python package looks for its library in three places, and a copy bundled by a wheel build wins over the development build. The 2026-09-07 wheel build left such a copy in the checkout, so from 2026-09-08 to 2026-09-24 the Python suites and the differential ran against the 2026-09-07 build. The Swift suite was unaffected. It surfaced when the IPC work's new Python tests failed with "view columns unsupported", which only the old library says.

Fixed by pinning the path: ARROWMETAL_LIB names the build under test, and the run stops unless Python reports loading exactly that file (Python). Rerun against the fresh library, the differential gave the same counts as before (39,069 cases, 0 unclassified) and the Python suites passed, so nothing had regressed behind the stale copy. The 2026-09-17 crossover sweep was rerun the same way on 2026-09-24 (Benchmarks/router_check.py; Design, "CPU/GPU router").

2. A float literal of 2^63 or more ended the process

The fused expression compiler converted every float literal to Int64, even when the target was a float, and Swift's Int64(Double) traps outside its range. int64_column >= 1e19 or x * 4.49e307 ended the host process. Found while reviewing the Polars engine, through a division by a tiny literal. Float targets no longer compute the integer; an integer-typed literal that does not fit is an error that names it.

3. Two same-named columns came back as one twice

With no explicit projection, a stream looked every column up by name, so the second of two columns called a was replaced by a copy of the first: am.scan_ipc(...).collect() returned wrong data and no error. Found while reviewing the IPC reader, on a union fixture. A batch with duplicate names now stays positional; batches with unique names keep the fused path.

4. Skips recorded as failures

Twenty-three IPC test assertions called the pyarrow helper inside XCTAssertEqual. When no pyarrow interpreter exists, the helper throws XCTSkip, and a skip thrown inside the assertion's autoclosure is recorded as a failure. Found while reviewing the router: a full-suite run showed 12 such failures on a clean environment. The helper now runs before the assertion.

5. Four engine bugs the Polars engine had been working around

The Polars engine's differential suite compared every plan it could take against Polars and found four wrong answers inside the engine. That work routed around them and pinned each with a strict expected failure; they were then fixed at the root, each with a test that fails on the old code:

  • A string filter wrote the bytes a null row still held over the next row's slot, turning "banana" into "xanana". The gather kernel now copies the output slot's own width.
  • A Boolean column lost its null count through a sort inside a batch: results of an array whose count was still pending copied the placeholder. They now count their own nulls after the flush.
  • A filter refused any batch that also carried a date32 column, because the expression compiler bound every column, read or not. It binds only the ones the query reads.
  • A string sort returned wrong rows when a column held a null and a value of eight bytes or more: the null partition read the sort's indices on the CPU before the GPU had written them. It first waited for them, which made such a sort slower than the old, wrong one; the partition now runs on the GPU, as a bit in the sort's own prefix keys, and there is nothing to wait for.

The JSON reader's review found a fifth wrong answer, in new code: an explicit timestamp[ns] outside the years 1678 to 2261 wrapped around instead of raising, as pyarrow raises. It raises now.

6. pyarrow drops NaN rows when statistics are present

Found while reviewing the Parquet reader: ArrowMetal's own page skipping dropped a page holding a NaN under !=, because writers leave NaN out of min and max. After the fix, a test showed pyarrow doing the same at row-group level. Reproduced in plain pyarrow 25.0.1: x != 5.0 over [5, NaN, 5, 5] returns [nan] in memory and from a file without statistics, and [] from a file with them; ~(x <= 10.0) over [1, NaN, 10] behaves the same way. The cause is in ParquetFileFragment::EvaluateStatisticsAsExpression, unchanged on Arrow's main, and is distinct from the 2023 fix for NaN inside min or max (#28074). The same class of bug was fixed on our side in Delta partition pruning. Filed as apache/arrow#51491 (Upstream tracker).

7. Two Parquet changes that broke the Delta reader

The Parquet work added a filter value for integers above INT64_MAX and changed how a MAP column is described. With the Delta and Iceberg work in the same tree, the build failed, and once built, 54 lakehouse tests failed: Delta checkpoints could no longer be read. Both are fixed on main, with tests.

Round 11 (2026-09-20): The three Metal findings reported to Apple

TL;DR

  • Metal has no public issue tracker, so the three findings this log had accumulated about Metal itself went to Apple through Feedback Assistant on 2026-09-20. Feedback Assistant is private, so this entry and Reported to Apple are the readable record; the rows move in Upstream tracker when Apple answers.
  • FB24858110: the Feature Set Tables promise "the full set of 64-bit atomic operations" on Apple9; the shading language compiles exactly two, min and max. FB24858160: pipeline creation fails at random on the "Apple Paravirtual device" of GitHub's macOS runners, 110 times in one test process. FB24858235: a running kernel sees CPU stores to shared memory only sporadically, so a persistent GPU worker cannot be built; filed as a suggestion for a system-scope primitive.
  • Re-verifying each report against its own attachments before filing changed two of them. The rule held: nothing was sent that the attachments do not show.

1. 64-bit atomics: the documentation and the compiler disagree (FB24858110)

Apple's Metal Feature Set Tables list 64-bit atomics for the Apple9 family, which the M4 series belongs to, and say in a footnote that "the full set of 64-bit atomic operations is supported on all platforms starting with Apple9". On an M4 Max the compiler accepts exactly two 64-bit atomic operations, on device memory only: atomic_min_explicit and atomic_max_explicit on device atomic_ulong, void-returning, relaxed order. Fetch-add, fetch-sub, the value-returning min and max, exchange, compare-exchange, load and store are all compile errors at every language version from 3.1 to 4.0, and there is no signed atomic_long. The shipped header and the shading-language specification's own table of 64-bit atomic functions agree with the compiler, not with the Feature Set Tables. A 64-bit add cannot be built from parts either, because there is no 64-bit compare-exchange to loop on.

Measured with a short Swift program that compiles one tiny kernel per operation at three language versions and prints each verdict, then runs the two that compile over ten million elements and checks the results. It was run again on the day of filing with the same outcome as two weeks earlier.

What it costs here: every 64-bit accumulation carries a workaround. An int64 sum per group is a split 32-bit add with an explicit carry; a hash table publishes a 32-bit row index because a 64-bit key cannot be published atomically; grouped variance and standard deviation, which accumulate in software binary64, run a counting sort by group first because they cannot use per-group atomic accumulators. On the matrix's 50-million-row, 1,000-group rows the grouped sum, where the carry trick suffices, is 3.8x ahead of pyarrow's threaded engine; grouped variance, which cannot use it, is level with it.

Asked: on Apple9, the operations the footnote already claims; failing that, a per-family statement of which ulong operations exist, in place of "the full set".

2. Pipeline creation fails at random on GitHub's virtual GPU (FB24858160)

On GitHub-hosted macOS runners the Metal device is an "Apple Paravirtual device". Compiling a shader library from source succeeds every time; creating a compute pipeline for a function from that library then fails sporadically with the text "Compilation failed" and nothing else, for ordinary kernels that succeed on the same runner image in other runs and in the same process moments later. Rounds 4 and 5 below are where this was first met.

Measured from two complete CI logs on runner image macos-15-arm64 20260829.0321.1 with Xcode 16.4. In the first run one test process saw 110 pipeline-creation failures across 23 distinct kernels and no library-compilation failure, the most frequent a filter kernel, 64 times. In the second the engine retried each creation once after 20 ms and skipped GPU tests on virtual devices; the test step passed by creating few pipelines, and the benchmark step, which still created them, failed on a group-by kernel even with the retry. On real Apple silicon the same source has never produced one such failure.

What it costs: GPU code cannot be validated on GitHub-hosted macOS runners. CI here proves the build, the language interop and the CPU paths; every GPU test and benchmark runs on local hardware.

Asked: deterministic pipeline creation for a function from a library the same device just compiled; or an error that names the unsupported construct and a device property that reports the limitation before any work is submitted.

3. A running kernel cannot reliably see CPU stores to shared memory (FB24858235)

Small operations are dominated by the fixed cost of a dispatch, about 60 microseconds of submission and completion notification on this machine. The standard escape elsewhere is a persistent worker: one long-running kernel spinning on a ring of work descriptors in shared memory, so that submitting work is a CPU store. That needs a CPU store to become visible to a running kernel within a bounded time.

Measured with a standalone program that compiles one kernel per memory qualifier the shading language offers, from plain pointers through volatile, coherent(device), device-scope atomics and device-memory barriers; one threadgroup spins on a doorbell word in a shared-storage buffer for three seconds while the CPU increments it every 5 ms. Five runs across three days: in one, no store was seen under any qualifier; in another, 24 of 433 under one qualifier and a handful under others, the first after 160 ms or more; in another, a single store of 598. The behaviour is consistent with visibility arriving only when a cache line happens to be evicted, which nothing in the language can request. coherent(device) is the widest scope the language has and there is no synchronisation call that can run mid-dispatch. The full study is The persistent-kernel result.

Asked: a system-scope coherence primitive with a stated visibility bound; or a documented, explicitly costed way for a running kernel to observe CPU stores without a command-buffer boundary; or, failing both, a documented submit-and-notify path under 20 microseconds, which removes the motive.

What changed before filing

Re-running the coherence probe, which on 2026-09-08 had seen no CPU store at all under any qualifier, saw 24 of 433 in one of three runs that day, a single store in another and none in the third. "Never" became "sporadic and unbounded": the more accurate claim, and the harder one to dismiss. The paravirtual draft quoted an error code that appears in neither attached CI log; it was replaced by what the logs show, "Compilation failed" with an empty userInfo. Verifying every sentence of a report against its own attachments caught both.

Round 10 (2026-09-08): arrow-go's span iterator, found while designing its aggregates

TL;DR

  • Arrow Go's compute package has no aggregate functions (apache/arrow-go#1296). The maintainer asked for them; a design note with a tested prototype of sum, mean, min_max, min, max, count, any and all is on the issue.
  • Reviewing that prototype the way this project reviews its own kernels found a bug in arrow-go itself: ArraySpan.SetSlice carries a stale null count into the next slice, so and_kleene and or_kleene return false where the answer is null once ExecCtx.ChunkSize is below the input length, on main and in v18.7.0. Reported as #1305, fixed by #1306.
  • Two more things the C++ reference does that a straightforward Go port gets wrong: C's fmin/fmax rank -0.0 below +0.0, and float sums are pairwise, not a left fold.
  • Two documentation pull requests from the same day, #1302 and #1303, were merged within hours.

The setting

compute.GetFunctionRegistry() in arrow-go v18.7.0 holds 85 functions, none of aggregate kind (89 on main: 76 scalar, 8 vector, 5 meta). FuncScalarAgg exists as a kind; execInternal returns ErrNotImplemented for it. The Go binding here therefore uses plain loops as its reference for reductions (Go). The maintainer's reply to the report was that the kernels had been on his list for a long time and he would love to see them; the design note was promised before any code, so the interface would be one he wants to maintain.

Research steps

  1. Read the C++ ScalarAggregateKernel (kernel.h), the executor (exec.cc), the options (api_aggregate.h) and the kernels (aggregate_basic.cc, aggregate_basic.inc.cc, aggregate_internal.h) at f251bc3, and the Go compute package at 67ef40b, and write the design.
  2. Check the note row by row against pyarrow built from that C++ commit and against the Go identifiers it names, compiling the proposed constraint change.
  3. Build the prototype from the corrected note, run arrow-go's own lint under the Go version it pins, and cross-check every expected test value against pyarrow.
  4. Attack the code: sliced inputs at bitmap-hostile offsets, forced small ChunkSize, executor pool reuse, the race detector, a checked allocator on every call, bad options and input kinds.
  5. Differential fuzz: 31,760 cases across the twelve input types, plain, sliced, chunked and scalar, under every skip_nulls/min_count combination, compared bit for bit with pyarrow.
  6. Fix what was found, rerun everything, and only then post.

Finding 1: the note's own errors, caught before posting

The note said What C++ does (pyarrow from the same commit agrees)
mean of an empty input follows the same rule as sum (0 with min_count=0) NaN for numeric and boolean inputs, 0.0 for the null type
in min_max, NaN never wins true against a value; when every non-null value is NaN the result is NaN, because the state starts at NaN
mode, quantile and tdigest are scalar aggregates for later mode and quantile are vector functions; tdigest returns an array, so a scalar-returning Finalize cannot implement it
no other options type in the package inverts a field for the zero value ArithmeticOptions.NoCheckOverflow does, and TakeOptions uses a constructor

Finding 2: signed zeros in min, max and min_max

One divergence in the first 31,760 fuzz cases, 2,884 times, all the same cause. C's fmin and fmax, which the C++ kernels use, order -0.0 below +0.0, so pyarrow returns min -0.0 and max +0.0 whenever both appear, in any order. A comparison-based port keeps whichever zero came first:

// before: b < a is false for (-0.0, +0.0), so the incumbent stays
case b < a:
    return b
// after: the sign is observable, so it is a case of its own
case b < a:
    return b
case b == 0 && a == 0 && math.Signbit(float64(b)):
    return b

After the fix the rerun matched on every case.

Finding 3: float sums are pairwise

C++ SumArray sums floats pairwise in blocks of sixteen, the same algorithm numpy uses, and mean accumulates in float64 for every input type. A plain left fold over 100,001 values gives 75247.35643756675 where pyarrow gives 75247.35643756694; the port of the pairwise loop matches bit for bit, and the test that proves it is in the prototype.

Finding 4: ArraySpan.SetSlice carries a stale null count

Every prototype kernel returned wrong answers once ExecCtx.ChunkSize was smaller than the input: count of 339 valid values in 493 gave 193 at chunk size 1, 490 at 2, 467 at 4. The cause is not in the kernels. iterateExecSpans reuses one ArraySpan per argument and advances it with SetSlice, which on main reads:

if a.Type.ID() != arrow.NULL {
    if a.Nulls != 0 {
        if a.Nulls == a.Len {
            a.Nulls = length          // "all null" carries over
        } else {
            a.Nulls = array.UnknownNullCount
        }
    }                                 // "no nulls" carries over too
} else {
    a.Nulls = length
}

That is right only while Nulls describes the whole array. A kernel that calls UpdateNullCount() on the shared span stores the current slice's count; the next SetSlice then treats the following slice as all valid or all null. Two kernels on main do exactly that, and the reproducer shows it without any new code:

ChunkSize and_kleene([T, null, T, null, F, T, null, T], [null, T, T, F, null, null, T, T])
default [null null true false false null null true]
1 [null false true false false null false true]
2 [null null true false false false false true]
3 and above correct

or_kleene fails the same way; the SetSlice code is identical in v18.7.0. The C++ ArraySpan::SetSlice never carries a count when a validity bitmap is present, and the fix is that rule:

if a.Type.ID() != arrow.NULL {
    if a.Nulls != 0 || len(a.Buffers[0].Buf) != 0 {
        a.Nulls = array.UnknownNullCount
    }
} else {
    a.Nulls = length
}

With it, both Kleene kernels pass at every chunk size and the whole compute suite still passes. The prototype's kernels, independently of that fix, count each span from its bitmap and never write to the shared span, with a test at chunk sizes 1 to 65.

What shipped, and what is open

  • Merged the same day: #1302 (allocator alignment and which allocator to use when C keeps a buffer, closing #1297) and #1303 (what the compute registry holds).
  • Open: #1296 (design approved 2026-09-21) and the framework plus count/sum pull request #1336; #1305 with its fix #1306 by singhpratech.
  • Status of each is on the Upstream tracker.

Logs, scripts and raw outputs of every run above (the audit reports, the build and lint log, the fuzz fixtures and results, the reproducer and its output on both commits) are kept with the release records.

Round 9 (2026-09-06): radix select, and the LSD radix sort's blocking at small n

TL;DR

  • A GPU radix select needs two passes over the column, not three, when the histogram keeps its counts per sub-block: at 50M rows that is 1 ms saved out of 3.
  • After selection, ordering the ~200k survivors was the bottleneck, and the cause was a fixed 4096-element block in the LSD radix sort. Halving the block until there are at least 64 blocks took argsort of 20k from 1.99 ms to 0.41 ms and is most of why top_k(100) went from 5.6 ms to 2.9 ms.
  • The selected bin's size depends entirely on the key distribution's top byte. Float keys concentrate in a handful of bins, so a 50M Float64 median varies from run to run while Int64 keys are stable.

The setting

A GPU radix select normally costs three passes over the column: histogram the top digit, count how many rows fall in the selected range per block, then scatter them in order. This round built the select behind top_k, then measured where the time went once selection itself was cheap: the final ordering of the candidates, and the size of the bin the selection leaves behind.

Research steps

  1. Lay the histogram out per sub-block, so the count pass is redundant and the select is two passes.
  2. Make the sub-block a simdgroup's slice rather than a threadgroup's, removing the barriers from the scatter.
  3. Time the final argsort over the ~200k candidates; trace its cost to the fixed elemsPerBlock = 4096.
  4. Halve the block size until there are at least 64 blocks; remeasure argsort at 20k and 200k rows and top_k(100) end to end.
  5. Run the histogram and compaction a second time over the compacted candidates.
  6. Read the benchmark generator's key distribution, and the top byte of a float64 key, against the bin sizes observed.

Finding 1: the two-pass trick

The count pass is redundant if the histogram keeps its counts per sub-block instead of only globally. The digit-major table counts[digit * subBlocks + sub] is simultaneously the global histogram (summed over sub-blocks) and the offset table the scatter needs (summed over digits <= target). At 50M rows that is 1 ms saved out of 3.

Finding 2: one sub-block per simdgroup, not per threadgroup

Making the output granularity a simdgroup's slice rather than a threadgroup's removes every threadgroup_barrier from the scatter: the rank of a selected row within its slice is simd_prefix_exclusive_sum over 32 lanes plus a running base each lane computes identically. The cost is an 8x larger count table (256 * 8 * groups words, ~8 MB at 50M rows), which is noise next to the 400 MB the pass reads anyway.

Finding 3: the final sort was the bottleneck, and it was a blocking bug

After selection there are only ~200k candidates left to order, but argsort of 200k UInt64 measured 2.2-2.9 ms, the time of sorting 800k. The cause was elemsPerBlock = 4096 fixed: 200k rows is 49 threadgroups, 20k rows is five, on a GPU with 40 cores, and radix_scatter does an O(TG) rank loop per element that nothing else can overlap. Halving the block size until there are at least 64 blocks (inputs above ~256k rows are untouched, so the 50M argsort is unchanged) gave:

Input Before After
argsort of 20k 1.99 ms 0.41 ms
argsort of 200k 2.20 ms 1.34 ms
top_k(100) 5.6 ms 2.9 ms

The block-size change is most of the top_k(100) improvement. The rule as it stands in Sources/ArrowMetal/Kernels/Sort.swift:

func blockPlan(_ rows: Int) -> (elemsPerBlock: Int, blocks: Int) {
    var e = Swift.max(4096, ((rows + 127) / 128 + 255) / 256 * 256)
    while e > 256 && (rows + e - 1) / e < 64 { e >>= 1 }
    return (e, Swift.max(1, (rows + e - 1) / e))
}

Finding 4: refine on the compacted array, not the column

The selected bin is ~n/256 rows, which still dominates the final ordering. Running the same histogram + compaction again over the compacted candidates (a few hundred thousand keys, microseconds) shrinks it by another 256x. One extra round trip, and it takes the survivors under the 2048 pairs a single-threadgroup bitonic sort can order in one dispatch.

Finding 5: benchmark data hides skew

rng.integers(-(2**62), 2**62) spreads over only 128 of the 256 top-digit bins, so the candidate bin is n/128, not n/256. Worth remembering when reading a selection benchmark: the bin size, and therefore the final sort, depends entirely on the key distribution's top byte.

Finding 6: a float key's top byte is the exponent, so float columns are the skewed case

The top byte of a float64 key is the sign plus seven exponent bits, so a column of uniform doubles concentrates in a handful of bins rather than spreading over 256, and the bin holding the wanted rank can be a large fraction of the column. When it is over the compaction budget the search narrows another digit over the column instead, one more full pass, which is why the same 50M Float64 median varies from run to run depending on exactly where the rank lands, while Int64 keys are stable. Raising the budget so the big bin gets compacted instead is faster still, but a 25M-row bin needs 300 MB of scratch for a 400 MB column, and a median already 80x faster than pyarrow (4.61 ms against 368.88 ms at 50M rows, Benchmarks/results/full_matrix_2026-09-07-parallel.csv) is not worth a 75% memory overhead.

What shipped

Round 8 (2026-09-06): a threadgroup atomic read that is not uniform (top_k)

TL;DR

  • One cell of a 13,000-case differential run diverged and then passed on every rerun. A stress test against a CPU oracle reproduced it at roughly 1 in 900 calls.
  • Every thread of topk_select read the threadgroup atomic count held itself with a relaxed load; one simdgroup out of eight occasionally saw a newer value, so the sentinel fill left holes of zeroed memory that read as (key 0, row 0) and sorted to the front.
  • Fix: thread 0 reads held once per chunk into a plain threadgroup variable between two barriers. The cost is below the noise floor: 3.11 / 3.31 / 3.38 ms with the fix against 3.22 / 3.30 / 4.37 ms without it for top_k(100 of 20M Int64).
  • Rule: never let a barrier-carrying branch, or a loop bound whose strides must tile a range, come from a per-thread atomic_load_explicit(..., memory_order_relaxed).

The setting

Symptom. One cell of a 13,000-case differential run diverged and then passed on every rerun: top_k / float64, 100,003 rows, 30% nulls, k = 17, descending. result[0] was row 0 where pyarrow says row 35254. A randomised stress against a CPU oracle (TopKTests.testStressAgainstCPUOracle) reproduced it at roughly 1 in 900 calls across every key type, both directions, n from 32k to 1M and k from 1 to 1024. The failure always looked the same: a run of zeros at the front of the result, the true answer after them.

Research steps

  1. A stress test comparing against a CPU oracle (not against argsort, which shares the sort keys), with pool-churning kernels in between; this is testStressAgainstCPUOracle in Tests/ArrowMetalTests/TopKTests.swift.
  2. Poison the candidate buffers before the dispatch, to prove the kernel really wrote those zeros rather than leaving stale bytes.
  3. Add a debug buffer carrying each threadgroup's held, arrival count and compaction count, which showed the holes were inside [0, c) and [c, cap) in simdgroup-sized runs.
  4. Instrument the kernel to count the holes per threadgroup.
  5. Fix, measure the cost, and check the other atomic_load_explicit sites for the same pattern.

Finding 1: a relaxed atomic load is not uniform across the threadgroup

Root cause. topk_select keeps a per-threadgroup candidate buffer in threadgroup memory with an atomic count held. Every thread read that count itself:

threadgroup_barrier(mem_flags::mem_threadgroup);
uint c = atomic_load_explicit(&held, memory_order_relaxed);   // Kernels/TopKSource.swift
...
for (uint i = c + lid; i < cap; i += TG) { bufKey[i] = keyMax; bufRow[i] = TK_NOROW; }

c bounds the range each thread pads with sentinels, and the union over lid covers [c, cap) only if every thread has the same c. It does not: on an M4 Max, one simdgroup out of eight occasionally comes back with a newer value than the rest. A relaxed atomic load is not ordered by the preceding barrier the way a plain threadgroup read is, so it can be satisfied late, after other threads have already run ahead and incremented held. The threads holding the larger c start their stride later and the slots they should have covered are never written. Instrumenting the kernel showed 7 to 63 such holes per threadgroup, clustered at 32 and 64: one and two simdgroups.

Finding 2: what a hole does to the answer

Those holes hold zeroed pool memory, which reads as (key 0, row 0). That pair is the minimum of the (key, row) order, so the bitonic sort moves it to the front of the buffer and it becomes the block's answer; the host then sees row 0 as the top candidate. Worse, if a block accumulates k or more holes the compaction sets its threshold to bufKey[k-1] = (0, 0), which nothing can beat, and the block is blind for the rest of the scan. The same read also decides if (c + TG > cap), a branch containing threadgroup_barrier, so a non-uniform c was undefined behaviour in its own right.

What shipped

  • Fix. Thread 0 reads held once per chunk into a plain threadgroup uint shared_c (clamped to cap) between two barriers, and every thread takes c from there; the branch and the fill ranges are now uniform by construction. The kernel as it stands in Sources/ArrowMetal/Kernels/TopKSource.swift:
threadgroup_barrier(mem_flags::mem_threadgroup);
if (lid == 0u) shared_c = min(atomic_load_explicit(&held, memory_order_relaxed), cap);
threadgroup_barrier(mem_flags::mem_threadgroup);
uint c = shared_c;
if (c + TG > cap) {
    for (uint i = c + lid; i < cap; i += TG) { bufKey[i] = \(keyMax); bufRow[i] = TK_NOROW; }
  • In addition, the buffer is filled with sentinels once at kernel entry, so a slot no one writes reads as "no row" and the host drops it instead of it masquerading as row 0.
  • Cost. One extra threadgroup_barrier per 256-row chunk, and one cap-element sentinel fill per block (2 to 8 strided stores per thread, once). Below the noise floor: top_k(100 of 20M Int64) on M4 Max runs 3.11 / 3.31 / 3.38 ms with the fix against 3.22 / 3.30 / 4.37 ms without it. The pass is still about one read per row.
  • Test. TopKTests.testStressAgainstCPUOracle, the stress against the CPU oracle.
  • Rule. In MSL, never let a value that decides a barrier-carrying branch, or a per-thread loop bound whose strides must tile a range, come from a per-thread atomic_load_explicit(..., memory_order_relaxed). Broadcast it through a plain threadgroup variable between barriers. The other atomic_load_explicit sites (SortSource, GroupBySource, JoinSource, StringExtraSource) were checked: each reads a slot the calling thread owns, so none of them tiles or branches on a shared count.

Round 8b (2026-09-06): the differential matrix over the whole type surface

TL;DR

  • Extending python/tests/test_differential.py to every type ArrowMetal imports (45 columns, 181 operations, 33,156 cases at the time; 217 operations and 41,688 cases in the run recorded in docs/EVALUATION.md) turned up three bugs and one unreproduced crash in pyarrow 25.0.1, not in ArrowMetal.
  • Each has a workaround in the harness and a test that fails if a later pyarrow fixes it.
  • Two properties of the harness itself: time-of-day cases have to drop their null rows, and a 38-digit Decimal has to be read off as_tuple().digits.

The setting

The differential matrix compares every ArrowMetal operation against pyarrow over every imported type. Extending it to the whole type surface is what surfaced the pyarrow behaviours below. They are recorded here because the harness has to work around them, and each has a test that fails if a later pyarrow fixes it.

Research steps

  1. Extend the matrix to 45 columns and 181 operations, 33,156 cases (217 operations and 41,688 cases in the run recorded in docs/EVALUATION.md).
  2. Trace one segfault back from the faulting frame, Array.nbytes inside the harness's own array cache, to the preceding pc.year_month_day call, which cost an hour.
  3. Compare pc.utf8_normalize against Python's unicodedata and the Unicode annex for each form.
  4. Run pc.pairwise_diff on a sliced column whose values are not an arithmetic progression, then the same slice check on the boolean fill and replace kernels.
  5. Give each oracle a workaround, and pin each pyarrow behaviour with a test that fails when it changes.

Finding 1: a single unreproduced segfault after pc.year_month_day

A single unreproduced segfault was observed a few allocations after pc.year_month_day on a timestamp[s] array with nulls; it has not recurred. It landed in whatever unrelated call happened next: the faulting frame was Array.nbytes inside the harness's own array cache, which cost an hour to trace back. Out of caution the matrix does not use the two struct-valued temporal kernels as oracles: it compares iso_calendar and year_month_day field by field against pc.iso_year/ pc.iso_week/pc.day_of_week and pc.year/pc.month/pc.day, which is a stronger check anyway.

Finding 2: pc.utf8_normalize ignores its form option

pc.utf8_normalize ignores its form option: NFC and NFKC come back decomposed, so its NFC is NFD. Python's unicodedata and ArrowMetal agree with each other and with the Unicode annex; the matrix uses unicodedata as the oracle:

for form in ("NFC", "NFKC", "NFD", "NFKD"):
    got.append(arrow(x.utf8_normalize(form)))
    expected.append(pa.array([None if s is None else unicodedata.normalize(form, s)

Pinned by test_pyarrow_utf8_normalize_ignores_its_form_option.

Finding 3: pc.pairwise_diff ignores ArrowArray.offset

pc.pairwise_diff ignores ArrowArray.offset: on a sliced column it reads the values buffer from the start and answers with the wrong rows. Only visible when the values are not an arithmetic progression, which is why it hid for a while. The matrix hands that oracle a materialised copy while ArrowMetal still gets the slice, so the case remains a test of the offset handling:

def _materialised(src):
    """The same values with the Arrow offset folded away."""
    if src.offset == 0:
        return src
    return pa.concat_arrays([src.slice(0, 0), src])

Fixed upstream in pyarrow 26.0.0; the workaround stayed while 25.0.1 was the pinned version and was retired with its pinning test on 2026-10-15, when the matrix moved to 26.0.0 (Round 22).

Finding 4: the same offset bug on boolean columns

pc.fill_null_forward, pc.fill_null_backward and pc.replace_with_mask have the same offset bug on a boolean column (the values bitmap, not the validity one). Same mitigation.

Finding 5: pc.add on time-of-day validates the values under the validity bitmap

pc.add on a time32/time64 column validates the values under the validity bitmap, so a null row whose hidden value would leave [0, 86400) makes the oracle raise even though the row is null. The generator deliberately puts real numbers under the null bits, so the time-of-day cases have to drop their null rows rather than mask them.

Finding 6: a 38-digit Decimal and the default context

A Decimal that came out of a 38-digit column cannot be scaled with Decimal.scaleb or multiplied by 10 ** scale under the default decimal context; 28 digits of precision silently round it. Read the unscaled magnitude off as_tuple().digits instead:

def _unscaled_magnitude(value):
    return int("".join(str(d) for d in value.as_tuple().digits) or "0")

What changed

  • The oracles in python/tests/test_differential.py: field-by-field comparison for iso_calendar and year_month_day, unicodedata for normalisation, _materialised copies for pc.pairwise_diff and the boolean fill and replace kernels, dropped null rows for time-of-day addition, and _unscaled_magnitude for decimals.
  • The pinning tests in the same file: test_pyarrow_utf8_normalize_ignores_its_form_option, test_pyarrow_pairwise_diff_ignores_the_array_offset and test_pyarrow_boolean_fill_null_forward_ignores_the_array_offset; each fails when a later pyarrow changes the behaviour it pins. (They did, on 26.0.0, and were retired on 2026-10-15: Round 22.)

Round 7 (2026-09-06): strings and sort

TL;DR

  • Generated MSL written through a shell heredoc needs \(K) (one backslash) for Swift interpolation.
  • LSD radix sort with 8-bit digits, 4096-element blocks and a stable in-chunk ranking is correct across all types and sizes; descending order must invert keys, not reverse the ascending result.
  • MurmurHash3 x86_32 matches the reference vectors; four collisions among 100k 32-bit hashes is the birthday bound, not a bug.

The setting

The string kernels (hashing) and the LSD radix sort, with their MSL generated from Swift source written through a shell heredoc.

Research steps

  1. Generate MSL through a shell heredoc and check what reaches the Metal compiler.
  2. Run the LSD radix sort across all types and sizes, in both directions, and check ties.
  3. Check MurmurHash3 x86_32 against its reference vectors and count collisions among 100k hashes.

Finding 1: heredoc interpolation

Generated MSL written through a shell heredoc must use \(K) (one backslash) for Swift interpolation; a doubled backslash reaches the Metal compiler as literal text. Same newline rule as before for #define (Round 6, Finding 3).

Finding 2: the radix sort, and descending order

LSD radix sort with 8-bit digits, 4096-element blocks and a stable in-chunk ranking is correct across all types and sizes; descending order must invert keys rather than reverse the ascending result, or ties flip.

Finding 3: MurmurHash3 reference vectors

MurmurHash3 x86_32 reference vectors (seed 0):

Input Hash
"" 0
"a" 0x3c2569b2
"abc" 0xb3dd93fa
"hello" 0x248bfa47

Four collisions among 100k 32-bit hashes is normal (birthday bound), not a bug.

What shipped

Round 6 (2026-09-06): software IEEE-754 double on the GPU

TL;DR

  • Add, subtract and multiply on ulong with 3 guard bits and sticky are bit-exact against Swift's Double for 1M random pairs including subnormals, signed zeros, infinities and NaN.
  • A float-seeded Newton division was within 1 ulp only 93% of the time and wrong for subnormal inputs; restoring long division on the significands (57 quotient bits) replaced it.
  • Generated MSL fragments that start with a #define must begin with a newline, because Swift multi-line strings drop the final newline.
  • The Float64 GPU sum accumulates with d_add in tree order and differs from a sequential CPU sum only by normal floating-point reordering.

The setting

Float64 arithmetic implemented in MSL on ulong bit patterns, tested against Swift's Double.

Research steps

  1. Implement add, subtract and multiply with 3 guard bits and sticky; compare against Double on 1M random pairs including subnormals, signed zeros, infinities and NaN.
  2. Implement division as a float-seeded Newton iteration and measure how often it is within 1 ulp.
  3. Replace it with restoring long division on the significands, and test on ratios above and below 1.
  4. Fix the #define that glued to the previous line in the generated source.
  5. Sum Float64 on the GPU with d_add and combine the partials on the CPU.

Finding 1: add, subtract and multiply are bit-exact

Add, subtract and multiply implemented on ulong with 3 guard bits and sticky are bit-exact against Swift's Double for 1M random pairs including subnormals, signed zeros, infinities and NaN (d_finish handles normalisation, subnormal shift-with-sticky, round-to-nearest-even, and rounding carry).

Finding 2: division

A float-seeded Newton iteration was within 1 ulp only 93% of the time and wrong for subnormal inputs. Replaced with restoring long division on the significands (57 quotient bits). Two bugs on the way: one extra quotient bit shifted every result by 2x, and the restoring loop needs rem < mb before the first step (take the first quotient bit explicitly). Lesson: test division on ratios above and below 1.

Finding 3: a #define glued to the previous line

A preprocessor #define glued to the previous line (}#define) because Swift multi-line strings drop the final newline. Generated MSL fragments that start with a directive must begin with a newline.

Finding 4: the Float64 sum

Float64 sum on the GPU accumulates with d_add in tree order; per-threadgroup partials are combined on the CPU in Double. Results differ from a sequential CPU sum only by normal floating-point reordering.

What shipped

Round 5 (2026-09-06): pipeline creation on the paravirtual GPU, and lengths that stay on the device

TL;DR

  • The paravirtual GPU on GitHub runners fails makeComputePipelineState for arbitrary trivial kernels with no diagnostic while identical kernels pass in the same run. Mitigation: one retry, and GPU tests XCTSkip on devices whose name contains "Paravirtual".
  • Kernels now read their element count from a device buffer, so a filter followed by a sum is one round trip.

The setting

Round 4 left two things open: makeComputePipelineState failures on GitHub's paravirtual GPU, and the second round trip a sum() on a batched filter result paid to learn the filtered length.

Research steps

  1. Enable full compiler logs on the paravirtual GPU and compare the kernels that fail with those that pass in the same run.
  2. Add one retry on pipeline creation, and skip GPU tests on virtual devices.
  3. Move the element count into a device buffer and bind a pending filter's GPU-written total as that buffer.

Finding 1: the paravirtual GPU fails arbitrary trivial kernels

With full compiler logs enabled, the paravirtual GPU on GitHub runners fails makeComputePipelineState for arbitrary trivial kernels (bitmap_not, cast_kernel) with no diagnostic while identical kernels pass in the same run. It is the virtual Metal stack, not a construct. The retry in Sources/ArrowMetal/MetalContext.swift:

do { p = try device.makeComputePipelineState(function: fn) }
catch {
    // Virtualised GPUs (GitHub's "Apple Paravirtual device") fail pipeline creation sporadically; retry once.
    usleep(20_000)
    p = try device.makeComputePipelineState(function: fn)
}

Finding 2: lengths that stay on the device

Kernels now read their element count from a device buffer (device const uint* nPtr). A pending filter result binds its GPU-written total as that buffer, so compare / arithmetic / cast / bitmap ops / another filter / a reduction can consume it inside the same command buffer with worst-case dispatch sizes. Reductions still sync to read partials, but a filter followed by a sum is now one round trip.

What shipped

  • One retry on pipeline creation, quoted above.
  • GPU tests XCTSkip on devices whose name contains "Paravirtual" (ARROWMETAL_FORCE_GPU_TESTS=1 overrides): requireRealGPU() in Tests/ArrowMetalTests/TestSupport.swift. CI validates the build, interop and CPU paths; GPU correctness runs on real hardware.
  • device const uint* nPtr as the element count of every kernel, so pending lengths flow on the GPU.

Round 4 (2026-09-06): the paravirtual GPU on GitHub runners

TL;DR

  • GitHub's macos-15 Apple silicon runners expose an "Apple Paravirtual device" GPU with 3 CPU cores, ~16 GB/s for a sum. Use CI for correctness only; never publish its numbers.
  • The paravirtual GPU failed makeComputePipelineState for kernels that pass on M4 Max; ARROWMETAL_DEBUG_SHADERS=1 now dumps the generated MSL and the compiler log on failure.
  • The ~120 µs per-call floor is the round trip; batching is what removes it, and a sum() on a batched filter still paid a second trip, which Round 5 removed.

The setting

The first CI runs on GitHub's hosted Apple silicon runners, after the Round 3 push, and the per-call latency of a single dispatch on M4 Max.

Research steps

  1. Measure a sum on the macos-15 runner's GPU.
  2. Read the pipeline creation failures on the runner for kernels that pass on M4 Max, and add a way to see the generated source and the compiler log.
  3. Try makeCommandBufferWithUnretainedReferences plus a spin-wait against the per-call latency.
  4. Count the round trips of a sum() on a batched filter result.

Finding 1: the runner GPU

GitHub's macos-15 Apple silicon runners expose an "Apple Paravirtual device" GPU with 3 CPU cores: ~16 GB/s for a sum. Use CI for correctness only; never publish its numbers.

Finding 2: pipeline creation fails on the runner

The paravirtual GPU failed makeComputePipelineState for kernels that pass on M4 Max (round 3 push). ARROWMETAL_DEBUG_SHADERS=1 now dumps generated MSL and the full compiler log on failure; CI sets it.

Finding 3: the per-call floor

makeCommandBufferWithUnretainedReferences plus a spin-wait did not measurably reduce per-call latency; the ~120 µs floor is the round trip. Batching is what works: chains pay it once.

Finding 4: a reduction on a batched filter

A sum() on a batched filter result still costs a second round trip because the reduction needs the filtered length on the CPU. Done in Round 5 above: kernels read n from a device buffer so pending lengths flow on the GPU.

What shipped

  • ARROWMETAL_DEBUG_SHADERS=1, read in Sources/ArrowMetal/MetalContext.swift; the CI job in .github/workflows/ci.yml runs ARROWMETAL_DEBUG_SHADERS=1 swift test.
  • The rule that CI numbers are never published.

Round 3 (2026-09-06): integer division by zero, and the group-by atomics

TL;DR

  • Metal integer division by zero returns an unspecified value; ArrowMetal defines it as 0 in both the GPU kernels and the CPU reference, and Int.min / -1 wraps.
  • Threadgroup-privatised group-by with 32-bit atomics reaches ~300 GB/s for up to 1024 keys, the same rate as a plain sum; device atomics at 100k keys halve it.
  • Crossing the C boundary from Python, and exporting a result back to pyarrow, cost nothing measurable; importing pyarrow buffers is one memcpy.

The setting

The arithmetic kernels, the group-by, and the Python binding's path across the C boundary.

Research steps

  1. Observe what Metal returns for integer division by zero, and define it the same way in the GPU kernels and the CPU reference.
  2. Measure the threadgroup-privatised group-by at up to 1024 keys and with device atomics at 100k keys.
  3. Time a call across the C boundary from Python, with and without exporting the result to pyarrow.
  4. Check the alignment of pyarrow's buffers against what a zero-copy import needs.

Finding 1: integer division by zero

Metal integer division by zero returns an unspecified value (observed 1 on M4 Max). ArrowMetal now defines it as 0 in both the GPU kernels and the CPU reference, and Int.min / -1 wraps instead of trapping. The generator in Sources/ArrowMetal/Kernels/KernelSource.swift:

/// Integer division by zero yields 0 (defined here, matching the CPU reference); overflow wraps.
if name == "div" && isInt {
    sclArray = "(b[i] == (\(T))0) ? (\(T))0 : a[i] / b[i]"

Finding 2: the group-by atomics

Threadgroup-privatised group-by with 32-bit atomics reaches ~300 GB/s for up to 1024 keys, the same rate as a plain sum: the atomics are not the bottleneck at that key count. Device atomics at 100k keys halve it.

Finding 3: the C boundary from Python

Crossing the C boundary from Python costs nothing measurable per call (ctypes overhead is ~10 µs); exporting a result back to pyarrow costs nothing measurable:

Call Time
without export 3.55 ms
with export 3.57 ms

Finding 4: importing pyarrow buffers

Importing pyarrow buffers is one memcpy: pyarrow's allocator is 64-byte aligned, not page aligned.

What shipped

  • Division by zero defined as 0 in the GPU kernels and the CPU reference; Int.min / -1 wraps.
  • The threadgroup-privatised group-by.

Ecosystem research (2026-09-06)

TL;DR

  • No existing Arrow library computes on Metal; the pieces that exist are types, IPC, device buffer wrappers and tensors.

What exists

  • apache/arrow-swift v21: types, IPC, Flight, C Data Interface. No compute kernels, nothing Metal.
  • arrow-nanoarrow device extension: C wrapper for ARROW_DEVICE_METAL buffers, no compute.
  • cuDF: CUDA only. MLX: unified-memory tensors, zero-copy MTLBuffer access, no nulls or columnar semantics.
  • A DuckDB extension with Metal aggregates exists (gpudb); it does not use Arrow buffers.

The device interface

  • The Arrow C Device Data Interface defines ARROW_DEVICE_METAL = 8 and expects MTLEvent* as sync_event.

Toolchain

TL;DR

  • Run tests with Xcode's DEVELOPER_DIR and always in release as well as debug.
  • Swift 6.3.3 miscompiles a withUnsafeBytes closure in a generic throws function under -O (swiftlang/swift#90477, open); MetalArray.init(_:) uses a plain loop instead.
  • The macos-15 runner's Swift 6.1 refuses closures Swift 6.3 accepts; release builds shorten object lifetimes; posix_memalign memory is not zero.

Running the tests

  • XCTest is not in the Command Line Tools. Run tests with DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer.
  • Always run swift test -c release.

Compilers

  • Swift 6.3.3 miscompiles a withUnsafeBytes closure inside a generic throws function under -O when the caller is in another module: the closure clobbers the error register around an Objective-C message send, so the function reports a phantom error and the caller crashes retaining it (swiftlang/swift#90477, open). MetalArray.init(_:) uses a plain element loop instead; the Metal-free reproducer is in UPSTREAM.md.
  • GitHub macos-15 runners come with Xcode 16.4 / Swift 6.1, which refuses to type-check dense closures that Swift 6.3 accepts. Keep test expressions simple.

Memory

  • Swift release builds shorten object lifetimes to last use: a raw pointer taken from a buffer object can outlive the object. Use withExtendedLifetime or the closure accessors.
  • posix_memalign memory is not zero for small blocks (recycled heap); only fresh mmap pages are zero.

Metal: what the M4 Max gives a kernel

TL;DR

  • 32 KB threadgroup memory, SIMD width 32, unified memory, no 64-bit atomic add from MSL.
  • Runtime makeLibrary(source:) needs no metal toolchain; makeBuffer(length:) buffers are not page aligned.
  • Memory-bound element-wise kernels run at ~375 GB/s against ~350 GB/s on the 16-core CPU; reductions and compaction favour the GPU by 2x to 3x.

The hardware

  • Apple M4 Max: 32 KB threadgroup memory, SIMD width 32, unified memory, no 64-bit atomic add from MSL (atomic_ulong fetch_add fails to compile; min and max do). 64-bit sums use split 32-bit atomics with carry.

The API

  • Runtime makeLibrary(source:) works without the metal toolchain. Errors carry line numbers of the generated source.
  • makeBuffer(length:) buffers are heap sub-allocated and not page aligned; bytesNoCopy requires page alignment of pointer and length.

Bandwidth

  • Memory-bound element-wise kernels run at ~375 GB/s on M4 Max once allocation overhead is removed; the 16-core CPU reaches ~350 GB/s on the same loops. Reductions and compaction favour the GPU by 2x to 3x.
  • Fusing the predicate into the filter's counting pass avoids materialising a boolean array.
docs/UPSTREAM.md

Upstream tracker

What ArrowMetal's differential matrix and its platform work found in other projects, and where each report stands. A row is added when a finding is confirmed by a test in this repository, and updated when the report is filed, answered or fixed. Nothing here is a complaint about another project: every entry is a divergence the matrix has to carry, and each has a test that fails on the day upstream fixes it, so the workaround can be removed.

Merged in Apache Arrow from this work: in C++, utf8_normalize composes for NFC and NFKC (apache/arrow#51237, 26.0.0); in Go, the scalar aggregate framework with the count and sum kernels (apache/arrow-go#1336), the allocator alignment docs (#1302), the registry docs (#1303), the NewDatumWithoutOwning contract (#1320) and the ArraySpan.SetSlice null-count fix behind the chunked Kleene results (#1306). Fixed by others from our reports: the boolean fill-null offset (apache/arrow#51240, 26.0.0) and winsorize on a sliced array (apache/arrow#51251). Listed in Apache DataFusion's user guide: datafusion-arrowmetal on the integrations list (apache/datafusion#26004, merged 2026-10-09).

Project Finding Our evidence Report Status
pyarrow 25.0.1 pc.utf8_normalize decomposes whatever form says (its NFC output is NFD) test_pyarrow_utf8_normalize_ignores_its_form_option filed 2026-09-08: apache/arrow#51225; fixed by our apache/arrow#51237, merged 2026-09-10 fixed in 26.0.0; the matrix moved to pyarrow 26.0.0 on 2026-10-15, its oracle for utf8_normalize is pc.utf8_normalize again and the pinning test is retired
pyarrow 25.0.1 pc.pairwise_diff on a sliced array ignores ArrowArray.offset test_pyarrow_pairwise_diff_ignores_the_array_offset reported by others: apache/arrow#50524, fixed by #50858 fixed in 26.0.0; the matrix moved to pyarrow 26.0.0 on 2026-10-15, hands the oracle the slice itself again and the pinning test is retired
pyarrow 25.0.1 pc.fill_null_forward, pc.fill_null_backward, pc.replace_with_mask on a sliced boolean array ignore the offset test_pyarrow_boolean_fill_null_forward_ignores_the_array_offset filed 2026-09-08: apache/arrow#51223; fixed by a contributor's apache/arrow#51240, merged 2026-09-23 fixed in 26.0.0; the matrix moved to pyarrow 26.0.0 on 2026-10-15, hands the oracle the slice itself again and the pinning test is retired
pyarrow 25.0.1 pc.winsorize on a sliced array nulls the wrong rows and places values where the input has nulls test_pyarrow_winsorize_ignores_the_array_offset filed 2026-09-08: apache/arrow#51224; fixed by a contributor in apache/arrow#51251, merged 2026-09-28 fixed in 26.0.0; the matrix moved to pyarrow 26.0.0 on 2026-10-15, hands the oracle the slice itself again and the pinning test is retired
pyarrow 25.0.1 pc.binary_slice with its own default stop raises ArrowInvalid: Negative buffer resize test_pyarrow_binary_slice_overflows_on_its_own_default_stop filed 2026-09-08: apache/arrow#51222; the same overflow was fixed for utf8_slice_codeunits in #36575, the binary path was not; a contributor's fix apache/arrow#52451 opened 2026-10-08 filed, open; taken by a contributor 2026-09-16, fix pull request under review (changes requested 2026-10-13)
pyarrow 25.0.1 pc.year_month_day and pc.iso_calendar crashed the process in the first matrix run the matrix never calls them; temporal_struct compares field by field (Evaluation method) withheld: not reproduced on 2026-09-07 under guard malloc unexplained, observed once
pyarrow 25.0.1 A filtered Parquet read drops NaN rows when the filter is true for NaN (!=, negated comparisons, is_nan when min == max, is_null(nan_is_null=True), isin([nan])) and the file has row-group statistics: min/max leave NaN out and the statistics guarantee assumes every value is inside them test_not_equal_keeps_a_nan_row_group_where_pyarrow_drops_it (python/tests/test_parquet_nested.py) filed 2026-09-24: apache/arrow#51491 filed, open
Arrow (vendored date library) DST rules stop being applied after the last tabulated transition (2037), so a summer instant in 2050 gets the winter offset, on a fat TZif file finding 14 in Evaluation method, 144 matrix cases tracked on apache/arrow#36110 (open since 2023), fat-file reproduction added 2026-09-08; #42157 covers slim files open on #36110
Apple Metal the Feature Set Tables say the Apple9 family has "the full set of 64-bit atomic operations", but the shading-language headers admit device ulong only for atomic min and max: no 64-bit add, compare-exchange, load or store compile, whatever language version is asked for (measured on an M4 Max, macOS 26.6.2). Grouped sums and hash tables carry 32-bit workarounds because of it Decisions, Design; the probe is attached to the report filed 2026-09-20 through Feedback Assistant as FB24858110 (summary), with the compile probe and the three kernels that carry the workarounds filed, open
Apple Metal makeComputePipelineState fails sporadically on the "Apple Paravirtual device" GitHub-hosted runners expose retry in MetalContext.pipeline; two complete CI logs (runner image macos-15-arm64 20260829.0321.1): 110 pipeline-creation failures across 23 kernels in the swift test step of one run; in the other, 0 in swift test (31 of 34 tests skipped) and one in the bench step (gb_accumulate_priv); makeLibrary succeeding every time filed 2026-09-20 through Feedback Assistant as FB24858160 (summary), with both CI logs in full filed, open
Apple Metal a persistent GPU worker polling shared memory cannot be made to work: CPU/GPU coherence and the driver's submit-and-notify floor The persistent-kernel result, two measured probes filed 2026-09-20 through Feedback Assistant as FB24858235 (summary), as a suggestion: a system-scope coherence primitive or a documented sub-20 µs submit path filed, open
Apache Arrow JS no C Data Interface export or import in the JavaScript library, so the Node binding builds the ArrowArray/ArrowSchema structs itself from a Vector's buffers; and Data.slice advances the values and offsets buffers but not the validity bitmap, leaving the row offset in Data.offset, which a C Data consumer must undo node/src/addon.cc (the rewind on import, the advance on export); TypeScript / Node.js tracked on apache/arrow-js#88 (open since 2022); the addon's mapping and an offer to build import and export were posted 2026-09-08 and 2026-09-10 open on #88, offer to build it posted
arrow R 25.0.0 the C Data Interface entry points (Array$export_to_c, Array$import_from_c) exist but the only allocators for the structs they take (allocate_arrow_array(), allocate_arrow_schema()) are unexported and none of the four has an Rd topic (133 topics, none matching import/export), so a third-party package must use ::: or allocate the structs in its own C r/arrowmetal/src/shim.c allocates them itself; R tracked on apache/arrow#39793 (open since 2024); our apache/arrow#51236, opened 2026-09-08, exports and documents the allocators and the two methods pull request reviewed 2026-09-16; the maintainer's choice of scope (export the allocators, or document the methods and point packages at nanoarrow) is open
arrow R 25.0.0 Array$create() on an R double or integer vector borrows R's memory, whose data begins 48 bytes into a page-aligned allocation, so the values buffer sits at offset 48 mod 64: below the columnar spec's recommended 64-byte alignment and below the page alignment a GPU consumer needs, invisible to R users; measured 20/20 at 1M and 10M (am_buffer_alignment()) R; the import copy costs 1.39 ms (median) per 80 MB filed 2026-09-08: apache/arrow#51227 closed the same day by the R maintainer as expected behaviour (not planned)
Apache Arrow Go v18.7.0 the compute package registers 85 scalar and vector functions and no aggregate at all (no sum, mean, min_max, count, no hash_*), and its package doc does not say so; every reduction and group-by oracle in the Go binding had to be a plain Go loop go/arrowmetal TestArrowGoHasNoAggregates; Go filed 2026-09-08: apache/arrow-go#1296; docs apache/arrow-go#1303 merged 2026-09-08; the scalar aggregate framework with count and sum, apache/arrow-go#1336, merged 2026-09-25; docs follow-up apache/arrow-go#1338 merged 2026-10-05 filed, open; arrow-go main has its first two scalar aggregates since 2026-09-25
Apache Arrow Go v18.7.0 no allocator promises an alignment: NewGoAllocator buffers inherit Go's 8 KiB spans, so a buffer starts at offset 0 or 8192 within a 16 KiB page and page-aligned import is a coin flip whose odds depend on heap fragmentation (measured between 5/20 and 15/20 across runs); memory/mallocator (libc, NewMallocatorWithAlignment) does give a chosen alignment, which the report as first written missed, so the remaining ask is the documentation. Related and already closed: apache/arrow-go#70 (the C Data Interface storing unpinned Go pointers, which runtime.Pinner addresses) TestAllocatorAlignment; arrowmetal.PageAlignedAllocator is the binding's own answer to alignment filed 2026-09-08: apache/arrow-go#1297; the doc paragraph the maintainer asked for, apache/arrow-go#1302, merged 2026-09-08 fixed upstream, issue closed
Apache Arrow Go v18.7.0 compute.NewDatumWithoutOwning returns the same Datum type as an owning datum, so an over-release compiles and frees buffers under a live array with no error; its doc comment is truncated (datum.go:278, "convenience function.+-") reproduced while writing the Go timing harness; Go filed 2026-09-08: apache/arrow-go#1298; our docs apache/arrow-go#1320 merged 2026-09-21 (commit 6f2e895), stating the contract for both call patterns filed, open; the docs merged 2026-09-21; the type question stays on the issue, where a contributor proposed a non-owning datum whose Release() is a no-op
Apache Arrow Go v18.7.0 and_kleene and or_kleene return false where the answer is null when ExecCtx.ChunkSize is below the input length (same code on main 281776d): ArraySpan.SetSlice carries a cached null count into the next slice, and the Kleene kernels write the slice's count into the shared span the reproducer attached to apache/arrow-go#1305 (fails on main at ChunkSize 1 and 2; passes with the one-line SetSlice fix, whole compute suite still green) filed 2026-09-08: apache/arrow-go#1305 with the reproducer; the one-line fix with a chunk-size test is our apache/arrow-go#1306, reviewed 2026-09-15 (the ASF header asked for was added the same day), approved and merged 2026-10-13 (commit 7090f9e); the issue was closed with it fixed upstream on main by our #1306, not in a release yet; the test retires when the Go module moves to the release that carries it
Swift 6.3.3 in a generic throws function compiled at -O in its own module, the closure passed to withUnsafeBytes uses the error register x21 as scratch around an Objective-C message send and exits by tail-calling memmove without restoring it, so a function with no reachable throw reports a phantom error and the program crashes retaining it. ArrowMetal's array initialiser hit it and carries a plain-loop workaround MetalArray.init(_:), Findings against pyarrow; the two-module Metal-free reproducer posted on swiftlang/swift#90477 (-O fails, -Onone passes) reported by others: swiftlang/swift#90477, where the two-module Metal-free reproducer was added 2026-09-08; related #90874 open upstream, reproducer posted

Nothing found in pandas, numpy, Polars or DuckDB is their defect: the costs the matrix measures there are crossings and idioms, not bugs.

Checked and found not to be a defect

Kept so the same question is not asked twice.

Project What looked like a limitation Why it is not one
arrow-rs 59.3 max of a float column with NaN returns NaN while min returns the smallest non-NaN Its documented total order puts NaN above every value (apache/arrow-rs#101, closed 2022); pyarrow and ArrowMetal skip NaN instead. A cross-implementation divergence, pinned by rust/arrowmetal/tests/compute.rs and tabled in Rust
arrow-rs 59.3 values buffers are page aligned only from about 64 KiB upward That is the macOS allocator's mmap threshold, not arrow-rs; measured in rust/arrowmetal/tests/copy_rule.rs, which prints the table for the reader's machine
bit64 as.vector() on an integer64 reinterprets the bit pattern as a double Documented in ?bit64::integer64 ("Limitations inherited from Base R"); as.numeric() is the route, named in R
arrow R 25.0.0 an int64 scalar comes back as base integer when it fits 32 bits and bit64::integer64 otherwise Documented (arrow.int64_downcast); the package takes an integer64 = argument because of it
cargo 1.95 a build script's rustc-link-arg does not reach dependents, so the safe crate needs its own three-line build.rs for the rpath By design (rust-lang/cargo#9554, closed after an FCP); the DEP_<links>_ mechanism is the sanctioned route and is what the crate uses

Where each report goes

Project Route Public tracker
Apache Arrow (pyarrow, the vendored date library) GitHub issues, and a pull request when the fix is ours github.com/apache/arrow
Apache DataFusion GitHub issues and pull requests; the user guide's integrations list is where community crates are recorded (apache/datafusion#26004, merged 2026-10-09) github.com/apache/datafusion
Swift compiler GitHub issues, pull requests welcome github.com/swiftlang/swift
Apple Metal, the shading language, the driver Feedback Assistant (private; the FB number is what goes in the Report column, and Reported to Apple carries a readable summary of each report), the Apple Developer Forums for a public thread, a Developer Technical Support incident or a WWDC lab for a conversation none: Metal is not open source and has no public issue tracker
Apple's open-source projects that touch this work (MLX, Swift packages) GitHub, like any other project github.com/ml-explore/mlx, github.com/apple

How a row moves

  1. open: confirmed here, test in place, nothing filed.
  2. filed: the report link goes in the Report column with the date.
  3. fixed in X: upstream released the fix; the test that pins the workaround starts failing on that version and is retired with it, and the row stays as history.
docs/APPLE_REPORTS.md

What was reported to Apple

Metal has no public issue tracker, so the three findings about it went to Apple through Feedback Assistant on 2026-09-20. Feedback Assistant is private: the reports, their attachments and any reply are visible only to Apple and to the account that filed them. This page is the readable record of what each report says, what was measured, and what it asks for, so that nobody has to take the tracker's one-line summaries on trust. The measurements behind each one are in the repository; the probes can be run by anyone with an Apple silicon Mac.

Report Type Reference Status
64-bit atomics: the documentation and the compiler disagree incorrect or unexpected behaviour FB24858110 open, no similar reports at filing
Pipeline creation fails sporadically on GitHub's virtual GPU incorrect or unexpected behaviour FB24858160 open
A running kernel cannot reliably see CPU stores suggestion FB24858235 open

Every number below is from an Apple M4 Max on macOS 26.6.2 (25G83) with Xcode 26.6, unless the text says otherwise. The rows move in Upstream tracker when Apple answers.

1. 64-bit atomics: the Feature Set Tables promise more than the shading language accepts (FB24858110)

What the documents say. Apple's Metal Feature Set Tables list 64-bit atomics for the Apple9 GPU family, which the M4 series belongs to, and state in a footnote that "the full set of 64-bit atomic operations is supported on all platforms starting with Apple9".

What the compiler accepts. Exactly two 64-bit atomic operations, on device memory only: atomic_min_explicit and atomic_max_explicit on device atomic_ulong, void-returning, relaxed order. Every other 64-bit atomic is a compile error at every language version from 3.1 to 4.0: fetch-add, fetch-sub, the value-returning min and max, exchange, compare-exchange, load and store, and there is no signed atomic_long. The shipped header agrees with the compiler, and so does the shading-language specification's own table of 64-bit atomic functions, which lists min and max and nothing else. So either the hardware has only min and max and the Feature Set Tables overstate it, or the hardware has more and the language does not expose it. Neither document says, per GPU family, which operations exist. A 64-bit add cannot be built from parts either, because there is no 64-bit compare-exchange to loop on.

How it was measured. A short Swift program with no dependencies compiles one tiny kernel per operation at three language versions and prints the compiler's verdict for each, then runs the two operations that do compile over ten million elements and checks their results. It was run on the day of filing with the same outcome as two weeks earlier. The program and its output were attached.

What it costs this project. Every 64-bit accumulation in the engine carries a workaround: an int64 sum per group is a split 32-bit add with an explicit carry; a hash table publishes a 32-bit row index because a 64-bit key cannot be published atomically; and grouped variance and standard deviation, which accumulate in software binary64, cannot use per-group atomic accumulators at all and run a counting sort by group first. On the matrix's 50-million-row, 1,000-group rows the grouped sum, where the carry trick suffices, is 3.8x ahead of pyarrow's threaded engine, while grouped variance, which cannot use the trick, is level with it. The design notes are in Design and Decisions.

What was asked. On Apple9, the operations the footnote already claims. Failing that, a precise per-family statement of which ulong operations exist, in place of "the full set".

2. Pipeline creation fails sporadically on the virtual GPU of GitHub's macOS runners (FB24858160)

What happens. On GitHub-hosted macOS runners the Metal device is an "Apple Paravirtual device". Compiling a shader library from source succeeds every time. Creating a compute pipeline for a function from that library then fails at random, with the error text "Compilation failed" and no other detail, for ordinary kernels that succeed on the same runner image in other runs and in the same process moments later.

How it was measured. Two complete CI logs were attached, from runner image macos-15-arm64 version 20260829.0321.1 with Xcode 16.4. In the first run, one test process saw 110 pipeline-creation failures across 23 distinct kernels and no library-compilation failure; the most frequent was a filter kernel, 64 times. In the second run the engine retried each creation once after 20 ms and skipped GPU tests on virtual devices; the test step then created few pipelines and passed, and the benchmark step, which still created pipelines, failed on a group-by kernel even with the retry. On real Apple silicon the same source has never produced one such failure across every local run of the full suite.

What it costs. GPU code cannot be validated on GitHub-hosted macOS runners. This project's CI proves only the build, the language interop and the CPU paths there; every GPU test and benchmark runs on local hardware, and the test harness skips GPU tests when the device name contains "Paravirtual". Any project that uses Metal compute in hosted CI meets the same failure, with an error that gives it nothing to act on.

What was asked. Deterministic pipeline creation for a function from a library the same device just compiled; or, if the virtual device cannot support some construct, an error that names it and a device property that reports the limitation before any work is submitted.

3. A running kernel cannot reliably observe CPU stores to shared memory (FB24858235)

Why it matters. Small operations on the GPU are dominated by the fixed cost of a dispatch, about 60 microseconds of submission and completion notification on this machine. The standard escape on other platforms is a persistent worker: one long-running kernel whose threads spin on a ring of work descriptors in shared memory, so that submitting work is a CPU store and collecting a result is a CPU load. That needs a CPU store to become visible to a running kernel within a bounded time, and the reverse.

How it was measured. A standalone Swift program compiles one kernel per memory qualifier the shading language offers, from plain pointers through volatile, coherent(device), device-scope atomics and device-memory barriers. One threadgroup spins on a doorbell word in a shared-storage buffer for three seconds while the CPU increments that word every 5 ms, and the kernel writes a heartbeat the CPU samples. The program was run five times across three days.

What it found. Visibility is sporadic and has no bound. In one run no store was seen under any qualifier. In another, 24 of 433 stores were seen under one qualifier and a handful under others, the first after 160 ms or more. In another, a single store of 598. The heartbeat in the other direction arrived anywhere from 129 ms to 2.3 s, or never within the window. The behaviour is consistent with visibility arriving only when a cache line happens to be evicted, which nothing in the language can request. coherent(device) is the widest scope the shading language has, there is no system scope, and there is no synchronisation call that can run in the middle of a dispatch. The full study, including the measurement of where the 60 microseconds go and the finding that the GPU watchdog is not the obstacle, is The persistent-kernel result.

What was asked. Either a system-scope coherence primitive in the shading language, with a stated visibility bound, so that a running kernel can observe CPU stores on a shared-storage buffer; or a documented, explicitly costed way to do the same without a command-buffer boundary; or, failing both, a documented submit-and-notify path under 20 microseconds, which removes the motive.

What is not in these reports

No attachment on this page. The reports carried the probe programs, their outputs, the CI logs and the kernel sources that hold the workarounds; all of those are either in this repository already or reproducible from it. The reports contain no personal data beyond the filing account Apple requires.

docs/TESTING.md

Testing

How ArrowMetal is tested, what each layer compares against, and what "green" means before anything is pushed. Each row names the run its numbers are from, on an M4 Max.

Layer Size Oracle
Swift suites (Tests/ArrowMetalTests) 1,046 tests in 87 files, run in release (all 1,046 executed, 13 skipped in the run of 2026-10-02 at 5ef0320: the three opt-in throughput measurements and the ten 4 GiB-index tests behind ARROWMETAL_BIG_TESTS, which passed in a run with it set) plain-Swift CPU references, hand-computed vectors, pyarrow 25.0.1 answers pinned as literals where noted
Python suites (python/tests) 7,188 collected cases over the ctypes API, the integrations and the readers in the run of 2026-10-02 at 245990d (7,159 passed and 29 skipped; the differential matrix in test_differential.py is counted in its own row) pyarrow.compute, Polars, DuckDB, pandas
Rust suites (rust/arrowmetal/tests, rust/arrowmetal-sys) 66 tests, 64 in the safe crate against arrow-rs plus 2 in arrowmetal-sys over the raw ABI, run in release, plus 5 no_run doc-tests (compiled, not executed); all passed on 2026-10-02 at 245990d arrow::compute (arrow-rs 59) on the same data; a HashMap fold where arrow-rs has no kernel; include/arrowmetal.h re-parsed for the ABI signatures (Rust)
Differential matrix (python/tests/test_differential.py, differential_report.py) generated cases over 46 column types (utf8 also imported as utf8_view), every public operation; the counts of the recorded run, in total and per Arrow function, are generated into Coverage summary pyarrow.compute, option by option (Evaluation method)
Engine conformance grid (python/tests/engine_report.py, engine_polars_grid.py, engine_duckdb_grid.py) 15,376 Polars cases and 33,376 DuckDB queries, generated over shapes, dtypes, null patterns and sizes (Polars run of 2026-10-02 on Polars 1.44.1, DuckDB run of 2026-10-04 on DuckDB 1.5.6: 0 unclassified; 33 documented, all float summation order) Polars' own lf.collect(); DuckDB with the rewrite off (Coverage summary)
TypeScript suites (node/test) 84 tests in 9 files over the N-API addon (84 passed on 2026-10-02) Apache Arrow JS 21.2.0 and plain JS over the same rows (TypeScript / Node.js)
Go binding (go/arrowmetal) 66 test functions and three Examples, 69 runnable, and 515 subtests: 584 passing results as go test -count=1 -v ./... reported them on 2026-10-02, 0 failed, run three times (plain, under -race, and under the cgo pointer checker, GOEXPERIMENT=cgocheck2, with the same counts) arrow-go/v18's own compute where it has the function, plain Go loops where it does not (Go)
R suites (r/arrowmetal/tests/testthat) 88 test_that() blocks in the sources (89 as testthat runs them: the one in test-dispatch.R runs once per attach order), 920 expectations (0 failed, 0 skipped on 2026-10-02; the four blocks in test-carriers.R cover the ArrowArray/ArrowSchema carriers the shim allocates: release on drop without import, refusal of a moved or unfilled pair, tag checks), over the 42 ABI entry points the R binding wraps base R and the arrow R package's own kernels on the same data (R)
Review pass before release the integrations, the engine, the kernels, the C ABI and the readers each finding carries a regression test
Benchmarks (Benchmarks/) 339 operation-and-size rows over 173 operations, against four CPU libraries in two idioms each — the plain eager one and the most parallel one that library has for the same answer (polars-lazy, pyarrow-threaded); streaming and engine benches measured, never estimated; the baseline is the fastest idiom of any library, and against it 145 rows are at or above 3x, 102 between 1x and 3x, 77 to improve, where the fastest CPU idiom is ahead, and 15 without a CPU equivalent (The full matrix, Where ArrowMetal needs to improve)

Run everything:

export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
swift build -c release
# release is required: a release-only miscompile has bitten this project once
swift test -c release
# all Python suites, including the differential file
PYTHONPATH=python python -m pytest python/tests -q
# the matrix as one report; exit 0 = nothing unclassified
PYTHONPATH=python python python/tests/differential_report.py
# both engines against their hosts; exit 0 = nothing unclassified (needs the DuckDB extension built)
PYTHONPATH=python python python/tests/engine_report.py
# TypeScript: builds the addon, then 84 node:test cases
(cd node && npm install && npm test)
# the Rust binding, against arrow-rs's own kernels
(cd rust && cargo test --release)
# the Go binding
(cd go/arrowmetal && ARROWMETAL_LIB=$PWD/../../.build/release/libArrowMetalC.dylib go test ./...)
# and with the cgo pointer checker
(cd go/arrowmetal && ARROWMETAL_LIB=$PWD/../../.build/release/libArrowMetalC.dylib GOEXPERIMENT=cgocheck2 go test -count=1 ./...)
# the R binding
ARROWMETAL_LIB=$PWD/.build/release/libArrowMetalC.dylib \
  Rscript -e 'testthat::test_local("r/arrowmetal")'

The R and Go bindings compile against copies of include/arrowmetal.h and include/arrow_abi.h (r/arrowmetal/src/, go/arrowmetal/include/); when a header changes, copy it over both, and python/tests/test_header_copies.py fails until you do.

The Rust suite finds libArrowMetalC.dylib in .build/release on its own; from outside the repository, set ARROWMETAL_LIB to the dylib's full path.

The R suite needs R with arrow and testthat; R CMD INSTALL r/arrowmetal first, or point ARROWMETAL_LIB at the dylib as above. A conda-built R names its own compiler in Makeconf, so activate the environment (or put its bin on PATH) before installing; setting CC = clang in ~/.R/Makevars to use Xcode's clang works too. R CMD check --no-manual on the built tarball is the fuller check; the recorded run is the testthat suite, not R CMD check, so no R CMD check result is claimed here.

Tests that need a real GPU skip on virtual Metal devices (requireRealGPU()), so a hosted CI runner exercises the host paths only; the numbers above are from a physical Mac.

The router and the suites. The CPU/GPU router (Design) sends small inputs of seven operations to a CPU loop under its default auto mode, and the suites are full of small inputs. So three harnesses pin the router to the GPU and keep exercising the kernels: Swift's TestSupport.swift sets the process mode to gpu from requireRealGPU(), which the GPU test files call (a test that runs a routed operation without calling it runs under auto); Python's python/tests/conftest.py sets it for every pytest run; and python/tests/differential_report.py, a plain script that pytest's conftest does not reach, sets it itself, and prints the mode in its header. ARROWMETAL_ROUTER, when set, wins over each pin, so the same suites run every routed operation through its CPU loop with

ARROWMETAL_ROUTER=cpu swift test -c release
ARROWMETAL_ROUTER=cpu PYTHONPATH=python python -m pytest python/tests -q
ARROWMETAL_ROUTER=cpu PYTHONPATH=python python python/tests/differential_report.py

The Swift and Python harnesses also pin the shipped crossover table, so a per-machine table this Mac may hold in ~/.arrowmetal/router/ (python -m arrowmetal.router calibrate, The crossover) does not change what the suites check; ARROWMETAL_ROUTER_TABLE, when set, wins.

and a green run there means the CPU loops give the answers the GPU tests expect. The router's own suites (RouterTests, test_router.py) choose the path per call and run gpu, cpu and auto regardless of either setting.

The other bindings' suites are not pinned: the Go tests (go/arrowmetal/*_test.go), the Rust crate's tests (rust/arrowmetal/tests), the R package's testthat suite and the Node tests (node/test) call the C ABI in their own processes under the process default auto, so a routed operation they run on an input below its crossover runs the CPU loop. Run them with ARROWMETAL_ROUTER=gpu to exercise the GPU kernels, and with ARROWMETAL_ROUTER=cpu for the CPU loops. The Polars plugin and the DuckDB extension are tested from pytest (test_polars.py, test_duckdb.py); outside it they, too, run under auto unless ARROWMETAL_ROUTER says otherwise.

1. Swift suites

A CPU reference sits behind the kernels (Sources/ArrowMetal/CPUReference.swift and per-suite oracles) and each is compared against it across types, sizes, null densities and input shapes. Sizes are chosen to cross every threadgroup and simdgroup boundary (0, 1, 31, 32, 33, 1023, 1024, 1025, 65535, 65536, 65537, and odd lengths above a million), null ratios are 0, 0.3 and 1.0, and every selection entry point is run on a sliced input at offsets 1, 7, 31, 32, 33, 63 and 64 against the same rows built standalone.

Suite family Files What is pinned
Kernels and types KernelTestsV2, ArrowMetalTests, TypesExtraTests, DecimalTests, NestedTests, StructuralTests, SliceOffsetTests, ConditionalTests, CheckedTests every reduction, arithmetic, compare, cast, filter/take/slice, conditional and checked kernel on every type, with nulls, against the CPU reference; nested, decimal, dictionary and run-end arrays
Software binary64 DoubleTranscendentalTests, TrigTests, MathKernelTests, MathExtraTests sqrt correctly rounded, exp/ln/log2/log10/power within 1 ulp, trigonometry within 4–5 ulp of the host libm, over random and edge-case inputs
Sorting and selection TopKTests, SegmentedTests, StringSortTests, SelectionExtraTests, PartitionNthNaNTests, AdversarialRankTests stable radix sort in both directions, null placement, IEEE total order, top-k for every k against the full sort, rank with all four tiebreakers against pyarrow's answers, partition_nth with NaN
Hashing and grouping HashTableTests, StringHashTableTests, UniqueTests, GroupByTests, GroupByKeysTests, AggregateTests, AggregatesExtraTests, WindowTests unique/value_counts/dictionary_encode, dense and arbitrary-key group-by, grouped moments, first/last, window functions; crafted hash collisions (1,000 keys folding to one 32-bit value)
Strings StringTests, StringExtraTests, StringTransformTests, TextTests, RegexPrefilterTests lengths, predicates, MurmurHash3 vectors, LIKE with every wildcard shape, split, full-Unicode case mapping including byte-length changes, the regex pre-filter's literal claims
Temporal TemporalTests, TemporalExtraTests, OptionsTests calendar fields, rounding with every RoundTemporalOptions flag, timezone transitions, strftime/strptime
Execution model BatchTests, AsyncTests, ResidentTests, AdversarialStringAndBatchTests batched chains equal unbatched, a throwing body leaves no batch open, results readable after commit, four workers over two contexts; the persistent-kernel negative result
Interop ArrowMetalTests, KernelTestsV2, SliceOffsetTests, NestedTests, IPCTests, IPCViewTests C Data / C Device / C Stream import and export with offsets and release callbacks, IPC round trips, struct and stream import; every type the IPC writer emits — decimals, float16, fixed-size binary, the three interval units, null, the three list layouts, struct, map, both unions, run-end encoded and extension columns — written from ArrowMetal and read back by pyarrow in both encapsulations, and each one written by pyarrow and read back by ArrowMetal value for value, with nulls, empty arrays, sliced inputs and nested nulls (a null row, an empty row, a null element, a null struct whose children are not null, a null map); LZ4 and ZSTD compressed bodies; a replacement and a delta dictionary in a stream; a batch declaring a buffer the schema does not use, rejected; the view types as pyarrow writes them (flat, nested in struct and list, sliced, with nulls, out-of-order list views, LZ4 bodies, batches past one 64K-row block) read and written back as classic types; Arrow's big-endian integration files against their little-endian twins and pyarrow; arrow.fixed_shape_tensor round trips with pyarrow, its shape product checked for overflow; other extension names read as their storage; tensor messages and offsets past 2 GB refused with their messages
Expression compiler and engine ExprTests, ExprLiteralTypeTests, EngineTests, JoinTests fused expressions against the unfused kernels, literal promotion, every optimizer rule against the unoptimized plan, six join kinds and as-of against a dictionary oracle
Parquet ParquetTests, ParquetWriterTests, ParquetNestedTests, ParquetArrowSchemaTests, ParquetPageIndexTests, ParquetFilterEdgeTests, ParquetBloomFilterTests pyarrow-written fixtures across encodings and compressions, damaged files that must error rather than trap; nested structs, maps and lists from pyarrow, DuckDB and Polars against the generator's formulas and against each other; the stored Arrow schema's types and metadata; page skipping with identical matches with and without the index, != over a NaN hidden in a constant float page, uint64 statistics past the int64 range, the null type below repeated fields; bloom filters against xxHash64's published vectors
Delta Lake and Iceberg LakehouseTests the 62 reads in Tests/Fixtures/lakehouse/expected.json (what deltalake 1.6.5 and pyiceberg 0.12.0 returned for every version, snapshot, projection and filter recorded there) replayed row for row; checkpoint against full log replay, log cleanup, multi-part checkpoints, column mapping, the refused reader features and delete files by name, pruning counters, partition-transform projection, Iceberg bound decoding, the Avro container with the null, deflate and hand-built snappy codecs, malformed Avro containers, Delta partition values and reader-feature lists, Iceberg snapshots without manifests and data files holding none of the table's columns as errors, a NaN Delta partition kept for != only, the row-filter literal rules at the edges of their types, float32 exact comparison, empty-string partitions as null, byte-wise string row-group pruning, Iceberg paths used as written (Delta Lake and Iceberg)
CSV CSVReaderTests, CSVFloatParseTests the GPU structure scan against an independent CPU parser over random hazard-filled files at eight scan block sizes and both file-access modes; inference, forced types, errors, the timestamp[ns] range, skip_rows_after_names over ragged rows, scanBlockBytes past 32 bits; the GPU float parse against Swift's Double / Float initialisers bit for bit over a randomized and adversarial corpus (CSV on the GPU)
JSON JSONReaderTests the structure scan's record boundaries and top-level errors against a sequential CPU scanner, the max-scan against a CPU loop, the walk's entries, RapidJSON's error texts, timestamps against a day-counting reference, random flat files against Foundation's JSONSerialization, grouped slot matrices (JSON on the GPU)
Streaming StreamTests every streaming operator against the in-memory answer at 1–120 batches with ragged and empty batches, HyperLogLog within 3σ, external sort over 120 runs, grace join against the in-memory join
CPU/GPU router RouterTests both paths of every routed operation byte-identical (values including slots under nulls, validity bits, null count, buffer size) on all ten primitives, sizes across word and threadgroup boundaries, 0/10/90% nulls, slices at offset 7, signed zeros, infinities, quiet and signaling NaNs with payloads, subnormals; float sums bit-identical past one block per GPU thread; group-by sum (and sumUnsigned) with null and out-of-range keys; the CPU loops against CPUReference; the decision rules (table, multiply's own row, batch, pending input, no CPU path, modes, per-thread override)
Hostile-input pins AdversarialKernelTests, AdversarialKernelTests2, AdversarialGroupSliceTests the probes the review pass ran that came back clean, kept so they stay clean: 100–200 repeat determinism loops, boundary lengths, slice-of-slice, group-by with one group and with one group per row, threadgroup-size invariants

2. Python suites

The column below counts def test_* functions. The 5,892 in the table at the top of this page is what pytest collected on 2026-09-24, which is larger because a parametrised function collects once per parameter set.

File Test functions Compares against
test_arrowmetal.py 115 pyarrow.compute and plain Python over the ctypes API, zero-copy import/export, the wheel loader
test_options.py, test_checked.py, test_strings_extra.py, test_float64_math.py 114 option surfaces (null placement, tiebreakers, cast safety, rounding), checked arithmetic, string kernels, binary64 math
test_functions.py 8 executes every runnable row of the Arrow-name registry through call_function against pyarrow.compute, with a second input in another type family where the claim spans several
test_expr.py, test_lazy.py, test_lazy_optimizer.py, test_lazy_memory.py 84 fused expressions and lazy plans against Polars and pyarrow; the optimizer against the unoptimized plan; RSS over 40,000 explain() and 10,000 collect() calls
test_parquet.py, test_parquet_robustness.py 31 pyarrow.parquet.read_table on generated files; fuzzed and damaged files
test_lakehouse.py 31 deltalake's to_pyarrow_table and pyiceberg's scan().to_arrow(): the recorded reads in expected.json, the committed tables live (types included), and tables generated at test time read with time travel and with filters of every comparison over every column type; empty-string partitions, decomposed strings in small row groups, partition values pyiceberg escapes on disk, float32 literals, a NaN float partition under every comparison; filter literals at the edges of their types and malformed manifest lists, each read in a subprocess so a crash fails the test; the differences from the reference readers that Delta Lake and Iceberg lists. The live comparisons skip when deltalake or pyiceberg is not installed
test_csv.py 28 pyarrow.csv.read_csv on generated files: identical tables (floats bit for bit) or identical error messages, one case per inference and parsing rule, every option, 60 seeded random files, block-boundary straddles, the type decided past the inference sample, files written by Polars and DuckDB (CSV on the GPU)
test_ipc_views.py 12 view types, big-endian integration files and arrow.fixed_shape_tensor through scan_ipc against pyarrow, the little-endian twins and the files' JSON; tensor shapes whose product overflows refused; a column naming another extension type through every streaming operator, matching the same column without the keys
test_parquet_nested.py 39 pyarrow.parquet.read_table on nested fixtures from pyarrow, DuckDB and Polars, value for value and type for type; the stored Arrow schema's types, field metadata and schema metadata, including crafted ARROW:schema values pyarrow ignores or refuses; page skipping and bloom filters against pyarrow's exact matches with the index and filters on and off, including Polars' NaN pages; filter values of unsupported types; damaged nested files and damaged page indexes
test_json.py 39 (636 collected) pyarrow.json.read_json on every probe, explicit-schema case, generated, truncated and mutated file (values, nulls, types, or the error text), plus one test per documented difference (JSON on the GPU)
test_stream.py 27 streaming results against pyarrow and Polars, including a 2 GB IPC directory generated at test time
test_polars.py 53 the bridge and namespaces against native Polars; the 17 Rust-plugin test functions (28 tests as pytest collects them, the parametrised one expanding to 12) run when polars-plugin/ is built
test_polars_engine.py 74 MetalEngine (tier 4 of Polars) against Polars' own collect of the same LazyFrame, over test_differential.py's generators at five sizes, three null ratios, sliced, special-value, two-chunk and DataFrame.slice frames, joins and unique included; each fallback with its reason; the Polars surfaces and IR version it relies on; the four ArrowMetal findings it once worked around, fixed and passing; one case reruns test_polars.py and test_lazy.py with every collect also run through the engine (metal_engine_everywhere.py)
test_duckdb.py 38 the bridge against DuckDB SQL; the 11 extension tests run when duckdb-extension/build.sh has produced the extension, and one test compiles the public C header as C
test_duckdb_rewrite.py 38 (178 collected) the rewrite extension (DuckDB §4b) against DuckDB's own operators: every query with arrowmetal_rewrite off and forced, same types and values bit for bit, over generated tables reaching both ends of every integer type, each connection-taking test once in one block and once in 2,048-row streamed blocks; plus the auto gate against router_2026-09-24.json and the benchmark results (duckdb_rewrite_2026-10-04-ac.csv). Skips until duckdb-extension/build_rewrite.sh has built the extension
test_pandas.py 72 the accessor and accel mode against plain pandas across five null-carrying dtype flavours; install()/uninstall() restore every patched slot
test_numpy.py 6 the numpy bridge: which dtypes cross without a copy, NaN as a value, and float64 arithmetic against numpy bit for bit (numpy)
test_router.py 13 both router paths byte-identical through the ctypes API and equal to pyarrow.compute (and to pyarrow's hash_sum for the group-by, signed and uint64), am.last_route() and its reasons, am.router() / am.set_router(), ARROWMETAL_ROUTER in a subprocess, differential_report.py's GPU pin, Benchmarks/router_table.py --check against the committed table, the table's header naming which CPU loops it measured, --from-check fitting a table inside the brackets of a router_check.py run, and multiply switching at its own row, fitted inside the bracket its check file measured
test_router_calibrate.py 9 determinism: 200 (operation, size) pairs get the same decision across 1,000 calls and across two processes, and am.route_decision / am.explain_route give what a routed call records; python -m arrowmetal.router explain text and JSON; router_table.py --json-out loading back as the shipped crossovers; an unreadable ARROWMETAL_ROUTER_TABLE leaving the shipped table; calibrate --quick end to end under a temporary HOME, its file loaded by a new process; python -m arrowmetal.bench --calibrate; the shared fit giving the shipped rows
test_bench.py 12 python -m arrowmetal.bench in a subprocess at 200,000 rows: --json exits 0 with match true and a positive timing for every one of the four operations, and --quiet with Polars hidden from the import system prints the table alone, one header and four rows, with no Polars column. The generated dataset has the documented types, null fraction, ranges, distribution and 1,000 keys, is the same on every run, and its SplitMix64 stream matches a plain-Python reference; both modes run with NumPy hidden. --parquet on small generated files: the value column is the largest numeric one (ties by encoded bytes, then file order), the key the lowest-cardinality integer or string column (nulls one group, a constant column last), the size guard refuses with the limit named and exit 2, a file with no usable column gets one line and exit 2, a string-only file measures the read only, and a full run matches pyarrow while its output carries no path and no column name
test_bench_share.py 8 the Share it block of python -m arrowmetal.bench against the issue form .github/ISSUE_TEMPLATE/benchmark_result.yml: the form declares exactly the fields the block fills plus free text, the block parses back into chip, macOS, machine, versions, run, router table and the result table (generated and --parquet runs, with and without Polars), the prefilled issue link carries the same field values, paths name the home directory as ~, and a real run at 200,000 rows prints a block with this Mac's chip and the shipped router table
test_coverage_report.py 8 the per-function table in Coverage summary and the matrix section of Arrow functions, one by one: every matrix operation assigned to Arrow function names from the registry (or listed as checking none), the record docs/data/differential_coverage.json holding exactly the matrix's (operation, type) cells with consistent counts and no unclassified case, both pages equal to their rendering from the record, and 24 seeded cells rerun against their recorded counts
test_differential.py (standalone part) 95 plus 17 documented xfails one test per finding and per fixed finding, plus the guards that every public operation and every module-level function has a matrix case

3. The differential matrix

differential_report.py builds, for every public operation, a generated column of each type it accepts in several shapes (random, sorted, all-equal, special values, three null ratios, plain and sliced) and compares the ArrowMetal answer with the pyarrow.compute answer for the same options. Every utf8 operation runs a second time with the same values imported as utf8_view (the oracle stays on utf8, which pyarrow.compute supports). The counts of the recorded run, in total and per Arrow function, are the generated table in Coverage summary (pass, documented divergences, skips where an operation does not apply to a type, 0 unclassified). A divergence counts as documented only if it matches one of the 22 open findings in FINDINGS, each of which has a section in Evaluation method with a reproduction; a finding may be narrowed by the data (data_check) so a dataset that does not contain the triggering value still has to agree exactly. The report exits non-zero on any unclassified divergence, which is what gates a push. Findings that were later fixed move to "Findings that were fixed" with a plain regression test, so a relapse is a failure rather than a re-classified divergence.

test_every_public_operation_has_a_differential_case and its module-level twin fail the suite when a method is added without a matrix case, so the harness cannot silently fall behind the library.

The per-function table. One command runs the matrix, records each (operation, type) cell's counts in docs/data/differential_coverage.json and renders the table in COVERAGE.md and the section in ARROW_FUNCTIONS.md from that record:

PYTHONPATH=python python python/tests/coverage_report.py            # run, record, render both pages
PYTHONPATH=python python python/tests/coverage_report.py --check    # run and compare, write nothing
PYTHONPATH=python python python/tests/coverage_report.py --render   # render the pages from the record

Which Arrow function a case counts toward is OPERATION_FUNCTIONS in that file: the function whose ArrowMetal implementation the operation calls, or, for an ArrowMetal call with no Arrow name of its own, the function its answer is compared with; the operations that check no Arrow function (interop, slicing, ArrowMetal's hashes and window functions) are listed apart. The table cannot drift: differential_report.py compares every full default run with the record and exits 3 when a cell differs, and test_coverage_report.py fails when an operation is added without an assignment, when the record's cells are not the matrix's, when a page is not the rendering of the record, or when a seeded sample of cells run again gives other counts.

4. Review before release

A review pass over the integrations, the engine and expression compiler, the GPU kernels, the C ABI, the bindings and the Parquet reader ran a checklist of hostile inputs (sliced and chunked inputs, null-only columns, crafted hash collisions, corrupt files, threadgroup-boundary lengths, escape sequences, Unicode edge cases, 40,000-call leak loops, fuzzed C entry points). Every finding is either fixed with a regression test or recorded as a finding in EVALUATION.md; the fixes are listed under "Fixed" in the CHANGELOG. Each finding's test fails on the commit before its fix.

5. What "green" means before a push

Every merge to main runs steps 1-5 (the commands under "Run everything" above), on a quiet machine, in this order:

  1. swift build -c release with no errors.
  2. swift test -c release: every test, 0 failures (the skips on a physical Mac are the opt-in measurements behind ARROWMETAL_IPC_THROUGHPUT, ARROWMETAL_LATENCY_BENCH and ARROWMETAL_UNIQUE_BENCH, and the fourteen pyarrow cross-checks in IPCTests when no python with pyarrow is found; on a virtual Metal device requireRealGPU() skips the GPU-only suites as well).
  3. differential_report.py exits 0: 0 unclassified divergences, and the run equals the record behind the per-function table (coverage table: matches ...; exit 3 and the differing cells otherwise).
  4. pytest python/tests (every suite): 0 failures; xfails must be strict and tied to a finding.
  5. The standalone tests in test_differential.py: 0 failures.

Before a release the binding suites run as well:

  1. cd rust && cargo test --release: 66 tests and 5 compile-only doc-tests, 0 failures.
  2. go test plain and under GOEXPERIMENT=cgocheck2.
  3. npm test (84 tests).
  4. testthat (920 expectations).

A benchmark comparison is never part of the release checks, because timings on a loaded machine are noise; the benchmark matrix is rerun on an idle machine before its numbers are published.

6. Benchmark method

Benchmarks/full_matrix.py measures every operation family the Python package exposes against the fastest idiom of pyarrow, Polars and pandas on the same in-process data at 10M and 50M rows (1M and 10M for strings): one warm-up call, then best-of-5 wall time under a per-measurement budget; CPU time is the process user+system delta so a 16-thread CPU kernel shows about 16× its wall time; every library gets the same values (pyarrow the Arrow array, Polars a Series built from it, pandas an Arrow-backed Series when nulls are present and a numpy-backed one otherwise); an operation ArrowMetal lacks is an error row, never a skip. One measured caveat: the first operation in a family that asks the buffer pool for hundreds of megabytes can read up to 60% slow while the pool is cold (partition_nth_indices measured 7.9 ms first in a fresh process and 5.1 ms after any large operation, on the same build), so a row that moves between runs is re-measured in place before it is called a regression. The streaming bench runs every (workload, engine) cell in its own subprocess so peak RSS is that engine's alone. Results land in Benchmarks/results/*.csv and are rendered into The full matrix; rows to improve stay in the table.

7. Pull requests: the conformance subset on GitHub's runner

.github/workflows/ci.yml runs on every pull request, in three jobs on GitHub's macos-15 runner:

Job Runs
conformance swift build -c release --product ArrowMetalC, then .github/scripts/conformance_subset.sh differential standalone coverage engines: 2,000 cases of the differential matrix sampled with seed 20261001 (differential_report.py --sample 2000 --seed 20261001), the standalone tests of test_differential.py, test_coverage_report.py, and the Polars engine grid without its 100,000-row tables over int64, float64 and string columns (engine_report.py --engine polars --quick --dtypes int64,float64,string)
swift-tests swift test -c release
datafusion-rules the DataFusion crate's rule tests, cargo test --test rule (conformance_subset.sh datafusion), not its grid; skipped on the virtual device (below)

The script prints the summary lines of the full run above (differential:, differential-standalone:, engines:, datafusion:, and a GATE line with - for the parts it did not run), plus a coverage-table: line, and runs the same way on a Mac: from the repository root, after the dylib build, PY=python .github/scripts/conformance_subset.sh runs all five parts.

What the runner cannot do: its GPU is an "Apple Paravirtual device" (GPU family mac2 only). The Swift GPU suites skip there (requireRealGPU()). It cannot build every kernel: on 2026-10-02 it built the Float64 math_unary_abs kernel, the 32-bit-key radix histogram and single-construct kernels with threadgroup atomics, barriers, grid-stride loops and 64-bit shifts, but refused the 64-bit-key radix_histogram in every form tried, including the loop it had before 0.4.0; the reported error is Compilation failed with nothing else. After two pipeline creations have failed in one process, every later creation in that process fails too, even of a kernel that built a moment earlier (the device-diag workflow, Tests/ArrowMetalTests/DeviceDiagnosticTests.swift, runs each variant in its own process). The subset therefore treats a case that fails with Metal pipeline creation failed on a device whose name contains "Paravirtual" as skipped, with the kernel's name as the reason, and never runs it again; a pytest part whose every failure carries that error counts as passed with those tests noted as skipped; the engines and datafusion parts, which need every kernel, are skipped there. Each line says so in the log. No timing is taken on the runner, and the full differential matrix, the full engine grids, the DataFusion grid and the binding suites run locally (section 5).

8. Hardware and toolchain of the published numbers

Apple M4 Max (16 cores, 64 GB unified memory, internal SSD), macOS 26.6.2, Swift 6.3.3 (Xcode toolchain), Python 3.13, pyarrow 25.0.1 for the timings (the conformance matrix runs on pyarrow 26.0.0 since 2026-10-15), Polars 1.44.1 (and Polars 2.0.0 where a figure names it), DuckDB 1.5.6, pandas 3.0.5.

docs/BENCHMARKS.md

Benchmark history

Submitting a result from your Mac

Every round below was measured on one Apple M4 Max. A run on another Mac goes in as a GitHub issue:

  1. Run python -m arrowmetal.bench (10,000,000 generated rows; --rows N for another size, --parquet FILE for your own file). With --calibrate it also runs the router's quick calibration and writes this Mac's crossover table to ~/.arrowmetal/router/<chip>.json.
  2. Copy the Share it block it prints, fences included. The block is one piece: an ArrowMetal bench result line; chip:, macOS:, machine: (CPU cores, memory, Metal device), versions: (ArrowMetal, pyarrow, Polars when installed, Python), run: (data, rows, router mode, whether every answer matched pyarrow), file: for a --parquet run (shape and codecs only, never the path, a column name or a value) and router table: (the table in force, with the home directory written as ~); then the result table, one row per operation with wall and CPU milliseconds for each library and the speedup.
  3. Open the Benchmark result issue form (label benchmark result), or the prefilled link the command prints, which fills the first three fields. The form has four: the block, the chip, the macOS version, and free text for anything unusual about the machine or the run. A router table written by --calibrate can be attached in the free-text field.

Nothing is sent by the command. Submitted results are reviewed. python/tests/test_bench_share.py checks that the block parses back into the form's fields (arrowmetal.bench.parse_share_block and form_fields) and that the form declares exactly those fields.

2026-09-07, Apple M4 Max, round 10: the integration benchmarks rerun and recorded

The four integration benchmark scripts were rerun and their complete stdout recorded as tracked files, so every figure in Polars, DuckDB and Query engine now traces to one of them:

Only 50M rows was rerun for polars_bench and duckdb_bench, so the 10M-row tables those two documents carried were removed rather than kept unrecorded. Three results moved enough to change the prose: DuckDB's crossing is now 52.1 ms rather than the ~286 ms an earlier run showed, the GPU sort is ahead of DuckDB's parallel sort on a resident column (2.2x, was 0.5x), and Polars' lazy semi join is now slightly ahead of ArrowMetal on engine case (f) (3.06 ms against 3.50).

2026-09-07, Apple M4 Max, round 9: the parallel baseline

The matrix now measures every CPU library twice: its plain eager idiom, and the most parallel idiom it has for the same answer — polars-lazy is the same expression through pl.LazyFrame collected on the in-memory or the streaming engine, pyarrow-threaded is an Acero plan over the same values split into 16 record batches. pandas' threaded paths, numexpr and numba, were not installed for this run, and its pandas-parallel rows say so; numpy's ufuncs are single-threaded and its rows say the same. "Fastest CPU" is now the best wall time of all of those idioms, and the note column of each CSV row names the exact idiom that was run.

The reason is in Benchmarks/results/full_matrix_2026-09-07-parallel_cores.txt, which reports cpu_ms / wall_ms per idiom over every measured row: the eager idioms use a median of 1.0 core on most families however many threads their pool has, while Polars lazy uses a median of 11.5 and pyarrow through Acero 11.1. A comparison against one core is not the comparison this project wants to make.

Against that baseline, of 339 measured rows: 145 at or above 3x, 102 between 1x and 3x, 77 to improve, where the fastest CPU idiom is ahead, 15 with no CPU equivalent. Against the eager idioms alone, the morning run of the same day (09:44, before the sort-gather change) had 247 rows at or above 3x, 62 between, 15 where an eager library was ahead and 15 without an equivalent (Benchmarks/results/full_matrix_2026-09-07.csv). Both CSVs are kept. The 77 rows to improve are grouped by measured cause in Where ArrowMetal needs to improve; the row-by-row tables are in The full matrix.

Ten ArrowMetal rows that a regression check flagged against the eager run were re-measured on a quieter machine and eight of them spliced back into the parallel CSV in place; those rows say so in their note column.

Reproduce with PYTHONPATH=python python Benchmarks/full_matrix.py (add --cores for the cores table, --verify to assert the eager and parallel idioms return the same answer).

2026-09-06, Apple M4 Max, round 8: what real binary64 costs

Superseded by the 2026-09-07 matrix — sqrt is now 3.92 ms at 50M rows, which is 3.2x the fastest eager CPU library and 0.87x the fastest parallel one (Polars lazy at 3.42 ms on 12.7 cores; Benchmarks/results/full_matrix_2026-09-07-parallel.csv), so re-running the reproduce line below will not produce the table that follows it. Kept for the before/after of removing the float detour.

50M Float64 rows, best of 5, release build, against numpy 2.5, pyarrow 25 and Polars 1.44 on the same data. Reproduce with PYTHONPATH=python python Benchmarks/float64_math_bench.py.

sqrt, exp, ln, log2 and log10 used to narrow a float64 column to float, call Metal's own library and widen the answer back — seven correct significant decimal digits out of sixteen — and power was not implemented for float64 at all. They now run in software binary64 from end to end: sqrt correctly rounded, the rest within 1 ulp. Here is the bill.

op before (float detour) after (binary64) GB/s after fastest CPU ratio
sqrt 3.2 ms 12.7 ms 63.1 12.7 (numpy) 1.00x
exp 5.2 ms 59.4 ms 13.5 76.7 (numpy) 1.29x
ln 3.2 ms 82.3 ms 9.7 92.9 (numpy) 1.13x
log2 4.5 ms 76.5 ms 10.5 92.7 (numpy) 1.21x
log10 3.1 ms 81.9 ms 9.8 105.4 (numpy) 1.29x
power(x, 2.5) not implemented 120.9 ms 6.6 242.7 (pyarrow) 2.01x
power(x, y) not implemented 123.8 ms 9.7 238.8 (numpy) 1.93x

The old kernels were memory bound at 250 GB/s because they were doing float32 work on float64 data. The new ones are compute bound on forty-odd software binary64 operations per element, and a logarithm costs 25x more than it used to. It still matches or beats every CPU library on the same machine, by 1.0-2x rather than by the 25x the old ln would have shown — sqrt is an exact tie — and the old ln's answer was wrong in the ninth digit. The float32 kernels are untouched and still run at 200-240 GB/s (ln on float32: 1.7 ms, 45x the fastest CPU), so a column that does not need sixteen digits should not be float64.

Two arithmetic ops moved the other way in the same round, from reworking DoubleMath itself — clz normalisation instead of shift loops, four 32x32 partial products instead of an emulated 64x64, and a Newton reciprocal with an exact remainder correction instead of a 57-step restoring long division:

op before after GB/s before GB/s after ratio vs fastest CPU
divide (float64) 9.6 ms 3.6 ms 125.3 331.1 1.12x -> 3.00x
multiply (float64) 4.6 ms 3.1 ms 260.9 390.8 2.32x -> 3.46x
add (float64) 3.1 ms 3.1 ms 390.1 388.4 3.33x

multiply and add now sit at the ~390 GB/s these single-pass float64 rows reach and divide is within 15% of it, so the software arithmetic has all but stopped being visible. Both rewrites stay correctly rounded — DoubleMathTests compares them with Swift's Double bit for bit.

divide measured 3.2 ms (377 GB/s, 3.26x) with two Newton steps, which is provably enough while MetalContext compiles with mathMode = .safe: that makes the float seed correctly rounded and good to 2^-22, and two steps saturate the 63 bits the reciprocal holds. The code in 0.1.0 takes a third step and pays 0.4 ms for it, because the two-step version is only correct given a compile flag set in another file — under fast math the seed would be looser, the quotient would land further than the single correction step covers, and d_div would quietly stop being correctly rounded. 0.4 ms on an operation already near the memory ceiling is a cheap price for removing that coupling.

Measure on a quiet machine. These runs were repeated until two agreed: a second Metal process on the same GPU inflates the short memory-bound rows (sqrt 12.7 -> 30.6 ms in one contended run) while barely touching the compute-bound ones, which reads as a plausible result rather than an obvious error.

2026-09-06, Apple M4 Max, round 7: sort and strings

50M Int64/Float64 rows and 10M utf8 values (1000 distinct keys, cust_NNN_region, 130 MB of bytes), best of 5, release build. CPU time is process user+system time consumed by the call (all threads).

Swift, Metal vs all 16 CPU cores on the same Arrow buffers:

Operation Metal CPU-ms 16-core CPU CPU-ms
argsort Int64, 50M 128.52 ms 0.4 653.97 (chunk sort + merge tree) 5017.1
sort Float64, 50M 138.52 0.9 591.97 (chunk sort + merge tree) 4468.6
sort Float64, 50M (1 core, for scale) 4283.22 ([Double].sort()) 4276.2
top_k 100 of 50M Int64 128.67 0.4 1.96 (per-core running top-k) 25.6
string contains("north"), 10M (25% hit) 1.65 0.4 17.77 (byte scan) 250.0
string starts_with("cust_1"), 10M 1.75 0.4 3.10 42.7
string equals("cust_042_east"), 10M 1.13 0.4 3.81 52.4
string filter, 10M (~30% kept) 4.72 2.4 2.74 (count, prefix, copy) 34.1
dictionary_encode + group-by sum, 10M 749.61 747.3 13.89 (hash dict + group-by) 184.3
group-by sum on cached codes (GPU part only) 1.21 0.4

Called from Python on the same in-process data, against Polars 1.44 (16 threads), pyarrow 25 and numpy 2.5. Wall ms, with CPU-ms in parentheses:

Operation ArrowMetal Polars pyarrow numpy
argsort Int64, 50M 128.51 (0.5) 334.27 (3538.1) 5339.10 (5337.4) 4675.49 (4672.7)
sort Float64, 50M 138.03 (0.9) 148.55 (1213.8) 6321.34 (6315.9) 1782.05 (1781.2)
sort Float64 + export to pyarrow 138.03 (0.9)
top_k 100 of 50M Int64 128.38 (0.4) 68.70 (68.8) 24.27 (24.3, select_k_unstable) 191.48 (191.5)
string contains("north"), 10M 1.61 (0.4) 147.78 (147.8) 121.91 (121.9) 122.31 (pandas)
string starts_with("cust_1"), 10M 1.46 (0.4) 36.00 (36.1) 32.14 (32.1)
string equals("cust_042_east"), 10M 1.79 (0.4) 42.24 (42.2) 36.77 (36.8)
string filter, 10M (~30% kept) 5.17 (2.9) 4.19 (4.2) 49.70 (49.7)
dictionary_encode + group-by sum, 10M 850.04 (847.6) 40.59 (361.9) 15.84 (146.2)
group-by sum on cached codes 2.13 (0.4)

Findings: - The GPU LSD radix sort is 4.6x faster than 16 CPU cores at 50M Int64 and 2.6x faster than Polars, and it costs the CPU under a millisecond against Polars' 3.5 CPU-seconds. Wall-clock parity with Polars is closer on Float64 (138 vs 149 ms) because the sorted copy adds a gather. - String predicates are where the GPU is furthest ahead: 1.1 to 1.8 ms against 32 to 148 ms, i.e. 20x to 90x Polars and pyarrow, at 100 to 155 GB/s over the utf8 data buffer. - As of 2026-09-06, since fixed: top_k was a full argsort plus a slice, so a CPU running top-k selection (which touches each value once and rarely writes) won by 65x. A partial radix / threadgroup selection kernel was the fix. In the 2026-09-07 matrix top_k (k=100, int64) at 50M rows is 2.67 ms against pyarrow's 24.00 ms, a 9.0x win. - As of 2026-09-06, since fixed: dictionaryEncode ran on the CPU and allocated per row, which dominated the string group-by (750 ms of the 751). Once codes existed, the GPU group-by over 10M string keys was 1.2 ms, 12x the all-core CPU hash group-by and 13x pyarrow. Hashing moved onto the GPU; in the 2026-09-07 matrix dictionary_encode (utf8) at 10M rows is 5.65 ms against 113.44 ms for the fastest CPU idiom (Polars lazy), a 20.1x win. - String filter is the one string kernel where the CPU idiom is ahead (2.7 vs 4.7 ms): the byte gather is short-string dominated, one thread per row copying ~13 bytes.

Reproduce: swift build -c release && .build/release/arrowmetal-bench 50000000 5, python Benchmarks/python_bench.py 50000000 5, PYTHONPATH=python python Benchmarks/python_gpu_bench.py 50000000 5.

2026-09-06, Apple M4 Max, round 6: CPU time per operation (the "CPU stays free" claim, measured)

50M rows, best of 5. CPU time is process user+system time consumed by the call (all threads).

Operation Implementation Wall ms CPU ms
sum Int64, 10% nulls Metal 1.07 0.4
sum Int64, 10% nulls 16-core Swift 4.87 66.6
sum Int64, 10% nulls Polars (from Python) 15.65 15.6
filter Int64 > 0 Metal 2.95 1.0
filter Int64 > 0 16-core Swift 3.49 48.8
filter Int64 > 0 Polars 22.79 22.7
take 25M random indices Metal 5.91 0.8
take 25M random indices 16-core Swift 11.75 154.6
take 25M random indices Polars gather 163.92 163.9
group-by sum, 5 keys Metal 1.72 0.4
group-by sum, 5 keys 16-core Swift 5.67 83.1
group-by sum, 1000 keys Metal (from Python) 1.91 0.4
group-by sum, 1000 keys Polars 84.06 1182.7
group-by sum, 1000 keys pyarrow 18.67 250.8
query: filter two columns + sum Metal, batched 1.75 0.4
query: filter two columns + sum 16-core Swift fused loop 6.27 86.5
query: filter two columns + sum Polars lazy 15.81 26.6
Float64 compare + filter Metal 3.37 0.8
Float64 compare + filter 16-core Swift 5.86 73.7

A GPU query costs the CPU well under a millisecond; the same work on the CPU costs 50 to 1200 CPU-milliseconds, which is time the rest of the application does not get.

Float64 add, sub, mul and div now run on the GPU through software IEEE-754 and are bit-exact against Swift's Double; sum accumulates in tree order, so it differs from a sequential CPU sum by reordering only (see round 6 in docs/FINDINGS.md).

2026-09-06, Apple M4 Max, round 5: lengths flow on the GPU

5-op chain (compare, compare, and, filter, sum), µs per call, best of 30:

rows unbatched batched (round 4, two syncs) batched (round 5, one sync)
1,000 642 346 256
10,000 580 320 232
100,000 573 330 243
1,000,000 872 549 428
10,000,000 1,344 1,047 970

2026-09-06, Apple M4 Max, round 4: latency and batched execution

Fixed cost per call (µs, best of 30). "5-op chain" is compare, compare, and, filter, sum.

rows GPU sum CPU 1-core sum GPU filter CPU 1-core filter GPU group-by chain unbatched chain batched
1,000 152 0 167 1 144 624 346
10,000 144 1 169 8 165 575 320
100,000 136 6 153 223 198 590 330
1,000,000 228 64 244 2,351 497 853 549
10,000,000 315 798 625 23,726 615 1,390 1,047

Raw Metal floor on this machine: empty kernel encode + commit + wait 116 µs; ten kernels in one command buffer 100 µs total. The GPU overtakes one CPU core at roughly 100k rows for filter and 1M rows for sum.

Query at 50M rows: unbatched 2.21 ms, batched 1.88 ms, 16-core CPU 6.46 ms, Polars lazy 15.68 ms. From Python: 2.31 ms unbatched, 1.90 ms with with am.batch():.

2026-09-06, Apple M4 Max, round 3: group-by, fused query, and ArrowMetal called from Python

Swift side (Metal vs 16-core Swift):

Operation Metal 16-core Swift
fused filter(where Int64 > 0) 3.60 ms 3.49 (count/prefix/scatter)
group-by sum Int64, 5 keys (privatised) 1.96 5.92
group-by sum Int64, 1000 keys (privatised) 2.02
group-by sum Int64, 100000 keys (device atomics) 4.02
query: sum(amount) where region == 2 and amount > 100 2.33 6.36 (fused loop)

Python side, same in-process data, ArrowMetal through the C ABI vs Polars 1.44 (16 threads), pyarrow 25, pandas 3.0:

Operation ArrowMetal from Python Polars pyarrow.compute pandas
sum Int64, 10% nulls 1.05 ms 15.78 48.26 47.75
filter Int64 > 0 (fused) 3.55 22.99 211.05 271.50
filter + export back to pyarrow 3.57
take 25M random indices 5.98 164.82 138.61
group-by sum, 1000 keys 2.05 84.65 18.73
query: filter two columns + sum 3.13 16.41 (lazy, fused) 109.46 (numpy)

Importing a 50M-row Int64 column from pyarrow into Metal memory (one copy, pyarrow buffers are not page aligned) took about 60 ms; every later operation on it is zero-copy in and out.

All runs: best of 5, release build, 50,000,000 rows. Hardware is named per run. Reproduce with swift run -c release arrowmetal-bench and python Benchmarks/python_bench.py (venv with polars, pyarrow, pandas).

2026-09-06, Apple M4 Max (16 CPU cores), round 2: buffer pool, all-core baselines

Metal vs 16-core Swift vs Accelerate (all cores) vs Polars 1.44 (16 threads) vs pyarrow 25 vs pandas 3.0.

Operation Metal 16-core Swift / Accelerate Polars pyarrow pandas
sum Int64, 10% nulls 1.40 ms 4.89 15.87 46.81 46.61
min Int64, 10% nulls 1.34 3.73 12.65 45.97 45.61
compare Int64 > 0 1.07 2.12 4.48 5.89 6.09
filter Int64 (45% kept) 2.90 3.49 17.85 197.86 254.67
compare + filter 4.07 5.62 22.54 205.38 263.24
multiply Int64 * 3 2.13 2.26 23.54 12.95 53.35
take 25M random indices 5.85 11.93 162.47 132.94 160.72
cast Int64 to Float32 3.14 1.80 71.56 54.62
Float64 compare + filter 3.40 5.96 22.31 103.00 189.81
sum Float32 0.90 0.84 (vDSP) 2.17 6.27 5.31 (numpy)
max Float32 0.88 0.79 (vDSP) 2.99 22.39 2.08 (numpy)
multiply Float32 * 2.5 1.72 1.63 (vDSP) 10.87 3.22 3.21 (numpy)
Float32 compare + filter 2.14 5.73 15.40 94.50 165.43 (numpy)

Notes: the Metal numbers include allocation of the output from the pool and the CPU-side wait. Polars numbers are eager single-expression calls; its lazy engine would fuse compare + filter.

2026-09-06, Apple M4 Max, round 1 (before the pool, single-core baselines)

Kept for history: sum Int64 1.07 ms, compare 1.18, filter 5.36, multiply 6.84, cast 5.04, take 8.09. The pool removed 2 to 3x of allocation overhead from write-heavy kernels.

docs/BENCHMARKS_MATRIX.md

The complete comparison matrix

Generated by Benchmarks/full_matrix.py on 2026-09-07, Apple M4 Max, 16 CPU cores, 64 GB unified memory, Darwin 25.6.0.

ArrowMetal 0.1.0 on Apple M4 Max against Polars 1.44.1 (16 threads), pyarrow 25.0.1 (16 threads), pandas 3.0.5, numpy 2.5.3.

Run time 34.3 minutes; raw numbers in Benchmarks/results/full_matrix_2026-09-07-parallel.csv.

How to read this

  • Every number is the best wall time of up to five calls after one warm-up, with the process CPU time (all threads) of that same call beside it, and then the cores that call used (cpu-ms / wall-ms). A slow call is repeated fewer times, never fewer than twice; the CSV records the count.
  • Every CPU library gets two columns: its plain eager idiom and its most parallel idiom. polars-lazy is the same expression through pl.LazyFrame, collected on the in-memory or the streaming engine; pyarrow-threaded is an Acero plan over the same values split into 16 record batches with to_table(use_threads=True), or pa.Table.group_by over that 16-chunk table. The note column of every such CSV row names the exact idiom. This exists because the eager idioms use about one core on the element-wise and whole-column reduction rows however many threads the pool has — see the cores table below for where that is and is not true — and a comparison against one core is not the comparison this project wants to make.
  • pandas' threaded paths, numexpr and numba, were not installed for this run (numexpr is element-wise arithmetic through pd.eval / DataFrame.eval; the numba engine with parallel=True is rolling, groupby.agg / transform, apply), so pandas is measured in its eager idiom and every pandas-parallel row's note says so. numpy's ufuncs are single-threaded. Both are recorded as pandas-parallel / numpy-parallel rows saying so, rather than left out.
  • ratio is the fastest CPU idiom's wall time divided by ArrowMetal's, taken across all the idioms of all the libraries; the fastest CPU column names the idiom that won. The verdict is the project's own bar: ✅ at or above 3x, ⚠️ between 1x and 3x, ❌ to improve: the fastest CPU idiom is ahead.
  • -- in a library column means that library has no equivalent operation, or the matrix has not measured a threaded idiom for it (the reason is in the CSV's note column); err means the call raised, and the message is in the CSV. Nothing is skipped silently.
  • 1,883 timed records, 1,860 rows: the nested family at 10,000,000 rows was measured twice and the later pass is shown.
  • Bandwidth (GB/s) is bytes touched (input + output) over wall time. Where ArrowMetal and the best baseline are both near the ~400 GB/s the single-pass rows of this matrix reach the operation is memory-bound and no ratio above ~1.5x is available to either side.

339 rows measured, over 173 operations (13 measured at one size, 154 at two, 6 at three). 145 at or above 3x (✅), 102 between 1x and 3x (⚠️), 77 to improve, where the fastest CPU idiom is ahead (❌) — none of them a call that raised — and 15 with no CPU equivalent to compare against (—). 43% of the measured rows meet the bar. Four of the 145 — any and all at 10M and 50M rows — are host CPU scans rather than GPU passes: the kernel stops at the first decisive bit without a dispatch (Coverage summary), so their ratio depends on where that bit is.

reductions

op rows ArrowMetal wall / CPU ms ArrowMetal GB/s polars polars-lazy pyarrow pyarrow-threaded pandas / numpy fastest CPU idiom ratio verdict
sum(int64, 10% nulls) 10,000,000 0.303 / 0.337 264 3.03 / 3.04 (1.0c) 0.575 / 6.03 (10.5c) 9.34 / 9.34 (1.0c) 1.29 / 13.89 (10.7c) 9.15 / 9.16 (1.0c) polars-lazy 1.90x ⚠️
mean(int64, 10% nulls) 10,000,000 0.286 / 0.321 279 2.39 / 2.39 (1.0c) 2.43 / 2.43 (1.0c) 11.62 / 11.62 (1.0c) 1.50 / 16.26 (10.9c) 11.53 / 11.54 (1.0c) pyarrow-threaded 5.23x ✅
min(int64, 10% nulls) 10,000,000 0.559 / 0.513 143 2.46 / 2.46 (1.0c) 0.533 / 5.52 (10.4c) 9.29 / 9.30 (1.0c) 1.26 / 13.77 (11.0c) 9.83 / 9.82 (1.0c) polars-lazy 0.95x ❌
max(int64, 10% nulls) 10,000,000 0.842 / 0.510 95.0 2.56 / 2.56 (1.0c) 0.544 / 5.42 (10.0c) 9.22 / 9.22 (1.0c) 1.28 / 14.18 (11.1c) 9.29 / 9.29 (1.0c) polars-lazy 0.65x ❌
min_max(int64) 10,000,000 1.57 / 1.37 50.9 5.08 / 5.08 (1.0c) 2.66 / 8.03 (3.0c) 9.33 / 9.33 (1.0c) 1.29 / 14.30 (11.1c) 18.64 / 18.63 (1.0c) pyarrow-threaded 0.82x ❌
variance(float64, ddof=1) 10,000,000 1.93 / 0.797 41.4 9.60 / 9.60 (1.0c) 9.82 / 9.81 (1.0c) 23.70 / 23.70 (1.0c) 2.80 / 31.87 (11.4c) 23.81 / 23.81 (1.0c) pyarrow-threaded 1.45x ⚠️
stddev(float64, ddof=1) 10,000,000 1.92 / 0.778 41.6 9.77 / 9.77 (1.0c) 9.53 / 9.53 (1.0c) 23.45 / 23.44 (1.0c) 2.70 / 31.98 (11.8c) 23.89 / 23.89 (1.0c) pyarrow-threaded 1.41x ⚠️
count_distinct(int64) 10,000,000 2.87 / 2.05 27.9 48.88 / 125 (2.6c) 49.06 / 122 (2.5c) 37.11 / 37.10 (1.0c) 4.83 / 50.46 (10.4c) 38.66 / 38.65 (1.0c) pyarrow-threaded 1.68x ⚠️
quantile(float64, 0.5) 10,000,000 3.63 / 1.77 22.1 35.04 / 230 (6.6c) 35.03 / 232 (6.6c) 61.86 / 61.85 (1.0c) -- 62.27 / 62.27 (1.0c) polars-lazy 9.66x ✅
mode(int64) 10,000,000 10.00 / 7.46 8.00 28.70 / 385 (13.4c) 32.22 / 377 (11.7c) 19.51 / 19.50 (1.0c) -- 66.79 / 66.70 (1.0c) pyarrow 1.95x ⚠️
product(int64) 10,000,000 0.430 / 0.370 186 9.39 / 9.39 (1.0c) 8.95 / 8.95 (1.0c) 9.88 / 9.88 (1.0c) 1.33 / 14.02 (10.6c) 9.75 / 9.74 (1.0c) pyarrow-threaded 3.09x ✅
any(bool, 10% nulls) 10,000,000 0.001 / 0.003 1035 0.098 / 0.098 (1.0c) 0.108 / 0.108 (1.0c) 0.029 / 0.029 (1.0c) 0.360 / 3.48 (9.7c) 0.033 / 0.033 (1.0c) pyarrow 23.75x ✅
all(bool, 10% nulls) 10,000,000 0.002 / 0.002 857 0.105 / 0.105 (1.0c) 0.114 / 0.115 (1.0c) 0.029 / 0.030 (1.0c) 0.361 / 3.49 (9.7c) 0.033 / 0.034 (1.0c) pyarrow 19.20x ✅
first(int64) 10,000,000 0.002 / 0.002 54832 4.78 / 4.78 (1.0c) 0.009 / 0.010 (1.1c) 0.033 / 0.034 (1.0c) -- 4.88 / 4.88 (1.0c) polars-lazy 6.33x ✅
last(int64) 10,000,000 0.002 / 0.003 53333 4.82 / 4.82 (1.0c) 0.009 / 0.009 (1.0c) 0.037 / 0.038 (1.0c) -- 6.94 / 6.94 (1.0c) polars-lazy 5.87x ✅
skew(float64) 10,000,000 2.48 / 0.771 32.2 9.48 / 9.48 (1.0c) 24.55 / 24.55 (1.0c) 36.01 / 36.01 (1.0c) 4.04 / 47.09 (11.7c) 18.41 / 18.41 (1.0c) pyarrow-threaded 1.63x ⚠️
kurtosis(float64) 10,000,000 2.52 / 0.784 31.8 10.73 / 10.73 (1.0c) 24.50 / 24.49 (1.0c) 48.21 / 48.21 (1.0c) 5.50 / 63.35 (11.5c) 17.55 / 17.55 (1.0c) pyarrow-threaded 2.19x ⚠️
tdigest(float64, q=0.5) 10,000,000 7.05 / 1.70 11.3 -- -- 195 / 195 (1.0c) 23.58 / 234 (9.9c) -- pyarrow-threaded 3.34x ✅
sum(int64, 10% nulls) 50,000,000 1.11 / 0.405 360 15.86 / 15.86 (1.0c) 2.30 / 25.61 (11.1c) 52.09 / 52.08 (1.0c) 5.09 / 66.26 (13.0c) 51.54 / 51.53 (1.0c) polars-lazy 2.07x ⚠️
mean(int64, 10% nulls) 50,000,000 1.15 / 0.428 349 13.25 / 13.25 (1.0c) 13.28 / 13.29 (1.0c) 64.61 / 64.58 (1.0c) 6.11 / 81.29 (13.3c) 64.18 / 64.17 (1.0c) pyarrow-threaded 5.34x ✅
min(int64, 10% nulls) 50,000,000 1.18 / 0.554 338 13.39 / 13.39 (1.0c) 2.01 / 20.29 (10.1c) 47.17 / 47.16 (1.0c) 5.05 / 67.45 (13.3c) 47.68 / 47.68 (1.0c) polars-lazy 1.70x ⚠️
max(int64, 10% nulls) 50,000,000 1.17 / 0.497 341 13.71 / 13.71 (1.0c) 2.22 / 22.39 (10.1c) 50.46 / 50.45 (1.0c) 5.08 / 67.96 (13.4c) 50.65 / 50.66 (1.0c) polars-lazy 1.89x ⚠️
min_max(int64) 50,000,000 3.77 / 1.60 106 27.41 / 27.42 (1.0c) 14.06 / 37.44 (2.7c) 50.63 / 50.62 (1.0c) 5.07 / 67.44 (13.3c) 101 / 101 (1.0c) pyarrow-threaded 1.35x ⚠️
variance(float64, ddof=1) 50,000,000 5.45 / 0.788 73.5 52.62 / 52.61 (1.0c) 51.99 / 51.98 (1.0c) 128 / 128 (1.0c) 11.88 / 157 (13.3c) 128 / 128 (1.0c) pyarrow-threaded 2.18x ⚠️
stddev(float64, ddof=1) 50,000,000 5.49 / 0.826 72.8 52.73 / 52.73 (1.0c) 52.18 / 52.18 (1.0c) 128 / 128 (1.0c) 11.93 / 157 (13.2c) 128 / 128 (1.0c) pyarrow-threaded 2.17x ⚠️
count_distinct(int64) 50,000,000 9.61 / 2.32 41.6 251 / 609 (2.4c) 252 / 611 (2.4c) 200 / 200 (1.0c) 18.92 / 248 (13.1c) 204 / 204 (1.0c) pyarrow-threaded 1.97x ⚠️
quantile(float64, 0.5) 50,000,000 4.61 / 1.63 86.7 179 / 1209 (6.7c) 178 / 1216 (6.8c) 369 / 369 (1.0c) -- 366 / 366 (1.0c) polars-lazy 38.71x ✅
mode(int64) 50,000,000 33.61 / 8.56 11.9 143 / 1917 (13.4c) 132 / 1930 (14.6c) 104 / 104 (1.0c) -- 342 / 342 (1.0c) pyarrow 3.08x ✅
product(int64) 50,000,000 1.36 / 0.428 294 48.54 / 48.54 (1.0c) 48.41 / 48.42 (1.0c) 50.64 / 50.65 (1.0c) 4.96 / 68.44 (13.8c) 50.76 / 50.76 (1.0c) pyarrow-threaded 3.64x ✅
any(bool, 10% nulls) 50,000,000 0.001 / 0.002 4411 0.544 / 0.545 (1.0c) 0.555 / 0.556 (1.0c) 0.149 / 0.149 (1.0c) 1.41 / 19.73 (13.9c) 0.154 / 0.154 (1.0c) pyarrow 106.36x ✅
all(bool, 10% nulls) 50,000,000 0.001 / 0.003 4545 0.773 / 0.773 (1.0c) 0.591 / 0.591 (1.0c) 0.149 / 0.149 (1.0c) 1.44 / 20.24 (14.1c) 0.154 / 0.155 (1.0c) pyarrow 106.43x ✅
first(int64) 50,000,000 0.002 / 0.002 239952 24.43 / 24.44 (1.0c) 0.010 / 0.011 (1.1c) 0.149 / 0.150 (1.0c) -- 23.39 / 23.39 (1.0c) polars-lazy 5.65x ✅
last(int64) 50,000,000 0.002 / 0.003 252526 24.47 / 24.48 (1.0c) 0.010 / 0.010 (1.0c) 0.150 / 0.150 (1.0c) -- 35.25 / 35.26 (1.0c) polars-lazy 6.38x ✅
skew(float64) 50,000,000 10.84 / 0.796 36.9 50.20 / 50.21 (1.0c) 128 / 128 (1.0c) 193 / 193 (1.0c) 17.64 / 234 (13.2c) 95.94 / 95.94 (1.0c) pyarrow-threaded 1.63x ⚠️
kurtosis(float64) 50,000,000 10.89 / 0.846 36.7 58.74 / 58.75 (1.0c) 133 / 133 (1.0c) 259 / 259 (1.0c) 22.91 / 314 (13.7c) 92.45 / 92.48 (1.0c) pyarrow-threaded 2.10x ⚠️
tdigest(float64, q=0.5) 50,000,000 33.18 / 2.02 12.1 -- -- 1061 / 1061 (1.0c) 79.96 / 1227 (15.3c) -- pyarrow-threaded 2.41x ⚠️

element-wise

op rows ArrowMetal wall / CPU ms ArrowMetal GB/s polars polars-lazy pyarrow pyarrow-threaded pandas / numpy fastest CPU idiom ratio verdict
add scalar (int64) 10,000,000 0.547 / 0.373 292 4.35 / 4.35 (1.0c) 0.775 / 7.82 (10.1c) 2.70 / 2.70 (1.0c) 1.01 / 10.59 (10.5c) 10.01 / 10.01 (1.0c) polars-lazy 1.42x ⚠️
multiply scalar (int64) 10,000,000 0.482 / 0.356 332 4.59 / 4.60 (1.0c) 0.875 / 10.34 (11.8c) 2.62 / 2.62 (1.0c) 1.05 / 9.90 (9.4c) 10.32 / 10.32 (1.0c) polars-lazy 1.81x ⚠️
add array (int64 + int64) 10,000,000 0.684 / 0.406 351 5.13 / 5.13 (1.0c) 1.09 / 12.39 (11.4c) 2.90 / 2.90 (1.0c) 1.23 / 13.30 (10.8c) 28.31 / 28.32 (1.0c) polars-lazy 1.59x ⚠️
multiply array (int64 * int64) 10,000,000 0.713 / 0.419 337 5.21 / 5.22 (1.0c) 1.14 / 12.88 (11.3c) 3.10 / 3.10 (1.0c) 1.21 / 12.52 (10.3c) 28.31 / 28.30 (1.0c) polars-lazy 1.59x ⚠️
divide (float64 / float64) 10,000,000 1.47 / 0.475 164 5.11 / 5.12 (1.0c) 1.07 / 12.21 (11.5c) 16.55 / 16.56 (1.0c) 2.20 / 22.89 (10.4c) 46.71 / 46.71 (1.0c) polars-lazy 0.73x ❌
power (float32 ** 2) 10,000,000 1.07 / 0.390 74.8 2.27 / 2.32 (1.0c) 0.471 / 4.45 (9.5c) 52.29 / 52.24 (1.0c) 5.81 / 65.15 (11.2c) 6.51 / 6.51 (1.0c) polars-lazy 0.44x ❌
sqrt (float64) 10,000,000 1.29 / 0.390 124 4.82 / 4.82 (1.0c) 0.775 / 8.27 (10.7c) 2.22 / 2.22 (1.0c) 0.822 / 8.48 (10.3c) 2.22 / 2.22 (1.0c) (numpy) polars-lazy 0.60x ❌
exp (float32) 10,000,000 1.15 / 0.432 69.4 12.82 / 12.82 (1.0c) 1.13 / 14.10 (12.4c) 11.18 / 11.18 (1.0c) 1.51 / 16.35 (10.8c) 12.51 / 12.51 (1.0c) (numpy) polars-lazy 0.98x ❌
ln (float64) 10,000,000 16.46 / 0.410 9.72 21.25 / 21.31 (1.0c) 1.81 / 23.64 (13.0c) 20.05 / 20.04 (1.0c) 2.52 / 26.47 (10.5c) 17.87 / 17.87 (1.0c) (numpy) polars-lazy 0.11x ❌
sin (float64) 10,000,000 30.19 / 0.396 5.30 58.99 / 58.98 (1.0c) 5.06 / 69.74 (13.8c) 57.22 / 57.17 (1.0c) 6.34 / 70.19 (11.1c) 57.00 / 57.00 (1.0c) (numpy) polars-lazy 0.17x ❌
round (float64) 10,000,000 1.01 / 0.418 159 9.35 / 9.36 (1.0c) 0.862 / 8.94 (10.4c) 22.08 / 22.08 (1.0c) 2.80 / 28.95 (10.4c) 21.61 / 21.60 (1.0c) polars-lazy 0.86x ❌
abs (float64) 10,000,000 1.01 / 0.384 159 4.42 / 4.43 (1.0c) 0.741 / 7.71 (10.4c) 1.32 / 1.32 (1.0c) 0.870 / 8.21 (9.4c) 10.01 / 10.01 (1.0c) polars-lazy 0.73x ❌
negate (int64) 10,000,000 0.956 / 0.404 167 4.47 / 4.47 (1.0c) 0.771 / 7.50 (9.7c) 2.53 / 2.54 (1.0c) 0.883 / 8.46 (9.6c) 10.03 / 10.02 (1.0c) polars-lazy 0.81x ❌
add_checked (int64 + int64) 10,000,000 1.17 / 0.504 206 -- -- 15.16 / 15.16 (1.0c) 2.04 / 20.53 (10.1c) -- pyarrow-threaded 1.75x ⚠️
bit_wise_and (int64) 10,000,000 0.856 / 0.432 280 5.05 / 5.05 (1.0c) 1.10 / 12.33 (11.2c) 16.46 / 16.46 (1.0c) 2.14 / 22.01 (10.3c) 1.86 / 1.86 (1.0c) (numpy) polars-lazy 1.28x ⚠️
shift_left (int64 << 2) 10,000,000 1.00 / 0.442 160 -- -- 9.99 / 9.97 (1.0c) 1.55 / 16.20 (10.4c) 1.21 / 1.21 (1.0c) (numpy) numpy 1.21x ⚠️
if_else (bool ? int64 : int64) 10,000,000 2.18 / 0.749 110 5.47 / 5.53 (1.0c) 1.32 / 15.57 (11.8c) 22.73 / 22.73 (1.0c) 2.70 / 31.40 (11.6c) 2.89 / 2.89 (1.0c) (numpy) polars-lazy 0.60x ❌
coalesce (2 int64 columns) 10,000,000 2.01 / 0.379 120 10.31 / 10.36 (1.0c) 1.39 / 16.03 (11.5c) 14.09 / 14.09 (1.0c) 1.89 / 21.09 (11.2c) 27.44 / 27.45 (1.0c) polars-lazy 0.69x ❌
fill_null (int64) 10,000,000 0.941 / 0.381 170 4.70 / 4.77 (1.0c) 1.05 / 10.35 (9.9c) 8.07 / 8.06 (1.0c) 1.48 / 14.98 (10.1c) 8.18 / 8.18 (1.0c) polars-lazy 1.11x ⚠️
fill_null_forward (int64) 10,000,000 2.34 / 1.36 68.3 27.17 / 27.17 (1.0c) 27.55 / 27.55 (1.0c) 11.53 / 11.53 (1.0c) -- 11.11 / 11.11 (1.0c) pandas 4.75x ✅
is_nan (float64) 10,000,000 0.461 / 0.362 195 0.896 / 0.898 (1.0c) 0.491 / 4.70 (9.6c) 1.76 / 1.76 (1.0c) 0.791 / 7.39 (9.3c) 0.850 / 0.851 (1.0c) (numpy) polars-lazy 1.07x ⚠️
is_in (int64, 100-value set) 10,000,000 2.21 / 1.62 40.8 43.68 / 43.73 (1.0c) 3.65 / 44.95 (12.3c) 74.30 / 74.30 (1.0c) 8.14 / 96.57 (11.9c) 78.45 / 78.45 (1.0c) polars-lazy 1.65x ⚠️
index_in (int64, 100-value set) 10,000,000 4.03 / 2.63 29.8 -- -- 62.46 / 62.45 (1.0c) 6.70 / 78.30 (11.7c) 32.86 / 32.86 (1.0c) (numpy) pyarrow-threaded 1.66x ⚠️
case_when (2 conditions) 10,000,000 2.01 / 0.990 199 11.14 / 11.19 (1.0c) 1.56 / 19.90 (12.7c) 102 / 102 (1.0c) 11.09 / 125 (11.3c) 37.04 / 37.04 (1.0c) (numpy) polars-lazy 0.78x ❌
hash64 (int64) 10,000,000 0.955 / 0.370 168 11.29 / 11.29 (1.0c) 1.06 / 12.72 (12.0c) -- -- 15.87 / 15.87 (1.0c) polars-lazy 1.11x ⚠️
add scalar (int64) 50,000,000 1.95 / 0.414 411 23.35 / 23.36 (1.0c) 3.00 / 36.38 (12.1c) 13.28 / 13.29 (1.0c) 4.44 / 50.20 (11.3c) 51.89 / 51.89 (1.0c) polars-lazy 1.54x ⚠️
multiply scalar (int64) 50,000,000 2.10 / 0.400 381 24.76 / 24.76 (1.0c) 4.13 / 52.12 (12.6c) 13.04 / 13.04 (1.0c) 4.26 / 48.88 (11.5c) 54.87 / 54.91 (1.0c) polars-lazy 1.97x ⚠️
add array (int64 + int64) 50,000,000 3.12 / 0.614 384 26.99 / 27.00 (1.0c) 4.31 / 55.30 (12.8c) 15.15 / 15.14 (1.0c) 5.64 / 67.88 (12.0c) 148 / 148 (1.0c) polars-lazy 1.38x ⚠️
multiply array (int64 * int64) 50,000,000 2.96 / 0.619 405 27.80 / 27.80 (1.0c) 4.32 / 59.77 (13.8c) 15.11 / 15.11 (1.0c) 5.91 / 67.15 (11.4c) 147 / 148 (1.0c) polars-lazy 1.46x ⚠️
divide (float64 / float64) 50,000,000 3.83 / 0.611 313 27.81 / 27.83 (1.0c) 4.38 / 57.68 (13.2c) 88.60 / 88.60 (1.0c) 9.16 / 115 (12.5c) 247 / 247 (1.0c) polars-lazy 1.14x ⚠️
power (float32 ** 2) 50,000,000 1.91 / 0.437 210 11.84 / 11.90 (1.0c) 1.82 / 20.68 (11.4c) 272 / 272 (1.0c) 23.86 / 327 (13.7c) 34.61 / 34.61 (1.0c) polars-lazy 0.95x ❌
sqrt (float64) 50,000,000 3.92 / 0.384 204 27.29 / 27.29 (1.0c) 3.42 / 43.35 (12.7c) 12.47 / 12.47 (1.0c) 3.74 / 43.47 (11.6c) 12.39 / 12.39 (1.0c) (numpy) polars-lazy 0.87x ❌
exp (float32) 50,000,000 1.93 / 0.450 208 63.34 / 63.34 (1.0c) 5.29 / 71.06 (13.4c) 55.94 / 55.96 (1.0c) 6.42 / 80.74 (12.6c) 61.94 / 61.93 (1.0c) (numpy) polars-lazy 2.75x ⚠️
ln (float64) 50,000,000 81.31 / 0.512 9.84 108 / 108 (1.0c) 8.46 / 120 (14.2c) 99.61 / 99.61 (1.0c) 9.85 / 131 (13.3c) 86.95 / 86.92 (1.0c) (numpy) polars-lazy 0.10x ❌
sin (float64) 50,000,000 150 / 0.445 5.33 312 / 312 (1.0c) 23.28 / 351 (15.1c) 299 / 299 (1.0c) 25.57 / 350 (13.7c) 301 / 301 (1.0c) (numpy) polars-lazy 0.16x ❌
round (float64) 50,000,000 3.18 / 0.406 252 51.07 / 51.06 (1.0c) 4.72 / 45.34 (9.6c) 112 / 112 (1.0c) 11.49 / 141 (12.3c) 110 / 110 (1.0c) polars-lazy 1.49x ⚠️
abs (float64) 50,000,000 3.14 / 0.406 255 23.42 / 23.42 (1.0c) 3.31 / 37.88 (11.4c) 7.11 / 7.12 (1.0c) 3.85 / 42.38 (11.0c) 51.80 / 51.79 (1.0c) polars-lazy 1.06x ⚠️
negate (int64) 50,000,000 2.14 / 0.403 373 23.41 / 23.40 (1.0c) 3.10 / 40.32 (13.0c) 13.32 / 13.32 (1.0c) 3.98 / 42.93 (10.8c) 52.47 / 52.50 (1.0c) polars-lazy 1.44x ⚠️
add_checked (int64 + int64) 50,000,000 4.50 / 0.642 267 -- -- 75.74 / 75.74 (1.0c) 8.63 / 104 (12.1c) -- pyarrow-threaded 1.92x ⚠️
bit_wise_and (int64) 50,000,000 2.97 / 0.626 405 28.01 / 28.02 (1.0c) 4.32 / 57.38 (13.3c) 86.63 / 86.62 (1.0c) 9.37 / 117 (12.5c) 10.12 / 10.12 (1.0c) (numpy) polars-lazy 1.46x ⚠️
shift_left (int64 << 2) 50,000,000 1.89 / 0.393 422 -- -- 53.59 / 53.59 (1.0c) 6.81 / 80.53 (11.8c) 6.73 / 6.73 (1.0c) (numpy) numpy 3.55x ✅
if_else (bool ? int64 : int64) 50,000,000 3.75 / 0.958 320 27.17 / 27.26 (1.0c) 5.31 / 72.20 (13.6c) 123 / 123 (1.0c) 12.67 / 158 (12.5c) 14.61 / 14.62 (1.0c) (numpy) polars-lazy 1.42x ⚠️
coalesce (2 int64 columns) 50,000,000 3.32 / 0.413 362 55.26 / 55.33 (1.0c) 5.89 / 82.17 (14.0c) 74.51 / 74.56 (1.0c) 8.48 / 106 (12.5c) 147 / 147 (1.0c) polars-lazy 1.78x ⚠️
fill_null (int64) 50,000,000 2.11 / 0.421 380 24.34 / 24.41 (1.0c) 4.16 / 53.59 (12.9c) 41.82 / 41.81 (1.0c) 6.44 / 76.63 (11.9c) 41.97 / 41.98 (1.0c) polars-lazy 1.98x ⚠️
fill_null_forward (int64) 50,000,000 9.63 / 1.74 83.1 137 / 137 (1.0c) 138 / 138 (1.0c) 59.24 / 59.23 (1.0c) -- 59.67 / 59.65 (1.0c) pyarrow 6.15x ✅
is_nan (float64) 50,000,000 1.21 / 0.379 372 4.87 / 4.87 (1.0c) 1.92 / 24.92 (13.0c) 9.66 / 9.66 (1.0c) 3.50 / 38.36 (11.0c) 4.94 / 4.94 (1.0c) (numpy) polars-lazy 1.58x ⚠️
is_in (int64, 100-value set) 50,000,000 2.17 / 1.51 208 270 / 270 (1.0c) 15.97 / 240 (15.0c) 384 / 384 (1.0c) 33.25 / 484 (14.6c) 407 / 407 (1.0c) polars-lazy 7.37x ✅
index_in (int64, 100-value set) 50,000,000 5.93 / 3.51 101 -- -- 332 / 332 (1.0c) 27.68 / 392 (14.2c) 163 / 163 (1.0c) (numpy) pyarrow-threaded 4.67x ✅
case_when (2 conditions) 50,000,000 6.92 / 1.36 289 58.29 / 58.38 (1.0c) 6.52 / 87.79 (13.5c) 522 / 522 (1.0c) 42.79 / 624 (14.6c) 199 / 199 (1.0c) (numpy) polars-lazy 0.94x ❌
hash64 (int64) 50,000,000 3.20 / 0.418 250 55.37 / 55.37 (1.0c) 4.54 / 60.10 (13.2c) -- -- 79.79 / 79.78 (1.0c) polars-lazy 1.42x ⚠️

compare+select

op rows ArrowMetal wall / CPU ms ArrowMetal GB/s polars polars-lazy pyarrow pyarrow-threaded pandas / numpy fastest CPU idiom ratio verdict
compare scalar (int64 > 0) 10,000,000 0.471 / 0.381 191 0.875 / 0.877 (1.0c) 0.610 / 4.87 (8.0c) 1.20 / 1.20 (1.0c) 0.843 / 8.17 (9.7c) 1.28 / 1.28 (1.0c) polars-lazy 1.29x ⚠️
compare array (int64 > int64) 10,000,000 1.23 / 0.474 138 1.28 / 1.29 (1.0c) 0.874 / 10.24 (11.7c) 1.74 / 1.74 (1.0c) 1.00 / 10.31 (10.3c) 14.99 / 14.99 (1.0c) polars-lazy 0.71x ❌
filter int64 (30% kept) 10,000,000 0.778 / 0.582 134 3.41 / 3.41 (1.0c) 0.651 / 6.07 (9.3c) 23.88 / 23.88 (1.0c) 5.33 / 60.29 (11.3c) 30.61 / 30.61 (1.0c) polars-lazy 0.84x ❌
filter int64 (90% kept) 10,000,000 1.64 / 0.650 92.5 5.22 / 5.22 (1.0c) 0.971 / 9.88 (10.2c) 19.44 / 19.43 (1.0c) 4.51 / 50.85 (11.3c) 27.84 / 27.83 (1.0c) polars-lazy 0.59x ❌
take (n/2 random indices) 10,000,000 1.28 / 0.638 78.3 28.88 / 28.88 (1.0c) 28.64 / 28.65 (1.0c) 22.99 / 22.99 (1.0c) -- 28.26 / 28.26 (1.0c) pyarrow 17.99x ✅
drop_null (int64, 10% nulls) 10,000,000 1.56 / 0.641 97.2 4.72 / 4.72 (1.0c) 0.890 / 10.11 (11.4c) 18.88 / 18.87 (1.0c) 2.62 / 29.12 (11.1c) 31.92 / 31.91 (1.0c) polars-lazy 0.57x ❌
slice (zero-copy view) 10,000,000 0.001 / 0.002 0.00 0.000 / 0.002 (5.0c) -- 0.000 / 0.000 -- 0.034 / 0.034 (1.0c) polars 0.40x ❌
replace_with_mask (30% replaced) 10,000,000 2.54 / 1.34 67.0 4.89 / 4.89 (1.0c) 0.983 / 10.06 (10.2c) 22.25 / 22.24 (1.0c) 2.77 / 30.91 (11.1c) 4.99 / 4.99 (1.0c) polars-lazy 0.39x ❌
indices_nonzero (bool) 10,000,000 1.59 / 0.784 56.6 24.03 / 24.03 (1.0c) 23.95 / 23.95 (1.0c) 22.64 / 22.64 (1.0c) -- 3.40 / 3.40 (1.0c) (numpy) numpy 2.14x ⚠️
compare scalar (int64 > 0) 50,000,000 2.41 / 0.411 186 4.69 / 4.69 (1.0c) 1.91 / 22.57 (11.8c) 6.00 / 6.00 (1.0c) 3.53 / 40.47 (11.5c) 6.16 / 6.16 (1.0c) polars-lazy 0.79x ❌
compare array (int64 > int64) 50,000,000 2.91 / 0.641 292 6.83 / 6.83 (1.0c) 3.17 / 43.75 (13.8c) 8.59 / 8.59 (1.0c) 4.17 / 49.87 (12.0c) 79.96 / 79.95 (1.0c) polars-lazy 1.09x ⚠️
filter int64 (30% kept) 50,000,000 2.23 / 0.673 233 17.49 / 17.48 (1.0c) 2.85 / 35.77 (12.6c) 123 / 123 (1.0c) 22.61 / 298 (13.2c) 157 / 157 (1.0c) polars-lazy 1.28x ⚠️
filter int64 (90% kept) 50,000,000 4.24 / 0.907 179 26.90 / 26.89 (1.0c) 3.70 / 50.07 (13.5c) 96.39 / 96.40 (1.0c) 19.82 / 258 (13.0c) 142 / 142 (1.0c) polars-lazy 0.87x ❌
take (n/2 random indices) 50,000,000 5.90 / 0.786 84.7 172 / 172 (1.0c) 172 / 172 (1.0c) 143 / 143 (1.0c) -- 171 / 171 (1.0c) pyarrow 24.15x ✅
drop_null (int64, 10% nulls) 50,000,000 4.20 / 0.912 181 24.33 / 24.33 (1.0c) 3.81 / 51.02 (13.4c) 96.35 / 96.34 (1.0c) 11.14 / 141 (12.7c) 167 / 167 (1.0c) polars-lazy 0.91x ❌
slice (zero-copy view) 50,000,000 0.001 / 0.002 0.00 0.000 / 0.000 -- 0.001 / 0.001 (2.0c) -- 0.155 / 0.156 (1.0c) polars 0.33x ❌
replace_with_mask (30% replaced) 50,000,000 10.55 / 1.78 80.6 27.18 / 27.18 (1.0c) 3.78 / 50.54 (13.4c) 120 / 120 (1.0c) 12.36 / 154 (12.5c) 24.60 / 24.60 (1.0c) polars-lazy 0.36x ❌
indices_nonzero (bool) 50,000,000 3.17 / 0.826 142 130 / 130 (1.0c) 130 / 130 (1.0c) 118 / 118 (1.0c) -- 17.03 / 17.03 (1.0c) (numpy) numpy 5.38x ✅

sort

op rows ArrowMetal wall / CPU ms ArrowMetal GB/s polars polars-lazy pyarrow pyarrow-threaded pandas / numpy fastest CPU idiom ratio verdict
argsort int64 10,000,000 7.81 / 0.826 15.4 57.63 / 655 (11.4c) 57.81 / 651 (11.3c) 841 / 841 (1.0c) -- 699 / 699 (1.0c) (numpy) polars 7.38x ✅
argsort float64 10,000,000 8.11 / 0.851 14.8 78.56 / 1004 (12.8c) 80.27 / 1014 (12.6c) 984 / 983 (1.0c) -- 842 / 842 (1.0c) (numpy) polars 9.69x ✅
argsort utf8 10,000,000 13.73 / 3.33 12.8 233 / 3178 (13.7c) 228 / 3129 (13.8c) 1601 / 1601 (1.0c) -- 1601 / 1601 (1.0c) polars-lazy 16.58x ✅
sort utf8 10,000,000 22.96 / 7.13 15.4 133 / 1326 (10.0c) 135 / 1279 (9.5c) 1865 / 1865 (1.0c) 2486 / 2488 (1.0c) 1867 / 1867 (1.0c) polars 5.80x ✅
sort float64 10,000,000 6.85 / 1.17 23.3 26.16 / 250 (9.6c) 26.31 / 249 (9.5c) 1006 / 1006 (1.0c) 1159 / 1162 (1.0c) 303 / 303 (1.0c) (numpy) polars 3.82x ✅
top_k (k=100, int64) 10,000,000 1.83 / 1.33 43.6 11.74 / 11.77 (1.0c) 11.86 / 11.92 (1.0c) 4.85 / 4.85 (1.0c) -- 45.21 / 45.20 (1.0c) (numpy) pyarrow 2.64x ⚠️
top_k (k=10000, int64) 10,000,000 2.47 / 1.86 32.3 11.83 / 11.89 (1.0c) 11.92 / 11.98 (1.0c) 11.98 / 11.99 (1.0c) -- 45.55 / 45.54 (1.0c) (numpy) polars 4.78x ✅
rank (min) 10,000,000 12.37 / 1.90 12.9 102 / 695 (6.8c) 112 / 705 (6.3c) 890 / 890 (1.0c) -- 2260 / 2259 (1.0c) polars 8.24x ✅
dense_rank 10,000,000 12.04 / 1.80 13.3 111 / 702 (6.3c) 103 / 700 (6.8c) 884 / 884 (1.0c) -- 2223 / 2223 (1.0c) polars-lazy 8.53x ✅
lexsort (2 int32 keys) 10,000,000 7.29 / 2.30 21.9 111 / 1217 (10.9c) 115 / 1220 (10.6c) 1123 / 1122 (1.0c) -- 3371 / 3370 (1.0c) (numpy) polars 15.28x ✅
partition_nth_indices (n/2) 10,000,000 5.82 / 3.63 20.6 -- -- 49.89 / 49.88 (1.0c) -- 54.64 / 54.63 (1.0c) (numpy) pyarrow 8.57x ✅
unique (int32, 1000 distinct) 10,000,000 5.90 / 4.83 6.78 23.24 / 116 (5.0c) 23.24 / 120 (5.2c) 48.45 / 48.44 (1.0c) 3.37 / 36.70 (10.9c) 12.84 / 12.84 (1.0c) pyarrow-threaded 0.57x ❌
value_counts (int32, 1000 distinct) 10,000,000 6.67 / 5.26 6.00 24.93 / 24.93 (1.0c) 24.73 / 24.75 (1.0c) 63.86 / 63.81 (1.0c) 3.70 / 40.15 (10.8c) 11.88 / 11.91 (1.0c) pyarrow-threaded 0.55x ❌
dictionary_encode (int32) 10,000,000 7.08 / 5.72 11.3 -- -- 71.24 / 71.23 (1.0c) -- 16.93 / 16.93 (1.0c) pandas 2.39x ⚠️
dictionary_encode (utf8) 10,000,000 5.65 / 4.03 31.2 114 / 114 (1.0c) 113 / 113 (1.0c) 131 / 131 (1.0c) -- 137 / 137 (1.0c) polars-lazy 20.08x ✅
argsort int64 50,000,000 39.15 / 0.855 15.3 302 / 3574 (11.8c) 305 / 3577 (11.7c) 5197 / 5196 (1.0c) -- 4545 / 4544 (1.0c) (numpy) polars 7.71x ✅
argsort float64 50,000,000 39.91 / 0.825 15.0 420 / 5531 (13.2c) 423 / 5526 (13.1c) 5885 / 5885 (1.0c) -- 5472 / 5471 (1.0c) (numpy) polars 10.52x ✅
sort float64 50,000,000 32.12 / 1.25 24.9 133 / 1284 (9.7c) 133 / 1288 (9.7c) 6044 / 6044 (1.0c) 7498 / 7519 (1.0c) 1759 / 1759 (1.0c) (numpy) polars 4.14x ✅
top_k (k=100, int64) 50,000,000 2.67 / 1.38 150 65.48 / 65.53 (1.0c) 65.08 / 65.12 (1.0c) 24.00 / 24.01 (1.0c) -- 328 / 329 (1.0c) (numpy) pyarrow 8.98x ✅
top_k (k=10000, int64) 50,000,000 4.96 / 1.89 80.6 66.16 / 66.22 (1.0c) 65.45 / 65.49 (1.0c) 34.67 / 34.66 (1.0c) -- 328 / 328 (1.0c) (numpy) pyarrow 6.99x ✅
rank (min) 50,000,000 68.03 / 3.08 11.8 700 / 3953 (5.6c) 709 / 3968 (5.6c) 5553 / 5553 (1.0c) -- 21473 / 21473 (1.0c) polars 10.29x ✅
dense_rank 50,000,000 67.76 / 3.07 11.8 699 / 3943 (5.6c) 716 / 3973 (5.5c) 5552 / 5551 (1.0c) -- 21375 / 21375 (1.0c) polars 10.32x ✅
lexsort (2 int32 keys) 50,000,000 37.59 / 2.42 21.3 920 / 10023 (10.9c) 903 / 9948 (11.0c) 7774 / 7773 (1.0c) -- 29655 / 29655 (1.0c) (numpy) polars-lazy 24.02x ✅
partition_nth_indices (n/2) 50,000,000 12.16 / 3.84 49.4 -- -- 298 / 298 (1.0c) -- 349 / 349 (1.0c) (numpy) pyarrow 24.49x ✅
unique (int32, 1000 distinct) 50,000,000 15.72 / 6.06 12.7 102 / 466 (4.6c) 103 / 462 (4.5c) 236 / 236 (1.0c) 13.04 / 178 (13.6c) 63.76 / 63.77 (1.0c) pyarrow-threaded 0.83x ❌
value_counts (int32, 1000 distinct) 50,000,000 17.04 / 6.92 11.7 115 / 115 (1.0c) 116 / 116 (1.0c) 309 / 309 (1.0c) 14.31 / 196 (13.7c) 58.38 / 58.38 (1.0c) pyarrow-threaded 0.84x ❌
dictionary_encode (int32) 50,000,000 19.10 / 7.09 20.9 -- -- 342 / 342 (1.0c) -- 84.22 / 84.23 (1.0c) pandas 4.41x ✅

group-by

op rows ArrowMetal wall / CPU ms ArrowMetal GB/s polars polars-lazy pyarrow pyarrow-threaded pandas / numpy fastest CPU idiom ratio verdict
sum by int32 key (1000 groups) 10,000,000 1.73 / 1.03 69.2 20.35 / 232 (11.4c) 20.24 / 242 (12.0c) 4.80 / 44.26 (9.2c) 4.61 / 50.92 (11.0c) 45.52 / 45.53 (1.0c) pyarrow-threaded 2.66x ⚠️
mean by int32 key (1000 groups) 10,000,000 2.21 / 1.17 54.4 20.33 / 232 (11.4c) 20.35 / 234 (11.5c) 4.81 / 44.16 (9.2c) 4.63 / 51.19 (11.0c) 49.66 / 49.63 (1.0c) pyarrow-threaded 2.10x ⚠️
min by int32 key (1000 groups) 10,000,000 3.71 / 1.64 32.4 20.28 / 242 (11.9c) 20.54 / 235 (11.4c) 5.06 / 46.48 (9.2c) 4.99 / 54.20 (10.9c) 52.37 / 52.38 (1.0c) pyarrow-threaded 1.35x ⚠️
max by int32 key (1000 groups) 10,000,000 3.53 / 1.46 34.0 19.90 / 237 (11.9c) 20.67 / 242 (11.7c) 5.09 / 46.80 (9.2c) 4.94 / 53.82 (10.9c) 53.11 / 53.11 (1.0c) pyarrow-threaded 1.40x ⚠️
count by int32 key (1000 groups) 10,000,000 1.52 / 1.03 79.2 21.78 / 163 (7.5c) 21.17 / 166 (7.8c) 4.56 / 41.85 (9.2c) 4.43 / 48.34 (10.9c) 27.53 / 27.53 (1.0c) pyarrow-threaded 2.92x ⚠️
sum by utf8 key (1000 distinct) 10,000,000 5.66 / 3.37 35.3 30.92 / 331 (10.7c) 32.68 / 399 (12.2c) 9.40 / 86.98 (9.3c) 9.48 / 101 (10.7c) 105 / 104 (1.0c) pyarrow 1.66x ⚠️
sum by two int32 keys (~1024 groups) 10,000,000 2.19 / 1.77 73.1 33.96 / 385 (11.3c) 35.05 / 397 (11.3c) 5.65 / 52.10 (9.2c) 5.50 / 60.21 (11.0c) 84.11 / 84.14 (1.0c) pyarrow-threaded 2.51x ⚠️
sum by int32 key (100000 groups) 10,000,000 1.95 / 1.08 61.5 22.67 / 275 (12.1c) 24.75 / 287 (11.6c) 16.42 / 98.47 (6.0c) 21.58 / 137 (6.4c) 67.53 / 67.52 (1.0c) pyarrow 8.42x ✅
mean by int32 key (100000 groups) 10,000,000 2.48 / 1.17 48.4 23.54 / 282 (12.0c) 23.30 / 284 (12.2c) 16.23 / 98.11 (6.0c) 20.83 / 137 (6.6c) 85.62 / 85.61 (1.0c) pyarrow 6.55x ✅
min by int32 key (100000 groups) 10,000,000 2.79 / 1.43 43.1 23.58 / 280 (11.9c) 23.32 / 280 (12.0c) 18.12 / 105 (5.8c) 24.52 / 145 (5.9c) 68.60 / 68.61 (1.0c) pyarrow 6.50x ✅
max by int32 key (100000 groups) 10,000,000 2.78 / 1.43 43.2 23.51 / 274 (11.6c) 24.46 / 282 (11.5c) 17.95 / 104 (5.8c) 24.99 / 145 (5.8c) 68.75 / 68.75 (1.0c) pyarrow 6.46x ✅
count by int32 key (100000 groups) 10,000,000 1.96 / 1.13 61.3 28.24 / 203 (7.2c) 27.42 / 208 (7.6c) 12.96 / 81.24 (6.3c) 17.07 / 110 (6.4c) 32.18 / 32.18 (1.0c) pyarrow 6.62x ✅
sum by utf8 key (100000 distinct) 10,000,000 6.61 / 3.70 30.3 35.77 / 407 (11.4c) 35.79 / 433 (12.1c) 39.91 / 245 (6.1c) 47.81 / 364 (7.6c) 158 / 158 (1.0c) polars 5.42x ✅
sum by two int32 keys (~100489 groups) 10,000,000 2.49 / 1.89 64.3 37.96 / 448 (11.8c) 38.19 / 456 (11.9c) 19.09 / 116 (6.1c) 25.64 / 162 (6.3c) 108 / 108 (1.0c) pyarrow 7.67x ✅
sum by int32 key (10000000 groups) 10,000,000 9.46 / 1.64 12.7 71.95 / 921 (12.8c) 69.53 / 942 (13.6c) 312 / 627 (2.0c) 319 / 672 (2.1c) 321 / 321 (1.0c) polars-lazy 7.35x ✅
mean by int32 key (10000000 groups) 10,000,000 11.62 / 1.73 10.3 80.85 / 1100 (13.6c) 82.18 / 1142 (13.9c) 305 / 627 (2.1c) 315 / 679 (2.2c) 326 / 326 (1.0c) polars 6.96x ✅
min by int32 key (10000000 groups) 10,000,000 14.93 / 1.68 8.04 72.56 / 1044 (14.4c) 78.22 / 1071 (13.7c) 311 / 637 (2.0c) 321 / 682 (2.1c) 325 / 325 (1.0c) polars 4.86x ✅
max by int32 key (10000000 groups) 10,000,000 14.98 / 1.82 8.01 75.46 / 1039 (13.8c) 79.62 / 1070 (13.4c) 309 / 630 (2.0c) 321 / 695 (2.2c) 325 / 325 (1.0c) polars 5.04x ✅
count by int32 key (10000000 groups) 10,000,000 5.07 / 1.66 23.7 87.77 / 770 (8.8c) 90.59 / 799 (8.8c) 267 / 554 (2.1c) 271 / 594 (2.2c) 229 / 229 (1.0c) polars 17.31x ✅
sum by utf8 key (10000000 distinct) 10,000,000 27.54 / 5.82 7.26 112 / 1333 (11.9c) 113 / 1328 (11.8c) 572 / 1029 (1.8c) 581 / 1151 (2.0c) 802 / 802 (1.0c) polars 4.06x ✅
sum by two int32 keys (~10004569 groups) 10,000,000 12.41 / 3.71 12.9 104 / 1301 (12.5c) 104 / 1356 (13.0c) 337 / 670 (2.0c) 353 / 736 (2.1c) 443 / 443 (1.0c) polars 8.37x ✅
variance by key (1000 groups) 10,000,000 5.31 / 1.86 22.6 19.66 / 237 (12.0c) 19.57 / 233 (11.9c) 6.33 / 58.39 (9.2c) 6.12 / 67.31 (11.0c) 52.00 / 51.99 (1.0c) pyarrow-threaded 1.15x ⚠️
stddev by key (1000 groups) 10,000,000 5.37 / 1.88 22.4 19.55 / 238 (12.2c) 19.64 / 235 (12.0c) 6.35 / 58.70 (9.3c) 6.17 / 67.54 (10.9c) 48.63 / 48.62 (1.0c) pyarrow-threaded 1.15x ⚠️
min by key, float64 (1000 groups) 10,000,000 1.74 / 1.41 68.8 19.79 / 238 (12.0c) 20.20 / 242 (12.0c) 5.05 / 46.55 (9.2c) 4.86 / 53.36 (11.0c) 49.28 / 49.29 (1.0c) pyarrow-threaded 2.79x ⚠️
max by key, float64 (1000 groups) 10,000,000 3.77 / 1.63 31.9 19.63 / 233 (11.9c) 20.05 / 235 (11.7c) 5.00 / 46.16 (9.2c) 4.83 / 53.33 (11.0c) 51.00 / 51.00 (1.0c) pyarrow-threaded 1.28x ⚠️
count_distinct by key (1000 groups) 10,000,000 7.13 / 2.33 16.8 26.64 / 348 (13.1c) 26.65 / 349 (13.1c) 327 / 649 (2.0c) 323 / 672 (2.1c) 855 / 855 (1.0c) polars 3.74x ✅
first by key (1000 groups) 10,000,000 3.82 / 2.24 31.4 15.17 / 161 (10.6c) 15.12 / 163 (10.8c) 42.39 / 42.38 (1.0c) -- 39.43 / 39.41 (1.0c) polars-lazy 3.96x ✅
list by key (1000 groups) 10,000,000 5.91 / 2.88 33.8 58.57 / 203 (3.5c) 58.06 / 204 (3.5c) 73.48 / 110 (1.5c) 74.51 / 122 (1.6c) 1621 / 1621 (1.0c) polars-lazy 9.82x ✅
variance by key (100000 groups) 10,000,000 9.47 / 2.19 12.7 22.97 / 280 (12.2c) 23.48 / 285 (12.2c) 39.41 / 260 (6.6c) 46.53 / 361 (7.8c) 84.34 / 84.33 (1.0c) polars 2.43x ⚠️
stddev by key (100000 groups) 10,000,000 9.66 / 2.39 12.4 23.75 / 287 (12.1c) 23.05 / 285 (12.4c) 39.06 / 263 (6.7c) 46.79 / 355 (7.6c) 84.76 / 84.75 (1.0c) polars-lazy 2.39x ⚠️
min by key, float64 (100000 groups) 10,000,000 2.58 / 1.38 46.5 22.58 / 279 (12.3c) 22.31 / 277 (12.4c) 17.65 / 102 (5.8c) 24.33 / 146 (6.0c) 64.55 / 64.54 (1.0c) pyarrow 6.85x ✅
max by key, float64 (100000 groups) 10,000,000 2.56 / 1.39 46.9 22.74 / 275 (12.1c) 23.13 / 275 (11.9c) 17.78 / 103 (5.8c) 24.55 / 142 (5.8c) 64.88 / 64.86 (1.0c) pyarrow 6.95x ✅
count_distinct by key (100000 groups) 10,000,000 7.28 / 2.33 16.5 31.61 / 410 (13.0c) 31.04 / 414 (13.3c) 364 / 776 (2.1c) 376 / 887 (2.4c) 869 / 869 (1.0c) polars-lazy 4.27x ✅
first by key (100000 groups) 10,000,000 2.94 / 1.97 40.8 17.86 / 201 (11.2c) 17.95 / 201 (11.2c) 54.45 / 54.44 (1.0c) -- 49.27 / 49.27 (1.0c) polars 6.07x ✅
list by key (100000 groups) 10,000,000 8.83 / 3.45 22.7 74.52 / 255 (3.4c) 74.15 / 255 (3.4c) 88.77 / 152 (1.7c) 92.38 / 185 (2.0c) 2533 / 2533 (1.0c) polars-lazy 8.40x ✅
variance by key (10000000 groups) 10,000,000 20.42 / 2.83 5.88 73.39 / 1018 (13.9c) 78.82 / 1064 (13.5c) 460 / 1099 (2.4c) 462 / 1114 (2.4c) 348 / 348 (1.0c) polars 3.59x ✅
stddev by key (10000000 groups) 10,000,000 37.03 / 18.81 3.24 76.64 / 1035 (13.5c) 79.81 / 1101 (13.8c) 461 / 1111 (2.4c) 464 / 1070 (2.3c) 352 / 352 (1.0c) polars 2.07x ⚠️
min by key, float64 (10000000 groups) 10,000,000 14.39 / 1.70 8.34 75.36 / 1054 (14.0c) 79.29 / 1079 (13.6c) 299 / 620 (2.1c) 313 / 650 (2.1c) 318 / 318 (1.0c) polars 5.24x ✅
max by key, float64 (10000000 groups) 10,000,000 14.43 / 1.73 8.32 77.05 / 1034 (13.4c) 80.91 / 1078 (13.3c) 301 / 633 (2.1c) 311 / 630 (2.0c) 322 / 321 (1.0c) polars 5.34x ✅
count_distinct by key (10000000 groups) 10,000,000 11.44 / 2.73 10.5 82.80 / 1139 (13.8c) 83.93 / 1165 (13.9c) 646 / 1284 (2.0c) 668 / 1374 (2.1c) 1033 / 1033 (1.0c) polars 7.24x ✅
first by key (10000000 groups) 10,000,000 14.57 / 2.63 8.23 93.98 / 785 (8.3c) 98.18 / 803 (8.2c) 308 / 308 (1.0c) -- 300 / 300 (1.0c) polars 6.45x ✅
list by key (10000000 groups) 10,000,000 59.99 / 4.48 3.33 186 / 860 (4.6c) 191 / 891 (4.7c) 306 / 579 (1.9c) 318 / 592 (1.9c) 49627 / 49623 (1.0c) polars 3.10x ✅
sum by int32 key (1000 groups) 50,000,000 4.89 / 1.23 123 82.40 / 1193 (14.5c) 81.93 / 1186 (14.5c) 18.51 / 250 (13.5c) 18.45 / 250 (13.6c) 248 / 248 (1.0c) pyarrow-threaded 3.77x ✅
mean by int32 key (1000 groups) 50,000,000 4.91 / 1.24 122 81.49 / 1180 (14.5c) 82.10 / 1178 (14.4c) 18.71 / 254 (13.6c) 18.63 / 251 (13.5c) 265 / 265 (1.0c) pyarrow-threaded 3.79x ✅
min by int32 key (1000 groups) 50,000,000 5.91 / 1.54 101 82.49 / 1170 (14.2c) 80.64 / 1195 (14.8c) 19.73 / 271 (13.8c) 19.64 / 273 (13.9c) 271 / 271 (1.0c) pyarrow-threaded 3.32x ✅
max by int32 key (1000 groups) 50,000,000 5.91 / 1.59 102 83.39 / 1199 (14.4c) 83.23 / 1200 (14.4c) 19.82 / 271 (13.6c) 19.53 / 267 (13.7c) 274 / 274 (1.0c) pyarrow-threaded 3.30x ✅
count by int32 key (1000 groups) 50,000,000 4.23 / 1.23 142 89.01 / 810 (9.1c) 89.61 / 813 (9.1c) 17.48 / 236 (13.5c) 17.38 / 235 (13.5c) 138 / 138 (1.0c) pyarrow-threaded 4.11x ✅
sum by utf8 key (1000 distinct) 50,000,000 18.22 / 3.46 54.9 144 / 1926 (13.4c) 142 / 1946 (13.7c) 40.48 / 564 (13.9c) 40.06 / 566 (14.1c) 555 / 554 (1.0c) pyarrow-threaded 2.20x ⚠️
sum by two int32 keys (~1024 groups) 50,000,000 5.91 / 2.12 135 164 / 2216 (13.5c) 177 / 2242 (12.7c) 23.88 / 322 (13.5c) 24.00 / 323 (13.5c) 448 / 448 (1.0c) pyarrow 4.04x ✅
sum by int32 key (100000 groups) 50,000,000 7.41 / 1.25 81.0 102 / 1457 (14.2c) 99.43 / 1461 (14.7c) 48.06 / 542 (11.3c) 46.39 / 542 (11.7c) 335 / 335 (1.0c) pyarrow-threaded 6.26x ✅
mean by int32 key (100000 groups) 50,000,000 7.39 / 1.21 81.2 101 / 1498 (14.8c) 99.19 / 1458 (14.7c) 47.29 / 539 (11.4c) 47.02 / 540 (11.5c) 430 / 430 (1.0c) pyarrow-threaded 6.36x ✅
min by int32 key (100000 groups) 50,000,000 10.57 / 1.50 56.8 100 / 1460 (14.5c) 103 / 1477 (14.3c) 52.71 / 586 (11.1c) 52.06 / 586 (11.2c) 335 / 335 (1.0c) pyarrow-threaded 4.93x ✅
max by int32 key (100000 groups) 50,000,000 10.58 / 1.53 56.7 116 / 1462 (12.6c) 106 / 1460 (13.8c) 59.60 / 607 (10.2c) 56.33 / 612 (10.9c) 335 / 335 (1.0c) pyarrow-threaded 5.32x ✅
count by int32 key (100000 groups) 50,000,000 4.92 / 1.26 122 121 / 1060 (8.8c) 122 / 1071 (8.8c) 39.68 / 436 (11.0c) 38.29 / 421 (11.0c) 171 / 171 (1.0c) pyarrow-threaded 7.78x ✅
sum by utf8 key (100000 distinct) 50,000,000 23.80 / 3.82 42.0 178 / 2296 (12.9c) 184 / 2484 (13.5c) 145 / 1781 (12.3c) 145 / 1743 (12.0c) 783 / 783 (1.0c) pyarrow 6.08x ✅
sum by two int32 keys (~100489 groups) 50,000,000 8.27 / 2.21 96.7 214 / 2617 (12.2c) 213 / 2700 (12.7c) 66.55 / 709 (10.6c) 61.18 / 657 (10.7c) 573 / 572 (1.0c) pyarrow-threaded 7.40x ✅
sum by int32 key (10000000 groups) 50,000,000 46.12 / 2.16 13.0 327 / 4615 (14.1c) 322 / 4585 (14.3c) 1309 / 3771 (2.9c) 1303 / 3682 (2.8c) 1294 / 1294 (1.0c) polars-lazy 6.98x ✅
mean by int32 key (10000000 groups) 50,000,000 49.64 / 2.23 12.1 343 / 4847 (14.1c) 337 / 4702 (14.0c) 1294 / 3657 (2.8c) 1285 / 3658 (2.8c) 1350 / 1350 (1.0c) polars-lazy 6.78x ✅
min by int32 key (10000000 groups) 50,000,000 72.04 / 1.95 8.33 329 / 4707 (14.3c) 347 / 4689 (13.5c) 1356 / 3824 (2.8c) 1314 / 3696 (2.8c) 1423 / 1421 (1.0c) polars 4.57x ✅
max by int32 key (10000000 groups) 50,000,000 72.10 / 2.01 8.32 334 / 4749 (14.2c) 326 / 4651 (14.2c) 1346 / 3814 (2.8c) 1346 / 3795 (2.8c) 1402 / 1402 (1.0c) polars-lazy 4.53x ✅
count by int32 key (10000000 groups) 50,000,000 16.66 / 2.02 36.0 429 / 4056 (9.5c) 427 / 3965 (9.3c) 1067 / 3188 (3.0c) 1065 / 3167 (3.0c) 773 / 773 (1.0c) polars-lazy 25.65x ✅
sum by utf8 key (10000000 distinct) 50,000,000 126 / 6.39 7.93 603 / 7396 (12.3c) 685 / 7854 (11.5c) 2944 / 6272 (2.1c) 2928 / 6293 (2.1c) 4288 / 4288 (1.0c) polars 4.78x ✅
sum by two int32 keys (~10004569 groups) 50,000,000 47.94 / 3.21 16.7 580 / 7954 (13.7c) 619 / 7801 (12.6c) 1363 / 3768 (2.8c) 1363 / 3811 (2.8c) 1610 / 1610 (1.0c) polars 12.10x ✅
variance by key (1000 groups) 50,000,000 30.36 / 1.92 19.8 83.36 / 1197 (14.4c) 84.94 / 1201 (14.1c) 29.17 / 371 (12.7c) 33.73 / 423 (12.5c) 239 / 239 (1.0c) pyarrow 0.96x ❌
stddev by key (1000 groups) 50,000,000 30.56 / 2.11 19.6 121 / 1759 (14.6c) 82.71 / 1201 (14.5c) 60.82 / 640 (10.5c) 37.74 / 465 (12.3c) 243 / 243 (1.0c) pyarrow-threaded 1.24x ⚠️
min by key, float64 (1000 groups) 50,000,000 6.11 / 1.72 98.2 139 / 2022 (14.5c) 88.94 / 1176 (13.2c) 41.79 / 501 (12.0c) 30.50 / 386 (12.7c) 225 / 225 (1.0c) pyarrow-threaded 4.99x ✅
max by key, float64 (1000 groups) 50,000,000 6.09 / 1.72 98.6 142 / 2028 (14.3c) 89.60 / 1160 (12.9c) 41.34 / 502 (12.1c) 28.70 / 358 (12.5c) 234 / 234 (1.0c) pyarrow-threaded 4.71x ✅
count_distinct by key (1000 groups) 50,000,000 41.78 / 2.64 14.4 308 / 4018 (13.0c) 157 / 2195 (14.0c) 1886 / 4226 (2.2c) 1859 / 4251 (2.3c) 4773 / 4773 (1.0c) polars-lazy 3.75x ✅
first by key (1000 groups) 50,000,000 6.82 / 3.40 87.9 54.24 / 775 (14.3c) 55.79 / 781 (14.0c) 211 / 211 (1.0c) -- 197 / 197 (1.0c) polars 7.95x ✅
list by key (1000 groups) 50,000,000 28.42 / 3.94 35.2 319 / 1024 (3.2c) 308 / 1026 (3.3c) 401 / 630 (1.6c) 394 / 609 (1.5c) 8179 / 8178 (1.0c) polars-lazy 10.85x ✅
variance by key (100000 groups) 50,000,000 37.72 / 2.47 15.9 98.06 / 1454 (14.8c) 94.86 / 1395 (14.7c) 240 / 2822 (11.8c) 162 / 1855 (11.5c) 422 / 421 (1.0c) polars-lazy 2.52x ⚠️
stddev by key (100000 groups) 50,000,000 38.10 / 2.90 15.7 124 / 1809 (14.6c) 95.84 / 1401 (14.6c) 235 / 2746 (11.7c) 167 / 1937 (11.6c) 424 / 424 (1.0c) polars-lazy 2.52x ⚠️
min by key, float64 (100000 groups) 50,000,000 9.70 / 1.68 61.9 123 / 1826 (14.9c) 95.42 / 1392 (14.6c) 84.79 / 896 (10.6c) 64.28 / 678 (10.5c) 311 / 311 (1.0c) pyarrow-threaded 6.63x ✅
max by key, float64 (100000 groups) 50,000,000 9.67 / 1.66 62.0 121 / 1782 (14.7c) 95.58 / 1383 (14.5c) 73.51 / 780 (10.6c) 64.88 / 663 (10.2c) 310 / 310 (1.0c) pyarrow-threaded 6.71x ✅
count_distinct by key (100000 groups) 50,000,000 42.32 / 2.67 14.2 162 / 2370 (14.6c) 143 / 2174 (15.3c) 2028 / 4969 (2.4c) 2029 / 4962 (2.4c) 4674 / 4673 (1.0c) polars-lazy 3.37x ✅
first by key (100000 groups) 50,000,000 11.30 / 3.38 53.1 65.60 / 956 (14.6c) 69.25 / 945 (13.6c) 261 / 261 (1.0c) -- 242 / 242 (1.0c) polars 5.81x ✅
list by key (100000 groups) 50,000,000 35.36 / 4.42 28.3 344 / 1267 (3.7c) 345 / 1252 (3.6c) 488 / 830 (1.7c) 490 / 861 (1.8c) 9555 / 9554 (1.0c) polars 9.72x ✅
variance by key (10000000 groups) 50,000,000 79.45 / 3.06 7.55 323 / 4654 (14.4c) 323 / 4758 (14.7c) 2444 / 11772 (4.8c) 2438 / 11794 (4.8c) 1368 / 1368 (1.0c) polars-lazy 4.06x ✅
stddev by key (10000000 groups) 50,000,000 93.19 / 15.72 6.44 329 / 4638 (14.1c) 327 / 4782 (14.6c) 2443 / 11748 (4.8c) 2453 / 11739 (4.8c) 1370 / 1370 (1.0c) polars-lazy 3.51x ✅
min by key, float64 (10000000 groups) 50,000,000 63.04 / 1.93 9.52 321 / 4582 (14.3c) 325 / 4708 (14.5c) 1259 / 3727 (3.0c) 1234 / 3769 (3.1c) 1326 / 1325 (1.0c) polars 5.10x ✅
max by key, float64 (10000000 groups) 50,000,000 63.01 / 1.94 9.52 327 / 4649 (14.2c) 329 / 4671 (14.2c) 1241 / 3805 (3.1c) 1228 / 3784 (3.1c) 1343 / 1343 (1.0c) polars 5.20x ✅
count_distinct by key (10000000 groups) 50,000,000 60.71 / 3.09 9.88 408 / 6021 (14.8c) 411 / 6032 (14.7c) 3210 / 7895 (2.5c) 3195 / 7901 (2.5c) 5238 / 5224 (1.0c) polars 6.71x ✅
first by key (10000000 groups) 50,000,000 65.65 / 3.87 9.14 336 / 3892 (11.6c) 345 / 3960 (11.5c) 1146 / 1146 (1.0c) -- 1047 / 1047 (1.0c) polars 5.12x ✅
list by key (10000000 groups) 50,000,000 139 / 5.77 7.18 797 / 4366 (5.5c) 794 / 4321 (5.4c) 1487 / 3624 (2.4c) 1433 / 3569 (2.5c) 85183 / 85178 (1.0c) polars-lazy 5.70x ✅

temporal

op rows ArrowMetal wall / CPU ms ArrowMetal GB/s polars polars-lazy pyarrow pyarrow-threaded pandas / numpy fastest CPU idiom ratio verdict
year 10,000,000 5.55 / 0.414 21.6 81.24 / 81.25 (1.0c) 6.92 / 98.75 (14.3c) 37.58 / 37.58 (1.0c) 4.70 / 47.21 (10.1c) 226 / 226 (1.0c) pyarrow-threaded 0.85x ❌
month 10,000,000 5.56 / 0.401 21.6 83.71 / 83.70 (1.0c) 7.16 / 102 (14.3c) 37.80 / 37.80 (1.0c) 5.17 / 45.30 (8.8c) 226 / 226 (1.0c) pyarrow-threaded 0.93x ❌
day 10,000,000 5.99 / 0.389 20.0 83.97 / 83.97 (1.0c) 7.16 / 103 (14.4c) 41.88 / 41.87 (1.0c) 5.06 / 51.71 (10.2c) 226 / 226 (1.0c) pyarrow-threaded 0.84x ❌
floor_temporal (day) 10,000,000 1.92 / 0.685 83.5 7.80 / 7.87 (1.0c) 0.740 / 7.93 (10.7c) 17.67 / 17.67 (1.0c) 2.25 / 23.58 (10.5c) 8.60 / 8.61 (1.0c) polars-lazy 0.39x ❌
add_duration (+1h) 10,000,000 0.541 / 0.373 296 4.71 / 4.79 (1.0c) 0.800 / 7.86 (9.8c) 2.66 / 2.67 (1.0c) 1.01 / 9.80 (9.8c) 37.64 / 37.64 (1.0c) polars-lazy 1.48x ⚠️
days_between 10,000,000 9.89 / 1.94 24.3 12.30 / 12.30 (1.0c) 1.46 / 18.14 (12.4c) 10.43 / 10.43 (1.0c) 1.72 / 15.63 (9.1c) 90.00 / 89.99 (1.0c) polars-lazy 0.15x ❌
week 10,000,000 5.03 / 0.707 23.9 112 / 112 (1.0c) 9.50 / 136 (14.4c) 126 / 126 (1.0c) 13.91 / 149 (10.7c) 285 / 285 (1.0c) polars-lazy 1.89x ⚠️
strftime (%Y-%m-%d) 10,000,000 30.75 / 1.39 6.50 941 / 941 (1.0c) 74.98 / 1114 (14.9c) 2158 / 2158 (1.0c) 175 / 2615 (15.0c) 936 / 936 (1.0c) polars-lazy 2.44x ⚠️
strptime (%Y-%m-%d %H:%M:%S) 10,000,000 3.37 / 0.775 83.1 418 / 418 (1.0c) 32.54 / 483 (14.8c) 345 / 345 (1.0c) 29.97 / 418 (13.9c) 967 / 967 (1.0c) pyarrow-threaded 8.89x ✅
assume_timezone (America/New_York) 10,000,000 3.36 / 0.708 47.6 540 / 540 (1.0c) 41.02 / 630 (15.4c) 215 / 215 (1.0c) 21.68 / 263 (12.1c) 669 / 669 (1.0c) pyarrow-threaded 6.45x ✅
local_timestamp (America/New_York) 10,000,000 3.28 / 0.686 48.8 494 / 494 (1.0c) 37.22 / 564 (15.1c) 101 / 101 (1.0c) 11.96 / 128 (10.7c) 225 / 225 (1.0c) pyarrow-threaded 3.65x ✅
is_dst (America/New_York) 10,000,000 3.08 / 0.418 29.2 388 / 388 (1.0c) 29.49 / 444 (15.1c) 110 / 110 (1.0c) 12.64 / 135 (10.7c) -- pyarrow-threaded 4.10x ✅
year 50,000,000 26.82 / 0.408 22.4 406 / 406 (1.0c) 32.65 / 496 (15.2c) 177 / 177 (1.0c) 17.91 / 246 (13.7c) 1096 / 1096 (1.0c) pyarrow-threaded 0.67x ❌
month 50,000,000 26.91 / 0.411 22.3 419 / 419 (1.0c) 33.53 / 514 (15.3c) 177 / 177 (1.0c) 17.84 / 248 (13.9c) 1095 / 1095 (1.0c) pyarrow-threaded 0.66x ❌
day 50,000,000 29.11 / 0.419 20.6 420 / 420 (1.0c) 33.56 / 519 (15.5c) 195 / 195 (1.0c) 19.36 / 269 (13.9c) 1097 / 1097 (1.0c) pyarrow-threaded 0.67x ❌
floor_temporal (day) 50,000,000 8.74 / 1.87 91.5 41.11 / 41.16 (1.0c) 3.35 / 39.84 (11.9c) 87.51 / 87.50 (1.0c) 9.71 / 122 (12.5c) 44.82 / 44.81 (1.0c) polars-lazy 0.38x ❌
add_duration (+1h) 50,000,000 2.09 / 0.383 383 25.26 / 25.34 (1.0c) 3.26 / 41.32 (12.7c) 13.16 / 13.16 (1.0c) 4.37 / 52.33 (12.0c) 183 / 183 (1.0c) polars-lazy 1.56x ⚠️
days_between 50,000,000 46.31 / 2.05 25.9 63.35 / 63.36 (1.0c) 5.75 / 79.48 (13.8c) 51.32 / 51.32 (1.0c) 6.66 / 84.53 (12.7c) 451 / 451 (1.0c) polars-lazy 0.12x ❌
week 50,000,000 24.18 / 1.84 24.8 557 / 557 (1.0c) 44.96 / 690 (15.3c) 587 / 587 (1.0c) 56.03 / 750 (13.4c) 1391 / 1391 (1.0c) polars-lazy 1.86x ⚠️
strftime (%Y-%m-%d) 50,000,000 151 / 2.10 6.62 4709 / 4709 (1.0c) 361 / 5593 (15.5c) 10645 / 10644 (1.0c) 839 / 12953 (15.4c) 4629 / 4629 (1.0c) polars-lazy 2.39x ⚠️
strptime (%Y-%m-%d %H:%M:%S) 50,000,000 15.54 / 2.05 90.1 2167 / 2167 (1.0c) 157 / 2450 (15.6c) 1728 / 1728 (1.0c) 143 / 2117 (14.8c) 4887 / 4886 (1.0c) pyarrow-threaded 9.18x ✅
assume_timezone (America/New_York) 50,000,000 15.85 / 1.83 50.5 2697 / 2697 (1.0c) 205 / 3152 (15.4c) 1069 / 1069 (1.0c) 92.53 / 1329 (14.4c) 3119 / 3119 (1.0c) pyarrow-threaded 5.84x ✅
local_timestamp (America/New_York) 50,000,000 15.53 / 1.84 51.5 2461 / 2461 (1.0c) 184 / 2844 (15.5c) 508 / 508 (1.0c) 42.51 / 650 (15.3c) 1122 / 1122 (1.0c) pyarrow-threaded 2.74x ⚠️
is_dst (America/New_York) 50,000,000 14.15 / 0.443 31.8 1938 / 1938 (1.0c) 144 / 2219 (15.4c) 545 / 545 (1.0c) 49.40 / 675 (13.7c) -- pyarrow-threaded 3.49x ✅

window

op rows ArrowMetal wall / CPU ms ArrowMetal GB/s polars polars-lazy pyarrow pyarrow-threaded pandas / numpy fastest CPU idiom ratio verdict
cumulative_sum (float64) 10,000,000 2.83 / 0.427 56.5 26.91 / 26.90 (1.0c) 25.96 / 25.95 (1.0c) 19.93 / 19.93 (1.0c) -- 24.55 / 24.55 (1.0c) pyarrow 7.04x ✅
cumulative_prod (float64) 10,000,000 2.70 / 0.401 59.3 26.44 / 26.45 (1.0c) 26.37 / 26.37 (1.0c) 20.18 / 20.18 (1.0c) -- 25.25 / 25.24 (1.0c) pyarrow 7.48x ✅
shift (lag 1, int64) 10,000,000 2.37 / 0.774 67.4 0.038 / 0.073 (1.9c) 0.030 / 0.066 (2.2c) -- -- 0.047 / 0.047 (1.0c) polars-lazy 0.013x ❌
pairwise_diff (float64) 10,000,000 0.937 / 0.582 171 4.52 / 4.61 (1.0c) 4.67 / 4.73 (1.0c) 21.02 / 21.02 (1.0c) -- 4.98 / 4.98 (1.0c) polars 4.82x ✅
rolling_sum (window 64) 10,000,000 5.35 / 1.73 29.9 86.72 / 86.71 (1.0c) 85.72 / 85.72 (1.0c) -- -- 47.35 / 47.34 (1.0c) pandas 8.85x ✅
rolling_mean (window 64) 10,000,000 5.87 / 1.76 27.2 85.49 / 85.49 (1.0c) 85.64 / 85.63 (1.0c) -- -- 42.96 / 42.95 (1.0c) pandas 7.32x ✅
cumulative_sum (float64) 50,000,000 13.04 / 0.405 61.3 132 / 132 (1.0c) 135 / 135 (1.0c) 99.50 / 99.50 (1.0c) -- 120 / 120 (1.0c) pyarrow 7.63x ✅
cumulative_prod (float64) 50,000,000 12.40 / 0.409 64.5 134 / 134 (1.0c) 136 / 136 (1.0c) 99.98 / 99.98 (1.0c) -- 123 / 123 (1.0c) pyarrow 8.06x ✅
shift (lag 1, int64) 50,000,000 3.58 / 0.874 223 0.045 / 0.082 (1.8c) 0.029 / 0.063 (2.2c) -- -- 0.152 / 0.154 (1.0c) polars-lazy 0.008x ❌
pairwise_diff (float64) 50,000,000 3.57 / 0.889 224 25.54 / 25.62 (1.0c) 25.62 / 25.77 (1.0c) 102 / 103 (1.0c) -- 24.68 / 24.67 (1.0c) pandas 6.91x ✅
rolling_sum (window 64) 50,000,000 23.32 / 2.09 34.3 431 / 431 (1.0c) 428 / 428 (1.0c) -- -- 234 / 234 (1.0c) pandas 10.04x ✅
rolling_mean (window 64) 50,000,000 25.34 / 2.08 31.6 417 / 417 (1.0c) 415 / 415 (1.0c) -- -- 213 / 213 (1.0c) pandas 8.42x ✅

decimal

op rows ArrowMetal wall / CPU ms ArrowMetal GB/s polars polars-lazy pyarrow pyarrow-threaded pandas / numpy fastest CPU idiom ratio verdict
decimal add (128-bit) 10,000,000 1.44 / 0.394 334 55.32 / 55.33 (1.0c) 4.36 / 59.54 (13.7c) 15.02 / 15.02 (1.0c) 2.54 / 25.75 (10.1c) 15.01 / 15.01 (1.0c) pyarrow-threaded 1.77x ⚠️
decimal multiply (by scalar) 10,000,000 1.60 / 0.399 200 90.98 / 90.99 (1.0c) 7.41 / 106 (14.4c) 14.87 / 14.87 (1.0c) 2.16 / 22.43 (10.4c) 15.17 / 15.18 (1.0c) pyarrow-threaded 1.35x ⚠️
decimal compare (> scalar) 10,000,000 0.918 / 0.434 185 2.99 / 2.99 (1.0c) 0.825 / 9.77 (11.9c) 10.85 / 10.85 (1.0c) 1.36 / 14.03 (10.3c) 10.92 / 10.92 (1.0c) polars-lazy 0.90x ❌
decimal sum 10,000,000 0.605 / 0.374 264 18.42 / 18.42 (1.0c) 2.34 / 23.35 (10.0c) 19.78 / 19.78 (1.0c) 1.88 / 23.78 (12.7c) 19.75 / 19.75 (1.0c) pyarrow-threaded 3.11x ✅
decimal round (2 places) 10,000,000 7.64 / 0.385 41.9 86.79 / 86.78 (1.0c) 6.61 / 97.55 (14.8c) 217 / 217 (1.0c) 21.00 / 263 (12.5c) 223 / 223 (1.0c) polars-lazy 0.87x ❌
decimal add (128-bit) 50,000,000 5.42 / 0.407 443 276 / 276 (1.0c) 21.56 / 305 (14.1c) 74.07 / 74.06 (1.0c) 11.04 / 141 (12.8c) 74.38 / 74.38 (1.0c) pyarrow-threaded 2.04x ⚠️
decimal multiply (by scalar) 50,000,000 3.59 / 0.405 446 459 / 459 (1.0c) 35.48 / 536 (15.1c) 74.06 / 74.05 (1.0c) 9.64 / 119 (12.4c) 75.48 / 75.48 (1.0c) pyarrow-threaded 2.69x ⚠️
decimal compare (> scalar) 50,000,000 2.20 / 0.405 387 14.93 / 14.93 (1.0c) 3.41 / 47.63 (14.0c) 41.87 / 41.87 (1.0c) 5.51 / 70.69 (12.8c) 42.10 / 42.10 (1.0c) polars-lazy 1.55x ⚠️
decimal sum 50,000,000 1.81 / 0.443 441 92.15 / 92.17 (1.0c) 11.44 / 109 (9.5c) 94.99 / 94.98 (1.0c) 9.21 / 118 (12.8c) 93.91 / 93.90 (1.0c) pyarrow-threaded 5.08x ✅
decimal round (2 places) 50,000,000 37.28 / 0.404 42.9 438 / 438 (1.0c) 32.44 / 482 (14.9c) 1080 / 1080 (1.0c) 89.91 / 1324 (14.7c) 1105 / 1105 (1.0c) polars-lazy 0.87x ❌

nested

op rows ArrowMetal wall / CPU ms ArrowMetal GB/s polars polars-lazy pyarrow pyarrow-threaded pandas / numpy fastest CPU idiom ratio verdict
list_value_length 10,000,000 0.361 / 0.372 886 5.47 / 5.47 (1.0c) 0.733 / 7.48 (10.2c) 0.730 / 0.731 (1.0c) 0.659 / 6.34 (9.6c) 0.788 / 0.789 (1.0c) pyarrow-threaded 1.83x ⚠️
list_flatten 10,000,000 0.001 / 0.002 853335 19.84 / 19.84 (1.0c) 19.31 / 19.32 (1.0c) 0.002 / 0.002 (0.9c) -- 15.60 / 15.60 (1.0c) pyarrow 3.14x ✅
list_element (index 1) 10,000,000 2.39 / 1.37 134 27.80 / 27.86 (1.0c) 2.74 / 36.95 (13.5c) 70.58 / 70.58 (1.0c) 7.45 / 87.50 (11.8c) 70.58 / 70.58 (1.0c) polars-lazy 1.15x ⚠️
struct_field 10,000,000 0.001 / 0.003 174481 0.013 / 0.014 (1.1c) 0.210 / 0.813 (3.9c) 0.003 / 0.004 (1.3c) 0.590 / 6.29 (10.7c) 0.014 / 0.016 (1.1c) pyarrow 3.33x ✅

chains

op rows ArrowMetal wall / CPU ms ArrowMetal GB/s polars polars-lazy pyarrow pyarrow-threaded pandas / numpy fastest CPU idiom ratio verdict
filter two columns + sum 10,000,000 1.94 / 1.64 61.8 4.42 / 7.60 (1.7c) 0.826 / 9.37 (11.3c) 12.74 / 12.75 (1.0c) 4.07 / 48.53 (11.9c) 23.00 / 23.00 (1.0c) (numpy) polars-lazy 0.43x ❌
filter two columns + sum [batched] 10,000,000 1.29 / 0.464 93.0 -- -- -- -- -- -- -- —
group-by after filter 10,000,000 3.75 / 2.63 53.3 8.33 / 64.25 (7.7c) 1.86 / 19.89 (10.7c) 17.91 / 21.30 (1.2c) 4.44 / 50.81 (11.5c) 16.67 / 16.67 (1.0c) polars-lazy 0.49x ❌
group-by after filter [batched] 10,000,000 2.04 / 2.07 98.1 -- -- -- -- -- -- -- —
compare + filter + take 10,000,000 0.979 / 0.994 204 7.55 / 7.55 (1.0c) 7.39 / 7.49 (1.0c) 23.88 / 23.87 (1.0c) -- 40.27 / 40.25 (1.0c) (numpy) polars-lazy 7.55x ✅
compare + filter + take [batched] 10,000,000 1.44 / 0.451 138 -- -- -- -- -- -- -- —
filter two columns + sum 50,000,000 3.53 / 1.75 170 21.08 / 34.19 (1.6c) 2.92 / 40.65 (13.9c) 63.48 / 63.47 (1.0c) 17.73 / 240 (13.5c) 112 / 112 (1.0c) (numpy) polars-lazy 0.83x ❌
filter two columns + sum [batched] 50,000,000 2.97 / 0.487 202 -- -- -- -- -- -- -- —
group-by after filter 50,000,000 6.73 / 2.67 148 36.20 / 279 (7.7c) 4.85 / 66.38 (13.7c) 75.19 / 108 (1.4c) 18.57 / 248 (13.4c) 84.66 / 84.70 (1.0c) polars-lazy 0.72x ❌
group-by after filter [batched] 50,000,000 6.74 / 2.33 148 -- -- -- -- -- -- -- —
compare + filter + take 50,000,000 4.54 / 1.13 220 45.72 / 45.71 (1.0c) 46.46 / 46.54 (1.0c) 124 / 124 (1.0c) -- 202 / 202 (1.0c) (numpy) polars 10.06x ✅
compare + filter + take [batched] 50,000,000 4.25 / 0.412 235 -- -- -- -- -- -- -- —

join

op rows ArrowMetal wall / CPU ms ArrowMetal GB/s polars polars-lazy pyarrow pyarrow-threaded pandas / numpy fastest CPU idiom ratio verdict
inner hash join (10000000 x 200000 on int64) 10,000,000 3.88 / 1.54 42.0 22.08 / 209 (9.5c) 22.55 / 211 (9.4c) 10.39 / 147 (14.1c) 10.63 / 150 (14.1c) 96.20 / 96.19 (1.0c) pyarrow 2.68x ⚠️
hash join indices (10000000 x 200000 on int64) 10,000,000 2.84 / 0.788 28.8 22.04 / 210 (9.5c) 22.58 / 208 (9.2c) 10.39 / 147 (14.1c) 10.39 / 148 (14.3c) 103 / 103 (1.0c) pyarrow-threaded 3.66x ✅
left outer hash join (10000000 x 200000 on int64) 10,000,000 3.04 / 1.03 26.8 57.86 / 299 (5.2c) 57.73 / 299 (5.2c) 9.39 / 131 (14.0c) 9.52 / 133 (14.0c) 253 / 253 (1.0c) pyarrow 3.08x ✅
inner hash join (50000000 x 1000000 on int64) 50,000,000 21.80 / 1.57 37.4 106 / 1053 (10.0c) 107 / 1067 (10.0c) 63.87 / 959 (15.0c) 64.65 / 963 (14.9c) 543 / 543 (1.0c) pyarrow 2.93x ⚠️
hash join indices (50000000 x 1000000 on int64) 50,000,000 18.30 / 0.805 22.3 107 / 1041 (9.7c) 110 / 1056 (9.6c) 63.28 / 953 (15.1c) 64.70 / 968 (15.0c) 589 / 559 (0.9c) pyarrow 3.46x ✅
left outer hash join (50000000 x 1000000 on int64) 50,000,000 19.08 / 1.33 21.4 285 / 1428 (5.0c) 286 / 1408 (4.9c) 57.95 / 858 (14.8c) 58.08 / 857 (14.8c) 1690 / 1690 (1.0c) pyarrow 3.04x ✅

strings

op rows ArrowMetal wall / CPU ms ArrowMetal GB/s polars polars-lazy pyarrow pyarrow-threaded pandas / numpy fastest CPU idiom ratio verdict
char_length 1,000,000 0.229 / 0.273 77.1 5.09 / 5.09 (1.0c) 0.802 / 6.62 (8.3c) 5.20 / 5.20 (1.0c) 0.621 / 6.36 (10.2c) 5.28 / 5.28 (1.0c) pyarrow-threaded 2.72x ⚠️
upper 1,000,000 2.55 / 1.14 13.8 30.28 / 30.29 (1.0c) 4.08 / 36.43 (8.9c) 17.33 / 17.33 (1.0c) 1.68 / 20.38 (12.1c) 17.34 / 17.33 (1.0c) pyarrow-threaded 0.66x ❌
lower 1,000,000 2.63 / 1.18 13.4 36.89 / 36.90 (1.0c) 4.03 / 43.71 (10.9c) 17.13 / 17.11 (1.0c) 1.71 / 20.04 (11.7c) 17.15 / 17.14 (1.0c) pyarrow-threaded 0.65x ❌
contains("north") 1,000,000 0.509 / 0.349 34.6 14.40 / 14.47 (1.0c) 1.92 / 17.63 (9.2c) 12.31 / 12.31 (1.0c) 1.39 / 15.38 (11.0c) 11.62 / 11.63 (1.0c) pyarrow-threaded 2.74x ⚠️
starts_with("cust_1") 1,000,000 0.341 / 0.352 51.7 3.55 / 3.62 (1.0c) 0.591 / 4.57 (7.7c) 3.10 / 3.10 (1.0c) 0.429 / 4.04 (9.4c) 3.12 / 3.12 (1.0c) pyarrow-threaded 1.26x ⚠️
match_like("cust%") [pure prefix] 1,000,000 0.267 / 0.313 66.1 2.25 / 2.31 (1.0c) 0.450 / 3.01 (6.7c) 1.89 / 1.89 (1.0c) 0.303 / 2.64 (8.7c) 18.59 / 18.59 (1.0c) pyarrow-threaded 1.13x ⚠️
match_like("cust_1%") [_ wildcard] 1,000,000 0.946 / 0.419 18.6 10.08 / 10.14 (1.0c) 1.58 / 13.24 (8.4c) 18.29 / 18.29 (1.0c) 2.02 / 22.52 (11.1c) 20.48 / 20.48 (1.0c) polars-lazy 1.67x ⚠️
match_substring_regex (literal) 1,000,000 0.513 / 0.354 34.3 14.49 / 14.56 (1.0c) 1.92 / 17.88 (9.3c) 25.16 / 25.16 (1.0c) 2.73 / 30.74 (11.3c) 25.43 / 25.43 (1.0c) polars-lazy 3.74x ✅
match_substring_regex (real regex) 1,000,000 16.58 / 258 1.06 17.40 / 17.47 (1.0c) 2.34 / 21.71 (9.3c) 33.34 / 33.34 (1.0c) 3.69 / 41.04 (11.1c) 33.20 / 33.20 (1.0c) polars-lazy 0.14x ❌
replace_substring("cust" -> "cx") 1,000,000 1.33 / 1.08 26.4 29.88 / 30.00 (1.0c) 3.29 / 35.06 (10.6c) 14.30 / 14.30 (1.0c) 1.58 / 17.65 (11.2c) 14.21 / 14.21 (1.0c) pyarrow-threaded 1.18x ⚠️
split_pattern("_") 1,000,000 3.36 / 1.82 10.5 26.67 / 26.74 (1.0c) 3.51 / 32.43 (9.2c) 22.37 / 22.37 (1.0c) 2.40 / 27.18 (11.3c) 22.51 / 22.52 (1.0c) pyarrow-threaded 0.72x ❌
trim (whitespace) 1,000,000 2.50 / 1.14 17.3 14.20 / 14.26 (1.0c) 1.59 / 16.19 (10.2c) 13.90 / 13.90 (1.0c) 1.45 / 16.68 (11.5c) 13.92 / 13.92 (1.0c) pyarrow-threaded 0.58x ❌
pad_left (width 20) 1,000,000 1.30 / 1.07 40.7 16.89 / 16.96 (1.0c) 2.05 / 19.61 (9.6c) 18.28 / 18.28 (1.0c) 1.90 / 22.00 (11.6c) 18.39 / 18.39 (1.0c) pyarrow-threaded 1.46x ⚠️
slice_codeunits [5:10] 1,000,000 1.27 / 1.06 13.9 8.82 / 8.89 (1.0c) 1.25 / 10.48 (8.4c) 7.00 / 7.00 (1.0c) 0.858 / 9.42 (11.0c) 7.05 / 7.05 (1.0c) pyarrow-threaded 0.68x ❌
concat (a + '-' + b) 1,000,000 1.25 / 1.16 42.4 30.42 / 30.42 (1.0c) 2.61 / 28.74 (11.0c) 20.14 / 20.14 (1.0c) 2.14 / 23.40 (10.9c) 37.09 / 37.09 (1.0c) pyarrow-threaded 1.72x ⚠️
is_alpha 1,000,000 0.694 / 0.375 25.4 8.42 / 8.48 (1.0c) 1.38 / 10.84 (7.9c) 4.69 / 4.70 (1.0c) 0.542 / 5.56 (10.2c) 4.89 / 4.89 (1.0c) pyarrow-threaded 0.78x ❌
to_strings (int64 -> utf8) 1,000,000 1.52 / 0.993 10.5 8.88 / 8.89 (1.0c) 1.24 / 10.65 (8.6c) 5.89 / 5.89 (1.0c) 0.762 / 7.71 (10.1c) 90.51 / 90.53 (1.0c) pyarrow-threaded 0.50x ❌
parse (utf8 -> int64) 1,000,000 2.84 / 0.438 7.04 4.13 / 4.13 (1.0c) 0.637 / 4.94 (7.8c) 5.40 / 5.40 (1.0c) 0.636 / 6.35 (10.0c) 43.10 / 43.11 (1.0c) pyarrow-threaded 0.22x ❌
is_in (utf8, 100-value set) 1,000,000 2.28 / 1.88 7.73 8.49 / 8.57 (1.0c) 1.10 / 8.53 (7.8c) 10.29 / 10.29 (1.0c) 1.19 / 12.84 (10.8c) 10.81 / 10.81 (1.0c) polars-lazy 0.48x ❌
char_length 10,000,000 1.10 / 0.387 161 52.56 / 52.56 (1.0c) 4.22 / 62.27 (14.8c) 52.30 / 52.29 (1.0c) 5.45 / 62.96 (11.6c) 52.47 / 52.46 (1.0c) polars-lazy 3.85x ✅
upper 10,000,000 5.32 / 1.38 66.2 296 / 296 (1.0c) 25.14 / 356 (14.2c) 168 / 168 (1.0c) 15.29 / 198 (12.9c) 169 / 169 (1.0c) pyarrow-threaded 2.87x ⚠️
lower 10,000,000 5.35 / 1.34 65.9 373 / 373 (1.0c) 29.29 / 425 (14.5c) 172 / 172 (1.0c) 15.57 / 198 (12.7c) 172 / 172 (1.0c) pyarrow-threaded 2.91x ⚠️
contains("north") 10,000,000 1.64 / 0.394 107 142 / 142 (1.0c) 11.47 / 173 (15.0c) 119 / 119 (1.0c) 13.83 / 154 (11.1c) 119 / 119 (1.0c) polars-lazy 6.99x ✅
starts_with("cust_1") 10,000,000 1.81 / 0.406 97.3 35.31 / 35.38 (1.0c) 2.84 / 41.45 (14.6c) 30.96 / 30.96 (1.0c) 3.39 / 37.70 (11.1c) 31.04 / 31.04 (1.0c) polars-lazy 1.57x ⚠️
match_like("cust%") [pure prefix] 10,000,000 1.15 / 0.422 153 22.95 / 23.02 (1.0c) 2.06 / 26.38 (12.8c) 18.89 / 18.89 (1.0c) 2.12 / 23.47 (11.1c) 186 / 186 (1.0c) polars-lazy 1.80x ⚠️
match_like("cust_1%") [_ wildcard] 10,000,000 1.69 / 0.422 104 99.98 / 100 (1.0c) 8.56 / 125 (14.6c) 184 / 184 (1.0c) 18.31 / 222 (12.1c) 203 / 203 (1.0c) polars-lazy 5.05x ✅
match_substring_regex (literal) 10,000,000 2.86 / 0.414 61.7 145 / 145 (1.0c) 11.57 / 172 (14.9c) 257 / 257 (1.0c) 24.63 / 299 (12.1c) 248 / 248 (1.0c) polars-lazy 4.05x ✅
match_substring_regex (real regex) 10,000,000 167 / 2542 1.05 174 / 174 (1.0c) 14.57 / 208 (14.3c) 337 / 337 (1.0c) 29.32 / 402 (13.7c) 331 / 331 (1.0c) polars-lazy 0.087x ❌
replace_substring("cust" -> "cx") 10,000,000 3.05 / 1.26 116 300 / 300 (1.0c) 23.39 / 353 (15.1c) 141 / 141 (1.0c) 14.65 / 171 (11.7c) 141 / 141 (1.0c) pyarrow-threaded 4.81x ✅
split_pattern("_") 10,000,000 8.47 / 2.56 41.6 263 / 263 (1.0c) 25.36 / 327 (12.9c) 224 / 224 (1.0c) 20.74 / 269 (13.0c) 223 / 223 (1.0c) pyarrow-threaded 2.45x ⚠️
trim (whitespace) 10,000,000 5.07 / 1.32 85.4 144 / 144 (1.0c) 10.83 / 160 (14.8c) 139 / 139 (1.0c) 13.60 / 163 (12.0c) 139 / 139 (1.0c) polars-lazy 2.14x ⚠️
pad_left (width 20) 10,000,000 3.15 / 1.31 168 174 / 174 (1.0c) 13.15 / 187 (14.2c) 182 / 182 (1.0c) 17.17 / 214 (12.5c) 182 / 182 (1.0c) polars-lazy 4.18x ✅
slice_codeunits [5:10] 10,000,000 2.79 / 1.21 63.2 86.68 / 86.75 (1.0c) 6.96 / 100 (14.4c) 70.53 / 70.53 (1.0c) 7.44 / 86.32 (11.6c) 70.39 / 70.45 (1.0c) polars-lazy 2.50x ⚠️
concat (a + '-' + b) 10,000,000 4.16 / 1.50 127 312 / 312 (1.0c) 20.18 / 289 (14.3c) 201 / 201 (1.0c) 19.50 / 247 (12.7c) 364 / 364 (1.0c) pyarrow-threaded 4.69x ✅
is_alpha 10,000,000 2.09 / 0.542 84.5 85.61 / 85.67 (1.0c) 7.27 / 103 (14.1c) 46.16 / 46.16 (1.0c) 4.58 / 53.82 (11.8c) 47.66 / 47.65 (1.0c) pyarrow-threaded 2.20x ⚠️
to_strings (int64 -> utf8) 10,000,000 11.95 / 2.06 13.4 87.90 / 87.89 (1.0c) 7.17 / 103 (14.4c) 59.29 / 59.28 (1.0c) 6.76 / 75.24 (11.1c) 923 / 923 (1.0c) pyarrow-threaded 0.57x ❌
parse (utf8 -> int64) 10,000,000 19.59 / 0.759 10.2 40.85 / 40.84 (1.0c) 3.15 / 45.96 (14.6c) 52.33 / 52.34 (1.0c) 5.46 / 61.00 (11.2c) 439 / 439 (1.0c) polars-lazy 0.16x ❌
is_in (utf8, 100-value set) 10,000,000 3.81 / 2.00 46.2 96.03 / 96.10 (1.0c) 5.66 / 77.06 (13.6c) 105 / 105 (1.0c) 10.62 / 124 (11.7c) 109 / 109 (1.0c) polars-lazy 1.48x ⚠️

latency

op rows ArrowMetal wall / CPU ms ArrowMetal GB/s polars polars-lazy pyarrow pyarrow-threaded pandas / numpy fastest CPU idiom ratio verdict
sum(int64) 1,000 0.112 / 0.148 0.07 0.000 / 0.001 (5.0c) 0.011 / 0.011 (1.0c) 0.002 / 0.003 (1.5c) 0.097 / 0.231 (2.4c) 0.003 / 0.005 (1.5c) polars 0.002x ❌
sum(int64) [batched] 1,000 0.130 / 0.192 0.06 -- -- -- -- -- -- -- —
filter(int64 > 0) 1,000 0.154 / 0.192 0.05 0.003 / 0.003 (1.1c) 0.090 / 0.208 (2.3c) 0.008 / 0.009 (1.1c) 0.109 / 0.353 (3.2c) 0.037 / 0.037 (1.0c) polars 0.017x ❌
filter(int64 > 0) [batched] 1,000 0.160 / 0.204 0.05 -- -- -- -- -- -- -- —
group-by sum (1000 keys) 1,000 0.401 / 0.513 0.03 0.555 / 3.43 (6.2c) 0.655 / 3.73 (5.7c) 0.092 / 0.107 (1.2c) 0.159 / 0.397 (2.5c) 0.080 / 0.081 (1.0c) pandas 0.20x ❌
group-by sum (1000 keys) [batched] 1,000 0.428 / 0.562 0.03 -- -- -- -- -- -- -- —
sum(int64) 100,000 0.126 / 0.167 6.36 0.007 / 0.007 (1.0c) 0.153 / 1.16 (7.5c) 0.009 / 0.010 (1.1c) 0.099 / 0.241 (2.4c) 0.010 / 0.011 (1.1c) polars 0.055x ❌
sum(int64) [batched] 100,000 0.145 / 0.204 5.53 -- -- -- -- -- -- -- —
filter(int64 > 0) 100,000 0.162 / 0.211 4.95 0.028 / 0.028 (1.0c) 0.095 / 0.259 (2.7c) 0.181 / 0.181 (1.0c) 0.142 / 0.672 (4.7c) 0.376 / 0.376 (1.0c) polars 0.17x ❌
filter(int64 > 0) [batched] 100,000 0.160 / 0.204 5.01 -- -- -- -- -- -- -- —
group-by sum (1000 keys) 100,000 0.499 / 0.612 2.40 0.800 / 6.21 (7.8c) 0.785 / 5.78 (7.4c) 0.413 / 0.424 (1.0c) 0.255 / 1.23 (4.8c) 0.346 / 0.348 (1.0c) pyarrow-threaded 0.51x ❌
group-by sum (1000 keys) [batched] 100,000 0.527 / 0.658 2.27 -- -- -- -- -- -- -- —
sum(int64) 1,000,000 0.199 / 0.236 40.2 0.077 / 0.080 (1.0c) 0.130 / 0.902 (6.9c) 0.078 / 0.078 (1.0c) 0.113 / 0.427 (3.8c) 0.081 / 0.081 (1.0c) polars 0.39x ❌
sum(int64) [batched] 1,000,000 0.214 / 0.266 37.3 -- -- -- -- -- -- -- —
filter(int64 > 0) 1,000,000 0.226 / 0.264 35.4 0.260 / 0.261 (1.0c) 0.170 / 0.635 (3.7c) 2.16 / 2.16 (1.0c) 0.341 / 2.99 (8.8c) 3.94 / 3.95 (1.0c) polars-lazy 0.75x ❌
filter(int64 > 0) [batched] 1,000,000 0.233 / 0.276 34.3 -- -- -- -- -- -- -- —
group-by sum (1000 keys) 1,000,000 1.08 / 0.973 11.1 2.49 / 22.86 (9.2c) 2.15 / 22.54 (10.5c) 3.48 / 3.50 (1.0c) 0.624 / 5.24 (8.4c) 2.74 / 2.75 (1.0c) pyarrow-threaded 0.58x ❌
group-by sum (1000 keys) [batched] 1,000,000 1.09 / 0.987 11.0 -- -- -- -- -- -- -- —

The 16-core Swift / vDSP baselines (copied from docs/BENCHMARKS.md)

These are not re-measured here. They are the numbers already recorded in docs/BENCHMARKS.md for the in-process Swift benchmark (Metal vs all 16 CPU cores on the same Arrow buffers), quoted so the CPU-side ceiling is visible next to the Python libraries. Rows are matched to this matrix by operation name where one exists.

op rows Metal (Swift bench) 16-core Swift wall / CPU ms source
argsort int64 50,000,000 128.52 653.97 / 5017.1 docs/BENCHMARKS.md round 7
sort float64 50,000,000 138.52 591.97 / 4468.6 docs/BENCHMARKS.md round 7
top_k (k=100, int64) 50,000,000 128.67 1.96 / 25.6 docs/BENCHMARKS.md round 7; superseded, the sort table above measures 2.67 ms
contains("north") 10,000,000 1.65 17.77 / 250.0 docs/BENCHMARKS.md round 7
starts_with("cust_1") 10,000,000 1.75 3.10 / 42.7 docs/BENCHMARKS.md round 7
sum(int64, 10% nulls) 50,000,000 1.07 4.87 / 66.6 docs/BENCHMARKS.md round 6
filter int64 (30% kept) 50,000,000 2.95 3.49 / 48.8 docs/BENCHMARKS.md round 6
take (n/2 random indices) 50,000,000 5.91 11.75 / 154.6 docs/BENCHMARKS.md round 6
filter two columns + sum 50,000,000 1.75 6.27 / 86.5 docs/BENCHMARKS.md round 6

Rows under the 3x bar

Sorted by distance from the 3x bar, furthest first. ❌ means the fastest CPU idiom is ahead of ArrowMetal on that row. The baseline here is the fastest of every idiom of every CPU library, named in its own column.

op rows ratio ArrowMetal ms fastest CPU idiom baseline ms AM GB/s baseline GB/s likely cause
sum(int64) 1,000 0.002x 0.112 polars 0.000 0.07 47.9 Below the 110-160 us dispatch floor (the latency family's sum and filter rows at 1,000 rows): encode + commit + wait dominates the kernel. Batching removes most of it, but a single small call cannot beat an in-cache CPU loop.
shift (lag 1, int64) 50,000,000 0.008x 3.58 polars-lazy 0.029 223 27826 The baseline is not moving the data: polars-lazy reports 27826 GB/s, far above the ~400 GB/s a single pass over the data reaches, so it returns a view or a metadata change rather than a materialised column, while ArrowMetal materialises the result.
shift (lag 1, int64) 10,000,000 0.013x 2.37 polars-lazy 0.030 67.4 5348 The baseline is not moving the data: polars-lazy reports 5348 GB/s, far above the ~400 GB/s a single pass over the data reaches, so it returns a view or a metadata change rather than a materialised column, while ArrowMetal materialises the result.
filter(int64 > 0) 1,000 0.017x 0.154 polars 0.003 0.05 2.95 Below the 110-160 us dispatch floor (the latency family's sum and filter rows at 1,000 rows): encode + commit + wait dominates the kernel. Batching removes most of it, but a single small call cannot beat an in-cache CPU loop.
sum(int64) 100,000 0.055x 0.126 polars 0.007 6.36 116 Below the 110-160 us dispatch floor (the latency family's sum and filter rows at 1,000 rows): encode + commit + wait dominates the kernel. Batching removes most of it, but a single small call cannot beat an in-cache CPU loop.
match_substring_regex (real regex) 10,000,000 0.087x 167 polars-lazy 14.57 1.05 12.1 ICU host fallback: only a metacharacter-free pattern (or ^literal) takes the GPU path, everything else is NSRegularExpression row by row across 4096-row chunks.
ln (float64) 50,000,000 0.10x 81.31 polars-lazy 8.46 9.84 94.5 Metal has no double: float64 transcendentals and division run ArrowMetal's software binary64 (Sources/ArrowMetal/Kernels/DoubleTranscendental.swift), tens of integer instructions per element against one vectorised hardware instruction on the CPU. Compute-bound, not bandwidth-bound.
ln (float64) 10,000,000 0.11x 16.46 polars-lazy 1.81 9.72 88.2 Metal has no double: float64 transcendentals and division run ArrowMetal's software binary64 (Sources/ArrowMetal/Kernels/DoubleTranscendental.swift), tens of integer instructions per element against one vectorised hardware instruction on the CPU. Compute-bound, not bandwidth-bound.
days_between 50,000,000 0.12x 46.31 polars-lazy 5.75 25.9 209 Civil-calendar arithmetic: days-from-civil and its inverse are a few dozen integer operations per row on both sides, so this is compute-bound rather than bandwidth-bound and the GPU's only advantage is its lane count. Both sides land within a factor of three.
match_substring_regex (real regex) 1,000,000 0.14x 16.58 polars-lazy 2.34 1.06 7.52 ICU host fallback: only a metacharacter-free pattern (or ^literal) takes the GPU path, everything else is NSRegularExpression row by row across 4096-row chunks.
days_between 10,000,000 0.15x 9.89 polars-lazy 1.46 24.3 164 Civil-calendar arithmetic: days-from-civil and its inverse are a few dozen integer operations per row on both sides, so this is compute-bound rather than bandwidth-bound and the GPU's only advantage is its lane count. Both sides land within a factor of three.
sin (float64) 50,000,000 0.16x 150 polars-lazy 23.28 5.33 34.4 Metal has no double: float64 transcendentals and division run ArrowMetal's software binary64 (Sources/ArrowMetal/Kernels/DoubleTranscendental.swift), tens of integer instructions per element against one vectorised hardware instruction on the CPU. Compute-bound, not bandwidth-bound.
parse (utf8 -> int64) 10,000,000 0.16x 19.59 polars-lazy 3.15 10.2 63.5 Variable-length output: the kernel measures the lengths, prefix-sums them and writes in a second pass, against one streaming pass on the CPU.
sin (float64) 10,000,000 0.17x 30.19 polars-lazy 5.06 5.30 31.6 Metal has no double: float64 transcendentals and division run ArrowMetal's software binary64 (Sources/ArrowMetal/Kernels/DoubleTranscendental.swift), tens of integer instructions per element against one vectorised hardware instruction on the CPU. Compute-bound, not bandwidth-bound.
filter(int64 > 0) 100,000 0.17x 0.162 polars 0.028 4.95 28.7 Below the 110-160 us dispatch floor (the latency family's sum and filter rows at 1,000 rows): encode + commit + wait dominates the kernel. Batching removes most of it, but a single small call cannot beat an in-cache CPU loop.
group-by sum (1000 keys) 1,000 0.20x 0.401 pandas 0.080 0.03 0.15 Below the 110-160 us dispatch floor (the latency family's sum and filter rows at 1,000 rows): encode + commit + wait dominates the kernel. Batching removes most of it, but a single small call cannot beat an in-cache CPU loop.
parse (utf8 -> int64) 1,000,000 0.22x 2.84 pyarrow-threaded 0.636 7.04 31.4 Variable-length output: the kernel measures the lengths, prefix-sums them and writes in a second pass, against one streaming pass on the CPU.
slice (zero-copy view) 50,000,000 0.33x 0.001 polars 0.000 0.00 0.00 MetalArray.slice (Sources/ArrowMetal/Slice.swift) is now a zero-copy view at every offset and O(1) in the length, like the baselines: what this row measures is the ctypes hop into the C ABI and the Python wrapper object around the returned handle, roughly a microsecond, against an in-process metadata tweak on the other side. Both are constant time; neither is moving data.
replace_with_mask (30% replaced) 50,000,000 0.36x 10.55 polars-lazy 3.78 80.6 225 Three passes: the mask's prefix sum, a gather of the replacements, then the merge. The Polars lazy idiom (when/then/otherwise over the mask) is one streaming pass over the column.
floor_temporal (day) 50,000,000 0.38x 8.74 polars-lazy 3.35 91.5 239 Civil-calendar arithmetic: days-from-civil and its inverse are a few dozen integer operations per row on both sides, so this is compute-bound rather than bandwidth-bound and the GPU's only advantage is its lane count. Both sides land within a factor of three.
floor_temporal (day) 10,000,000 0.39x 1.92 polars-lazy 0.740 83.5 216 Civil-calendar arithmetic: days-from-civil and its inverse are a few dozen integer operations per row on both sides, so this is compute-bound rather than bandwidth-bound and the GPU's only advantage is its lane count. Both sides land within a factor of three.
sum(int64) 1,000,000 0.39x 0.199 polars 0.077 40.2 104 Below the 110-160 us dispatch floor (the latency family's sum and filter rows at 1,000 rows): encode + commit + wait dominates the kernel. Batching removes most of it, but a single small call cannot beat an in-cache CPU loop.
replace_with_mask (30% replaced) 10,000,000 0.39x 2.54 polars-lazy 0.983 67.0 173 Three passes: the mask's prefix sum, a gather of the replacements, then the merge. The Polars lazy idiom (when/then/otherwise over the mask) is one streaming pass over the column.
slice (zero-copy view) 10,000,000 0.40x 0.001 polars 0.000 0.00 0.00 MetalArray.slice (Sources/ArrowMetal/Slice.swift) is now a zero-copy view at every offset and O(1) in the length, like the baselines: what this row measures is the ctypes hop into the C ABI and the Python wrapper object around the returned handle, roughly a microsecond, against an in-process metadata tweak on the other side. Both are constant time; neither is moving data.
filter two columns + sum 10,000,000 0.43x 1.94 polars-lazy 0.826 61.8 145 Memory-bound tie: ArrowMetal 61.8 GB/s vs polars-lazy 145 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
power (float32 ** 2) 10,000,000 0.44x 1.07 polars-lazy 0.471 74.8 170 Memory-bound tie: ArrowMetal 74.8 GB/s vs polars-lazy 170 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
is_in (utf8, 100-value set) 1,000,000 0.48x 2.28 polars-lazy 1.10 7.73 16.0 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
group-by after filter 10,000,000 0.49x 3.75 polars-lazy 1.86 53.3 108 The chain's group-by rebuilds the dense key mapping after the filter, which is most of the measured time; the filter and the aggregate themselves are each well inside the bar.
to_strings (int64 -> utf8) 1,000,000 0.50x 1.52 pyarrow-threaded 0.762 10.5 21.0 Variable-length output: the kernel measures the lengths, prefix-sums them and writes in a second pass, against one streaming pass on the CPU.
group-by sum (1000 keys) 100,000 0.51x 0.499 pyarrow-threaded 0.255 2.40 4.71 Below the 110-160 us dispatch floor (the latency family's sum and filter rows at 1,000 rows): encode + commit + wait dominates the kernel. Batching removes most of it, but a single small call cannot beat an in-cache CPU loop.
value_counts (int32, 1000 distinct) 10,000,000 0.55x 6.67 pyarrow-threaded 3.70 6.00 10.8 Sort-based path: ArrowMetal answers this with a full GPU radix sort plus a run scan, where the CPU libraries use a hash table (count_distinct, mode, unique, value_counts) or a partial selection (quantile, tdigest).
to_strings (int64 -> utf8) 10,000,000 0.57x 11.95 pyarrow-threaded 6.76 13.4 23.7 Variable-length output: the kernel measures the lengths, prefix-sums them and writes in a second pass, against one streaming pass on the CPU.
drop_null (int64, 10% nulls) 10,000,000 0.57x 1.56 polars-lazy 0.890 97.2 171 Memory-bound tie: ArrowMetal 97.2 GB/s vs polars-lazy 171 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
unique (int32, 1000 distinct) 10,000,000 0.57x 5.90 pyarrow-threaded 3.37 6.78 11.9 Sort-based path: ArrowMetal answers this with a full GPU radix sort plus a run scan, where the CPU libraries use a hash table (count_distinct, mode, unique, value_counts) or a partial selection (quantile, tdigest).
group-by sum (1000 keys) 1,000,000 0.58x 1.08 pyarrow-threaded 0.624 11.1 19.2 Below the 110-160 us dispatch floor (the latency family's sum and filter rows at 1,000 rows): encode + commit + wait dominates the kernel. Batching removes most of it, but a single small call cannot beat an in-cache CPU loop.
trim (whitespace) 1,000,000 0.58x 2.50 pyarrow-threaded 1.45 17.3 29.8 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
filter int64 (90% kept) 10,000,000 0.59x 1.64 polars-lazy 0.971 92.5 157 Memory-bound tie: ArrowMetal 92.5 GB/s vs polars-lazy 157 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
sqrt (float64) 10,000,000 0.60x 1.29 polars-lazy 0.775 124 206 Memory-bound tie: ArrowMetal 124 GB/s vs polars-lazy 206 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
if_else (bool ? int64 : int64) 10,000,000 0.60x 2.18 polars-lazy 1.32 110 182 Memory-bound tie: ArrowMetal 110 GB/s vs polars-lazy 182 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
max(int64, 10% nulls) 10,000,000 0.65x 0.842 polars-lazy 0.544 95.0 147 Memory-bound tie: ArrowMetal 95.0 GB/s vs polars-lazy 147 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
lower 1,000,000 0.65x 2.63 pyarrow-threaded 1.71 13.4 20.7 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
upper 1,000,000 0.66x 2.55 pyarrow-threaded 1.68 13.8 21.0 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
month 50,000,000 0.66x 26.91 pyarrow-threaded 17.84 22.3 33.6 Civil-calendar arithmetic: days-from-civil and its inverse are a few dozen integer operations per row on both sides, so this is compute-bound rather than bandwidth-bound and the GPU's only advantage is its lane count. Both sides land within a factor of three.
day 50,000,000 0.67x 29.11 pyarrow-threaded 19.36 20.6 31.0 Civil-calendar arithmetic: days-from-civil and its inverse are a few dozen integer operations per row on both sides, so this is compute-bound rather than bandwidth-bound and the GPU's only advantage is its lane count. Both sides land within a factor of three.
year 50,000,000 0.67x 26.82 pyarrow-threaded 17.91 22.4 33.5 Civil-calendar arithmetic: days-from-civil and its inverse are a few dozen integer operations per row on both sides, so this is compute-bound rather than bandwidth-bound and the GPU's only advantage is its lane count. Both sides land within a factor of three.
slice_codeunits [5:10] 1,000,000 0.68x 1.27 pyarrow-threaded 0.858 13.9 20.5 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
coalesce (2 int64 columns) 10,000,000 0.69x 2.01 polars-lazy 1.39 120 173 Memory-bound tie: ArrowMetal 120 GB/s vs polars-lazy 173 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
compare array (int64 > int64) 10,000,000 0.71x 1.23 polars-lazy 0.874 138 194 Memory-bound tie: ArrowMetal 138 GB/s vs polars-lazy 194 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
split_pattern("_") 1,000,000 0.72x 3.36 pyarrow-threaded 2.40 10.5 14.7 split_pattern runs on the GPU (Kernels/StringSplit.swift) as three passes over two scans: count the pieces of every row, measure them, copy the bytes into a list<utf8>. The CPU library splits in one streaming pass.
group-by after filter 50,000,000 0.72x 6.73 polars-lazy 4.85 148 206 The chain's group-by rebuilds the dense key mapping after the filter, which is most of the measured time; the filter and the aggregate themselves are each well inside the bar.
divide (float64 / float64) 10,000,000 0.73x 1.47 polars-lazy 1.07 164 225 Metal has no double: float64 transcendentals and division run ArrowMetal's software binary64 (Sources/ArrowMetal/Kernels/DoubleTranscendental.swift), tens of integer instructions per element against one vectorised hardware instruction on the CPU. Compute-bound, not bandwidth-bound.
abs (float64) 10,000,000 0.73x 1.01 polars-lazy 0.741 159 216 Memory-bound tie: ArrowMetal 159 GB/s vs polars-lazy 216 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
filter(int64 > 0) 1,000,000 0.75x 0.226 polars-lazy 0.170 35.4 47.1 Below the 110-160 us dispatch floor (the latency family's sum and filter rows at 1,000 rows): encode + commit + wait dominates the kernel. Batching removes most of it, but a single small call cannot beat an in-cache CPU loop.
case_when (2 conditions) 10,000,000 0.78x 2.01 polars-lazy 1.56 199 256 Memory-bound tie: ArrowMetal 199 GB/s vs polars-lazy 256 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
is_alpha 1,000,000 0.78x 0.694 pyarrow-threaded 0.542 25.4 32.5 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
compare scalar (int64 > 0) 50,000,000 0.79x 2.41 polars-lazy 1.91 186 236 Memory-bound tie: ArrowMetal 186 GB/s vs polars-lazy 236 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
negate (int64) 10,000,000 0.81x 0.956 polars-lazy 0.771 167 208 Memory-bound tie: ArrowMetal 167 GB/s vs polars-lazy 208 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
min_max(int64) 10,000,000 0.82x 1.57 pyarrow-threaded 1.29 50.9 62.0 min_max is two separate am_reduce_ex dispatches here (min_of_min_max then max_of_min_max), so it pays the dispatch floor twice for one pass' worth of work.
filter two columns + sum 50,000,000 0.83x 3.53 polars-lazy 2.92 170 206 Memory-bound tie: ArrowMetal 170 GB/s vs polars-lazy 206 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
unique (int32, 1000 distinct) 50,000,000 0.83x 15.72 pyarrow-threaded 13.04 12.7 15.3 Sort-based path: ArrowMetal answers this with a full GPU radix sort plus a run scan, where the CPU libraries use a hash table (count_distinct, mode, unique, value_counts) or a partial selection (quantile, tdigest).
filter int64 (30% kept) 10,000,000 0.84x 0.778 polars-lazy 0.651 134 160 Memory-bound tie: ArrowMetal 134 GB/s vs polars-lazy 160 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
value_counts (int32, 1000 distinct) 50,000,000 0.84x 17.04 pyarrow-threaded 14.31 11.7 14.0 Sort-based path: ArrowMetal answers this with a full GPU radix sort plus a run scan, where the CPU libraries use a hash table (count_distinct, mode, unique, value_counts) or a partial selection (quantile, tdigest).
day 10,000,000 0.84x 5.99 pyarrow-threaded 5.06 20.0 23.7 Civil-calendar arithmetic: days-from-civil and its inverse are a few dozen integer operations per row on both sides, so this is compute-bound rather than bandwidth-bound and the GPU's only advantage is its lane count. Both sides land within a factor of three.
year 10,000,000 0.85x 5.55 pyarrow-threaded 4.70 21.6 25.6 Civil-calendar arithmetic: days-from-civil and its inverse are a few dozen integer operations per row on both sides, so this is compute-bound rather than bandwidth-bound and the GPU's only advantage is its lane count. Both sides land within a factor of three.
round (float64) 10,000,000 0.86x 1.01 polars-lazy 0.862 159 186 Memory-bound tie: ArrowMetal 159 GB/s vs polars-lazy 186 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
decimal round (2 places) 10,000,000 0.87x 7.64 polars-lazy 6.61 41.9 48.4 128-bit decimal arithmetic is emulated from 32-bit lanes on the GPU, so each element costs several instructions where the CPU has native 128-bit adds.
decimal round (2 places) 50,000,000 0.87x 37.28 polars-lazy 32.44 42.9 49.3 128-bit decimal arithmetic is emulated from 32-bit lanes on the GPU, so each element costs several instructions where the CPU has native 128-bit adds.
sqrt (float64) 50,000,000 0.87x 3.92 polars-lazy 3.42 204 234 Memory-bound tie: ArrowMetal 204 GB/s vs polars-lazy 234 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
filter int64 (90% kept) 50,000,000 0.87x 4.24 polars-lazy 3.70 179 205 Memory-bound tie: ArrowMetal 179 GB/s vs polars-lazy 205 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
decimal compare (> scalar) 10,000,000 0.90x 0.918 polars-lazy 0.825 185 206 128-bit decimal arithmetic is emulated from 32-bit lanes on the GPU, so each element costs several instructions where the CPU has native 128-bit adds.
drop_null (int64, 10% nulls) 50,000,000 0.91x 4.20 polars-lazy 3.81 181 199 Memory-bound tie: ArrowMetal 181 GB/s vs polars-lazy 199 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
month 10,000,000 0.93x 5.56 pyarrow-threaded 5.17 21.6 23.2 Civil-calendar arithmetic: days-from-civil and its inverse are a few dozen integer operations per row on both sides, so this is compute-bound rather than bandwidth-bound and the GPU's only advantage is its lane count. Both sides land within a factor of three.
case_when (2 conditions) 50,000,000 0.94x 6.92 polars-lazy 6.52 289 307 Memory-bound tie: ArrowMetal 289 GB/s vs polars-lazy 307 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
min(int64, 10% nulls) 10,000,000 0.95x 0.559 polars-lazy 0.533 143 150 Memory-bound tie: ArrowMetal 143 GB/s vs polars-lazy 150 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
power (float32 ** 2) 50,000,000 0.95x 1.91 polars-lazy 1.82 210 220 Memory-bound tie: ArrowMetal 210 GB/s vs polars-lazy 220 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
variance by key (1000 groups) 50,000,000 0.96x 30.36 pyarrow 29.17 19.8 20.6 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
exp (float32) 10,000,000 0.98x 1.15 polars-lazy 1.13 69.4 70.6 Memory-bound tie: ArrowMetal 69.4 GB/s vs polars-lazy 70.6 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
abs (float64) 50,000,000 1.06x 3.14 polars-lazy 3.31 255 241 Memory-bound tie: ArrowMetal 255 GB/s vs polars-lazy 241 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
is_nan (float64) 10,000,000 1.07x 0.461 polars-lazy 0.491 195 183 Memory-bound tie: ArrowMetal 195 GB/s vs polars-lazy 183 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
compare array (int64 > int64) 50,000,000 1.09x 2.91 polars-lazy 3.17 292 268 Memory-bound tie: ArrowMetal 292 GB/s vs polars-lazy 268 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
hash64 (int64) 10,000,000 1.11x 0.955 polars-lazy 1.06 168 152 Memory-bound tie: ArrowMetal 168 GB/s vs polars-lazy 152 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
fill_null (int64) 10,000,000 1.11x 0.941 polars-lazy 1.05 170 153 Memory-bound tie: ArrowMetal 170 GB/s vs polars-lazy 153 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
match_like("cust%") [pure prefix] 1,000,000 1.13x 0.267 pyarrow-threaded 0.303 66.1 58.2 match_like should take the GPU prefix path here (Regex.likePredicate); if this row is short of 3x the GPU predicate itself is the cost, not a fallback.
divide (float64 / float64) 50,000,000 1.14x 3.83 polars-lazy 4.38 313 274 Metal has no double: float64 transcendentals and division run ArrowMetal's software binary64 (Sources/ArrowMetal/Kernels/DoubleTranscendental.swift), tens of integer instructions per element against one vectorised hardware instruction on the CPU. Compute-bound, not bandwidth-bound.
stddev by key (1000 groups) 10,000,000 1.15x 5.37 pyarrow-threaded 6.17 22.4 19.4 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
list_element (index 1) 10,000,000 1.15x 2.39 polars-lazy 2.74 134 117 Nested kernels are one thread per row over an offsets buffer; the CPU equivalents are often metadata-only (a zero-copy child view) and so cannot be beaten by any amount of bandwidth.
variance by key (1000 groups) 10,000,000 1.15x 5.31 pyarrow-threaded 6.12 22.6 19.6 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
replace_substring("cust" -> "cx") 1,000,000 1.18x 1.33 pyarrow-threaded 1.58 26.4 22.3 Variable-length output: the kernel measures every row's new length, prefix-sums, then writes.
shift_left (int64 << 2) 10,000,000 1.21x 1.00 numpy 1.21 160 132 Memory-bound tie: ArrowMetal 160 GB/s vs numpy 132 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
stddev by key (1000 groups) 50,000,000 1.24x 30.56 pyarrow-threaded 37.74 19.6 15.9 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
starts_with("cust_1") 1,000,000 1.26x 0.341 pyarrow-threaded 0.429 51.7 41.1 Under half a millisecond: the 110-160 us dispatch floor (the latency family's sum and filter rows at 1,000 rows) is a large share of the measurement.
filter int64 (30% kept) 50,000,000 1.28x 2.23 polars-lazy 2.85 233 183 Memory-bound tie: ArrowMetal 233 GB/s vs polars-lazy 183 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
bit_wise_and (int64) 10,000,000 1.28x 0.856 polars-lazy 1.10 280 219 Memory-bound tie: ArrowMetal 280 GB/s vs polars-lazy 219 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
max by key, float64 (1000 groups) 10,000,000 1.28x 3.77 pyarrow-threaded 4.83 31.9 24.8 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
compare scalar (int64 > 0) 10,000,000 1.29x 0.471 polars-lazy 0.610 191 148 Memory-bound tie: ArrowMetal 191 GB/s vs polars-lazy 148 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
min by int32 key (1000 groups) 10,000,000 1.35x 3.71 pyarrow-threaded 4.99 32.4 24.1 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
decimal multiply (by scalar) 10,000,000 1.35x 1.60 pyarrow-threaded 2.16 200 148 128-bit decimal arithmetic is emulated from 32-bit lanes on the GPU, so each element costs several instructions where the CPU has native 128-bit adds.
min_max(int64) 50,000,000 1.35x 3.77 pyarrow-threaded 5.07 106 78.8 min_max is two separate am_reduce_ex dispatches here (min_of_min_max then max_of_min_max), so it pays the dispatch floor twice for one pass' worth of work.
add array (int64 + int64) 50,000,000 1.38x 3.12 polars-lazy 4.31 384 278 Memory-bound tie: ArrowMetal 384 GB/s vs polars-lazy 278 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
max by int32 key (1000 groups) 10,000,000 1.40x 3.53 pyarrow-threaded 4.94 34.0 24.3 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
stddev(float64, ddof=1) 10,000,000 1.41x 1.92 pyarrow-threaded 2.70 41.6 29.6 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
add scalar (int64) 10,000,000 1.42x 0.547 polars-lazy 0.775 292 206 Memory-bound tie: ArrowMetal 292 GB/s vs polars-lazy 206 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
if_else (bool ? int64 : int64) 50,000,000 1.42x 3.75 polars-lazy 5.31 320 226 Memory-bound tie: ArrowMetal 320 GB/s vs polars-lazy 226 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
hash64 (int64) 50,000,000 1.42x 3.20 polars-lazy 4.54 250 176 Memory-bound tie: ArrowMetal 250 GB/s vs polars-lazy 176 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
negate (int64) 50,000,000 1.44x 2.14 polars-lazy 3.10 373 258 Memory-bound tie: ArrowMetal 373 GB/s vs polars-lazy 258 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
variance(float64, ddof=1) 10,000,000 1.45x 1.93 pyarrow-threaded 2.80 41.4 28.6 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
multiply array (int64 * int64) 50,000,000 1.46x 2.96 polars-lazy 4.32 405 278 Memory-bound tie: ArrowMetal 405 GB/s vs polars-lazy 278 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
bit_wise_and (int64) 50,000,000 1.46x 2.97 polars-lazy 4.32 405 278 Memory-bound tie: ArrowMetal 405 GB/s vs polars-lazy 278 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
pad_left (width 20) 1,000,000 1.46x 1.30 pyarrow-threaded 1.90 40.7 27.9 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
add_duration (+1h) 10,000,000 1.48x 0.541 polars-lazy 0.800 296 200 Civil-calendar arithmetic: days-from-civil and its inverse are a few dozen integer operations per row on both sides, so this is compute-bound rather than bandwidth-bound and the GPU's only advantage is its lane count. Both sides land within a factor of three.
is_in (utf8, 100-value set) 10,000,000 1.48x 3.81 polars-lazy 5.66 46.2 31.1 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
round (float64) 50,000,000 1.49x 3.18 polars-lazy 4.72 252 169 Memory-bound tie: ArrowMetal 252 GB/s vs polars-lazy 169 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
add scalar (int64) 50,000,000 1.54x 1.95 polars-lazy 3.00 411 267 Memory-bound tie: ArrowMetal 411 GB/s vs polars-lazy 267 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
decimal compare (> scalar) 50,000,000 1.55x 2.20 polars-lazy 3.41 387 249 128-bit decimal arithmetic is emulated from 32-bit lanes on the GPU, so each element costs several instructions where the CPU has native 128-bit adds.
add_duration (+1h) 50,000,000 1.56x 2.09 polars-lazy 3.26 383 245 Civil-calendar arithmetic: days-from-civil and its inverse are a few dozen integer operations per row on both sides, so this is compute-bound rather than bandwidth-bound and the GPU's only advantage is its lane count. Both sides land within a factor of three.
starts_with("cust_1") 10,000,000 1.57x 1.81 polars-lazy 2.84 97.3 62.0 Memory-bound tie: ArrowMetal 97.3 GB/s vs polars-lazy 62.0 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
is_nan (float64) 50,000,000 1.58x 1.21 polars-lazy 1.92 372 235 Memory-bound tie: ArrowMetal 372 GB/s vs polars-lazy 235 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
add array (int64 + int64) 10,000,000 1.59x 0.684 polars-lazy 1.09 351 221 Memory-bound tie: ArrowMetal 351 GB/s vs polars-lazy 221 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
multiply array (int64 * int64) 10,000,000 1.59x 0.713 polars-lazy 1.14 337 211 Memory-bound tie: ArrowMetal 337 GB/s vs polars-lazy 211 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
skew(float64) 10,000,000 1.63x 2.48 pyarrow-threaded 4.04 32.2 19.8 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
skew(float64) 50,000,000 1.63x 10.84 pyarrow-threaded 17.64 36.9 22.7 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
is_in (int64, 100-value set) 10,000,000 1.65x 2.21 polars-lazy 3.65 40.8 24.7 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
sum by utf8 key (1000 distinct) 10,000,000 1.66x 5.66 pyarrow 9.40 35.3 21.3 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
index_in (int64, 100-value set) 10,000,000 1.66x 4.03 pyarrow-threaded 6.70 29.8 17.9 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
match_like("cust_1%") [_ wildcard] 1,000,000 1.67x 0.946 polars-lazy 1.58 18.6 11.1 match_like with a _ wildcard runs on the GPU (Kernels/StringLike.swift): a pure prefix / suffix / contains / equality pattern routes to a plain predicate, and every other pattern is compiled into a small byte program run by one thread per row with a greedy-plus-backtrack wildcard match that counts code points. The per-row backtracking is the cost against the CPU library's scan.
count_distinct(int64) 10,000,000 1.68x 2.87 pyarrow-threaded 4.83 27.9 16.6 Sort-based path: ArrowMetal answers this with a full GPU radix sort plus a run scan, where the CPU libraries use a hash table (count_distinct, mode, unique, value_counts) or a partial selection (quantile, tdigest).
min(int64, 10% nulls) 50,000,000 1.70x 1.18 polars-lazy 2.01 338 199 Memory-bound tie: ArrowMetal 338 GB/s vs polars-lazy 199 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
concat (a + '-' + b) 1,000,000 1.72x 1.25 pyarrow-threaded 2.14 42.4 24.7 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
add_checked (int64 + int64) 10,000,000 1.75x 1.17 pyarrow-threaded 2.04 206 118 Memory-bound tie: ArrowMetal 206 GB/s vs pyarrow-threaded 118 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
decimal add (128-bit) 10,000,000 1.77x 1.44 pyarrow-threaded 2.54 334 189 128-bit decimal arithmetic is emulated from 32-bit lanes on the GPU, so each element costs several instructions where the CPU has native 128-bit adds.
coalesce (2 int64 columns) 50,000,000 1.78x 3.32 polars-lazy 5.89 362 204 Memory-bound tie: ArrowMetal 362 GB/s vs polars-lazy 204 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
match_like("cust%") [pure prefix] 10,000,000 1.80x 1.15 polars-lazy 2.06 153 85.4 match_like should take the GPU prefix path here (Regex.likePredicate); if this row is short of 3x the GPU predicate itself is the cost, not a fallback.
multiply scalar (int64) 10,000,000 1.81x 0.482 polars-lazy 0.875 332 183 Memory-bound tie: ArrowMetal 332 GB/s vs polars-lazy 183 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
list_value_length 10,000,000 1.83x 0.361 pyarrow-threaded 0.659 886 485 Nested kernels are one thread per row over an offsets buffer; the CPU equivalents are often metadata-only (a zero-copy child view) and so cannot be beaten by any amount of bandwidth.
week 50,000,000 1.86x 24.18 polars-lazy 44.96 24.8 13.3 Civil-calendar arithmetic: days-from-civil and its inverse are a few dozen integer operations per row on both sides, so this is compute-bound rather than bandwidth-bound and the GPU's only advantage is its lane count. Both sides land within a factor of three.
week 10,000,000 1.89x 5.03 polars-lazy 9.50 23.9 12.6 Civil-calendar arithmetic: days-from-civil and its inverse are a few dozen integer operations per row on both sides, so this is compute-bound rather than bandwidth-bound and the GPU's only advantage is its lane count. Both sides land within a factor of three.
max(int64, 10% nulls) 50,000,000 1.89x 1.17 polars-lazy 2.22 341 180 Memory-bound tie: ArrowMetal 341 GB/s vs polars-lazy 180 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
sum(int64, 10% nulls) 10,000,000 1.90x 0.303 polars-lazy 0.575 264 139 Memory-bound tie: ArrowMetal 264 GB/s vs polars-lazy 139 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
add_checked (int64 + int64) 50,000,000 1.92x 4.50 pyarrow-threaded 8.63 267 139 Memory-bound tie: ArrowMetal 267 GB/s vs pyarrow-threaded 139 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
mode(int64) 10,000,000 1.95x 10.00 pyarrow 19.51 8.00 4.10 Sort-based path: ArrowMetal answers this with a full GPU radix sort plus a run scan, where the CPU libraries use a hash table (count_distinct, mode, unique, value_counts) or a partial selection (quantile, tdigest).
multiply scalar (int64) 50,000,000 1.97x 2.10 polars-lazy 4.13 381 194 Memory-bound tie: ArrowMetal 381 GB/s vs polars-lazy 194 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
count_distinct(int64) 50,000,000 1.97x 9.61 pyarrow-threaded 18.92 41.6 21.1 Sort-based path: ArrowMetal answers this with a full GPU radix sort plus a run scan, where the CPU libraries use a hash table (count_distinct, mode, unique, value_counts) or a partial selection (quantile, tdigest).
fill_null (int64) 50,000,000 1.98x 2.11 polars-lazy 4.16 380 192 Memory-bound tie: ArrowMetal 380 GB/s vs polars-lazy 192 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
decimal add (128-bit) 50,000,000 2.04x 5.42 pyarrow-threaded 11.04 443 217 128-bit decimal arithmetic is emulated from 32-bit lanes on the GPU, so each element costs several instructions where the CPU has native 128-bit adds.
stddev by key (10000000 groups) 10,000,000 2.07x 37.03 polars 76.64 3.24 1.57 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
sum(int64, 10% nulls) 50,000,000 2.07x 1.11 polars-lazy 2.30 360 174 Memory-bound tie: ArrowMetal 360 GB/s vs polars-lazy 174 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
mean by int32 key (1000 groups) 10,000,000 2.10x 2.21 pyarrow-threaded 4.63 54.4 25.9 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
kurtosis(float64) 50,000,000 2.10x 10.89 pyarrow-threaded 22.91 36.7 17.5 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
trim (whitespace) 10,000,000 2.14x 5.07 polars-lazy 10.83 85.4 39.9 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
indices_nonzero (bool) 10,000,000 2.14x 1.59 numpy 3.40 56.6 26.5 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
stddev(float64, ddof=1) 50,000,000 2.17x 5.49 pyarrow-threaded 11.93 72.8 33.5 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
variance(float64, ddof=1) 50,000,000 2.18x 5.45 pyarrow-threaded 11.88 73.5 33.7 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
kurtosis(float64) 10,000,000 2.19x 2.52 pyarrow-threaded 5.50 31.8 14.5 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
is_alpha 10,000,000 2.20x 2.09 pyarrow-threaded 4.58 84.5 38.5 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
sum by utf8 key (1000 distinct) 50,000,000 2.20x 18.22 pyarrow-threaded 40.06 54.9 25.0 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
stddev by key (100000 groups) 10,000,000 2.39x 9.66 polars-lazy 23.05 12.4 5.21 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
dictionary_encode (int32) 10,000,000 2.39x 7.08 pandas 16.93 11.3 4.72 am_dictionary_encode routes an integer column to DictionaryCompute.dictionaryEncoded(), which is the GPU unique() pipeline: a full radix argsort of the column, run marks, a scan and a gather. pandas builds its categories from one hash-table pass.
strftime (%Y-%m-%d) 50,000,000 2.39x 151 polars-lazy 361 6.62 2.77 Two passes plus a prefix scan, and the output is 10 to 20 bytes a row: the whole cost is writing the text. Kernels/TemporalFormat.swift compiles the format into an op list on the host, so the kernel is generic and no format costs a shader recompile.
tdigest(float64, q=0.5) 50,000,000 2.41x 33.18 pyarrow-threaded 79.96 12.1 5.00 Sort-based path: ArrowMetal answers this with a full GPU radix sort plus a run scan, where the CPU libraries use a hash table (count_distinct, mode, unique, value_counts) or a partial selection (quantile, tdigest).
variance by key (100000 groups) 10,000,000 2.43x 9.47 polars 22.97 12.7 5.22 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
strftime (%Y-%m-%d) 10,000,000 2.44x 30.75 polars-lazy 74.98 6.50 2.67 Two passes plus a prefix scan, and the output is 10 to 20 bytes a row: the whole cost is writing the text. Kernels/TemporalFormat.swift compiles the format into an op list on the host, so the kernel is generic and no format costs a shader recompile.
split_pattern("_") 10,000,000 2.45x 8.47 pyarrow-threaded 20.74 41.6 17.0 split_pattern runs on the GPU (Kernels/StringSplit.swift) as three passes over two scans: count the pieces of every row, measure them, copy the bytes into a list<utf8>. The CPU library splits in one streaming pass.
slice_codeunits [5:10] 10,000,000 2.50x 2.79 polars-lazy 6.96 63.2 25.3 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
sum by two int32 keys (~1024 groups) 10,000,000 2.51x 2.19 pyarrow-threaded 5.50 73.1 29.1 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
variance by key (100000 groups) 50,000,000 2.52x 37.72 polars-lazy 94.86 15.9 6.33 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
stddev by key (100000 groups) 50,000,000 2.52x 38.10 polars-lazy 95.84 15.7 6.26 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
top_k (k=100, int64) 10,000,000 2.64x 1.83 pyarrow 4.85 43.6 16.5 top_k (Kernels/TopK.swift) keeps the best k of each block in threadgroup memory and radix-sorts the blocks * k candidates; a CPU running top-k touches each value once and rarely writes, so the GPU's fixed passes weigh more at 10M rows than at 50M.
sum by int32 key (1000 groups) 10,000,000 2.66x 1.73 pyarrow-threaded 4.61 69.2 26.0 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
inner hash join (10000000 x 200000 on int64) 10,000,000 2.68x 3.88 pyarrow 10.39 42.0 15.7 The GPU hash join (Sources/ArrowMetal/Kernels/Join.swift) is reached through am_join / am.join: build the table over the right keys, probe the left twice (count, GPU scan, write). If this row is short of 3x the build side no longer fits in cache and the probe's random reads into device memory are the cost.
decimal multiply (by scalar) 50,000,000 2.69x 3.59 pyarrow-threaded 9.64 446 166 128-bit decimal arithmetic is emulated from 32-bit lanes on the GPU, so each element costs several instructions where the CPU has native 128-bit adds.
char_length 1,000,000 2.72x 0.229 pyarrow-threaded 0.621 77.1 28.4 Under half a millisecond: the 110-160 us dispatch floor (the latency family's sum and filter rows at 1,000 rows) is a large share of the measurement.
local_timestamp (America/New_York) 50,000,000 2.74x 15.53 pyarrow-threaded 42.51 51.5 18.8 The zone's transition table (a few hundred instants) is uploaded once per zone and cached, so the per-row work is a ten-step binary search and an add — the pass is bandwidth-bound on the values themselves. See Kernels/TimezoneGPU.swift.
contains("north") 1,000,000 2.74x 0.509 pyarrow-threaded 1.39 34.6 12.6 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
exp (float32) 50,000,000 2.75x 1.93 polars-lazy 5.29 208 75.6 Memory-bound tie: ArrowMetal 208 GB/s vs polars-lazy 75.6 GB/s, both within reach of the ~400 GB/s the single-pass rows reach; there is no 3x available to either side on this operation.
min by key, float64 (1000 groups) 10,000,000 2.79x 1.74 pyarrow-threaded 4.86 68.8 24.7 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
upper 10,000,000 2.87x 5.32 pyarrow-threaded 15.29 66.2 23.1 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
lower 10,000,000 2.91x 5.35 pyarrow-threaded 15.57 65.9 22.6 Not yet diagnosed from the kernel; the ArrowMetal path is doing more passes over the column than the CPU library's fused one.
count by int32 key (1000 groups) 10,000,000 2.92x 1.52 pyarrow-threaded 4.43 79.2 27.1 The dense key mapping (am_group_by_keys) is rebuilt on every call and, at low cardinality, costs more than the aggregation itself; the CPU libraries' hash table over a thousand keys sits in L2. Reusing one am.group_by([...]) object across aggregates (as Benchmarks/python_gpu_bench.py does) removes that part.
inner hash join (50000000 x 1000000 on int64) 50,000,000 2.93x 21.80 pyarrow 63.87 37.4 12.8 The GPU hash join (Sources/ArrowMetal/Kernels/Join.swift) is reached through am_join / am.join: build the table over the right keys, probe the left twice (count, GPU scan, write). If this row is short of 3x the build side no longer fits in cache and the probe's random reads into device memory are the cost.

179 of 339 measured rows are below the 3x bar.

How many cores each idiom used

cpu_ms / wall_ms for every measured row, per library and idiom. 1.00 means the idiom ran on one core, whatever the size of the thread pool. This is the table that decides whether "on all cores" is a true sentence about a given row.

Cores actually used, per library and idiom: cpu_ms / wall_ms over every measured row of this CSV.

library / idiom        rows   median     mean      max  rows >2     %
---------------------------------------------------------------------
arrowmetal              343     0.31     0.54    15.56        8    2%
polars                  316     1.00     4.78    14.85      119   38%
pyarrow                 320     1.00     2.63    15.05       75   23%
pandas                  282     1.00     1.00     1.52        0    0%
numpy                    40     1.00     1.00     1.00        0    0%
polars-lazy             314    11.52     9.87    15.61      266   85%
pyarrow-threaded        268    11.10    10.07    15.44      260   97%

arrowmetal's number is host CPU time only -- the thread encoding the command buffer and
waiting on it. Time the GPU spends executing is not counted in it, by either side's clock.

By family (median cores)

family                  arrowmetal            polars           pyarrow            pandas             numpy       polars-lazy  pyarrow-threaded
----------------------------------------------------------------------------------------------------------------------------------------------
reductions                    0.45              1.00              1.00              1.00                --              1.02             12.42
element-wise                  0.28              1.00              1.00              1.00              1.00             12.23             11.29
compare+select                0.39              1.00              1.00              1.00              1.00             10.79             11.39
sort                          0.32              6.34              1.00              1.00              1.00              6.31             10.85
group-by                      0.24             12.85              5.79              1.00                --             12.97              6.33
temporal                      0.12              1.00              1.00              1.00                --             14.96             12.61
window                        0.20              1.00              1.00              1.00                --              1.00                --
decimal                       0.21              1.00              1.00              1.00                --             14.05             12.59
nested                        1.78              1.00              1.00              1.00                --              7.04             10.61
chains                        0.38              1.67              1.00              1.00              1.00             11.02             12.64
join                          0.17              9.49             14.47              1.00                --              9.29             14.51
strings                       0.44              1.00              1.00              1.00                --             11.89             11.29
latency                       1.26              1.11              1.03              1.00                --              5.70              3.78

Reproducing

DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
  swift build -c release --product ArrowMetalC
# full run
PYTHONPATH=python python Benchmarks/full_matrix.py
# 1M-row smoke
PYTHONPATH=python python Benchmarks/full_matrix.py --quick
# assert every parallel idiom answers what its default does
PYTHONPATH=python python Benchmarks/full_matrix.py --verify --sizes 1000000
docs/TO_IMPROVE.md

Where ArrowMetal needs to improve, and why

The project's bar is 3x over the fastest CPU idiom on every operation at scale. This page is the remainder: every measured row of the full matrix where a CPU library is ahead of ArrowMetal, grouped by the measured cause. A row leaves this page only when a rerun of the matrix moves it.

The matrix now measures every CPU library twice: its plain eager idiom, and the most parallel idiom it has for the same answer — polars-lazy is the same expression through pl.LazyFrame collected on the in-memory or the streaming engine, pyarrow-threaded is an Acero plan over the same values split into 16 record batches (pandas' threaded paths, numexpr and numba, were not installed for this run, and its row says so). "Fastest CPU" is the best wall time of all of those idioms. That is the baseline this page is written against: Benchmarks/results/full_matrix_2026-09-07-parallel.csv (The full matrix, idle M4 Max, 16 cores, 64 GB), with the cores each idiom used in Benchmarks/results/full_matrix_2026-09-07-parallel_cores.txt: a median of 11.5 cores for Polars lazy and 11.1 for pyarrow through Acero, against 1.0 for the eager idioms on most families.

Against the parallel idioms, of 339 measured rows: 145 at or above 3x, 102 faster but under 3x, 77 to improve, where the fastest CPU idiom is ahead, and 15 with no CPU equivalent. The 77 are the first section below, grouped into ten causes. The sections after them are this page as it stood against each library's eager idiom, kept as history.

Against the most parallel idiom

Each table gives the operation, the row count, ArrowMetal's wall time, the fastest CPU idiom with its wall time, the cores that idiom used (its CPU-ms divided by its wall ms), and the ratio. Every number in this section is computed from the parallel CSV. Eight ArrowMetal rows in that CSV were re-measured on a quieter machine after a regression check flagged them and spliced back in place — their note column says so.

What these rows are, and what they are not. Twelve to fifteen cores running a vectorised kernel over a column in unified memory is a fast, well-engineered thing to be, and on a single pass over a single column it reaches the same memory the GPU reads at close to the same rate. 28 of the 77 are that: a memory-bound single pass, where neither side has a 3x to give. 16 more are fixed cost — a Metal command buffer costs 110–160 µs per call for sum and filter at 1,000 rows (the matrix's latency family, 2026-09-07 parallel run), so below about a million rows the dispatch is the operation. The other 33 are three operations that do more arithmetic per element than hardware doubles do, one that never reaches the GPU at all, the rows where the CPU library returns a view and ArrowMetal materialises a column, and the causes named below. They are the rows where having the option of unified memory on the GPU buys you nothing, and each one names its cause.

1. The dispatch floor and the per-call fixed cost, at a million rows and below (16 rows)

operation rows ArrowMetal fastest CPU idiom its cores ratio
sum(int64) 1,000 0.11 ms Polars 0.00 ms 5.0 0.00x
sum(int64) 100,000 0.13 ms Polars 0.01 ms 1.0 0.05x
sum(int64) 1,000,000 0.20 ms Polars 0.08 ms 1.0 0.39x
filter(int64 > 0) 1,000 0.15 ms Polars 0.00 ms 1.1 0.02x
filter(int64 > 0) 100,000 0.16 ms Polars 0.03 ms 1.0 0.17x
filter(int64 > 0) 1,000,000 0.23 ms Polars lazy 0.17 ms 3.7 0.75x
group-by sum (1000 keys) 1,000 0.40 ms pandas 0.08 ms 1.0 0.20x
group-by sum (1000 keys) 100,000 0.50 ms pyarrow Acero 0.26 ms 4.8 0.51x
group-by sum (1000 keys) 1,000,000 1.08 ms pyarrow Acero 0.62 ms 8.4 0.58x
upper 1,000,000 2.55 ms pyarrow Acero 1.68 ms 12.1 0.66x
lower 1,000,000 2.63 ms pyarrow Acero 1.71 ms 11.7 0.65x
trim (whitespace) 1,000,000 2.50 ms pyarrow Acero 1.45 ms 11.5 0.58x
is_alpha 1,000,000 0.69 ms pyarrow Acero 0.54 ms 10.2 0.78x
is_in (utf8, 100-value set) 1,000,000 2.28 ms Polars lazy 1.10 ms 7.8 0.48x
slice_codeunits [5:10] 1,000,000 1.27 ms pyarrow Acero 0.86 ms 11.0 0.68x
split_pattern("_") 1,000,000 3.36 ms pyarrow Acero 2.40 ms 11.3 0.72x

A Metal dispatch costs 110–160 µs of command-buffer creation, encoding, commit and completion wait before the first byte is touched (sum and filter at 1,000 rows in the matrix's latency family; The persistent-kernel result: an empty round trip is 60–70 µs; round 4 measured ~116 µs), and the cheapest dispatch in this CSV — sum(int64) over a thousand values — is 0.11 ms, which is that floor and nothing else. Nine of these rows are the latency family, which exists to measure exactly this.

The seven string rows are the same shape with a larger constant: a string kernel encodes an offsets pass and a bytes pass, and at a million short strings that fixed cost is most of the call. Every one of them is ahead at ten million rows in the same CSV — upper 2.87x, lower 2.91x, slice_codeunits 2.50x, split_pattern 2.45x, is_alpha 2.20x, trim 2.14x, is_in 1.48x — so the break-even is between one and ten million, not somewhere past the sizes measured. The crossover now measures that break-even directly, per operation, with a size sweep from a thousand rows to fifty million.

What moves the break-even. Nothing removes the floor on this hardware; the persistent-kernel approach that would is impossible here (The persistent-kernel result). What moves the break-even down is putting more work in each command buffer: MetalContext.batch (8–32% off per call), the fused expression compiler (one dispatch for a whole expression tree) and the lazy engine (one command buffer for a whole plan).

2. Memory-bound single passes, where twelve to fifteen cores reach the same memory (28 rows)

operation rows ArrowMetal fastest CPU idiom its cores ratio
abs (float64) 10,000,000 1.01 ms Polars lazy 0.74 ms 10.4 0.73x
case_when (2 conditions) 10,000,000 2.01 ms Polars lazy 1.56 ms 12.7 0.78x
coalesce (2 int64 columns) 10,000,000 2.01 ms Polars lazy 1.39 ms 11.5 0.69x
divide (float64 / float64) 10,000,000 1.47 ms Polars lazy 1.07 ms 11.5 0.73x
exp (float32) 10,000,000 1.15 ms Polars lazy 1.13 ms 12.4 0.98x
if_else (bool ? int64 : int64) 10,000,000 2.18 ms Polars lazy 1.32 ms 11.8 0.60x
negate (int64) 10,000,000 0.96 ms Polars lazy 0.77 ms 9.7 0.81x
power (float32 ** 2) 10,000,000 1.07 ms Polars lazy 0.47 ms 9.5 0.44x
round (float64) 10,000,000 1.01 ms Polars lazy 0.86 ms 10.4 0.86x
sqrt (float64) 10,000,000 1.29 ms Polars lazy 0.78 ms 10.7 0.60x
case_when (2 conditions) 50,000,000 6.92 ms Polars lazy 6.52 ms 13.5 0.94x
power (float32 ** 2) 50,000,000 1.91 ms Polars lazy 1.82 ms 11.4 0.95x
sqrt (float64) 50,000,000 3.92 ms Polars lazy 3.42 ms 12.7 0.87x
compare array (int64 > int64) 10,000,000 1.23 ms Polars lazy 0.87 ms 11.7 0.71x
drop_null (int64, 10% nulls) 10,000,000 1.56 ms Polars lazy 0.89 ms 11.4 0.57x
filter int64 (30% kept) 10,000,000 0.78 ms Polars lazy 0.65 ms 9.3 0.84x
filter int64 (90% kept) 10,000,000 1.64 ms Polars lazy 0.97 ms 10.2 0.59x
replace_with_mask (30% replaced) 10,000,000 2.54 ms Polars lazy 0.98 ms 10.2 0.39x
compare scalar (int64 > 0) 50,000,000 2.41 ms Polars lazy 1.91 ms 11.8 0.79x
drop_null (int64, 10% nulls) 50,000,000 4.20 ms Polars lazy 3.81 ms 13.4 0.91x
filter int64 (90% kept) 50,000,000 4.24 ms Polars lazy 3.70 ms 13.5 0.87x
replace_with_mask (30% replaced) 50,000,000 10.55 ms Polars lazy 3.78 ms 13.4 0.36x
max(int64, 10% nulls) 10,000,000 0.84 ms Polars lazy 0.54 ms 10.0 0.65x
min(int64, 10% nulls) 10,000,000 0.56 ms Polars lazy 0.53 ms 10.4 0.95x
min_max(int64) 10,000,000 1.57 ms pyarrow Acero 1.29 ms 11.1 0.82x
decimal compare (> scalar) 10,000,000 0.92 ms Polars lazy 0.82 ms 11.9 0.90x
decimal round (2 places) 10,000,000 7.64 ms Polars lazy 6.61 ms 14.8 0.87x
decimal round (2 places) 50,000,000 37.28 ms Polars lazy 32.44 ms 14.9 0.87x

This is the largest group and the least dramatic one. Every row is a single pass that reads one or two columns and writes one, and both sides are moving the same bytes through the same memory controller: sqrt at 50M rows is 204 GB/s on the GPU against Polars lazy's 234, drop_null at 50M is 181 GB/s against 199, both within reach of the ~390-400 GB/s these single-pass rows reach. Twelve to fifteen cores get there too, and when they do the ratio is a coin toss decided by the dispatch cost on one side and the thread-pool wake-up on the other. There is no 3x on this shape for anybody. The full matrix prints the two bandwidths side by side for every one of these rows.

Two rows in the table are further behind than the rest and the matrix names why: replace_with_mask at 0.36–0.39x is three passes here — the mask's prefix sum, a gather of the replacements, then the merge — where the Polars lazy idiom (when/then/otherwise over the mask) is one streaming pass over the column. power (float32 ** 2) is 0.44x at 10M (74.8 GB/s against the CPU idiom's 170) and 0.95x at 50M (210 against 220).

What moves a memory-bound row. Not a faster kernel — the bytes are already moving at bandwidth. What changes a memory-bound row is reading the bytes fewer times: fusing the operator into an expression so the intermediate column is never written, which is what am.query and the lazy engine are for, and keeping the column resident on the GPU so the next operator does not re-read it from a fresh allocation.

3. Software binary64 transcendentals and calendar arithmetic (6 rows)

operation rows ArrowMetal fastest CPU idiom its cores ratio
ln (float64) 10,000,000 16.46 ms Polars lazy 1.81 ms 13.0 0.11x
ln (float64) 50,000,000 81.31 ms Polars lazy 8.46 ms 14.2 0.10x
sin (float64) 10,000,000 30.19 ms Polars lazy 5.06 ms 13.8 0.17x
sin (float64) 50,000,000 150 ms Polars lazy 23.28 ms 15.1 0.16x
days_between 10,000,000 9.89 ms Polars lazy 1.46 ms 12.4 0.15x
days_between 50,000,000 46.31 ms Polars lazy 5.75 ms 13.8 0.12x

This is the one group where the gap is arithmetic, and it is the price of an explicit decision. Apple GPUs have no binary64 hardware at all, so ln, sin and every other double transcendental is emulated: forty-odd software binary64 operations per element, three to four GPU instructions each. The answers are worth what they cost — arithmetic and sqrt correctly rounded, exp/ln within 1–2 ulp, trigonometry within 5 (asserted 6) (Evaluation method) — but a vectorised libm on twelve to fifteen cores does the same element with hardware doubles. days_between is the same story in integers, and the matrix records the diagnosis: days-from-civil and its inverse are a few dozen integer operations per row on both sides, so the row is compute bound rather than bandwidth bound and the GPU's only advantage is its lane count.

Against the eager idiom in full_matrix_2026-09-07.csv these rows were 1.07x, 1.99x and 1.11x at 50M rows, because one core is behind the GPU's lane count. Against twelve to fifteen cores of hardware doubles they are 0.10x, 0.16x and 0.12x.

4. A real regular expression, matched on the host (2 rows)

operation rows ArrowMetal fastest CPU idiom its cores ratio
match_substring_regex (real regex) 1,000,000 16.58 ms Polars lazy 2.34 ms 9.3 0.14x
match_substring_regex (real regex) 10,000,000 167 ms Polars lazy 14.57 ms 14.3 0.09x

A real regular expression does not run on the GPU here at all. Only a metacharacter-free pattern (or ^literal) takes the GPU path; everything else is NSRegularExpression row by row across 4096-row chunks, which the matrix records as the diagnosis. It is already spread across the machine — 2,542 CPU-ms over a 167 ms wall at 10M rows is 15 cores — so this is not a threading gap: it is ICU matching row by row against Polars' own compiled regex engine, at 1.05 GB/s against 12.1. LIKE patterns and literal substrings never take this path; they are GPU kernels and they are ahead.

5. The views a CPU library returns for free (4 rows)

operation rows ArrowMetal fastest CPU idiom its cores ratio
shift (lag 1, int64) 10,000,000 2.37 ms Polars lazy 0.03 ms 2.2 0.01x
shift (lag 1, int64) 50,000,000 3.58 ms Polars lazy 0.03 ms 2.2 0.01x
slice (zero-copy view) 10,000,000 0.00 ms Polars 0.00 ms 0.0 0.40x
slice (zero-copy view) 50,000,000 0.00 ms Polars 0.00 ms 0.0 0.33x

These two rows are not a computation contest. The matrix measures the default shift, which writes a new contiguous column: 800 MB moved at 50M rows in 3.58 ms is 223 GB/s, a pass at memory bandwidth. Polars answers with a two-chunk view — a null chunk in front of a slice of the original — and copies nothing; the parallel CSV records it at 27,826 GB/s, which is how you can tell from the numbers alone that no data moved. slice is pointer arithmetic on every side and the two slice rows are measurement noise on calls of a microsecond; they are in the table because the rule of this page is that every row where a CPU idiom is ahead appears.

ArrowMetal already has the same answer: shift(by, fill, view=True) returns a pyarrow.ChunkedArray over the same device memory in 0.04 ms at 10M rows (Benchmarks/loss_sort_shift_sqrt.py). It is opt-in, because a chunked array is not a MetalArray and cannot re-enter a kernel without being combined, and the matrix keeps measuring the default.

6. Temporal field extraction and truncation (8 rows)

operation rows ArrowMetal fastest CPU idiom its cores ratio
year 10,000,000 5.55 ms pyarrow Acero 4.70 ms 10.1 0.85x
month 10,000,000 5.56 ms pyarrow Acero 5.17 ms 8.8 0.93x
day 10,000,000 5.99 ms pyarrow Acero 5.06 ms 10.2 0.84x
year 50,000,000 26.82 ms pyarrow Acero 17.91 ms 13.7 0.67x
month 50,000,000 26.91 ms pyarrow Acero 17.84 ms 13.9 0.66x
day 50,000,000 29.11 ms pyarrow Acero 19.36 ms 13.9 0.67x
floor_temporal (day) 10,000,000 1.92 ms Polars lazy 0.74 ms 10.7 0.39x
floor_temporal (day) 50,000,000 8.74 ms Polars lazy 3.35 ms 11.9 0.38x

Extracting a calendar field is a civil-from-days conversion per element — a few dozen integer operations, the same on both sides — so these rows are compute bound, not bandwidth bound: ArrowMetal runs 22.4 GB/s on year at 50M rows against the ~390-400 GB/s these single-pass rows reach. When an operation is arithmetic per element rather than bytes per second, the GPU's advantage is its lane count alone, and thirteen to fourteen cores through Acero close it. Against the eager idioms the same rows were comfortable wins — year at 50M was 7.0x in full_matrix_2026-09-07.csv — and against Acero they are 0.66–0.67x at 50M and 0.84–0.93x at 10M. floor_temporal is the same conversion and its inverse, against a lazy Polars path that reaches 216–239 GB/s doing it.

7. unique and value_counts against a threaded hash aggregation (4 rows)

operation rows ArrowMetal fastest CPU idiom its cores ratio
unique (int32, 1000 distinct) 10,000,000 5.90 ms pyarrow Acero 3.37 ms 10.9 0.57x
unique (int32, 1000 distinct) 50,000,000 15.72 ms pyarrow Acero 13.04 ms 13.6 0.83x
value_counts (int32, 1000 distinct) 10,000,000 6.67 ms pyarrow Acero 3.70 ms 10.8 0.55x
value_counts (int32, 1000 distinct) 50,000,000 17.04 ms pyarrow Acero 14.31 ms 13.7 0.84x

These four rows are an algorithm mismatch, not a bandwidth one. unique and value_counts go through the GPU dictionary pipeline: a full radix argsort of the column, run marks, a scan and a gather — work proportional to n log n in passes over the data, whatever the number of distinct values. Acero answers the same question with one threaded hash pass over the column and a table of a thousand entries. On a column with a thousand distinct values out of ten million, the hash table is the right data structure and the sort is not; the CPU-ms say the same from the other side (value_counts at 10M: 5.3 ArrowMetal CPU-ms against Acero's 40 — the GPU is doing the work, it is simply doing more of it). The matrix records the same diagnosis and the same fix.

8. The grouped moments at a thousand groups (1 row)

operation rows ArrowMetal fastest CPU idiom its cores ratio
variance by key (1000 groups) 50,000,000 30.36 ms pyarrow 29.17 ms 12.7 0.96x

One row, and it is a tie: 30.4 ms against 29.2. stddev on the same shape is the row next to it at 1.24x, on the winning side of the same line. Everything the eager-baseline section below says about this operation still holds and is still the diagnosis: the moments accumulate in software binary64 because Metal has no 64-bit atomic add, which forces a counting sort by group id in front of the two moment passes, and the moment kernels give a whole threadgroup to one group so a thousand groups leaves most of the machine idle. Note that the fastest idiom here is pyarrow eager — Acero's threaded grouped variance is slower than its single-threaded one on this shape, so the parallel baseline did not move this row.

A 64-bit atomic add in the shading language would remove the sort; that is an ask of Apple, recorded in Upstream tracker.

9. String conversions with variable-length output (4 rows)

operation rows ArrowMetal fastest CPU idiom its cores ratio
parse (utf8 -> int64) 1,000,000 2.84 ms pyarrow Acero 0.64 ms 10.0 0.22x
parse (utf8 -> int64) 10,000,000 19.59 ms Polars lazy 3.15 ms 14.6 0.16x
to_strings (int64 -> utf8) 1,000,000 1.52 ms pyarrow Acero 0.76 ms 10.1 0.50x
to_strings (int64 -> utf8) 10,000,000 11.95 ms pyarrow Acero 6.76 ms 11.1 0.57x

Unlike the other string rows, these two do not turn into wins at ten million: parse is 0.22x at 1M and 0.16x at 10M. The output length of each row depends on the row's value, so the kernel measures the lengths, prefix-sums them and writes in a second pass, against one streaming pass on the CPU — the matrix records that diagnosis for all four rows. Two passes over the data against one is the whole of the gap, and it is the same 2x for any number of cores on the other side.

That the two conversion rows are the ones left behind while upper, trim, split_pattern and the predicates are all ahead at 10M says the shape of the pass is the problem, not the string machinery.

10. Whole-query chains against a streaming engine (4 rows)

operation rows ArrowMetal fastest CPU idiom its cores ratio
filter two columns + sum 10,000,000 1.94 ms Polars lazy 0.83 ms 11.3 0.43x
filter two columns + sum 50,000,000 3.53 ms Polars lazy 2.92 ms 13.9 0.83x
group-by after filter 10,000,000 3.75 ms Polars lazy 1.86 ms 10.7 0.49x
group-by after filter 50,000,000 6.73 ms Polars lazy 4.85 ms 13.7 0.72x

This is the cost of the change of baseline, and it lands on the operation the project is proudest of. Against eager Polars, filter two columns + sum at 50M rows was 5.96x; against the same query given to pl.LazyFrame on the streaming engine — which fuses the predicate and the aggregate exactly as our expression compiler does, and then runs it on fourteen cores — it is 0.83x. Both sides are now doing the same thing: one pass, no intermediate column. What is left is bandwidth (61.8 GB/s against 145 at 10M) and the fact that the GPU pays its dispatch once per query while the CPU pool is already awake. The CPU-ms are still the other half of the picture — 1.7 against 40.7 at 50M — and that is the argument the wall clock does not make: the same answer, with thirteen cores left for the rest of the application.

Where the time goes. For group-by after filter the matrix names the cost precisely: the chain's group-by rebuilds the dense key mapping after the filter, and that is most of the measured time — the filter and the aggregate are each well inside the bar on their own. For filter two columns + sum, the same dispatch cost as cause 1: [batched] already takes group-by after filter at 10M from 3.75 ms to 2.04 ms in this CSV.

Against each library's eager idiom — history, the 2026-09-07 run

Everything from here down is this page as it stood against full_matrix_2026-09-07.csv, the run of the same matrix earlier the same day, when every CPU library was measured only in its plain eager idiom — one core on most element-wise and whole-column rows, as the cores table now shows. Those verdicts are no longer the project's headline numbers and the counts below are that run's, not the parallel run's. It is kept because the diagnoses in it are still the diagnoses, because several rows above point back into it, and because the record of what a change was worth should not be edited after the fact. The sort section at the end is measured by Benchmarks/loss_sort_gather.py and stands on its own data.

Of 946 comparisons in that eager matrix: 822 at or above 3x, 93 faster but under 3x, 31 where some eager CPU library is ahead (against the fastest eager library the count is 15). The 31 fall into six causes. The morning run of the same day (full_matrix_2026-09-07-am.csv) had 50 with a CPU library ahead and 115 under 3x; what moved is at the end of the page.

Where the eager CPU library is ahead

1. The dispatch floor below a million rows (19 comparisons)

operation rows ArrowMetal fastest CPU ratio
sum(int64) 1,000 0.14 ms 0.00 ms (Polars) 0.00x
sum(int64) 100,000 0.15 ms 0.01 ms (Polars) 0.05x
sum(int64) 1,000,000 0.22 ms 0.07 ms (pyarrow) 0.31x
filter(int64 > 0) 1,000 0.18 ms 0.00 ms (Polars) 0.02x
filter(int64 > 0) 100,000 0.18 ms 0.03 ms (Polars) 0.17x
group-by sum (1000 keys) 1,000 0.80 ms 0.08 ms (pandas) 0.09x
group-by sum (1000 keys) 100,000 0.93 ms 0.35 ms (pandas) 0.38x

A Metal dispatch costs 110–160 µs before the first byte is touched: command-buffer creation, encoding, commit and the completion wait.[^round4floor] A CPU library sums a thousand integers in a fraction of a microsecond. Below about a million rows the GPU does not win a single operation in these measurements, and these rows stay on this page. What helps: batching several operations into one command buffer (MetalContext.batch, 8–32% off per call), the fused expression compiler (one dispatch for a whole expression tree), and the lazy engine (one command buffer for a whole plan) — all of which move the break-even point down, none of which remove the floor. The persistent-kernel approach that would remove it is impossible on this hardware (The persistent-kernel result).

[^round4floor]: This section previously read 60–140 µs, the range taken from the round-4 latency measurements in Benchmark method (empty kernel encode + commit + wait, ~116 µs; the per-call GPU sum and filter costs there are 136–167 µs at small sizes). 110–160 µs (sum and filter at 1,000 rows in the matrix's latency family, 2026-09-07 parallel run) is the figure the rest of this page uses, and it is the one that applies here.

2. shift as a copy (4 comparisons)

operation rows ArrowMetal Polars pandas ratio
shift (lag 1, int64) 10,000,000 2.30 ms 0.05 ms 0.05 ms 0.02x
shift (lag 1, int64) 50,000,000 3.63 ms 0.05 ms 0.15 ms 0.01x

The matrix measures the default shift, which writes a new contiguous column: 800 MB moved at 50M rows at 220 GB/s, a pass at memory bandwidth. Polars answers with a two-chunk view (a null chunk in front of a slice of the original) and copies nothing. Since the morning run shift(by, fill, view=True) returns exactly that — a pyarrow.ChunkedArray over the same device memory, 0.04 ms at 10M rows — and it is opt-in because the chunked form is not a MetalArray and cannot re-enter a kernel without being combined. The matrix keeps measuring the default, so the row stays here; a caller who wants Polars' answer has it.

3. Grouped variance and stddev in software binary64 (2 comparisons)

operation rows ArrowMetal pyarrow ratio
variance by key (1000 groups) 50,000,000 34.1 ms 26.4 ms 0.78x
stddev by key (1000 groups) 50,000,000 34.1 ms 26.2 ms 0.77x

The grouped moments accumulate in software IEEE-754 binary64, because Apple GPUs have no double arithmetic and a float32 accumulator is wrong past a few million rows. The answer agrees with Arrow to about 6e-10 relative (2e-8 on float32) — the moment accumulator's own stated accuracy — not to the last bit; the price is 3–4 GPU instructions per FLOP. At 10M rows the same rows tie pyarrow (1.00x, 1.02x); against Polars they win by 2.4x; at 100,000 and 10M groups they win by 2.1–4x.

These two rows are the ones the small-group work below did not fix, and the reason is not the arithmetic. The key-mapping change took 3.8 ms off them (33.5 → 29.7 ms at 50M rows against pyarrow's 19.0 in the same script, 0.64x); what is left is the shape the software binary64 forces. Measured, at 50 million rows and a thousand groups:

step cost the same step with the keys already sorted
the counting sort by group id 10.8 ms 4.7 ms
the two moment passes over it 15.3 ms 7.0 ms
one segmented float64 sum (for scale) 7.4 ms 1.7 ms

Both moment passes read their values through ord, the counting sort's permutation, and consecutive positions inside a group are about K rows apart, so each read pulls a cache line to use eight bytes of it. Sorting the key column first makes that permutation nearly the identity and halves the whole operation — which is a diagnosis, not a fix, since the input is not sorted.

The obvious fix, and the one this work set out to make, is to drop the sort: privatise the accumulators per threadgroup, as sum and count do. It cannot be done here. A threadgroup-private accumulator needs an atomic add, and Metal has 32-bit atomics only, so a binary64 accumulator can only be updated by a thread that owns it exclusively — which means one private table per lane, not per threadgroup. At a thousand groups that is 8 KB a lane, and 32 KB of threadgroup memory holds four of them. Every arrangement that fits — one lane owning g % 32, or the values shuffled to their owner — puts one lane's d_add under a mask while the other 31 wait, and pays 32 times the arithmetic. The counting sort exists precisely because there is no 64-bit atomic add (the shading language exposes 64-bit atomic min and max only; UPSTREAM.md).

What is left is the group count itself. The moment kernels give a whole threadgroup to one group, so a thousand groups is a thousand threadgroups, and only a fraction of them are resident at once: the resident ones read rows scattered through the column instead of sweeping it. Measured at 50M rows, the two moment passes cost 49.1 ms at 10 groups (too few threadgroups to fill the machine), 9.7 ms at 100, 15.4 ms at 1,000 and 16.1 ms at 4,000 — the wall is between 100 and 1,000 groups, exactly where these rows sit. The moment kernels give a whole threadgroup to one group, so between 100 and 1,000 groups most of the machine is idle.

4. Grouped min at 1000 groups, 10M rows (1 comparison)

operation rows ArrowMetal pyarrow ratio
min by int32 key (1000 groups) 10,000,000 5.77 ms 5.05 ms 0.87x

The morning run had this row at 4.64 ms (1.09x), the 50M row of the same operation did not move (9.86 ms, 2.06x over pyarrow), and the grouped min/max kernels were not touched between the runs. The targeted re-measurement at the end of the page put it back at 4.17 ms (1.22x): the matrix value is noise on a 5 ms call. The row stays in this table because the matrix is the record.

Since then the key-mapping change below has taken the operation to 1.66 ms against pyarrow's 4.62 in the same script (2.8x), and 5.71 ms against 18.5 at 50M rows (3.2x). The row stays until the matrix is rerun.

5. Regex on the host (1 comparison)

operation rows ArrowMetal Polars ratio
match_substring_regex (real regex) 1,000,000 18.9 ms 17.4 ms 0.92x

A real regex runs on the CPU (RE2-equivalent semantics through Foundation), behind a GPU pre-filter that clears rows that cannot match. At 10M rows the pre-filter wins 1.15x over Polars and 2.2x over pyarrow; at 1M rows the host regex dominates. LIKE patterns and literal substrings are GPU kernels and win by 7–87x; only a genuine regex takes this path.

6. Noise on zero-cost rows (4 comparisons)

slice (zero-copy view) reads 0.00 ms for every library — all are pointer arithmetic, and the ratio is measurement noise.

Faster, but under the 3x bar (93 comparisons)

The full list is in the matrix page under ⚠️. The clusters:

  • Software binary64 math — ln 1.06–1.08x, sin 1.98–1.99x, days_between 1.04–1.11x, sqrt at 10M rows 1.83x (3.13x at 50M, where the dispatch floor no longer shows). Within 2 ulp of the host libm for exp, ln, log2, log10 and power (the test bound), sqrt correctly rounded, 4–5 ulp for trigonometry; the CPU has hardware doubles and the GPU does not, and these rows are under 3x with this numerical contract.
  • Grouped aggregates against pyarrow at 1000 groups — sum/count/min/max/mean by int32, float64 and utf8 key at 1.3–2.2x, two int32 keys at 1.11x (10M rows; 3.8x at 50M), and variance/stddev at 1.0–2.4x. pyarrow's grouped kernels are memory-bound and 16-thread; the GPU's advantage grows with the number of groups (100,000 groups: 3.5–5.5x; 10M groups: 2.9–25x) and with wider values. Most of this cluster is fixed (see the last section): the mapping from key values to dense group ids was 6.8 ms of the 8.8 ms sum at 50M rows and a thousand groups, because it widened the key column to int64 before it read it twice. Re-measured against pyarrow in the same script, sum/count/ mean by int32 key are now 3.1–3.9x at 50M rows and min/max 3.2–3.3x, where they were 1.7–2.0x. Two rows in the cluster do not move and are not touched by that change: sum by utf8 key (2.0x at 50M and 1.7x at 10M in the matrix; 2.3x in loss_groupby_small.py), whose cost is the string hash table in front of the aggregate rather than the group-by, and variance/stddev, which cause 3 above now explains in full.
  • Memory-bound element-wise kernels against Polars and numpy — compare, is_nan, abs, bit_wise_and, if_else, negate, shift_left, replace_with_mask, drop_null at 1.1–2.98x. Both sides run at unified-memory bandwidth; the GPU's edge is the dispatch overhead it does not pay per thread. Fusing them into one expression (am.query) is where the 3x comes from, not from the single kernel; even so filter two columns + sum at 10M rows is 2.2x over Polars (6x at 50M).
  • sort float64 at 2.7x over Polars, both sizes, in the matrix. argsort of the same column is 10x; the difference was the take that materialised the sorted values (a random 8-byte gather). That gather is gone — the sorted values now come out of the sort's own keys — and the row is 6.503 ms at 10M and 31.948 ms at 50M when measured on its own (the last section of this page), which is 4.01x and 4.14x against the Polars figures in this matrix's own column. It stays in this list until a rerun of the matrix moves it, which is the rule this page keeps.
  • Joins at 2.7–2.8x over pyarrow for the materialised inner join (the index-only join and the left outer join are 3.0–3.8x).
  • Host-assisted strings — parse 1.4–2.7x, real regex 1.2–2.2x.
  • Small dictionaries — unique, value_counts, dictionary_encode and mode on 1000-distinct columns at 1.9–2.5x over pandas/pyarrow at 10M rows (4x at 50M).
  • list_value_length at 1.9–2.1x: 80 MB in 0.37 ms is bandwidth plus the dispatch floor.
  • The latency family at 1M rows — 1.0–2.1x, the floor again.

What changed since the morning run

Compared with full_matrix_2026-09-07-am.csv on the same machine, 55 rows are faster and 15 read slower — counting a row as moved when its ArrowMetal wall time changed by more than 10%, over the 339 rows the two runs share (full_matrix_2026-09-07.csv). The wins are the two changes recorded in the next section:

operation rows morning afternoon fastest CPU now ratio now
count_distinct by key (1000 groups) 50,000,000 708 ms 45.7 ms 156 ms (Polars) 3.4x
count_distinct by key (10M groups) 50,000,000 715 ms 61.0 ms 401 ms (Polars) 6.6x
tdigest(float64, q=0.5) 50,000,000 2,836 ms 48.5 ms 1,061 ms (pyarrow) 21.9x
sum by two int32 keys (~1024 groups) 50,000,000 22.7 ms 6.0 ms 22.9 ms (pyarrow) 3.8x
argsort int64 50,000,000 128 ms 39.2 ms 301 ms (Polars) 7.7x
argsort float64 50,000,000 129 ms 40.0 ms 429 ms (Polars) 10.7x
lexsort (2 int32 keys) 50,000,000 149 ms 37.4 ms 912 ms (Polars) 24x
argsort utf8 10,000,000 60.8 ms 13.7 ms 234 ms (Polars) 17x
sort float64 50,000,000 139 ms 49.6 ms 132 ms (Polars) 2.67x
sqrt (float64) 50,000,000 12.7 ms 3.95 ms 12.4 ms (numpy) 3.1x
upper / lower / trim 10,000,000 52–55 ms 5.2–5.4 ms 139–173 ms (pyarrow / pandas) 27–32x
list_value_length 10,000,000 1.12 ms 0.37 ms 0.71 ms (pyarrow) 1.9x

Ten of the 15 slower rows are at 10M rows, one at 1M (to_strings, 1.21x), two at 50M (min and mean of int64 with 10% nulls, 1.16x and 1.11x on calls of about a millisecond) and two are any and all of bool at 50M, 0.0012 → 0.0014 ms; none is above 1.7x. Where the slower row is at 10M, the 50M row of the same operation is unchanged (partition_nth_indices 4.9 → 8.0 ms at 10M against 19.8 ms unchanged at 50M; max by int32 key 2.65 → 3.85 ms against 9.87 ms unchanged; the rest are 1.1–1.5x on values of 1–4 ms in kernels the two changes did not touch: LIKE, floor_temporal, coalesce, fill_null_forward). That is the signature of run-to-run noise on short calls, not of a regression, and the targeted re-measurement below settles it.

The re-measurement (full_matrix.py --families sort,group-by,chains --sizes 10000000, idle machine, 5.5 minutes; not committed, summarised here):

operation, 10M rows morning afternoon matrix re-measured fastest CPU ratio
compare + filter + take 0.98 ms 1.52 ms 0.99 ms 7.33 ms (Polars) 7.4x
max by int32 key (1000 groups) 2.65 ms 3.85 ms 2.76 ms 5.07 ms (pyarrow) 1.8x
min by int32 key (1000 groups) 4.64 ms 5.77 ms 4.17 ms 5.08 ms (pyarrow) 1.2x
mean by int32 key (100000 groups) 3.50 ms 4.11 ms 3.46 ms 15.8 ms (pyarrow) 4.6x
sum by two int32 keys (~1024 groups) 6.26 ms 5.10 ms 2.24 ms 5.67 ms (pyarrow) 2.5x
sort float64 29.7 ms 9.73 ms 9.63 ms 28.1 ms (Polars) 2.9x
argsort int64 27.7 ms 7.86 ms 7.76 ms 56.5 ms (Polars) 7.3x
count_distinct by key (1000 groups) 124 ms 7.96 ms 8.26 ms 27.2 ms (Polars) 3.3x
partition_nth_indices (n/2) 4.90 ms 7.97 ms 8.09 ms 56.8 ms (pyarrow) 7.0x

Every grouped and chained row went back to its morning value, so those were noise; the two-key row's 10M value is the noisiest of all (2.2–6.3 ms across four runs) and its 50M value is stable at 6.0 ms.

The partition_nth_indices row was not a regression, and the investigation of it found a real cost anyway. Running the two builds alternately in one process each with Benchmarks/loss_partition_nth.py — full_matrix's columns, seed and rule, with the row measured where the matrix measures it, last in the sort family — the radix-sort commit moves it by nothing: 5.20 → 5.08 ms at 10M and 19.97 → 19.74 ms at 50M. What the 4.9 against 8.1 tracked is what ran before it. partition_nth_indices allocated about 280 MB of scratch per call out of the context's buffer pool, and in a fresh process with nothing in front of it the pool had none of that to give: measured first in the process it cost 7.7–8.0 ms on both builds, and measured after any operation that had already allocated the big buffers it cost 4.9–5.2 ms on both. The afternoon matrix and the re-measurement run put different work in front of the row, and that is the whole of the difference.

The cost the investigation did find is that the operation was doing far too much work for what it is. The split around the selected key was three compare + filter compactions of an index array, concatenated afterwards — seven command buffers, three reads of the keys, three worst-case index arrays — and two of its steps never touched the GPU at all: the row numbers it compacted were filled by a host loop and the output it concatenated into was allocated zeroed, a single-threaded write and a memset over 40 MB at 10M rows. It is now one stable counting sort over five buckets (null, NaN, below the key, equal, above) in a single command buffer, and the pool sensitivity mostly goes with it:

operation rows before after
partition_nth_indices (n/2), int64 10,000,000 5.13 ms 3.02 ms
partition_nth_indices (n/2), int64 50,000,000 19.84 ms 12.27 ms
the same, measured first in a fresh process 10,000,000 7.9 ms 5.5 ms

Both builds alternated over two rounds, best of five after a warm-up, idle machine. No other row of the sort family moves: argsort int64 7.84 → 7.80 ms at 10M and 39.0 → 39.0 at 50M, sort float64 9.74 → 9.68 and 49.3 → 49.4, top_k (k=100) 1.06 → 0.96 and 2.59 → 2.62, select_k_unstable 1.03 → 0.96 and 2.63 → 2.65, lexsort 7.14 → 7.14 and 37.4 → 37.4.

The two changes between the runs, as measured

This is the record of the two changes that separate the morning run from the afternoon run, with the numbers measured at the time. Each number comes from a per-operation script that uses full_matrix.py's columns, seed and rule (one warm-up, best of five), with the before and after builds run alternately in separate processes; the matrix rows above are the authoritative re-measurement and agree with these to within a few per cent.

Sort, sqrt and shift

Measured with Benchmarks/loss_sort_shift_sqrt.py.

operation rows before after fastest CPU before after
sqrt (float64) 10,000,000 2.67 ms 1.36 ms 2.53 ms (numpy) 0.95x 1.86x
sqrt (float64) 50,000,000 12.96 ms 3.89 ms 12.54 ms (numpy) 1.03x 3.23x
sort float64 10,000,000 32.3 ms 9.42 ms 30.1 ms (Polars) 1.09x 3.20x
sort float64 50,000,000 154.6 ms 49.2 ms 141.8 ms (Polars) 1.95x 2.88x
argsort int64 10,000,000 27.8 ms 7.68 ms 57.2 ms (Polars) 2.21x 7.45x
argsort int64 50,000,000 142.1 ms 39.0 ms 302.6 ms (Polars) 5.23x 7.76x
argsort float64 10,000,000 28.1 ms 7.93 ms 82.3 ms (Polars) 3.15x 10.4x
argsort float64 50,000,000 143.3 ms 39.8 ms 445.1 ms (Polars) 6.85x 11.2x
shift (lag 1, int64), view=True 10,000,000 — 0.037 ms 0.033 ms (Polars) — 0.91x
shift (lag 1, int64), view=True 50,000,000 — 0.154 ms 0.034 ms (Polars) — 0.22x

(The CPU figures on the 50M rows of the "before" pass ran while a second process was resident and came out 2–3x slower than the matrix's; the CPU column above therefore quotes the quiet "after" pass, which agrees with the matrix to within a few per cent. ArrowMetal's own before-numbers agree with the matrix in both passes.)

sqrt. Still correctly rounded — bit-identical to Foundation.sqrt, and to numpy and Python's math.sqrt over 10.6M values covering the subnormals, every binade, both sides of exact squares, ±0, ±inf, NaN and negatives. The 54-step restoring extraction is gone; in its place is a hardware rsqrt seed, three Newton steps on the reciprocal square root in Q62 fixed point, and the exact 128-bit remainder N - q² to settle the last bit, which is the same shape d_div has used since it stopped being a long division. At 50M rows the kernel now moves its 800 MB at 206 GB/s, so what is left is bandwidth, not arithmetic. At 10M rows it is 1.86x rather than 3x for the ordinary reason: 1.36 ms is close enough to the dispatch floor that the fixed cost shows.

The radix sort. Three changes, in order of what they were worth. The stable scatter used to find an element's rank among the earlier elements of its 256-element chunk carrying the same digit by walking the chunk — a 256-iteration loop per element for the rank and a second one per digit for the chunk totals, about 1,500 instructions an element per pass, which was roughly two thirds of the whole sort. It is now eight simd_ballots to isolate the lanes of the SIMD group holding the same digit, a popcount for the rank, and a byte per (SIMD group, digit) so the groups of a chunk add up across. Blocks then became far bigger and fewer — about 128 rather than n/4096 — because each block loads and clears the whole bin table on every pass and the digit scan is a single threadgroup over radix × blocks entries; the scatter is memory bound and does not miss the parallelism. Last, a pass whose digit is the same in every row is an identity permutation (the sort is stable) and is now dropped: the first histogram reports the bitwise OR and AND of the keys next to its counts, and or ^ and names every skippable pass at once, so a small-range, sorted-ish or single-valued column loses most of its passes. Every permutation is unchanged — checked against pc.array_sort_indices over 21 lengths × 2 directions × 2 null placements × 10 key shapes.

The "fewer, wider digits" line above was the wrong guess, and the measurement says so: eleven bits do take a 64-bit key in six passes instead of eight, but a 2048-bin scatter needs 24 KB of threadgroup memory against 3 KB, and the occupancy that costs is worth more than the two passes it saves. Argsort of 50M int64, same code, digit width only: 8 bits 37.7 ms, 10 bits 84.3 ms, 11 bits 96.3 ms; 12 bits does not fit in threadgroup memory at all.

sort float64 reaches 2.9x rather than 3x, and the remainder is not the sort: it is take. sorted() is take(argsort()), and the gather costs 9.4 ms of the 49.2 at 50M rows because a random 8-byte gather pulls a whole cache line per element. The sorted keys are already sitting in the sort's own buffer and the key map is a bijection for every value except -0.0 and NaN. That gather was removed afterwards; the last section of this page measures it.

shift. The copy is unchanged and is still the default, because it was never the thing that was wrong: 3.6 ms for the 800 MB it touches at 50M rows is 220 GB/s, rather more than the sqrt kernel next to it reaches, so a better kernel would still be fifty times behind a pointer. What was missing was the option not to copy. shift(by, fill, view=True) in Python returns a pyarrow.ChunkedArray of two chunks — |by| rows of fill or of nulls in front, and a slice of the input behind, still pointing at the same device memory — and moves nothing, which is exactly the answer Polars gives. It is opt-in rather than the default because the chunked form is not a MetalArray and cannot go back into an ArrowMetal kernel without being combined first, which costs the copy that was just avoided; MetalArray still has no chunked representation, and that has not changed.

The 50M view costs 0.154 ms rather than the 0.003 ms the pointer arithmetic takes, because pa.chunked_array settles the slice's null count — a popcount over 50M validity bits. A column with no nulls does not pay it. That is pyarrow's accounting, not a copy.

count_distinct by key, tdigest, two-key group-by and list lengths

Measured with Benchmarks/loss_bench.py, the "before" being the previous build loaded through ARROWMETAL_LIB.

count_distinct by key (cause 3) — a GPU hash set over the (key, value) pair replaces the dictionary encoding, the packed int64 column and the unique() over it. One insert pass over the rows, then one pass over the table's occupied slots, each of which is one distinct pair and increments its group's count. Answers identical to the previous implementation over 162 shapes (float64 / float32 / int32 values, NaN, ±0.0, inf, 0–100% nulls, 0 to 300k rows, 1 to 997 groups).

operation rows before after Polars pyarrow
count_distinct by key (1000 groups) 10,000,000 122.6 ms 8.6 ms 27.1 ms 325 ms
count_distinct by key (1000 groups) 50,000,000 703 ms 45.3 ms 160 ms 1,876 ms
count_distinct by key (100000 groups) 10,000,000 123.1 ms 8.6 ms 31.8 ms 372 ms
count_distinct by key (100000 groups) 50,000,000 704 ms 45.5 ms 154 ms 1,998 ms
count_distinct by key (10M groups) 10,000,000 125.6 ms 11.7 ms 83.5 ms 661 ms
count_distinct by key (10M groups) 50,000,000 710 ms 60.5 ms 418 ms 3,235 ms

tdigest (cause 4) — the host walk is gone, and so is the centroid merge it was doing, because there was never anything to merge. This digest scales its weight limit by the weight seen so far rather than by the column's final weight, and the k1 scale function's inverse is bounded by 1, so the limit never reaches the next value's weight and every centroid holds exactly one value — at any compression, for any column length. The digest of a sorted column is therefore the sorted column, and TDigest.quantile over unit centroids has a closed form (TDigest.sortedQuantile, held against the walk itself by TDigestTests). The nulls are also compacted away before the sort, because sorting a column that carries a validity bitmap costs three times sorting the same values without one. The answer is unchanged to the last bit.

operation rows before after pyarrow
tdigest(float64, q=0.5) 10,000,000 653 ms 28.7 ms 198–246 ms
tdigest(float64, q=0.5) 50,000,000 3,082 ms 151 ms 1,070 ms

Two-key group-by (cause 8) — several integer key columns whose ranges multiply out to at most 2^24 (and at most the row count) are packed into one key in a single pass instead of being folded pairwise through three range encodings and two materialised int64 columns. The dense ids are the fold's own, value for value, so the group order is unchanged.

operation rows before after Polars pyarrow
sum by two int32 keys (~1024 groups) 10,000,000 6.6–7.2 ms 2.2–4.9 ms 32.3 ms 5.8 ms
sum by two int32 keys (~1024 groups) 50,000,000 22.3 ms 6.2 ms 168 ms 22.2 ms

The 10M row is the one number here that a second job on the machine moves: the operation is now short enough that GPU contention shows up in it, and the single-key row next to it (untouched code) moved 2.8 ms → 5.6 ms across the same runs. At 50M, where the runs were stable, it is 3.6x pyarrow.

list_value_length (cause 9) — one thread per row read every offset twice and stored four bytes at a time, which measured 70 GB/s of real traffic where the element-wise arithmetic kernels next door (the same 80 MB, 4-wide vectors) run at 225 GB/s. Eight rows per thread through vector loads and stores; a slice that leaves the offsets pointer misaligned keeps the one-row kernel.

operation rows before after Polars pyarrow
list_value_length 10,000,000 1.05 ms 0.35 ms 5.19 ms 0.68 ms

The work after the afternoon run: grouped aggregates at a small group count

Measured with Benchmarks/loss_groupby_small.py, which is the group-by family of full_matrix.py alone — its seed, its distributions, its rule (one warm-up, best of five under a 1.2 s budget). The two builds ran alternately, one process at a time, two rounds each, on an idle machine; the pool is warmed with a sort, a sum and a group-by after every column exists, because warming it before the columns are built leaves the first timed row reading twice its settled value. pyarrow was measured in the same script and is a little faster there than in the matrix (different draw order, same distributions), so the ratios below are the conservative ones.

Where the time went. At 50 million rows and a thousand groups, sum by int32 key was 8.8 ms, of which the aggregation kernel was 1.9. The other 6.9 ms was the stage in front of it: the map from key values to dense ids 0 ..< K. That stage took the range path — mark which values occur, scan the marks, read each row's rank — but it first widened the key column to int64, because its two kernels were written for long only. At 50M int32 keys that is a 400 MB write nothing else needs, followed by two passes reading 400 MB where 200 would do: 1.2 GB of the 1.8 GB the mapping moved. The kernels are now generated per key element type, the conversion happens in a register, and the rank kernel reads the distinct count out of the scan's own last entry so the three dispatches share one command buffer instead of needing the host in between. The ids are unchanged, value for value.

key mapping alone rows groups before after
am.group_by([int32]) 10,000,000 1,000 2.11 ms 0.91 ms
10,000,000 100,000 1.98 ms 0.98 ms
10,000,000 10,000,000 3.51 ms 3.20 ms
50,000,000 1,000 6.82 ms 3.03 ms
50,000,000 100,000 9.19 ms 4.58 ms
50,000,000 10,000,000 9.98 ms 9.16 ms

Two host loops went with it. hash_mean over an integer column ran the whole column twice — once for the sum, once for a count the sum kernel had already produced as the thing that decides which keys are null — and then divided group by group on the CPU; hash_sum copied its own output buffer into a fresh column in a second loop. Neither matters at a thousand groups and they were 19 ms of a 68 ms mean and 10 ms of a 43 ms sum at ten million. The mean now reuses the counts and divides on the GPU, and the sum hands back the accumulator's buffer with a GPU-built bitmap. d_from_long / d_from_ulong round exactly as Double(Int64) / Double(UInt64) do and d_div is correctly rounded, so the quotient is bit for bit the host division's — checked over 1,762,240 group means across all eight integer element types, 0% and 10% value nulls, five row counts and five group counts, with zero differing bit patterns. Putting the accumulation and those finalizing kernels in one command buffer rather than three matters too: a command buffer is 110–160 µs before it does any work, which is nothing next to a 50-million-row pass and most of a group-by over a million rows.

operation, 1,000 groups rows before after pyarrow before after
sum by int32 key 50,000,000 8.75 ms 4.81 ms 16.45 ms 1.88x 3.42x
count by int32 key 50,000,000 7.88 ms 4.05 ms 15.58 ms 1.98x 3.85x
mean by int32 key 50,000,000 9.78 ms 4.80 ms 16.55 ms 1.69x 3.45x
min by int32 key 50,000,000 9.80 ms 5.71 ms 18.48 ms 1.89x 3.24x
max by int32 key 50,000,000 9.67 ms 5.74 ms 18.97 ms 1.96x 3.30x
min by key, float64 50,000,000 9.70 ms 5.79 ms 18.84 ms 1.94x 3.25x
max by key, float64 50,000,000 9.62 ms 5.74 ms 18.71 ms 1.94x 3.26x
variance by key 50,000,000 33.52 ms 29.68 ms 18.99 ms 0.57x 0.64x
stddev by key 50,000,000 33.32 ms 29.79 ms 19.05 ms 0.57x 0.64x
sum by utf8 key 50,000,000 18.18 ms 18.11 ms 42.14 ms 2.32x 2.33x
sum by two int32 keys 50,000,000 5.85 ms 5.88 ms 21.21 ms 3.62x 3.61x
sum by int32 key 10,000,000 2.78 ms 1.82 ms 4.07 ms 1.46x 2.24x
count by int32 key 10,000,000 2.55 ms 1.65 ms 3.89 ms 1.53x 2.35x
mean by int32 key 10,000,000 3.30 ms 1.77 ms 4.20 ms 1.27x 2.37x
min by int32 key 10,000,000 2.55 ms 1.66 ms 4.62 ms 1.81x 2.79x
max by int32 key 10,000,000 2.57 ms 1.70 ms 4.81 ms 1.87x 2.82x
variance by key 10,000,000 6.35 ms 5.66 ms 5.31 ms 0.84x 0.94x

At 10 million rows the operations are 1.7–1.8 ms and about a fifth of that is the dispatch floor, so they sit at 2.2–2.8x rather than 3x for the reason cause 1 gives.

The higher group counts move the same way and nothing there gets slower:

operation rows groups before after pyarrow after
sum by int32 key 50,000,000 100,000 11.24 ms 7.70 ms 48.28 ms 6.3x
count by int32 key 50,000,000 100,000 8.46 ms 4.96 ms 36.24 ms 7.3x
mean by int32 key 50,000,000 100,000 12.95 ms 7.70 ms 47.41 ms 6.2x
min by int32 key 50,000,000 100,000 13.47 ms 9.98 ms 52.12 ms 5.2x
variance by key 50,000,000 100,000 40.08 ms 36.75 ms 136.47 ms 3.7x
sum by int32 key 10,000,000 10,000,000 13.78 ms 10.07 ms 332.53 ms 33x
mean by int32 key 10,000,000 10,000,000 28.66 ms 11.38 ms 324.81 ms 29x
mean by int32 key 50,000,000 10,000,000 83.72 ms 55.85 ms 1,319.80 ms 24x
sum by int32 key 50,000,000 10,000,000 54.84 ms 53.31 ms 1,321.68 ms 25x

The shape sweep. Timings on the rows a benchmark happens to contain say nothing about the shapes it does not, so loss_groupby_small.py --sweep walks one axis at a time away from a base shape — row count 1M / 3M / 10M / 27M / 50M, group count 1 / 2 / 10 / 100 / 1,000 / 1,025 / 5,000 / 20,000 / 100,000 / 1M / 10M, keys uniform / 90%-in-one-group / sorted, keys with 10% nulls, values int64 and float64, values with 10% nulls, and a slice at offset 33 — over sum, count, mean, min, max and variance. 220 measurements, both builds, two rounds each (sweep output not committed; rerun Benchmarks/loss_groupby_small.py --sweep to reproduce). 218 are faster after the change and none is slower than main's own spread: the two that read below 1.0x are min and max at 3M rows and 1,000 groups, where main measured 1.42 and 2.49 ms across its two rounds and the new build 1.57 and 1.62 — the new build's slowest round is below main's slowest, and the comparison picked main's lucky round. --digest hashes every answer instead of timing it: all 220 are bit-identical between the two builds.

Two shapes in that sweep are slow on both builds, for the reason cause 3 describes: a grouped sum or mean over a float64 column with very few groups runs the segmented path, which gives a whole threadgroup to one group, so 10 million rows in one group is 35 ms and 90% of the rows in one group is 31 ms, against 4.6 ms for the same column spread over a thousand groups. Integer values do not have this shape — they take the atomic path — and neither does min, max or count.

After the afternoon run: sorted() stops gathering, and the nulls stop being a host pass

Measured with Benchmarks/loss_sort_gather.py, the before build (main at 86bd495) loaded through ARROWMETAL_LIB and its own ctypes package through ARROWMETAL_PYTHON — the change adds an entry point, so the old package has to come with the old library. full_matrix's rule throughout: its seed, its column builders, one warm-up, best of five. Every figure below comes from Benchmarks/loss_sort_gather.py; the run's raw tables are not committed.

Two harnesses, and it matters which is which. Matrix mode runs one build per process, which is how full_matrix.py measures, and the tables in the next two sections are its (matrix_table.txt, best of two before-runs and two after-runs). The shape sweep loads both builds into one process and alternates them case by case, which is the only way its 1M-row rows say anything; every sweep figure is labelled as such and comes from sweep_table.txt.

The gather

sorted() was take(argsort()). The radix sort maps every value to an order-preserving unsigned key, and that map is a bijection on every value except two: -0.0 shares +0.0's key (so that the two tie, which is what Arrow's order asks for) and every NaN payload shares one key. So for a column with neither, the sorted values are the sorted keys, read backwards through the map — a sequential read and a sequential write where the gather was a random 8-byte one that pulled a cache line per element.

The key kernel now says which, in a flag word it fills from two simd_ballots while it has the values loaded anyway; argsort does not ask for it and pays nothing. And once the values no longer come out of the permutation, the sort does not have to carry the permutation: dropping the uint payload takes every pass from 24 bytes an element to 16, which is worth more than the gather was.

A column that does hold a -0.0 or a NaN still gets its answer from the keys, and only the output positions the inverse cannot produce are copied back through the sorted row numbers: the run of zeros, the run of NaNs, the null block. Each is contiguous — they share a key — and each is found by a binary search over the sorted keys. Those columns keep the payload, so they win less: in the sweep at 50M rows they are 1.14x and 1.15x where the same column without them is 1.34–1.56x.

operation rows before after before after
sort float64 10,000,000 9.585 ms 6.503 ms 3.19x Polars 4.70x Polars
sort float64 50,000,000 50.281 ms 31.948 ms 2.86x 4.51x
sort int64 10,000,000 9.390 ms 6.142 ms 3.35x 5.11x
sort int64 50,000,000 49.409 ms 30.626 ms 3.40x 5.48x

The Polars column is measured by the same script (--polars, recorded in matrix.log) in the same processes as the after rows, so ArrowMetal's own numbers there are if anything a little pessimistic: sort float64 30.551 ms at 10M and 143.964 ms at 50M, sort int64 31.413 ms and 167.743 ms. The afternoon matrix's own Polars column is faster — 26.1 ms and 132.4 ms for float64, on a machine with nothing else resident — and against those the two float64 rows are 4.01x and 4.14x. Either accounting puts sort float64 past the 3x bar at both sizes, which is what this work was for.

The nulls

Sorting a column that carries a validity bitmap cost an order of magnitude more than sorting the same values without one — 139.7 ms against 9.6 ms at 10M float64 rows, and 725.9 ms against 50.3 ms at 50M. None of that was the GPU. The null rows went through the sort with whatever bytes sat under their bitmap, and a host pass then lifted them back out of the finished permutation: three loops over the whole index array plus a sort() of the null row numbers, because a null's place among the other nulls is its input position, not wherever its garbage key landed.

The nulls do not enter the sort at all now. A stable three-way partition — values, NaNs when .atStart wants them separated, nulls — runs first and compacts the value block's keys as it goes, so the sort runs over the value block alone and the nulls keep their input order by construction, which is the order Arrow asks for. Three kernels: a per-block bucket count, a scan, and a scatter that ranks a chunk's rows against its own SIMD group with three ballots, the same trick the radix scatter uses. The passes are then planned around the value block rather than the column, which is what the 50% rows below are: blocks sized for the column would leave half the GPU idle.

operation rows nulls before after source
sort float64 10,000,000 10% 139.745 ms 6.837 ms matrix
sort float64 50,000,000 10% 725.935 ms 33.669 ms matrix
argsort float64 10,000,000 10% 138.795 ms 7.654 ms matrix
argsort float64 50,000,000 10% 711.053 ms 39.225 ms matrix
sort float64 50,000,000 1% 187.955 ms 36.189 ms sweep
sort float64 50,000,000 50% 3,436.130 ms 22.879 ms sweep
argsort int32 50,000,000 50% 3,401.301 ms 11.938 ms sweep

The old cost grew with the null count because the host sort() of the null row numbers did; that is why the 50% rows are three seconds. Every caller of argsort gets this, not only sort: unique, value_counts, rank, the window functions, lexsort and the dictionary paths all sort nullable columns.

.atStart also stops being a host pass. Placing the nulls (and, on a float column, the NaNs) at the front used to scan every value on the CPU to count the NaNs before it could decide anything. The partition does that work on the GPU now, and it runs on any float column asked for .atStart whether or not it holds a NaN — so the worst case for it is a clean column, where it has nothing to separate. Measured on a clean 50M float64 column, both builds alternating in one process (atstart.txt): sort 64.07 → 36.81 ms and argsort 53.96 → 44.58 ms, so even the empty partition is well ahead of the host scan it replaced. It is not free — argsort .atEnd on the same column is 40.75 ms, so the partition costs about 4 ms at 50M rows — but there is nothing to gate it on: whether a NaN exists is not known until the keys have been mapped, which is the same command buffer the partition has to be encoded in. With 1% of the rows NaN the same two calls go 542.3 → 47.6 ms and 524.6 → 50.1 ms.

The tdigest workaround stays. tdigest compacts the nulls out with drop_null before sorting, which was worth an order of magnitude and is now worth 10–12% — but only at 50% nulls, where dropping them also halves the output buffer and the pass over it (10M float64: 5.14 ms sorting the column against 4.60 ms compacting first; 50M: 22.80 against 20.69, tdigest_vs_dropnull.txt). At 1% and 10% nulls the general path is now the faster of the two by 7–10% (10M: 7.63 against 8.41 and 7.16 against 7.64; 50M: 36.38 against 40.01 and 34.20 against 36.54). Since the rule was to remove the workaround only if the general path is at least as fast, it stays, and Kernels/TDigestGPU.swift records the same numbers.

What did not move

argsort of a column with no nulls is the same code as before and measures the same: int64 7.82 → 7.73 ms at 10M and 39.8 → 39.5 at 50M, float64 7.98 → 7.95 and 40.6 → 40.3. So does lexsort (6.86 → 6.66 at 10M, 37.4 → 37.3 at 50M). partition_nth_indices reads 2.91 → 2.51 ms at 10M and 11.67 → 11.65 at 50M, top_k 1.78 → 0.79 and 2.52 → 2.48, select_k_unstable 1.69 → 0.77 and 2.51 → 2.46; the three of them share the radix select, which this work did not touch, and their 10M rows are the pool-sensitivity partition_nth_indices already has a paragraph about further up this page rather than anything new.

The shape sweep

The matrix's columns are one shape. Every claim above was re-taken over five sizes (1M, 3M, 10M, 27M, 50M) × 29 columns — eleven shapes on float64 (random, sorted, reverse-sorted, all-equal, 1000-distinct, with -0.0, with NaN, 1% / 10% / 50% nulls, and a slice at offset 33) and nine each on int64 and int32, the two float-special shapes not applying to them — × two operations: 290 measured cases. The two builds are loaded into one process there and measured alternately, case by case, because a build measured in its own process is measured at a different minute and at 1M rows the difference between two minutes is larger than the difference between the two builds.

Every sort row at 3M rows and above is faster: 1.02x to 285x. At 1M the range is 0.99x to 38x, the 0.99x being the two float64 columns that hold a -0.0 or a NaN, where 1.858 ms became 1.878 and 1.677 became 1.701 — at that size the fix-up's extra command buffer eats the win. Every argsort row on a column with nulls is faster, from 1.76x to 301x. Every argsort row without nulls is unchanged to within 1% at 10M rows and above, which is what the code says it should be: that path is untouched. Two rows read below 0.97x, both argsort of a 1000-distinct column at 1M rows: int32 0.642 → 0.756 ms and int64 0.433 → 0.456 ms. The same sweep run with the same build on both sides — noise_control_table.txt, the harness measuring itself — produces ten such rows, down to 0.86x and including one at 50M, so that band is the harness and not the change. Nothing at 10M, 27M or 50M is slower.

operation, 50M rows shape before after
sort float64 random 50.308 ms 32.301 ms
sort float64 sorted 43.615 ms 32.298 ms
sort float64 reverse-sorted 43.265 ms 32.244 ms
sort float64 all-equal 8.962 ms 7.579 ms
sort float64 1000-distinct 26.418 ms 17.413 ms
sort float64 with -0.0 50.309 ms 44.241 ms
sort float64 with NaN 50.903 ms 44.438 ms
sort float64 sliced at offset 33 50.541 ms 32.395 ms
sort int64 random 48.277 ms 31.511 ms
sort int32 random 24.166 ms 14.017 ms
argsort float64 random 40.707 ms 41.210 ms
argsort int64 random 38.780 ms 38.804 ms
docs/CROSSOVER.md

The crossover: where the GPU path overtakes the CPU

Generated by Benchmarks/crossover.py from the files named at the top of Benchmarks/results/crossover_2026-09-24.csv; nothing on this page is typed by hand. Apple M4 Max, the matrix protocol (best of up to five after a warm-up, 1.2 s budget, at least two), 10% nulls on the numeric columns.

Two questions, two tables. The first is the one a caller asks: from what row count is ArrowMetal at or ahead of the fastest CPU idiom the matrix knows (Polars eager and lazy, pyarrow and Acero on 16 batches, pandas, numpy), at every larger size measured? The second is the one the engine's router asks: from what row count is the GPU kernel faster than a single-core Swift loop? That loop is the bench's own (cpu-1core, cpu-candidate for the group-by, in Sources/ArrowMetalBench/main.swift), not the router's shipped CPU loops in Sources/ArrowMetal/Router/RouterCPU.swift, which Benchmarks/router_check.py times. A row "not reached" means ArrowMetal was still behind at the largest size measured.

Per family, against the fastest CPU idiom

family operations reach the crossover earliest median latest
compare+select 9 4 1,000,000 30,000,000 50,000,000
element-wise 25 20 1,000,000 10,000,000 50,000,000
group-by 28 27 100,000 1,000,000 10,000,000
reductions 18 18 1,000 10,000,000 50,000,000
sort 12 10 1,000,000 1,000,000 10,000,000
strings 19 16 100,000 1,000,000 10,000,000

Not swept, so no crossover is stated: chains, decimal, join, temporal, window. The matrix measures them at 10M and 50M rows only (The full matrix).

The router's own crossover: GPU kernel against the CPU path it would run

arrowmetal-bench crossover: ArrowMetal crossover on Apple M4 Max, 16 CPU cores, best of up to 20 (5 at 10M rows and above), 10% nulls, 2026-09-24T22:54:48Z

operation crossover (rows) 1,000 10,000 100,000 1,000,000 10,000,000 50,000,000
add(int64, 1) 10,000,000 100 / 0 µs 107 / 1 µs 110 / 12 µs 169 / 132 µs 506 / 1324 µs 2094 / 6896 µs
compare(int64 > 0) 1,000,000 110 / 0 µs 118 / 1 µs 117 / 14 µs 145 / 154 µs 468 / 1550 µs 1232 / 7970 µs
filter(int64, mask) 1,000,000 347 / 0 µs 370 / 2 µs 366 / 29 µs 417 / 731 µs 1126 / 7842 µs 3105 / 39465 µs
group-by sum (1000 keys) 1,000,000 143 / 1 µs 177 / 10 µs 211 / 108 µs 556 / 1104 µs 1161 / 11144 µs 2188 / 51771 µs
max(int64) 1,000,000 112 / 0 µs 116 / 4 µs 124 / 40 µs 274 / 514 µs 492 / 5995 µs 1249 / 30310 µs
min(int64) 1,000,000 104 / 1 µs 106 / 4 µs 128 / 40 µs 263 / 557 µs 418 / 6174 µs 1199 / 30238 µs
sum(int64) 1,000,000 115 / 1 µs 106 / 5 µs 128 / 56 µs 197 / 993 µs 298 / 11304 µs 1041 / 55652 µs

Each cell is GPU / CPU-path wall microseconds, best of the run's repetitions; the CPU path is the tight single-core loop (cpu-1core), or for group-by the candidate dictionary loop (cpu-candidate). The bench CSV also carries cpu-ref, the tests' CPUReference oracle, which walks a closure per element and is one to two orders slower than the tight loop: it is the reason the router's CPU side is written as new loops rather than reused from the oracle. cpu-allcores, where present, is the bench's all-core loop, recorded for reference.

The router's table: how it is made, and making one for your Mac

The shipped table (Sources/ArrowMetal/Router/RouterTable.swift) is fitted from Benchmarks/results/router_check_2026-09-24.csv: ArrowMetal router check on Apple M4 Max, 16 CPU cores, Apple M4 Max, best of 20 (5 at 3M rows and above), int64 with 10% nulls, Python binding on resident arrays, 2026-09-24T17:58:16Z. The pipeline:

  1. Sweep: PYTHONPATH=python python Benchmarks/router_check.py --out Benchmarks/results/router_check_<date>.csv times every routed operation with the router pinned to gpu and to cpu, and under auto, on int64 columns with 10% nulls resident in Metal memory (12 cases, 1,000 to 10,000,000 rows).
  2. The CSV lands in Benchmarks/results/.
  3. Fit: python Benchmarks/router_table.py --from-check Benchmarks/results/router_check_<date>.csv takes, per operation, the first measured size from which the GPU stays ahead and the size below it, joins the two measured points of each path with a straight line, and puts the crossover where the lines meet. It writes RouterTable.swift; --json-out PATH also writes the same table as JSON (format arrowmetal-router-table/1), and --check fails when the committed table no longer matches the file it names.
  4. RouterTable.swift is a Swift literal compiled into the library.

On any Mac, one command runs the sweep and the fit for that machine:

python -m arrowmetal.router calibrate          # the router check grid: 12 cases x 6 sizes, gpu, cpu and auto
python -m arrowmetal.router calibrate --quick  # the 8 cases the table is fitted from, 30,000 to 10,000,000 rows
python -m arrowmetal.bench --calibrate         # the benchmark, then the quick calibration

It writes ~/.arrowmetal/router/<chip>.json, where <chip> is sysctl machdep.cpu.brand_string in lower case with other characters as - (apple-m4-max). The file records the chip, the CPU core counts, the Metal device, memory, macOS, the date, the ArrowMetal version, the grid that ran and every measurement, and the output names the grid. --out PATH writes elsewhere; --csv PATH also writes the sweep as a router check CSV for router_table.py --from-check.

The table in force is chosen once, at load: ARROWMETAL_ROUTER_TABLE=<path> (or shipped), else ~/.arrowmetal/router/<chip>.json when it exists, else the shipped table. An operation the sweep did not bring to a crossover keeps its shipped row, and a file that does not parse leaves the shipped table in force. am_router_load_table (C), am.load_router_table (Python) and Router.loadTable(path:) (Swift) replace it in a running process; am.router_table() and am_router_table_info describe it.

python -m arrowmetal.router explain sum 150000 [--dtype int64] [--nulls 0.1] [--keys 1000] [--json]

prints the decision under the mode and table in force (mode auto -> cpu), the reason, the table row with its crossover and the two measured points it was fitted between, and whether the table is the shipped one or this machine's.

A decision depends only on the operation, the value type, the row count, the mode, whether a batch is open and the table; nothing is timed at call time. The table has one shape, int64 with 10% nulls on resident arrays, so the null fraction and residency do not change a decision. python/tests/test_router_calibrate.py checks that 200 (operation, size) pairs get the same decision across 1,000 calls and across two processes, and RouterTests.testRouteIsPure checks the Swift rule the same way.

Per operation, against the fastest CPU idiom

family operation crossover (rows) 1,000 10,000 100,000 1,000,000 10,000,000 50,000,000
compare+select compare array (int64 > int64) 50,000,000 0.80x 0.72x 0.93x 0.93x 0.65x 1.09x
compare+select compare scalar (int64 > 0) not reached 0.96x 0.95x 0.96x 0.94x 1.19x 0.79x
compare+select drop_null (int64, 10% nulls) not reached 1.29x 0.76x 0.22x 0.22x 0.58x 0.91x
compare+select filter int64 (30% kept) 50,000,000 0.32x 0.42x 0.52x 0.23x 0.63x 1.28x
compare+select filter int64 (90% kept) not reached 0.24x 0.22x 0.21x 0.42x 0.54x 0.87x
compare+select indices_nonzero (bool) 10,000,000 0.01x 0.04x 0.25x 0.72x 2.27x 5.38x
compare+select replace_with_mask (30% replaced) not reached 0.00x 0.01x 0.02x 0.14x 0.33x 0.36x
compare+select slice (zero-copy view) not reached 0.40x 0.33x 0.30x 0.33x 0.33x
compare+select take (n/2 random indices) 1,000,000 0.01x 0.04x 0.14x 1.44x 11.29x 24.15x
element-wise abs (float64) 50,000,000 0.02x 0.03x 0.11x 0.49x 0.82x 1.06x
element-wise add array (int64 + int64) 10,000,000 0.48x 0.80x 0.92x 0.87x 1.56x 1.38x
element-wise add scalar (int64) 1,000,000 1.00x 1.00x 0.99x 1.17x 1.52x 1.54x
element-wise add_checked (int64 + int64) 1,000,000 0.03x 0.10x 0.84x 1.06x 1.67x 1.92x
element-wise bit_wise_and (int64) 10,000,000 0.01x 0.02x 0.15x 0.63x 1.29x 1.46x
element-wise case_when (2 conditions) not reached 0.02x 0.08x 0.23x 0.26x 0.37x 0.94x
element-wise coalesce (2 int64 columns) 50,000,000 0.03x 0.06x 0.67x 0.82x 0.67x 1.78x
element-wise divide (float64 / float64) 50,000,000 0.01x 0.03x 0.13x 0.26x 0.74x 1.14x
element-wise exp (float32) 1,000,000 0.01x 0.10x 0.82x 1.06x 1.02x 2.75x
element-wise fill_null (int64) 50,000,000 0.05x 0.10x 0.49x 0.72x 0.98x 1.98x
element-wise fill_null_forward (int64) 10,000,000 0.01x 0.02x 0.13x 0.71x 5.05x 6.15x
element-wise hash64 (int64) 50,000,000 0.06x 0.17x 0.62x 0.57x 0.55x 1.42x
element-wise if_else (bool ? int64 : int64) 50,000,000 0.00x 0.02x 0.11x 0.30x 0.59x 1.42x
element-wise index_in (int64, 100-value set) 10,000,000 0.00x 0.02x 0.13x 0.43x 1.83x 4.67x
element-wise is_in (int64, 100-value set) 10,000,000 0.01x 0.04x 0.20x 0.57x 1.82x 7.37x
element-wise is_nan (float64) 50,000,000 0.00x 0.01x 0.07x 0.53x 0.92x 1.58x
element-wise ln (float64) not reached 0.01x 0.07x 0.13x 0.18x 0.11x 0.10x
element-wise multiply array (int64 * int64) 10,000,000 0.57x 0.71x 0.75x 0.66x 1.46x 1.46x
element-wise multiply scalar (int64) 10,000,000 1.00x 1.06x 0.76x 0.67x 1.73x 1.97x
element-wise negate (int64) 50,000,000 0.02x 0.04x 0.20x 0.47x 0.83x 1.44x
element-wise power (float32 ** 2) not reached 0.05x 0.16x 0.35x 0.46x 0.45x 0.95x
element-wise round (float64) 50,000,000 0.04x 0.17x 0.53x 0.53x 0.60x 1.49x
element-wise shift_left (int64 << 2) 10,000,000 0.01x 0.01x 0.11x 0.58x 1.46x 3.55x
element-wise sin (float64) not reached 0.01x 0.10x 0.11x 0.22x 0.17x 0.16x
element-wise sqrt (float64) not reached 0.00x 0.02x 0.17x 0.26x 0.53x 0.87x
group-by count by int32 key (1000 groups) 10,000,000 0.10x 0.24x 0.55x 0.61x 1.17x 4.11x
group-by count by int32 key (100000 groups) 100,000 1.37x 3.98x 7.37x 7.78x
group-by count_distinct by key (1000 groups) 100,000 0.20x 0.51x 1.09x 1.27x 3.81x 3.75x
group-by count_distinct by key (100000 groups) 100,000 2.39x 1.73x 4.52x 3.37x
group-by first by key (1000 groups) 1,000,000 0.05x 0.09x 0.44x 1.50x 5.85x 7.95x
group-by first by key (100000 groups) 100,000 1.06x 1.58x 5.77x 5.81x
group-by list by key (1000 groups) 1,000,000 0.04x 0.10x 0.35x 1.16x 9.53x 10.85x
group-by list by key (100000 groups) 1,000,000 0.49x 2.95x 8.91x 9.72x
group-by max by int32 key (1000 groups) 10,000,000 0.10x 0.14x 0.49x 0.65x 2.98x 3.30x
group-by max by int32 key (100000 groups) 100,000 1.35x 1.83x 6.80x 5.32x
group-by max by key, float64 (1000 groups) 10,000,000 0.11x 0.13x 0.49x 0.68x 1.29x 4.71x
group-by max by key, float64 (100000 groups) 100,000 1.49x 1.86x 7.11x 6.71x
group-by mean by int32 key (1000 groups) 10,000,000 0.14x 0.20x 0.52x 0.60x 2.27x 3.79x
group-by mean by int32 key (100000 groups) 100,000 1.14x 2.29x 8.46x 6.36x
group-by min by int32 key (1000 groups) 10,000,000 0.10x 0.14x 0.45x 0.67x 2.55x 3.32x
group-by min by int32 key (100000 groups) 100,000 1.50x 1.73x 6.86x 4.93x
group-by min by key, float64 (1000 groups) 10,000,000 0.10x 0.18x 0.41x 0.71x 2.16x 4.99x
group-by min by key, float64 (100000 groups) 100,000 1.50x 1.91x 7.04x 6.63x
group-by stddev by key (1000 groups) 10,000,000 0.08x 0.09x 0.24x 0.29x 1.11x 1.24x
group-by stddev by key (100000 groups) 10,000,000 0.72x 0.82x 2.60x 2.52x
group-by sum by int32 key (1000 groups) 10,000,000 0.22x 0.43x 0.72x 0.63x 2.35x 3.77x
group-by sum by int32 key (100000 groups) 100,000 1.40x 2.67x 8.38x 6.26x
group-by sum by two int32 keys (~100489 groups) 100,000 1.23x 2.63x 7.91x 7.40x
group-by sum by two int32 keys (~1024 groups) 10,000,000 0.14x 0.21x 0.48x 0.50x 1.58x 4.04x
group-by sum by utf8 key (1000 distinct) 10,000,000 0.07x 0.13x 0.28x 0.36x 1.71x 2.20x
group-by sum by utf8 key (100000 distinct) 10,000,000 0.60x 0.79x 5.86x 6.08x
group-by variance by key (1000 groups) not reached 0.07x 0.10x 0.29x 0.27x 1.07x 0.96x
group-by variance by key (100000 groups) 10,000,000 0.73x 0.87x 2.44x 2.52x
reductions all(bool, 10% nulls) 100,000 0.17x 0.21x 1.07x 3.12x 22.50x 106.43x
reductions any(bool, 10% nulls) 100,000 0.14x 0.23x 1.08x 3.50x 22.29x 106.36x
reductions count_distinct(int64) 10,000,000 0.01x 0.03x 0.43x 0.55x 1.64x 1.97x
reductions first(int64) 1,000 1.59x 1.75x 2.13x 3.53x 5.29x 5.65x
reductions kurtosis(float64) 10,000,000 0.00x 0.02x 0.22x 0.39x 2.19x 2.10x
reductions last(int64) 1,000 1.59x 1.65x 1.82x 3.59x 5.62x 6.38x
reductions max(int64, 10% nulls) 10,000,000 0.17x 0.37x 0.37x 0.24x 1.07x 1.89x
reductions mean(int64, 10% nulls) 1,000,000 0.22x 0.47x 0.43x 1.25x 5.04x 5.34x
reductions min(int64, 10% nulls) 10,000,000 0.17x 0.37x 0.37x 0.35x 1.13x 1.70x
reductions min_max(int64) 50,000,000 0.00x 0.02x 0.14x 0.16x 0.78x 1.35x
reductions mode(int64) 10,000,000 0.00x 0.01x 0.03x 0.27x 1.97x 3.08x
reductions product(int64) 1,000,000 0.01x 0.07x 0.72x 1.01x 2.93x 3.64x
reductions quantile(float64, 0.5) 1,000,000 0.01x 0.04x 0.58x 2.39x 13.22x 38.71x
reductions skew(float64) 10,000,000 0.00x 0.02x 0.19x 0.31x 1.62x 1.63x
reductions stddev(float64, ddof=1) 10,000,000 0.00x 0.03x 0.26x 0.31x 1.92x 2.17x
reductions sum(int64, 10% nulls) 10,000,000 0.21x 0.49x 0.54x 0.67x 1.76x 2.07x
reductions tdigest(float64, q=0.5) 10,000,000 0.03x 0.16x 0.27x 0.82x 2.68x 2.41x
reductions variance(float64, ddof=1) 10,000,000 0.00x 0.03x 0.27x 0.37x 2.01x 2.18x
sort argsort float64 1,000,000 0.02x 0.37x 0.57x 4.66x 9.97x 10.52x
sort argsort int64 1,000,000 0.02x 0.33x 0.44x 3.87x 7.10x 7.71x
sort dense_rank 1,000,000 0.02x 0.26x 0.42x 4.24x 8.98x 10.32x
sort dictionary_encode (int32) 10,000,000 0.00x 0.02x 0.08x 0.35x 2.59x 4.41x
sort lexsort (2 int32 keys) 1,000,000 0.02x 0.19x 0.41x 1.83x 16.17x 24.02x
sort partition_nth_indices (n/2) 1,000,000 0.00x 0.02x 0.41x 2.28x 17.48x 24.49x
sort rank (min) 1,000,000 0.02x 0.27x 0.45x 4.97x 8.49x 10.29x
sort sort float64 10,000,000 0.03x 0.14x 0.21x 0.70x 3.85x 4.14x
sort top_k (k=100, int64) 10,000,000 0.00x 0.01x 0.06x 0.51x 2.76x 8.98x
sort top_k (k=10000, int64) 10,000,000 0.05x 0.01x 0.10x 0.79x 4.95x 6.99x
sort unique (int32, 1000 distinct) not reached 0.00x 0.01x 0.08x 0.14x 0.58x 0.83x
sort value_counts (int32, 1000 distinct) not reached 0.01x 0.02x 0.06x 0.12x 0.65x 0.84x
strings char_length 100,000 0.04x 0.33x 1.40x 2.72x 3.61x
strings concat (a + '-' + b) 1,000,000 0.06x 0.24x 0.53x 2.10x 4.61x
strings contains("north") 1,000,000 0.03x 0.51x 0.61x 3.14x 6.60x
strings is_alpha 10,000,000 0.01x 0.09x 0.29x 0.93x 2.24x
strings is_in (utf8, 100-value set) 10,000,000 0.01x 0.04x 0.21x 0.38x 1.11x
strings lower 10,000,000 0.04x 0.38x 0.33x 0.91x 2.82x
strings match_like("cust%") [pure prefix] 1,000,000 0.02x 0.12x 0.60x 1.11x 1.63x
strings match_like("cust_1%") [_ wildcard] 1,000,000 0.04x 0.39x 0.51x 1.33x 5.26x
strings match_substring_regex (literal) 1,000,000 0.06x 0.42x 0.68x 3.20x 6.63x
strings match_substring_regex (real regex) not reached 0.08x 0.21x 0.14x 0.12x 0.08x
strings pad_left (width 20) 1,000,000 0.05x 0.34x 0.61x 1.24x 4.09x
strings parse (utf8 -> int64) not reached 0.00x 0.04x 0.17x 0.20x 0.17x
strings replace_substring("cust" -> "cx") 1,000,000 0.05x 0.31x 0.55x 1.46x 4.61x
strings slice_codeunits [5:10] 10,000,000 0.02x 0.19x 0.27x 0.75x 2.40x
strings split_pattern("_") 10,000,000 0.05x 0.20x 0.45x 0.85x 2.50x
strings starts_with("cust_1") 1,000,000 0.02x 0.17x 0.54x 1.28x 2.05x
strings to_strings (int64 -> utf8) not reached 0.02x 0.14x 0.21x 0.42x 0.60x
strings trim (whitespace) 10,000,000 0.04x 0.26x 0.46x 0.61x 2.07x
strings upper 10,000,000 0.05x 0.36x 0.67x 0.89x 3.96x

A cell is fastest-CPU wall time divided by ArrowMetal wall time at that size: above 1.00x ArrowMetal is ahead. The CSV carries the times, the library that was fastest, and the CPU milliseconds of each.

docs/DECISIONS.md

Decision log

Architecture and product decisions, newest first. Each entry says what was decided, why, and what it rules out.

2026-09-24: 0.2.0, and how versions move from here

0.1.0 was published on 2026-09-08. The work since (the CPU/GPU router, the CSV and JSON readers, nested Parquet, Delta Lake and Iceberg, the IPC view types, the Polars engine and the DuckDB rewrite) is new functionality, so it ships as 0.2.0 rather than 0.1.1. Every manifest changes in the same commit; between releases, work collects under an "Unreleased" heading in CHANGELOG.md.

2026-09-06: Version stays 0.1.0 until the public launch

Everything built before going public is one release. Internal rounds are tracked in the changelog under a single 0.1.0 heading and in docs/BENCHMARKS.md as numbered rounds.

2026-09-06: Batched execution with lazy materialisation instead of futures

A per-thread open command buffer with sync-on-read keeps the synchronous API (every method still returns a real array) while removing the round trip between chained kernels — measured at about 65 µs for an empty round trip today (The persistent-kernel result; 116 µs in round 4 of docs/BENCHMARKS.md), and per call in the matrix's latency family 110–230 µs for sum and filter at 1k to 1M rows and 400–1,080 µs for group-by sum at a thousand keys (docs/TO_IMPROVE.md §1). Futures would have changed every signature. Cost: a reduction inside a batch still syncs; filter results carry a worst-case buffer until read.

2026-09-06: One C ABI, thin idiomatic wrappers per language

No per-language ports. libArrowMetalC takes and returns Arrow C Data Interface structs plus opaque handles. Python, Rust, Go, C#, R, C++ all have Arrow bindings that produce and consume those structs.

2026-09-06: Name is ArrowMetal

"AppleArrow" and "AppleMetalArrow" were rejected: "Apple" in a product name invites a trademark complaint and implies Apple built it. "Arrow" is an Apache Software Foundation mark; the README carries a non-endorsement note.

2026-09-06: Swift host, MSL kernels, C ABI only at the border

Swift with unsafe pointers matches C for CPU loops (the 16-core baselines reach memory bandwidth), gives native Metal API access, ARC for buffer lifetime, iOS, and SwiftPM. The hot code is Metal Shading Language regardless. C matters only for foreign-language bindings, which the Arrow C Data Interface already covers.

2026-09-06: No dependency on MLX or arrow-swift

MLX is a tensor library without nulls or columnar semantics; arrow-swift has no compute. Both become optional bridges through the Arrow C interfaces rather than foundations.

2026-09-06: Runtime shader compilation from generated MSL

No .metal files, no offline metal compiler, builds with Command Line Tools only. Kernels are string templates specialised per element type and cached per pipeline. Cost: ~100 ms first-use compile per library.

2026-09-06: Page-aligned, pooled buffers via posix_memalign + makeBuffer(bytesNoCopy:)

Metal sub-allocates small makeBuffer(length:) buffers from a heap, so they are not page aligned and cannot be re-wrapped zero-copy by another Metal consumer. Own allocation guarantees alignment. A size-bucketed pool avoids mmap and page-fault costs on repeated allocations, which cut the write-heavy kernels' times between round 1 and round 2 in docs/BENCHMARKS.md (Apple M4 Max, 2026-09-06): multiply 6.84 → 2.13 ms, filter 5.36 → 2.90, cast 5.04 → 3.14, take 8.09 → 5.85. Kernel outputs that are fully written skip zeroing.

2026-09-06: Reductions without atomics

Each threadgroup writes a partial; the host (or a scan kernel) finalises. Deterministic float sums, works for 64-bit integers (the Metal Shading Language exposes no 64-bit atomic add on Apple GPUs; only 64-bit atomic min and max, measured on an M4 Max, see Upstream tracker).

2026-09-06: Float64 on the GPU: bit-pattern ordering plus software IEEE-754

Metal has no double. Compare, min, max, filter, take and slice treat Float64 as long with an order-preserving key (NaN and signed zero handled). Add, subtract, multiply and divide use a software binary64 implementation on 64-bit integers, correctly rounded and bit-exact against Swift's Double; sum uses the same adder but reassociates over threadgroups, so it is exact to a tolerance rather than bit for bit. sqrt is correctly rounded too; the transcendentals measure 1 ulp against a 2-ulp asserted bound and the trigonometric family 5. Chosen over double-float (two float) emulation because Float64 is the default numeric type in Python and an approximate result there would be a support burden forever.

2026-09-06: NaN semantics

min/max skip NaN and return null if only NaN remains; sum propagates NaN; comparisons follow IEEE.

2026-09-06: Benchmarks compare against all cores and against Polars

Single-core baselines were removed. Baselines are 16-core Swift loops, Accelerate on all cores, and Polars, pyarrow and pandas (multi-threaded) on data of identical shape.

docs/RESIDENT.md

The resident GPU worker: why it does not work on Apple silicon today, and what took its place

Every unbatched ArrowMetal call is one command buffer, and a command-buffer round trip on an M4 Max costs about 65 µs of which the GPU runs for 1.7 (1.5 at best). Below roughly a million rows one CPU core wins, and below 100k the fixed cost is the whole story. The obvious escape is a resident worker: keep one compute dispatch alive whose threadgroups spin on a ring buffer of op descriptors in unified memory, so submitting an op is a store and collecting a result is a load. No encode, no commit, no completion notification.

It does not work on this platform. This document records the experiments, the numbers, and the latency work that replaced it.

Everything here was measured on an Apple M4 Max, macOS 26.6.2 (25G83), Xcode 26.6, release build.

1. What was attempted

                 shared MTLBuffer (.storageModeShared, page aligned)
   CPU  ──write op descriptor──►  ring[i] = { opcode, gpuAddress[], n, params }
                                  ring[i].state = READY          (release)
                                        │
   one long-running dispatch,  threadgroups spin:  while (ring[cursor].state != READY) ;
   never ends                            │        execute; ring[cursor].state = DONE
                                        ▼
   CPU  ◄──poll completion flag──  ring[i].state == DONE          (acquire)

Two things have to be true for this to beat 65 µs:

  1. a CPU store into shared memory must become visible to a running kernel, quickly;
  2. a store by that running kernel must become visible to the CPU, quickly.

Both were measured directly. Both fail.

2. Coherence: the crux, measured

Sources/ArrowMetal/Resident/ResidentMode.swift holds the in-tree reproduction (ResidentProbe), exercised by ResidentTests.testPersistentKernelCoherenceProbe. The method separates the two directions so neither measurement depends on the other:

  • CPU → GPU. The CPU raises a doorbell counter every 5 ms for 3 s (599 stores). The kernel logs every distinct value it observes into an output buffer. That log is read after the dispatch ends, where end-of-dispatch cache flushes make it visible unconditionally. So this direction is measured even when the other direction is completely broken.
  • GPU → CPU. The kernel writes a rising heartbeat. The CPU samples it while the kernel runs.

The doorbell and the heartbeat are 128 bytes apart. They must be: when they shared one 64-byte line, the GPU's eventual write-back clobbered the CPU's store, so the mailbox read back as zero after the kernel exited. That alone disqualifies a naive descriptor layout.

Every memory qualifier MSL offers was swept. 3 s window, 599 doorbells:

MSL qualifier on the shared buffer CPU stores the GPU saw first one seen after CPU first saw the heartbeat after
device uint* (plain) 0 of 599 never 227 ms
volatile device uint* 5 of 599 ~420 ms 380 ms
device coherent(device) uint* 3 of 599 ~815 ms 148 ms
volatile device coherent(device) uint* 2 of 599 ~1.36 s 76 ms
volatile device coherent(device) + threadgroup_barrier(mem_flags::mem_device) 4 of 599 ~990 ms 33 ms
atomic_load_explicit(…, memory_order_relaxed) 2 of 599 ~1.21 s 1.21 s
atomic_fetch_add_explicit(…, 0, relaxed) (device RMW) 0 of 599 never 990 ms
device RMW + mem_device fence 0 of 599 never never
coherent(device) + device RMW 0 of 599 never 1.99 s

Read that table twice. The best case is volatile + coherent(device) + a device fence, and it let four of 599 stores through, the first after about a second. Atomics — the tool the design calls for — are the worst: a device-scope read-modify-write never observed a CPU store at all.

The behaviour is consistent with visibility arriving only when the line is evicted from the GPU's cache, i.e. driven by unrelated memory traffic. Forcing evictions with a large device-memory sweep on every poll brings the latency down to about 5–6 ms, reliably — still two orders of magnitude worse than the 65 µs command buffer it is meant to replace, and it burns full memory bandwidth to do it.

There is no architectural fix available:

  • MSL exposes exactly two memory orders, memory_order_relaxed and memory_order_seq_cst, and seq_cst is not accepted for atomic_load_explicit on device address space (compile error: no matching function for call to 'atomic_load_explicit').
  • The only coherence scope the language has is coherent(device) — GPU-device scope. There is no system scope, so there is no way to express "coherent with the CPU".
  • MTLBuffer.didModifyRange does not apply: it is a managed-storage operation, and shared storage on Apple silicon has no such hint. There is no synchronize that can run mid-dispatch.

Conclusion: no MSL qualifier available today delivers the coherence a persistent spinning worker needs, so it cannot be built on this platform as it stands. The memory model simply does not promise CPU/GPU coherence inside a dispatch, and no qualifier delivers it in practice.

ResidentMode.available is false, MetalContext.setResidentMode(true) returns false, and am_resident_mode(1) returns 0. Nothing silently pretends otherwise.

3. The GPU watchdog: not the blocker

Worth recording, because it was expected to be the limit and is not.

Kernels that spun 2,000,000,000 iterations ran to completion uninterrupted for 65.4 s, 369.7 s, 381.2 s and 382.4 s, with MTLCommandBuffer.error == nil and status == .completed every time. No watchdog fired, no MTLCommandBufferError.timeout, no device reset, on a Mac with an attached display driving the desktop.

So the re-arm machinery a resident worker would have needed (bounded loop, relaunch from a completion handler) is unnecessary — but it is also moot. The GPU is fully occupied for the whole dispatch and unavailable to anything else, which makes a minutes-long spin a liability rather than a design.

4. Where the 65 µs goes

With the worker ruled out, the question becomes how much of ArrowMetal's per-call cost is Metal's and how much is ours. Per-call stages for an empty (nop) kernel, 500 calls, µs:

stage min median
makeCommandBufferWithUnretainedReferences 0.21 0.33
makeComputeCommandEncoder 1.33 1.71
setComputePipelineState + setBuffer + dispatchThreadgroups 0.25 0.42
endEncoding 0.00 0.12
commit 1.08 1.58
waitUntilCompleted 63.12 86.17
total round trip 67.54 90.67
of which driver work (kernelStartTime..kernelEndTime) 5.21 18.25
of which GPU execution (gpuStartTime..gpuEndTime) 1.50 1.71

Encoding costs 2.6 µs. The GPU runs for 1.7 µs. Everything else — about 60 µs — is submission and completion notification inside the driver, and it is not ours to remove.

(docs/BENCHMARKS.md round 4 quotes ~116 µs for the same empty round trip. These runs measure 67.5 µs min / 90.7 µs median over 500 warm calls in one process; the older figure was taken over 30 calls including the first ones. The floor is therefore lower than previously recorded, but the shape — GPU 1.7 µs, everything else driver — is the same.)

Wait strategies, same empty kernel:

strategy min median
waitUntilCompleted 68.62 77.83
spin on MTLCommandBuffer.status 61.33 79.75
spin 300 µs then waitUntilCompleted (what ArrowMetal did) 61.29 80.08
addCompletedHandler + DispatchSemaphore 67.33 76.50
encodeSignalEvent + CPU spin on MTLSharedEvent.signaledValue 56.17 65.54

An MTLSharedEvent's signaledValue is readable straight out of memory, so polling it skips the objc/driver path that MTLCommandBuffer.status goes through. That is 12 µs of median, for free.

Can the floor itself move?

Row min median CPU µs/op
shared-event spin, idle GPU 56.67 75.58 105
same, thread at QOS_CLASS_USER_INTERACTIVE 55.67 68.75 91
same, a second queue kept busy (GPU never idles) 29.50 68.12 116

Raising thread QoS is worth ~7 µs of median but is the caller's decision, not a library's, so ArrowMetal does not set it; do it in your own thread if you want it. Keeping the GPU warm improves the best case a lot and the median not at all.

Amortisation is the only real lever. Marginal cost per op when work is not waited on one at a time:

ops N separate command buffers, one wait N dispatches in one command buffer
1 68.8 µs 68.8 µs
2 38.4 µs/op 34.3 µs/op
5 22.3 µs/op 13.8 µs/op
10 15.6 µs/op 6.9 µs/op
20 11.5 µs/op 3.9 µs/op

Submission is about 11 µs; the ~57 µs remainder is the completion notification, paid once per wait. Ten dispatches in one command buffer complete in 69 µs total. That is what MetalContext.batch { } already does, and it is the only route below 20 µs per op on this hardware.

5. What changed

Three changes, all on the ordinary path. Nothing new is enabled by default that alters results.

  1. MTLSharedEvent completion wait (MetalContext.lowLatencyWait, on by default). Every synchronous command buffer signals a shared event; the CPU spins on signaledValue and falls back to waitUntilCompleted after spinMicroseconds. The event value is taken under commitLock together with the signal encode and the commit, so values are issued in commit order and signaledValue >= v really does mean "this command buffer finished" — without that lock a second thread could signal a higher value first and release a waiter whose own work had not run (ResidentTests.testConcurrentCallsAreNotReleasedEarly covers it).

  2. MSL source generated lazily. MetalContext.pipeline(source:function:cacheKey:) and Dispatch.pipeline now take the source as an @autoclosure, and the hot call sites pass the generator expression inline instead of binding it to a let first. Profiling a 1,000-row sum showed 20% of the call spent in CFStringFindWithOptionsAndLocale and _StringGuts.append — KernelSource.reductions rebuilding an MSL string for a kernel that was already compiled and cached, roughly 20 µs a call. On a cache hit the string is now never built. Applied to reductions, compare, filter, arithmetic, take and cast.

  3. A spin that polls instead of timing itself. The old spin called DispatchTime.now() on every iteration; the profile put 60% of the call's CPU samples inside dispatch_time → mach_absolute_time, against 4% reading MTLCommandBuffer.status. It now reads mach_absolute_time() directly and only once per 64 polls.

The 20% and 60% figures come from sample(1) on a tight col.sum() loop; throughput on that loop went from 108,781 to 168,190 iterations in 12 s (110 µs → 71 µs per op).

6. Measured per-op latency

Same standalone harness against the release library, median of 200, quiet machine. "before" is the library with lowLatencyWait = false and the eager MSL string build; "after" is 0.1.0. ARROWMETAL_LOW_LATENCY=0 reproduces the event-wait half only; the lazy MSL build has no runtime switch.

rows op before after Δ 1 CPU core 16 CPU cores
1,000 sum 114.4 88.4 −23% 0.1 0.5
1,000 compare 93.3 70.1 −25%
1,000 filter 92.2 81.6 −11% 0.5
1,000 multiply 81.7 69.7 −15%
1,000 add (array) 81.8 70.5 −14%
1,000 10 ops unbatched 820.0 695.5 −15%
1,000 10 ops batched 137.4 95.0 −31%
10,000 sum 101.2 69.1 −32% 0.6 1.0
10,000 compare 80.8 68.8 −15%
10,000 filter 92.7 81.6 −12% 6.3
10,000 10 ops batched 138.1 96.7 −30%
100,000 sum 101.2 68.5 −32% 7.2 8.2
100,000 compare 87.8 75.6 −14%
100,000 filter 101.9 86.7 −15% 192.2
100,000 10 ops batched 157.1 116.0 −26%
1,000,000 sum 126.8 97.0 −23% 78.8 74.5
1,000,000 compare 84.8 83.2 −2%
1,000,000 filter 118.1 107.8 −9% 2068.6
1,000,000 10 ops batched 370.1 330.7 −11%

Repeated three times interleaved (before, after, before, after, before, after) on a thermally loaded machine to rule out drift: absolute numbers rise about 30% across the board, the deltas hold — sum −16% to −24%, batched chain −16% to −19%, arithmetic −8% to −17%.

Reproduce in-tree:

DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
  ARROWMETAL_LATENCY_BENCH=1 swift test -c release --filter "ResidentTests/testLatencyBenchmark"
# ARROWMETAL_LOW_LATENCY=0 runs the same build with waitUntilCompleted, isolating the event half

The XCTest-hosted benchmark reads 15–30% higher than the standalone harness because of the test process; use it for relative comparisons.

Against the targets

  • ≤ 20 µs for one small op, synchronously, from Swift: not reachable. The platform floor for a single command-buffer round trip is 56 µs best case and ~65–69 µs typical, of which 1.7 µs is the GPU. There is nothing left in ArrowMetal to remove — encode is 2.6 µs.
  • A chain of 10 small ops in < 100 µs: reached. ctx.batch { } over ten 1,000-row ops is 95 µs, down from 137 µs. The raw Metal equivalent is 69 µs, so ArrowMetal now sits ~26 µs above the floor for a ten-op chain rather than ~68 µs above it.
  • The GPU/CPU crossover for sum is unchanged in shape and moved slightly: one core still wins below about 1M rows.

7. Power

There is no user-space GPU power counter without sudo powermetrics, which is not available in this environment, so CPU time (getrusage) is used as the proxy. 1,000-row sum, 2,000 calls:

configuration wall median µs CPU µs/op CPU per wall µs
lowLatencyWait, spinMicroseconds = 300 (default) 101.0 128.0 1.27
lowLatencyWait, spinMicroseconds = 0 109.4 31.6 0.29
waitUntilCompleted, spinMicroseconds = 300 110.6 136.8 1.24
waitUntilCompleted, spinMicroseconds = 0 109.3 30.7 0.28

Spinning costs slightly more than one full core per in-flight op (the excess over 1.0 is Metal's own helper threads). spinMicroseconds = 0 gives 4x of that CPU back for 8% more latency — the right setting for a throughput-oriented caller, and available as am_spin_microseconds(0) / arrowmetal.spin_microseconds(0). With spinning off the two wait strategies are identical, as expected: the event is never polled.

For scale, the resident worker it replaced would have burned one full CPU core plus one GPU threadgroup continuously and indefinitely — ResidentProbe measures 0.080 CPU-seconds over an 80 ms window, i.e. a core pinned, while the GPU is 100% occupied for the whole dispatch and unavailable to anything else. That cost would have been justified by a 20 µs op. It is not justified by a 5 ms one.

8. Limits and what is left

  • The 56 µs floor is the driver's submit + notify path. Nothing in ArrowMetal can touch it.
  • Everything below 20 µs per op comes from amortisation: batch { } (6.9 µs/op at ten dispatches) or batchAsync for pipelining (11.5 µs/op with 20 command buffers in flight). Small-op workloads should batch; the API already supports it in Swift, C and Python.
  • filter is still the furthest from the floor (~82 µs at 1,000 rows against ~69 µs for compare) because it encodes three dispatches and allocates.
  • Not attempted: MTLIndirectCommandBuffer; encoding is 2.6 µs of a 65 µs call.
  • If a future OS gives MSL a system-scope atomic or a documented CPU/GPU coherence guarantee inside a dispatch, ResidentProbe is the test to re-run first; ResidentTests prints a loud note rather than failing if both directions ever start working.
docs/ROADMAP.md

Roadmap

ArrowMetal's roadmap is its measured record. What each release added is in CHANGELOG.md, with the results file behind every number; what was measured and learned along the way is in Findings against pyarrow; the shapes where a CPU library is ahead, with their numbers, are in Where ArrowMetal needs to improve. This page carries no dates and no plans.

Scope

  • Apple silicon only: every kernel is Metal, on the GPU of an M-series Mac.
  • No dataframe API of its own: ArrowMetal runs under Polars, DuckDB, DataFusion and pandas, and over Arrow arrays directly.
  • Exact answers where Arrow specifies exact ones; each documented divergence is listed in Evaluation method.
  • Python, Swift, C, Rust, Go, TypeScript and R, all over one C ABI (include/arrowmetal.h).

Ideas and requests are welcome as GitHub issues.

Findings

What we found, written down.

Findings from building the kernels, the differential harness and the engine: what the hardware did, what pyarrow did, and what we got wrong first. Each one is a page with its own link.

Round 22 · 2026-10-15

The matrix moves to pyarrow 26.0.0

What. Apache Arrow 26.0.0 shipped on 2026-10-09 with four of the pyarrow bugs the differential matrix had been working around since Round 8b: utf8_normalize composing for NFC and…

Round 21 · 2026-10-08

Fitting the default's table on Polars 2.0.0

What. The default's crossover table is fitted from a sweep (every case at 250,000 to 50,000,000 rows, Polars against the engine with everything taken) and then charged with an…

Round 19 · 2026-10-07

Polars 2.0.0

What. Polars 2.0.0 (released 2026-10-06) changed five things the engine stands on. collect() runs the streaming engine by default (1.44: in-memory); engine="in-memory" and…

Round 16 · 2026-09-30

Float64 group sums without a group order

What. Float64 group-by aggregates were the slowest ones ArrowMetal ran. Over 50M rows a Float64 sum took 10.62 ms at 200 groups, 39.78 ms at 1M groups and 227.11 ms at 25M groups…

Round 15 · 2026-09-27

A count that depended on its neighbours

What. count(expr) in a plan's group_by gave three different answers for one column depending on the other aggregates of the same group_by. Alone, or next to aggregates the…

Round 14 · 2026-09-26

What the 0.3.0 reviews and sweeps found

The Polars engine imported NumPy for one reciprocal. NumPy comes with neither the wheel, pyarrow nor Polars, so on a clean install the engine raised ModuleNotFoundError.

Round 13 · 2026-09-26

A grid of 2^32 threads runs almost none of them

What. am.group_by([key]).sum(values) and .mean(values) over Float64 values, and over Float32 values (summed in Float64), came back null for 2^24 groups and more; 2^24 - 1 groups…

Round 12 · 2026-09-24

What the 0.2.0 reviews found

The 0.2.0 work (the CPU/GPU router, the Polars engine, DuckDB plan rewriting, the GPU CSV and NDJSON readers, the Parquet reader's nested and page-index gaps, Delta Lake and…

Round 11 · 2026-09-20

The three Metal findings reported to Apple

Metal has no public issue tracker, so the three findings this log had accumulated about Metal itself went to Apple through Feedback Assistant on 2026-09-20. Feedback Assistant is…

Round 7 · 2026-09-06

Strings and sort

Generated MSL written through a shell heredoc needs \(K) (one backslash) for Swift interpolation.

Round 6 · 2026-09-06

Software IEEE-754 double on the GPU

Add, subtract and multiply on ulong with 3 guard bits and sticky are bit-exact against Swift's Double for 1M random pairs including subnormals, signed zeros, infinities and NaN.

Round 4 · 2026-09-06

The paravirtual GPU on GitHub runners

GitHub's macos-15 Apple silicon runners expose an "Apple Paravirtual device" GPU with 3 CPU cores, ~16 GB/s for a sum. Use CI for correctness only; never publish its numbers.

Ecosystem research · 2026-09-06

Ecosystem research

No existing Arrow library computes on Metal; the pieces that exist are types, IPC, device buffer wrappers and tensors.

Toolchain

Toolchain

Run tests with Xcode's DEVELOPER_DIR and always in release as well as debug.

Round 22 · 2026-10-15

The matrix moves to pyarrow 26.0.0

What. Apache Arrow 26.0.0 shipped on 2026-10-09 with four of the pyarrow bugs the differential matrix had been working around since Round 8b: utf8_normalize composing for NFC and NFKC (our apache/arrow#51237), and pairwise_diff, the boolean fill_null_forward / _backward / replace_with_mask kernels and winsorize honouring ArrowArray.offset on a sliced array (apache/arrow#50858, #51240, #51251). Each workaround had a pinning test written to fail on the day pyarrow changed the behaviour it pinned. The venv and the CI pin moved from 25.0.1 to 26.0.0.

  • The pinning tests did their job. On 26.0.0 the four failed, every one on the != or == that asserted pyarrow's wrong answer, and nothing else in the suites moved: test_json.py and test_csv.py, which pin the documented differences from pyarrow, 979 passed unchanged. The fifth, test_pyarrow_binary_slice_overflows_on_its_own_default_stop, still passes: the binary_slice default-stop overflow is not in 26.0.0 (apache/arrow#51222, a contributor's fix #52451 under review).
  • One documented divergence was pyarrow's. The matrix on 26.0.0 (264.3 s): 38,898 pass, 1,643 fail, all documented, 1,147 skip, 0 unclassified, against 38,897 / 1,644 / 1,147 on 25.0.1. The one case gained is winsorize/float64 on the negative-zero dataset, which the classifier had counted under finding 22 (winsorize-negative-zero-limit); it was the offset bug. The finding keeps its two float32 cases.
  • Four more pinning tests moved, in the gate. pc.cumulative_max's default start no longer clamps negative floats to 1.18e-38 (the matrix still names the identity, the same answer either way); an out-of-range safe=false float-to-int cast wraps as the GPU does on every value the test holds, where 25.0.1 saturated; pyarrow reads the fixed-size-list-with-null-rows Parquet file its own writer produced, which 25.0.1 rejected, and agrees with ArrowMetal's rows; and five compute function names are new, remainder / remainder_checked (the C %, which modulo() here has always been, so remainder gets a GPU registry row), modulo / modulo_checked (Python's floored %) and search_sorted, the last four missing in the registry. The matrix's modulo operation checks against pc.remainder now instead of a divide / multiply / subtract construction.
  • binary_slice with a negative step and no stop. The function note recorded that pyarrow 25.0.1 overflowed on its INT64_MAX default and read out of bounds; 26.0.0 walks to the beginning of the value, the answer ArrowMetal has always given for an absent stop.

Result. The four pinning tests and the _materialised copies are retired; the oracle for string_normalize is pc.utf8_normalize again and pairwise_diff, the fill-null kernels, replace_with_mask and winsorize hand pyarrow the slice itself; the operations Arrow has no function for keep their references written in the harness, as before. The tracker rows in Upstream tracker read "fixed in 26.0.0" and stay as history.

Round 21 · 2026-10-08

Fitting the default's table on Polars 2.0.0

What. The default's crossover table is fitted from a sweep (every case at 250,000 to 50,000,000 rows, Polars against the engine with everything taken) and then charged with an idle benchmark: the default as it would run, at a few sizes, and any case it takes and loses moves its class up to the next benchmarked size. Two things about that procedure showed up on 2.0.0 (Benchmarks/results/polars_engine_crossover_2026-10-08-polars2.csv, polars_engine_default_groupby_raw_2026-10-08-polars2.csv, conditions files beside them).

  • The idle benchmark can only measure what the table in force takes. The first fit, charged from runs at 2,000,000 and 50,000,000 rows, moved the numeric sort class from 2,000,000 to 50,000,000 rows, since the sort was 0.94x at 2,000,000. Runs at 5,000,000, 10,000,000 and 20,000,000 rows with that table in force then recorded the sort as left to Polars at every one of them, and the fit could not see where it is ahead. Those sizes were run again with the sweep-only table in force; the sort is taken from 10,000,000 rows on 2.0.0 (1.44.1: 947,836).
  • A plan the default leaves runs on Polars' in-memory engine. The callback hands the plan back to the engine Polars named, and MetalEngine registers as the in-memory engine on both versions. On 2.0.0 a plain collect() runs the streaming engine, the faster one for sorts: a three-column sort by an Int64 key, Polars streaming against in-memory, 5.95 against 12.13 ms at 2,000,000 rows, 13.55 against 32.88 at 5,000,000, 52.88 against 149.27 at 20,000,000, 157.84 against 399.22 at 50,000,000. A MetalEngine() user on 2.0.0 pays the in-memory time on every plan the default leaves.

Result. One table per Polars major (_engine_policy.table_for, _engine_crossovers_pl2.py). Against Polars 2.0.0's faster hash tables the table gives up the one- and two-key sum and mean at 200 to 10,000 groups, the String-column sort, the String-key join, semi join and top-k; unique is taken from 2,943,034 rows (1.44.1: 5,494,090). Measured with it in force (polars_engine_bench_2026-10-08-polars2-refit.csv, 50,000,000 rows, AC): 52 of 107 in-memory cases taken, every one 1.35x to 7.94x faster than the faster Polars 2.0.0 engine, none behind; of the 12 cases it stopped taking, 11 were ahead by 1.15x to 2.75x in the run before, and one (the String-column sort) was 0.89x.

Round 20 · 2026-10-07

The integer mean paid for a Float64 column, not for atomics

What. The first explanation for the four cases behind was the fused expression group-by kernel (ExprGroupBy.swift: privatised 32-bit atomic tables per threadgroup, 64-bit sums as lo/hi pairs), which the planner picks whenever every aggregate is of a fusable type, against GroupBy's own per-aggregate kernels. Measured through the plan executor on 50,000,000 rows (Benchmarks/results/groupby_fused_matrix_2026-10-07.csv: one and two Int32 keys, 200 to 1,000,000 groups, sum, mean, count, sum + min + max, a Float32 sum, a sum over an expression), the two paths are equal on every integer shape, within 0.3 ms; the fused kernel is 2x to 3x faster on the Float32 sums. The hypothesis was wrong. What the matrix did show is that mean costs twice sum on every shape (14 to 28 ms against 6.6 to 18 ms): the translator plans a mean over an integer column as a mean over the column cast to Float64, so that extreme values average as Polars averages them, and the executor materialised that cast, a 400 MB column for 50,000,000 Int64 values, before the group-by.

Fix. Executor.integerMeanIfExact: one min/max pass over the integer column; when every |v| is below 2^53 and rows times the largest |v| is below 2^63, the mean runs from the integer column in one pass (GroupBy.meanInteger), otherwise the Float64 path as before. The two give the same bits; a test covers ordinary values, values near 2^62 and values above 2^53 on both Polars versions. Before and after on Polars 2.0.0, 50,000,000 rows, AC (polars_engine_bench_2026-10-07-polars2.csv and polars_engine_bench_2026-10-08-polars2-after.csv, the faster Polars engine in each run):

case Polars 2.0.0 ArrowMetal before after before after
two String keys, mean + max (c) 32.0 ms 32.7 ms 22.7 ms 0.98x 1.41x
2 keys, 200 groups, mean 18.0 20.7 14.3 0.88x 1.26x
2 keys, 1,000 groups, mean 20.9 20.9 14.8 0.97x 1.41x
2 keys, 10,000 groups, mean 34.6 26.5 18.4 1.27x 1.88x
2 keys, 100,000 groups, mean 101.2 28.2 18.9 3.56x 5.34x
2 keys, 1,000,000 groups, mean 128.9 36.6 25.5 3.72x 5.05x
1 key, 10,000 groups, mean 24.9 23.0 15.8 1.07x 1.58x
1 key, 100,000 groups, mean 58.4 24.5 16.7 2.02x 3.50x
1 key, 1,000,000 groups, mean 56.5 34.6 22.1 1.46x 2.56x
two String keys, Float64 sum + mean (l) 34.4 29.9 29.8 1.04x 1.15x
sort with a String column (o) 217.9 249.7 244.8 0.86x 0.89x

The Float64 case is unchanged, as expected; the String-column sort is sort-kernel time and stays behind. Every other case in the run is within noise of before.

Round 19 · 2026-10-07

Polars 2.0.0

What. Polars 2.0.0 (released 2026-10-06) changed five things the engine stands on. collect() runs the streaming engine by default (1.44: in-memory); engine="in-memory" and "streaming" are still accepted. The plan's IR version is (15, 2) (1.44: (14, 7)), and MetalEngine fails closed on a major it has not been tested on, so on 2.0.0 the engine as shipped in 0.4.0 left every plan to Polars. The post-optimisation callback is called with one argument (the plan; 1.44 also passed the time since the start). LazyFrame.profile and LazyFrame.with_context are gone, and with them the ExtContext node kind, the only IR node kind that changed. Series._get_buffer_info() is gone too: the engine named a key column by its buffer address to cache its group-count estimate between calls, and on 2.0.0 every probe sampled again. Polars 2.0.0 itself rejects 63 plans that 1.44.1 accepted (sum, min, max and mean over Date, Datetime, Time, Categorical and Enum), and keeps the input NaN under x * -1.0 where the in-memory engine of both versions flips its sign: the one bit-level difference the suites found was the test baseline moving to the streaming engine, not the arithmetic.

Measured. With the version gate opened, 50,000,000 rows, Apple M4 Max on AC power, the faster of Polars' two engines in each run (Benchmarks/results/polars_engine_bench_2026-10-02.csv on 1.44.1, polars_engine_bench_2026-10-07-polars2.csv on 2.0.0): over the 62 cases the default takes on both, Polars 2.0.0's own time is 0.72x of 1.44.1's at the median, down to 0.25x on unique over two keys (153.1 ms to 38.5 ms), and the streaming engine is the faster one in 56 of the 62. ArrowMetal's time is unchanged (median ratio 1.03). So the same code that was 1.52x to 11.54x ahead on 1.44.1 was 0.86x to 7.72x on 2.0.0, with four cases behind: two-key mean at 200 and 1,000 groups (0.88x, 0.97x), mean + max over two String keys (0.98x) and the sort with a String column (0.86x).

What shipped. TESTED_IR_VERSION holds one tested version per IR major ((14, 7) and (15, 2)), TESTED_POLARS adds 2.0.0, the extra installs polars>=1.44,<2.1, MetalEngine.profile raises NotImplementedError on 2.0, a single-chunk key column is named through PySeries.as_single_ptr() so the estimate cache holds on 2.0 as on 1.44, and the capability table generated on 2.0.0 is docs/ENGINE_CAPABILITIES_POLARS2.md: the same 1,326 of 2,523 plans run on Metal. The 1.44-built expression plugin loads on 2.0.0 and passes its 151 tests.

Round 18 · 2026-10-01

32-bit loop steps, block ends and byte positions near 2^32

What. Round 17 made the grid itself safe up to 2^32 - 1 elements. Inside the kernels, four kinds of 32-bit arithmetic still reached 2^32 for arrays of at most 2^32 - 1 elements:

  • Grid-stride loops, for (i = gid; i < n; i += gridSize): once n > 2^32 - gridSize a thread's i + gridSize wraps to a value below n again and the loop never ends. The whole-column aggregates run 2^19 threads (reduce_*, whose four-wide loop steps 2^21 and so wraps from 4,292,870,148 elements; agg_product, agg_minmax, agg_moment, gx_moment34, dec_sum: from 4,294,443,009), the partition's select and the utf8_view byte total 2^18 (pn_hist, sv_total: from 4,294,705,153).
  • Block ends, end = min(n, start + chunk): on the last block start + chunk passes 2^32, wraps to a small number, and the block reads nothing. The group-by passes split the rows into at most 1,024 or 4,096 blocks (GroupBySource, GroupByExtremaSource, GroupSumExactSource, the atomic group order: from 4,294,966,273 rows with 1,024 blocks, 4,294,963,201 with 4,096), the chunked group order into 2,048 sub-blocks (from 4,294,965,249), the radix sort and the partition into blocks of up to 2^25 rows (radix_histogram, part_count, part_scatter, radix_scatter, pn_count, pn_scatter: from 4,294,934,529), top-k into at most 2,048 blocks (from 4,294,965,249) and the radix select into sub-blocks of about 2^19 rows (from 4,294,705,153). Inside a block the loops step by 256 or 32 and wrap the same way from 2^32 - 255, as do the per-group loops that walk a segment of the group order up to its end (gx_seg_*, gm_*, seg_reduce, the streaming Float64 group sum).
  • Word counts, (n + 31) / 32: 0 for n > 2^32 - 32, so the bitmap kernels (bitmap_and, bitmap_or, bitmap_not, bitmap_and_not, lx_xor, the Kleene forms, st_fill_words), the filter's block scan and the fused expression reduce (am_reduce, am_scan) did no work.
  • Byte positions, row * width in 32 bits: the Parquet fixed-width decode wrote row r at byte r * width mod 2^32, over the start of the column, once the decoded column passed 4 GB: from 2^29 rows of Int64 or Float64, 357,913,942 of INT96, and 2^28 rows of a decimal widened to 16 bytes. A bit-packed run's bit position, index * bitWidth, wraps the same way in a run of more than 512 MiB. The group-by key gather of a decimal column (gk_limb) indexed row * limbs, which wraps from 2^31 rows of decimal128.

Measured on 2^32 - 1 rows with the build before (Benchmarks/results/index_wrap_limit_2026-10-01.txt): a group-by count over 1,024 blocks counted 4,290,772,992 rows (the last 4,194,303 went uncounted), the chunked group order placed 4,292,870,144, the sort's digit histogram counted 4,261,412,864, and, or, xor and not of two boolean columns wrote no word, a filter kept 4,096 rows of 2,147,483,648, and the fused reduce returned a count of 0, all with no error. A dictionary-encoded Int64 Parquet column of 541,065,216 rows and an INT96 column of 358,962,517 rows came back with their first rows overwritten. The grid-stride loops were not run on that build: a kernel that does not end holds the GPU.

Fix. A first version saturated every step at the end, i = (end - i > step) ? i + step : end (the form the fused group-by got in round 17), and took every block end in 64 bits. Timed against the build before, alternating (Benchmarks/results/index_wrap_forms_2026-10-01.csv), that cost more than 1% on three calls at 10M rows: Int32 min_max (best 5.0% and median 6.3% slower), a top-k of 5,000 (3.5% / 2.5%) and a group-by min_max over 1,000 keys (6.7% / 3.1%). The plain loop is the one the compiler can unroll. So the shipped forms keep it wherever it cannot wrap:

  • Block loops walk the block by offset: len = start < n ? min(chunk, n - start) : 0, then for (off = lid; off < len; off += 256) with the row at start + off. A block holds at most 2^25 rows in the sort and the partition, and at most half the array in a group-by over more than 4,096 rows, so off + 256 never wraps, and start + len never passes n. The same goes for the radix select's sub-blocks, the top-k blocks, the sort's and the partition's chunk loops (a lane is in range when off + lid < len) and the group order's.
  • Grid-stride loops are two copies of the loop (AM_GRID_STRIDE, in the kernel prelude): the plain i += step while n <= 2^32 - 1 - step, which holds for every array of up to 4,292,870,143 elements, and the saturating step past that.
  • Segment loops keep the saturating step: one group can hold every row, so a segment can end within 256 rows of 2^32.

The word count is (n >> 5) + ((n & 31) != 0). The Parquet byte positions, the bit position inside a run and the limb index are 64-bit. The four-wide aggregate loop's scalar tail (at most 3 elements, on a grid of at least 256 threads) is one if per thread. Below the wrap every kernel visits the same elements in the same order, so the results are bit-identical.

Testing at the limit. A column of 2^32 - 1 Float64 values is 32 GiB. IndexWrapTests maps one 1 GiB region back to back (mach_vm_remap) and wraps the whole range in one Metal buffer (makeBuffer(bytesNoCopy:)): a periodic column of any length up to the GPU's largest buffer (38.88 GiB on this machine) for 1 GiB of memory, with a host reference of whole periods plus a remainder. With ARROWMETAL_BIG_TESTS=1 each family runs once over 2^32 - 1 rows, and all pass; the largest real allocation is a 16 GiB Int32 output (the group order, the partition). test_parquet_4gb_columns.py reads the two Parquet columns above and a 4 GB decimal column against the values written. Not run at the limit: the decimal sum and the utf8_view byte total (a column of 16-byte values that long is 64 GiB, past this GPU's largest buffer), the radix sort's scatter passes (four 16 GiB buffers), the decimal key gather (2^31 decimal128 rows) and the Parquet bit position (a bit-packed run of more than 512 MiB).

Cost. The build before and this one alternated in one process: per call an untimed warm-up of at least 100 ms, the first call after a 500 ms idle on its own, then 8 rounds of up to 30 calls each (Benchmarks/index_wrap_bench.py, Benchmarks/results/index_wrap_2026-10-01.csv). One call per kernel family touched, at 10M and 50M rows. The rows where both best and median came out more than 1% slower were timed again alone, 20 rounds of 100 calls (marked *); none stays slower on both:

Call (kernel family) 10M best (ms) 10M median (ms) 50M best (ms) 50M median (ms)
sum int32 (reduce) 0.180 → 0.189 0.272 → 0.267 0.680 → 0.668 0.917 → 0.893
and boolean (bitmap words) 0.074 → 0.074* 0.099 → 0.099* 0.127 → 0.132 0.173 → 0.166
min_max int32 (agg) 1.234 → 1.237 1.294 → 1.331 1.870 → 1.802 2.445 → 2.092
variance float64 (agg) 1.597 → 1.590 2.010 → 2.065 5.256 → 5.282 5.395 → 5.401
skew float32 (moments) 1.650 → 1.780 2.447 → 2.426 2.206 → 2.165 2.389 → 2.406
sum decimal128 0.879 → 0.914 1.123 → 1.108 1.731 → 1.717 1.821 → 1.798
expr sum int32 (fused reduce) 0.184 → 0.200 0.278 → 0.235 0.662 → 0.671 0.911 → 0.885
group_by 64 keys sum int32 (atomic) 0.765 → 0.828 0.996 → 0.988 1.370 → 1.368 1.482 → 1.470
group_by 1000 keys min_max int32 (extrema) 1.102 → 1.997* 2.335 → 2.341* 3.608 → 3.637 4.864 → 4.264
group_by 1000 keys sum float64 (exact) 2.046 → 2.091* 2.888 → 2.880* 5.521 → 5.532 5.671 → 5.667
group_by 1000 keys variance int64 (group order, moments) 6.927 → 6.941 7.101 → 7.146 34.844 → 34.899 35.195 → 35.149
group_by 1000 keys product int64 (segmented) 1.265 → 1.228 1.326 → 1.330 7.492 → 7.479 7.567 → 7.569
group_by 1000 keys mean float32 (segmented reduce) 1.230 → 1.201 1.771 → 1.937 6.704 → 6.689 6.835 → 6.846
group_by decimal keys count (limb gather) 6.401 → 7.562 11.381 → 11.184 17.784 → 17.703 18.114 → 18.095
argsort int32 (radix) 2.977 → 2.962 3.058 → 3.069 14.380 → 14.381 15.187 → 15.187
argsort int32 with nulls (partition) 3.391 → 3.320 3.543 → 3.527 16.769 → 17.129 17.929 → 17.925
partition_nth_indices int32 3.178 → 3.164* 3.523 → 3.584* 5.361 → 5.360 5.764 → 5.757
top_k 10 float64 3.185 → 2.996 3.690 → 3.606 4.332 → 4.324 4.804 → 4.817
top_k 5000 float64 3.832 → 3.847 4.652 → 4.509 4.787 → 4.841 6.542 → 6.317
utf8_view import (byte total) 3.376 → 3.309 3.669 → 3.682 16.254 → 16.162 17.646 → 17.604
stream group_by 1M keys sum float64 71.448 → 72.618 74.536 → 75.216 472.742 → 475.619 480.280 → 483.785
read_parquet int64 dictionary 1.254 → 1.248* 1.728 → 1.711* 4.938 → 4.940 5.178 → 5.180
read_parquet int64 plain 1.628 → 1.651 1.795 → 1.803 6.452 → 6.334 7.298 → 7.231

A best and a median can move apart by several percent on calls of 1-2 ms from one run to the next (the group-by min_max before's best of 1.102 ms against medians of 2.335 and 2.341 ms); a loss shows in both.

Round 17 · 2026-10-01

The 2^32-thread wrap on row-wise kernels, and a totalOrder argsort re-timed

What. Round 13 folded the per-group grids; the row-wise ones were still (groups, 1, 1). A kernel with one thread per element launches ceil(n / 256) threadgroups of 256 threads, which is 2^24 threadgroups and 2^32 threads from n = 2^32 - 255, the width the GPU wraps. Measured on a UInt8 array of 2^32 - 1 elements (Benchmarks/results/grid_fold_limit_2026-10-01.txt): bitwise xor on that grid got 0 of 4,294,967,295 bytes right, with no error. Arrays are limited to 2^32 - 1 elements, so the affected lengths were 2^32 - 255 to 2^32 - 1: every dispatch1D kernel, and the two-level scans that size their own grids (string offsets, hash-join offsets, unique runs, cumulative and forward-fill scans, window scans), whose add pass launches one more threadgroup and reached the width from 2^32 - 511.

Fix. Dispatch.dispatch1D and Dispatch.dispatchRows fold a grid that would reach 2^32 threads into rows of 65,536 threadgroups, as perGroup does. At the limit that is 2^24 threadgroups in exactly 256 rows, so the folded grid launches the same threadgroups. Rather than edit some 350 kernels, MetalContext.pipeline rewrites each kernel's text before compiling it (Dispatch.foldGridPositions): uint i [[thread_position_in_grid]] becomes a uint2 and the body starts with uint i = (p.y << 24) + p.x;, and threadgroup_position_in_grid the same with a shift of 16. On a one-row grid .y is 0, so every kernel dispatched any other way computes the same index as before. Metal compiles a kernel only when its position inputs are all scalar or all vectors of one width ("expecting input declarations with either all scalar types or all vector types with the same number of elements"), so the kernel's other scalar position inputs (thread_position_in_threadgroup, threads_per_threadgroup, threads_per_grid) become uint2 read as .x. A parameter inside a #define argument list gets its declaration in every kernel that names the macro. A grid past 2^32 threads cannot be indexed by a uint: dispatch1D stops with a message there and dispatchRows throws, which is where a string scan over more than 2^32 - 256 rows now ends. The fused expression group-by kernels computed each chunk's end as start + chunk in 32 bits, which wraps on the last chunk near 2^32 rows; the end is now taken in 64 bits and the row loop cannot step past it.

Cost. Old and new builds alternated in one process, 8 rounds of 100 calls after a 100 ms warm-up (Benchmarks/results/grid_fold_2026-10-01.csv):

Case Rows Best before → after (ms) Median before → after (ms)
add float64 + float64 10M 0.625 → 0.626 0.781 → 0.788
bitwise_and int32 & scalar 10M 0.551 → 0.538 0.690 → 0.714
abs int64 10M 0.585 → 0.579 0.819 → 0.830
fused group-by sum int32, 64 keys 10M 0.874 → 0.836 1.236 → 1.117
add float64 + float64 50M 2.689 → 2.690 2.755 → 2.751
bitwise_and int32 & scalar 50M 0.981 → 0.987 1.061 → 1.057
abs int64 50M 1.847 → 1.848 1.913 → 1.913
fused group-by sum int32, 64 keys 50M 1.663 → 1.663 1.768 → 1.784

Tests. GridRowFoldTests checks the rewritten text, runs the rewritten kernels on a three-row folded grid at 40,000,003 elements (every element and threadgroup position, through a parameter and through a macro), runs xor, not, add, take, cumulative sum, forward fill and a fused expression with the fold forced on at 40M elements against the plain grid, and with ARROWMETAL_BIG_TESTS=1 runs xor and not over 2^32 - 1 elements. The whole Swift suite (1,030 tests) also passes with the fold forced on from 2^20 threads.

The totalOrder argsort row. The sort-options benchmark had a float64 argsort at 1M rows at 1.41 ms in totalOrder against 0.97 ms in the default order, from a best of five after one call (Benchmarks/results/sort_options_2026-09-28.csv: 1.411 and 0.968 ms). With a 100 ms warm-up, the first call after a 500 ms idle kept apart, and 8 alternating rounds of 100 calls (Benchmarks/results/argsort_total_order_2026-10-01.csv), the two orders run in the same time:

Rows Default best / median (ms) totalOrder best / median (ms) First call after idle, default / totalOrder (ms)
1M 1.119 / 1.876 1.096 / 1.607 5.112 / 5.188
10M 7.492 / 7.593 7.531 / 7.588 16.914 / 19.404
50M 37.911 / 38.109 37.934 / 38.068 45.869 / 51.189

The first call after an idle gap takes 4.6x (default) and 4.7x (totalOrder) the best at 1M rows (first_after_idle_ms ÷ best_ms in the same file), so a best of five taken with only one call before it can carry part of that state; the key map, the only difference between the two orders, costs nothing measurable.

Round 16 · 2026-09-30

Float64 group sums without a group order

What. Float64 group-by aggregates were the slowest ones ArrowMetal ran. Over 50M rows a Float64 sum took 10.62 ms at 200 groups, 39.78 ms at 1M groups and 227.11 ms at 25M groups (rows / 2), where an Int64 sum took 1.73, 7.94 and 52.16 ms and count 1.00, 1.66 and 15.29 ms (Benchmarks/results/groupby_float64_profile_2026-09-30.csv, the Swift API, best of 5).

Where the time went. A Float64 sum put each group's rows together first (the counting sort by group id, GroupBy.segments(): 7.02 ms at 200 groups, 29.20 ms at 1M, 92.60 ms at 25M) and then gave a 256-thread threadgroup to each group, which added its run in a fixed tree order (3.64, 10.38 and 134.15 ms). The order was the reason for the sort: the tree is what made the answer reproducible. At 200 groups that reduction ran 200 threadgroups over 250,000 rows each; at 25M groups it launched 25M threadgroups of 256 threads, 6.4 billion threads for 50M additions. min and max took two passes of 32-bit atomics, with three atomic updates per row in the first (the high word's minimum and maximum, and a count) spread over four arrays, and a plan with a min and a max of one column ran both passes twice.

Change. A correctly rounded sum does not depend on the order of its terms, so Float64 sum and mean no longer build a group order (Kernels/GroupSumExact.swift, DESIGN.md "Group-by"):

  • Pass one takes each group's largest exponent with an atomic max of the 11-bit field, counts its values, and records NaN, ±inf and whether a +0.0 was seen.
  • Pass two writes each value as an integer in a per-group fixed-point window, G = 74 - bitlength(rows) bits below the last bit of the group's largest exponent, and adds it to a 128-bit two's-complement accumulator with 32-bit atomics that carry (or borrow) word by word. Each word's carry comes from its own read-modify-write, so the four words hold the exact sum modulo 2^128 in any interleaving, and the headroom keeps every sum inside it. A value whose last bits fall below the window is truncated and counted.
  • A thread per group rounds once to nearest-even. With nothing truncated that is the correctly rounded sum, and the mean is the 128-bit sum divided by the count in a long division, rounded once (a power-of-two count is a shift). With d truncated values the exact sum lies strictly between W - d and W + d units, and the result stands when both ends round to the same double. A group whose rounding that cannot settle, or that holds a NaN other than 0x7FF8000000000000, is summed on the host from its own rows with an exact 2,240-bit accumulator.
  • Up to 1,024 groups each threadgroup accumulates in threadgroup memory and merges once.

min and max keep their two passes and change their table: a group's four words sit in one 16-byte entry, a row in pass one reads them and issues an atomic only when it improves on what it read, pass two reads the finished high words through a plain view of the table, and no count is kept (a group has a value exactly when its minimum key is at or below its maximum key). A sum and a mean, or a min and a max, of one column on one grouping share one call.

Behaviour change. Grouped Float64 sum is now correctly rounded: the binary64 value nearest the exact sum of the group's non-null values, ties to even, equal to math.fsum of those values. It no longer depends on row order, it differs from the earlier ordered sum in the last bits of some groups, and it is never further from the exact sum than that sum. mean is the exact sum divided by the count, rounded once. A group whose exact sum is finite returns it where a running sum overflowed ([max, max, -max] gives max); one whose exact sum is beyond the largest double gives ±inf. NaN in a group gives NaN: the canonical quiet NaN when every NaN of the group is that one, else the group's first NaN in row order, quieted. +inf with -inf gives the canonical quiet NaN; an exact zero is -0.0 only when every value is -0.0. min and max return the same bits as before. The streaming group-by keeps the ordered sum, so its per-batch, resident and host paths stay bit-identical to each other.

Measured. The builds before and after, timed alternately in three rounds, with a warm-up of at least 100 ms before each case and the first run after 500 ms of idle recorded apart (Benchmarks/results/groupby_float64_2026-09-30.csv; every round in …_raw_…, the conditions in …_conditions.txt). The array API is one am.group_by(keys) and one aggregate per call; the plan runner is a warm collect(). At 50M rows, one int32 key, no nulls:

aggregate groups array API plan runner
sum 200 12.73 → 5.62 ms (2.27x) 14.87 → 7.77 ms (1.91x)
sum 1,000,000 43.50 → 17.69 ms (2.46x) 47.75 → 22.11 ms (2.16x)
sum 25,000,000 283.67 → 197.05 ms (1.44x) 328.73 → 242.34 ms (1.36x)
min 1,000,000 14.59 → 9.68 ms (1.51x) 18.31 → 13.59 ms (1.35x)
min + max 1,000,000 14.04 → 9.57 ms (1.47x) 28.86 → 13.63 ms (2.12x)
min + max 25,000,000 176.05 → 130.96 ms (1.34x) 300.75 → 175.30 ms (1.72x)

Over every 50M-row case (one and two keys, 0 and 10% nulls, 200 to 25M groups), best of the rounds (the speedup_best column of Benchmarks/results/groupby_float64_2026-09-30.csv): sum and mean 1.41x-2.66x (array API) and 1.34x-2.33x (plan runner); min + max 0.98x-1.49x and 1.29x-2.12x; min or max alone 0.96x-1.51x and 0.95x-1.35x. count, an Int64 sum and a Float32 sum in the same runs are 0.96x-1.04x. The kernels alone at 50M rows (Benchmarks/results/groupby_float64_profile_2026-09-30.csv, the Swift API, best of 5): sum 10.62 → 3.48 ms (200 groups), 23.78 → 9.02 ms (10,000), 39.78 → 14.17 ms (1M), 227.11 → 110.43 ms (25M); min + max 10.64 → 5.91 ms (1M) and 82.82 → 35.65 ms (25M).

Polars MetalEngine() (Benchmarks/results/polars_engine_groupby_float64_2026-09-30.csv, three alternating rounds, every result equal to Polars'): at 50M rows the grid's min + max cases (over an int64 column) the default runs on the GPU are 1.19x-2.59x faster (1M groups, one key: 48.36 → 18.67 ms) and (l) Float64 sum + mean is 46.12 → 29.68 ms. At 2M rows six case-engine pairs were slower in that run; timed again alone, eight alternating rounds of 100 runs (Benchmarks/results/polars_engine_groupby_float64_retime_2026-09-30.csv), none is slower in both best and median: the Float64 min + max cases are 1.18x-1.38x faster cold (g1minmax10k 1.90 → 1.43 ms, g2minmax10k 2.28 → 1.82 ms, t7 2.18 → 1.58 ms), the Int64 g1sum1M 1.04x, and the default's g1minmax200 and t4, which it does not run on the GPU at 2M rows, 0.98x-1.02x. Through the DataFusion rule forced on at 50M rows (datafusion/results/datafusion_float64_groupby_2026-09-30.csv, one run of each build): Float64 sum and avg 1.33x-1.82x faster than before (1M groups: 58.03 → 31.95 ms against DataFusion alone's 55.51 ms), MIN + MAX 1.06x-1.19x.

The 10M-row, 10,000-group min/max rows. In the alternating run and in eight further rounds of 100 calls each (Benchmarks/results/groupby_float64_retime_2026-09-30.csv), ten Float64 min/max rows at 10M rows were slower in both best and median, nine of them at 10,000 groups (best 0.60x-0.92x, median 0.71x-0.88x). Their first run after 500 ms of idle was faster than before (groupby_float64_2026-09-30.csv; one key, max: 8.60 → 7.09 ms). In fresh processes that time only am.group_by(keys).max(values) (Benchmarks/results/groupby_float64_minmax_state_2026-09-30.csv, six rounds per build; median: the median of the rounds' medians), at 5M, 10M and 20M rows and 3,000, 10,000 and 30,000 groups, in a warm loop and with a 50M-row Int64 group sum run before every timed call, the new build was faster in 17 of 18 cells in best and 17 of 18 in median (10M rows, 10,000 groups, warm loop: 1.85 → 1.72 ms best, 3.21 → 2.38 ms median); no cell was slower in both.

To improve. Through the DataFusion rule, a Float64 MIN/MAX over a column holding a NaN or both zero signs carries four helper aggregates per extreme (the NaN, value, zero and negative-zero counts, each a sum over an if_else; when this was measured every Float64 column did, and since 2026-10-01 the others run on the plan runner's own min + max, DataFusion), which take most of the plan: 36.88 ms at 200 groups from 50M rows (datafusion/results/datafusion_float64_groupby_2026-09-30.csv, kernel_new_ms), where the plan runner's own min + max takes 7.34 ms (groupby_float64_2026-09-30.csv). At 25M groups from 50M rows the grouping itself (am.group_by(keys) with a count) is 110.12 ms of the 197.05 ms sum (the same file, array API, best).

Tests. GroupSumExactTests (Swift) checks sums and means against an independent correctly rounded reference (CPython's msum partials with its half-even correction, and a residual check of each mean against its two neighbours) over 1 to 100,000 groups on both sides of the private-table limit, with null and out-of-range keys, null values, magnitudes spread over 2^-300 to 2^300, hand-built groups of cancellation, signed zeros, subnormals, overflow, ties and every special value on both paths, cancellation-heavy groups of 10^15 to 10^17 terms that cancel, 2^24 + 3 groups, and min/max over Float64 (NaN, both zeros, inf), Int64 near its maximum, Float32 and UInt32 against a host reference. python/tests/test_group_sum_exact.py checks the array API and the plan runner against the exact Fraction sum and mean of each group, and two differential rows, group_by_sum_correctly_rounded and group_by_mean_correctly_rounded, check every shape of the matrix bit for bit against the exact value rounded once.

Round 15 · 2026-09-27

A count that depended on its neighbours

What. count(expr) in a plan's group_by gave three different answers for one column depending on the other aggregates of the same group_by. Alone, or next to aggregates the one-kernel group-by takes, a count of a Float64 column was right and a count of a utf8 column was rejected ("group_by count of a utf8 expression"). Next to a sum, mean, min or max over Float64, the count of the Float64 column failed ("group-by over Float64 values: cast to Float32 first"), and the count of the utf8 column returned the group's row count with its nulls: 4 for ["a", null, "b", null], where SQL's count is 2. No error was raised for the second. Found by a differential grid of group-by plans.

Cause. One aggregate the one-kernel group-by cannot take (its sums finish with 32-bit atomics) sends the whole group_by through GroupBy's per-aggregate kernels (Executor.aggregateOne). That path's count switched on the column's type: the numeric types went to GroupBy.count(_:), whose shared accumulate kernel refuses a Float64 MetalArray before it looks at the kind, although a count reads only the validity bitmap; every type the switch did not list fell to default: and to GroupBy.count(), the row count. The one-kernel path reads numeric expressions only and rejected the rest. The same default: counted rows in the streaming group-by's per-batch count, for boolean, decimal, dictionary and nested columns (667 per group where 500 rows were non-null), and the dense-key streaming result left out a key whose rows were all null in the first counted or summed column, because it chose its groups by that aggregate's count of values instead of rows.

Fix. A count is computed from validity and nothing else, for every type. GroupBy.count(validity:) runs the existing count kernel with a validity bitmap and the key column's buffer standing in for the values, which the count kind never loads; GroupBy.countValid(_: AnyMetalArray) takes a column of any type through AnyMetalArray.logicalValidity(), which is the column's own bitmap, or, where Arrow's nulls live elsewhere, one built on the host: all null for the null type, the code's and the dictionary entry's validity for a dictionary, the run's for run-end encoded, the selected child slot's for a union. The per-aggregate path, the streaming group-by, the C ABI's hash_count and the dense am_group_by count all call it. A count no longer decides the group-by path; in the one-kernel path a count of an expression the kernel does not read as a number is taken the same way next to the kernel, and the whole-table aggregate does the same. The dense-key streaming result keeps a row count per key, taken from a count the batch already has when its column holds no null.

Cost. The one-kernel group-by shapes run the same kernel as before. On the per-aggregate kernels a count reads only the validity bitmap. The dense-key streaming group-by counts a column once per batch where sum and count share it; a sum alone over a column with nulls adds one row count per batch.

Tests. GroupByCountTests (Swift) checks 27 column types, the ten numeric types, boolean, utf8, utf8 views, binary, date32, timestamp, decimal128, list, struct, two dictionaries (null codes, null entries), fixed-size binary, float16, null, run-end encoded, a sparse union and an extension type, at null fractions 0, 0.1 and 1.0, alone, next to a Float64 sum, mean, min and max, and next to an int32 sum and a row count, against a count of the validity each column was built from; and the same types through the whole-table aggregate with and without a filter, the dense GroupBy API at 13 and 5,000 keys, computed expressions with nulls, empty input, the streaming group-by with and without a dense key count, and the fused stream join aggregate on both sides. python/tests/test_group_count.py checks the plan runner, am.group_by(...).count(...), the dense count_values and the streaming group-by against pyarrow's is_valid per key, and rust/arrowmetal/tests/plan.rs checks the two minimal cases above.

Round 14 · 2026-09-26

What the 0.3.0 reviews and sweeps found

TL;DR

  • The Polars engine imported NumPy for one reciprocal. NumPy comes with neither the wheel, pyarrow nor Polars, so on a clean install the engine raised ModuleNotFoundError.
  • One 790 KB Snappy dictionary page took about 94 ms on the GPU, most of a 1,000,000-row read.
  • A grid of 2^32 threads or more wraps on the GPU, which made group-bys over 2^24 groups or more return nulls: round 13.
  • The engine conformance grid found three answers of the Polars engine different from Polars'. All three now match.
  • The engine's first crossover table was fitted from a sweep that started at a load average of 50.8. The sweeps rerun from a quiet start moved it: String shapes from 5,000,000 rows, the rows/2 group-count bucket never taken, and every shape taken from 1.5 times its fitted crossover.

Each change below is in 0.3.0.

1. The Polars engine needed NumPy

Polars divides a column by a scalar as x * (1 / c), with the reciprocal rounded in the result type, and the engine emits the same multiply so the bits match (Polars). It computed that reciprocal with NumPy. NumPy is installed by none of the wheel, pyarrow and Polars, so in a fresh virtualenv holding the wheel and its polars extra, tier 4 raised ModuleNotFoundError. Found by installing the wheel alone (scripts/check_wheel.sh). The reciprocal is now computed with Python floats, identical to the NumPy result on 800,046 checked values, zeros, infinities, NaN and subnormals among them, and scripts/check_wheel.sh runs all four tiers with NumPy not installed (Polars).

2. One Snappy page took 94 ms on the GPU

A 1,000,000-row file with an int64 and a float64 column, written with pyarrow's defaults, read in 102-106 ms, 94 ms of it one 790 KB dictionary page. Every Snappy page was decompressed by one GPU SIMD group, and one worker walks a page's token stream from start to end however the page is decoded. A token-dense page is tens of thousands of tokens of a few bytes: on the GPU it costs 130 ns per output byte on a SIMD group, on one host core 0.42 ns (Parquet on the GPU). The first fix decoded dictionary pages, and dispatches of at most 16 pages, on the host (20-36 ms for that file). The reader now splits the Snappy and LZ4 pages of a read between the host and the GPU by how token-dense each page header says it is, from measured costs on each side, and runs both at once: the 10,000,000-row, 7-column pyarrow-default Snappy file reads in 25.4 ms against 67.5 ms, its token-dense pages, the 790 KB dictionary pages among them, on the host.

3. A grid of 2^32 threads

Round 13.

4. Three engine answers that differed from Polars'

The engine conformance grid (python/tests/engine_report.py) compares the Polars engine with lf.collect() bit for bit over generated shapes, dtypes, null patterns and sizes from 0 to 100,000 rows (Coverage summary). It found three answers that differed, each now the same as Polars' (Polars):

  • A column of one row. Polars divides by a scalar as a reciprocal multiply and multiplies a float column by -1 as a negation, except over a column of one row, where the scalar and the column have the same length and it computes element-wise. The engine now chooses by the input's row count: from the in-memory frame, or counted when the plan runs if a filter or join decides it.
  • -0.0 and 0.0. Polars' min and max order -0.0 below 0.0; ArrowMetal treats the two as equal. The engine counts the zeros of the sign Polars prefers and takes the sign from that count.
  • A Float32 mean. Polars accumulates it in Float64. The engine casts the column to Float64 first, and the two match bit for bit.

The recorded run: 12,597 Polars cases, 12,392 pass, 32 documented (float summation order), 0 unclassified (Benchmarks/results/engine_conformance_2026-09-25.csv).

5. Crossovers fitted on a busy machine

The default MetalEngine() takes a subtree from its measured crossover against the faster Polars engine (Polars). The first table was fitted from Benchmarks/results/polars_engine_crossover_2026-09-26.csv, a sweep that started at a load average of 50.8 and ran between 13.6 and 58.2 (polars_engine_crossover_2026-09-26_conditions.txt). There the String sort (o) was 1.66x the faster Polars engine at 1,000,000 rows, 0.69x at 2,000,000 and 0.78x at 5,000,000. The final sweep started at 3.2 (bench_conditions_2026-09-26-final.txt), and there (polars_engine_crossover_2026-09-26-final.csv) the same case is 0.98x, 1.20x and 1.37x, growing with size. The table in 0.3.0 is fitted from the final sweep, with three rules on the fit:

  • String shapes from 5,000,000 rows (STRING_FLOOR), with a 35% margin where a numeric shape has 15% (MARGIN). A String shape's advantage grows slowly with size, and below 5,000,000 rows the String sorts, (o) 1.2x and (p) 1.38x at 2,000,000, sit inside the benchmark's noise band of 0.62x to 1.31x.
  • The rows/2 bucket is never taken (UNTAKEN_BUCKETS). The sweep is not monotone for group-bys over a quarter of the rows or more: the one-key count over 0.43 times as many groups as rows is 0.78x, 1.9x, 4.04x, 2.21x and 1.18x the faster Polars engine from 2,000,000 to 50,000,000 rows.
  • 1.5 times the fit (HEADROOM). The fit interpolates between sizes measured 2 to 2.5 times apart, and shapes just past their crossover were within run-to-run noise of Polars.

In the default benchmark on that table (Benchmarks/results/polars_engine_bench_2026-09-26-final3.csv, 194 case-size pairs) the default took 62 pairs, 42 of them group-bys, every one ahead of the faster Polars engine, 1.21x to 9.42x. On the 0.4.0 table (fitted from Benchmarks/results/polars_engine_crossover_2026-09-30-groupby.csv), in Benchmarks/results/polars_engine_bench_2026-10-02.csv (220 case-size pairs at 2,000,000 and 50,000,000 rows), the default takes 75 pairs, 55 of them group-bys, every one ahead of the faster Polars engine, 1.52x to 11.54x, and every result is equal to Polars'.

Round 13 · 2026-09-26

A grid of 2^32 threads runs almost none of them

What. am.group_by([key]).sum(values) and .mean(values) over Float64 values, and over Float32 values (summed in Float64), came back null for 2^24 groups and more; 2^24 - 1 groups were right. At exactly 2^24 groups every group was null, at 2^24 + 1 every group but one, at 2^24 + 2^20 the first 2^20 groups were right and the other 2^24 null. No error was raised. product and list were wrong for the same groups. Count, min and max over every type, and the Int64 sum and mean, were right. Found by the Polars engine's crossover sweep.

Cause. Those aggregates reduce one group per 256-thread threadgroup and dispatched a (groups, 1, 1) grid of threadgroups (seg_reduce, Kernels/Segmented.swift). On the M4 Max the thread count of a grid dimension, threadgroups times threads per threadgroup, is held in 32 bits: at 2^24 threadgroups of 256 threads it is 2^32 and wraps, and the dispatch runs groups mod 2^24 threadgroups. A standalone kernel shows it: 2^24 threadgroups of 32 threads all run, 2^24 of 256 run none, 2^23 of 512 run none, 2^25 + 3 of 128 run 3. The groups that no threadgroup reached kept a count of zero and came back null. The atomic path (count, 32-bit min/max, integer sum and mean) and the two-pass 64-bit min/max dispatch over rows, not groups, and never reach that width. The same one-threadgroup-per-group grid served the segmented 64-bit min/max, product, the list gather, the variance passes over groups of 32 rows or more on average, and the per-group run sort of the counting sort.

Fix. Dispatch.perGroup dispatches every one-threadgroup-per-group kernel. Below 2^32 threads the grid is the same (groups, 1, 1); from there it is folded into rows of 65,536 threadgroups, and the kernels read their group as (tgid.y << 16) + tgid.x. Reading the row width from threadgroups_per_grid instead nearly doubled the time of a light per-group kernel (a gather over 10M groups of 5 rows: 122.6 ms against 64.5 ms), so the width is a power of two known to the kernel. The 50M-row group-by rows run in the same time as before.

Tests. GroupByGridFoldTests (Swift) checks sum, mean, count, min and max over Float64, Float32 and Int64 at 2^24 - 1, 2^24, 2^24 + 1 and 2^24 + 2^20 groups against a host reference, and runs every per-group kernel with the fold forced on at 150,000 groups against the plain grid. python/tests/test_group_by_2_24.py checks the same aggregates, product and list at the same four group counts against pyarrow's Table.group_by, and the lazy-plan Float64 sum and mean at 2^24 groups.

Round 12 · 2026-09-24

What the 0.2.0 reviews found

TL;DR

  • The 0.2.0 work (the CPU/GPU router, the Polars engine, DuckDB plan rewriting, the GPU CSV and NDJSON readers, the Parquet reader's nested and page-index gaps, Delta Lake and Iceberg tables, and IPC view types, big-endian files and the tensor extension) was reviewed before it merged.
  • The reviews found seven bugs older than this work and one in pyarrow, filed as apache/arrow#51491 (Upstream tracker). Everything below is fixed on main with a test.
  • The Python suites had been loading a library built on 2026-09-07, left in the checkout by a wheel build. The test run now pins the library it loads.

1. The Python suites tested a stale library

The Python package looks for its library in three places, and a copy bundled by a wheel build wins over the development build. The 2026-09-07 wheel build left such a copy in the checkout, so from 2026-09-08 to 2026-09-24 the Python suites and the differential ran against the 2026-09-07 build. The Swift suite was unaffected. It surfaced when the IPC work's new Python tests failed with "view columns unsupported", which only the old library says.

Fixed by pinning the path: ARROWMETAL_LIB names the build under test, and the run stops unless Python reports loading exactly that file (Python). Rerun against the fresh library, the differential gave the same counts as before (39,069 cases, 0 unclassified) and the Python suites passed, so nothing had regressed behind the stale copy. The 2026-09-17 crossover sweep was rerun the same way on 2026-09-24 (Benchmarks/router_check.py; Design, "CPU/GPU router").

2. A float literal of 2^63 or more ended the process

The fused expression compiler converted every float literal to Int64, even when the target was a float, and Swift's Int64(Double) traps outside its range. int64_column >= 1e19 or x * 4.49e307 ended the host process. Found while reviewing the Polars engine, through a division by a tiny literal. Float targets no longer compute the integer; an integer-typed literal that does not fit is an error that names it.

3. Two same-named columns came back as one twice

With no explicit projection, a stream looked every column up by name, so the second of two columns called a was replaced by a copy of the first: am.scan_ipc(...).collect() returned wrong data and no error. Found while reviewing the IPC reader, on a union fixture. A batch with duplicate names now stays positional; batches with unique names keep the fused path.

4. Skips recorded as failures

Twenty-three IPC test assertions called the pyarrow helper inside XCTAssertEqual. When no pyarrow interpreter exists, the helper throws XCTSkip, and a skip thrown inside the assertion's autoclosure is recorded as a failure. Found while reviewing the router: a full-suite run showed 12 such failures on a clean environment. The helper now runs before the assertion.

5. Four engine bugs the Polars engine had been working around

The Polars engine's differential suite compared every plan it could take against Polars and found four wrong answers inside the engine. That work routed around them and pinned each with a strict expected failure; they were then fixed at the root, each with a test that fails on the old code:

  • A string filter wrote the bytes a null row still held over the next row's slot, turning "banana" into "xanana". The gather kernel now copies the output slot's own width.
  • A Boolean column lost its null count through a sort inside a batch: results of an array whose count was still pending copied the placeholder. They now count their own nulls after the flush.
  • A filter refused any batch that also carried a date32 column, because the expression compiler bound every column, read or not. It binds only the ones the query reads.
  • A string sort returned wrong rows when a column held a null and a value of eight bytes or more: the null partition read the sort's indices on the CPU before the GPU had written them. It first waited for them, which made such a sort slower than the old, wrong one; the partition now runs on the GPU, as a bit in the sort's own prefix keys, and there is nothing to wait for.

The JSON reader's review found a fifth wrong answer, in new code: an explicit timestamp[ns] outside the years 1678 to 2261 wrapped around instead of raising, as pyarrow raises. It raises now.

6. pyarrow drops NaN rows when statistics are present

Found while reviewing the Parquet reader: ArrowMetal's own page skipping dropped a page holding a NaN under !=, because writers leave NaN out of min and max. After the fix, a test showed pyarrow doing the same at row-group level. Reproduced in plain pyarrow 25.0.1: x != 5.0 over [5, NaN, 5, 5] returns [nan] in memory and from a file without statistics, and [] from a file with them; ~(x <= 10.0) over [1, NaN, 10] behaves the same way. The cause is in ParquetFileFragment::EvaluateStatisticsAsExpression, unchanged on Arrow's main, and is distinct from the 2023 fix for NaN inside min or max (#28074). The same class of bug was fixed on our side in Delta partition pruning. Filed as apache/arrow#51491 (Upstream tracker).

7. Two Parquet changes that broke the Delta reader

The Parquet work added a filter value for integers above INT64_MAX and changed how a MAP column is described. With the Delta and Iceberg work in the same tree, the build failed, and once built, 54 lakehouse tests failed: Delta checkpoints could no longer be read. Both are fixed on main, with tests.

Round 11 · 2026-09-20

The three Metal findings reported to Apple

TL;DR

  • Metal has no public issue tracker, so the three findings this log had accumulated about Metal itself went to Apple through Feedback Assistant on 2026-09-20. Feedback Assistant is private, so this entry and Reported to Apple are the readable record; the rows move in Upstream tracker when Apple answers.
  • FB24858110: the Feature Set Tables promise "the full set of 64-bit atomic operations" on Apple9; the shading language compiles exactly two, min and max. FB24858160: pipeline creation fails at random on the "Apple Paravirtual device" of GitHub's macOS runners, 110 times in one test process. FB24858235: a running kernel sees CPU stores to shared memory only sporadically, so a persistent GPU worker cannot be built; filed as a suggestion for a system-scope primitive.
  • Re-verifying each report against its own attachments before filing changed two of them. The rule held: nothing was sent that the attachments do not show.

1. 64-bit atomics: the documentation and the compiler disagree (FB24858110)

Apple's Metal Feature Set Tables list 64-bit atomics for the Apple9 family, which the M4 series belongs to, and say in a footnote that "the full set of 64-bit atomic operations is supported on all platforms starting with Apple9". On an M4 Max the compiler accepts exactly two 64-bit atomic operations, on device memory only: atomic_min_explicit and atomic_max_explicit on device atomic_ulong, void-returning, relaxed order. Fetch-add, fetch-sub, the value-returning min and max, exchange, compare-exchange, load and store are all compile errors at every language version from 3.1 to 4.0, and there is no signed atomic_long. The shipped header and the shading-language specification's own table of 64-bit atomic functions agree with the compiler, not with the Feature Set Tables. A 64-bit add cannot be built from parts either, because there is no 64-bit compare-exchange to loop on.

Measured with a short Swift program that compiles one tiny kernel per operation at three language versions and prints each verdict, then runs the two that compile over ten million elements and checks the results. It was run again on the day of filing with the same outcome as two weeks earlier.

What it costs here: every 64-bit accumulation carries a workaround. An int64 sum per group is a split 32-bit add with an explicit carry; a hash table publishes a 32-bit row index because a 64-bit key cannot be published atomically; grouped variance and standard deviation, which accumulate in software binary64, run a counting sort by group first because they cannot use per-group atomic accumulators. On the matrix's 50-million-row, 1,000-group rows the grouped sum, where the carry trick suffices, is 3.8x ahead of pyarrow's threaded engine; grouped variance, which cannot use it, is level with it.

Asked: on Apple9, the operations the footnote already claims; failing that, a per-family statement of which ulong operations exist, in place of "the full set".

2. Pipeline creation fails at random on GitHub's virtual GPU (FB24858160)

On GitHub-hosted macOS runners the Metal device is an "Apple Paravirtual device". Compiling a shader library from source succeeds every time; creating a compute pipeline for a function from that library then fails sporadically with the text "Compilation failed" and nothing else, for ordinary kernels that succeed on the same runner image in other runs and in the same process moments later. Rounds 4 and 5 below are where this was first met.

Measured from two complete CI logs on runner image macos-15-arm64 20260829.0321.1 with Xcode 16.4. In the first run one test process saw 110 pipeline-creation failures across 23 distinct kernels and no library-compilation failure, the most frequent a filter kernel, 64 times. In the second the engine retried each creation once after 20 ms and skipped GPU tests on virtual devices; the test step passed by creating few pipelines, and the benchmark step, which still created them, failed on a group-by kernel even with the retry. On real Apple silicon the same source has never produced one such failure.

What it costs: GPU code cannot be validated on GitHub-hosted macOS runners. CI here proves the build, the language interop and the CPU paths; every GPU test and benchmark runs on local hardware.

Asked: deterministic pipeline creation for a function from a library the same device just compiled; or an error that names the unsupported construct and a device property that reports the limitation before any work is submitted.

3. A running kernel cannot reliably see CPU stores to shared memory (FB24858235)

Small operations are dominated by the fixed cost of a dispatch, about 60 microseconds of submission and completion notification on this machine. The standard escape elsewhere is a persistent worker: one long-running kernel spinning on a ring of work descriptors in shared memory, so that submitting work is a CPU store. That needs a CPU store to become visible to a running kernel within a bounded time.

Measured with a standalone program that compiles one kernel per memory qualifier the shading language offers, from plain pointers through volatile, coherent(device), device-scope atomics and device-memory barriers; one threadgroup spins on a doorbell word in a shared-storage buffer for three seconds while the CPU increments it every 5 ms. Five runs across three days: in one, no store was seen under any qualifier; in another, 24 of 433 under one qualifier and a handful under others, the first after 160 ms or more; in another, a single store of 598. The behaviour is consistent with visibility arriving only when a cache line happens to be evicted, which nothing in the language can request. coherent(device) is the widest scope the language has and there is no synchronisation call that can run mid-dispatch. The full study is The persistent-kernel result.

Asked: a system-scope coherence primitive with a stated visibility bound; or a documented, explicitly costed way for a running kernel to observe CPU stores without a command-buffer boundary; or, failing both, a documented submit-and-notify path under 20 microseconds, which removes the motive.

What changed before filing

Re-running the coherence probe, which on 2026-09-08 had seen no CPU store at all under any qualifier, saw 24 of 433 in one of three runs that day, a single store in another and none in the third. "Never" became "sporadic and unbounded": the more accurate claim, and the harder one to dismiss. The paravirtual draft quoted an error code that appears in neither attached CI log; it was replaced by what the logs show, "Compilation failed" with an empty userInfo. Verifying every sentence of a report against its own attachments caught both.

Round 10 · 2026-09-08

Arrow-go's span iterator, found while designing its aggregates

TL;DR

  • Arrow Go's compute package has no aggregate functions (apache/arrow-go#1296). The maintainer asked for them; a design note with a tested prototype of sum, mean, min_max, min, max, count, any and all is on the issue.
  • Reviewing that prototype the way this project reviews its own kernels found a bug in arrow-go itself: ArraySpan.SetSlice carries a stale null count into the next slice, so and_kleene and or_kleene return false where the answer is null once ExecCtx.ChunkSize is below the input length, on main and in v18.7.0. Reported as #1305, fixed by #1306.
  • Two more things the C++ reference does that a straightforward Go port gets wrong: C's fmin/fmax rank -0.0 below +0.0, and float sums are pairwise, not a left fold.
  • Two documentation pull requests from the same day, #1302 and #1303, were merged within hours.

The setting

compute.GetFunctionRegistry() in arrow-go v18.7.0 holds 85 functions, none of aggregate kind (89 on main: 76 scalar, 8 vector, 5 meta). FuncScalarAgg exists as a kind; execInternal returns ErrNotImplemented for it. The Go binding here therefore uses plain loops as its reference for reductions (Go). The maintainer's reply to the report was that the kernels had been on his list for a long time and he would love to see them; the design note was promised before any code, so the interface would be one he wants to maintain.

Research steps

  1. Read the C++ ScalarAggregateKernel (kernel.h), the executor (exec.cc), the options (api_aggregate.h) and the kernels (aggregate_basic.cc, aggregate_basic.inc.cc, aggregate_internal.h) at f251bc3, and the Go compute package at 67ef40b, and write the design.
  2. Check the note row by row against pyarrow built from that C++ commit and against the Go identifiers it names, compiling the proposed constraint change.
  3. Build the prototype from the corrected note, run arrow-go's own lint under the Go version it pins, and cross-check every expected test value against pyarrow.
  4. Attack the code: sliced inputs at bitmap-hostile offsets, forced small ChunkSize, executor pool reuse, the race detector, a checked allocator on every call, bad options and input kinds.
  5. Differential fuzz: 31,760 cases across the twelve input types, plain, sliced, chunked and scalar, under every skip_nulls/min_count combination, compared bit for bit with pyarrow.
  6. Fix what was found, rerun everything, and only then post.

Finding 1: the note's own errors, caught before posting

The note said What C++ does (pyarrow from the same commit agrees)
mean of an empty input follows the same rule as sum (0 with min_count=0) NaN for numeric and boolean inputs, 0.0 for the null type
in min_max, NaN never wins true against a value; when every non-null value is NaN the result is NaN, because the state starts at NaN
mode, quantile and tdigest are scalar aggregates for later mode and quantile are vector functions; tdigest returns an array, so a scalar-returning Finalize cannot implement it
no other options type in the package inverts a field for the zero value ArithmeticOptions.NoCheckOverflow does, and TakeOptions uses a constructor

Finding 2: signed zeros in min, max and min_max

One divergence in the first 31,760 fuzz cases, 2,884 times, all the same cause. C's fmin and fmax, which the C++ kernels use, order -0.0 below +0.0, so pyarrow returns min -0.0 and max +0.0 whenever both appear, in any order. A comparison-based port keeps whichever zero came first:

// before: b < a is false for (-0.0, +0.0), so the incumbent stays
case b < a:
    return b
// after: the sign is observable, so it is a case of its own
case b < a:
    return b
case b == 0 && a == 0 && math.Signbit(float64(b)):
    return b

After the fix the rerun matched on every case.

Finding 3: float sums are pairwise

C++ SumArray sums floats pairwise in blocks of sixteen, the same algorithm numpy uses, and mean accumulates in float64 for every input type. A plain left fold over 100,001 values gives 75247.35643756675 where pyarrow gives 75247.35643756694; the port of the pairwise loop matches bit for bit, and the test that proves it is in the prototype.

Finding 4: ArraySpan.SetSlice carries a stale null count

Every prototype kernel returned wrong answers once ExecCtx.ChunkSize was smaller than the input: count of 339 valid values in 493 gave 193 at chunk size 1, 490 at 2, 467 at 4. The cause is not in the kernels. iterateExecSpans reuses one ArraySpan per argument and advances it with SetSlice, which on main reads:

if a.Type.ID() != arrow.NULL {
    if a.Nulls != 0 {
        if a.Nulls == a.Len {
            a.Nulls = length          // "all null" carries over
        } else {
            a.Nulls = array.UnknownNullCount
        }
    }                                 // "no nulls" carries over too
} else {
    a.Nulls = length
}

That is right only while Nulls describes the whole array. A kernel that calls UpdateNullCount() on the shared span stores the current slice's count; the next SetSlice then treats the following slice as all valid or all null. Two kernels on main do exactly that, and the reproducer shows it without any new code:

ChunkSize and_kleene([T, null, T, null, F, T, null, T], [null, T, T, F, null, null, T, T])
default [null null true false false null null true]
1 [null false true false false null false true]
2 [null null true false false false false true]
3 and above correct

or_kleene fails the same way; the SetSlice code is identical in v18.7.0. The C++ ArraySpan::SetSlice never carries a count when a validity bitmap is present, and the fix is that rule:

if a.Type.ID() != arrow.NULL {
    if a.Nulls != 0 || len(a.Buffers[0].Buf) != 0 {
        a.Nulls = array.UnknownNullCount
    }
} else {
    a.Nulls = length
}

With it, both Kleene kernels pass at every chunk size and the whole compute suite still passes. The prototype's kernels, independently of that fix, count each span from its bitmap and never write to the shared span, with a test at chunk sizes 1 to 65.

What shipped, and what is open

  • Merged the same day: #1302 (allocator alignment and which allocator to use when C keeps a buffer, closing #1297) and #1303 (what the compute registry holds).
  • Open: #1296 (design approved 2026-09-21) and the framework plus count/sum pull request #1336; #1305 with its fix #1306 by singhpratech.
  • Status of each is on the Upstream tracker.

Logs, scripts and raw outputs of every run above (the audit reports, the build and lint log, the fuzz fixtures and results, the reproducer and its output on both commits) are kept with the release records.

Round 9 · 2026-09-06

Radix select, and the LSD radix sort's blocking at small n

TL;DR

  • A GPU radix select needs two passes over the column, not three, when the histogram keeps its counts per sub-block: at 50M rows that is 1 ms saved out of 3.
  • After selection, ordering the ~200k survivors was the bottleneck, and the cause was a fixed 4096-element block in the LSD radix sort. Halving the block until there are at least 64 blocks took argsort of 20k from 1.99 ms to 0.41 ms and is most of why top_k(100) went from 5.6 ms to 2.9 ms.
  • The selected bin's size depends entirely on the key distribution's top byte. Float keys concentrate in a handful of bins, so a 50M Float64 median varies from run to run while Int64 keys are stable.

The setting

A GPU radix select normally costs three passes over the column: histogram the top digit, count how many rows fall in the selected range per block, then scatter them in order. This round built the select behind top_k, then measured where the time went once selection itself was cheap: the final ordering of the candidates, and the size of the bin the selection leaves behind.

Research steps

  1. Lay the histogram out per sub-block, so the count pass is redundant and the select is two passes.
  2. Make the sub-block a simdgroup's slice rather than a threadgroup's, removing the barriers from the scatter.
  3. Time the final argsort over the ~200k candidates; trace its cost to the fixed elemsPerBlock = 4096.
  4. Halve the block size until there are at least 64 blocks; remeasure argsort at 20k and 200k rows and top_k(100) end to end.
  5. Run the histogram and compaction a second time over the compacted candidates.
  6. Read the benchmark generator's key distribution, and the top byte of a float64 key, against the bin sizes observed.

Finding 1: the two-pass trick

The count pass is redundant if the histogram keeps its counts per sub-block instead of only globally. The digit-major table counts[digit * subBlocks + sub] is simultaneously the global histogram (summed over sub-blocks) and the offset table the scatter needs (summed over digits <= target). At 50M rows that is 1 ms saved out of 3.

Finding 2: one sub-block per simdgroup, not per threadgroup

Making the output granularity a simdgroup's slice rather than a threadgroup's removes every threadgroup_barrier from the scatter: the rank of a selected row within its slice is simd_prefix_exclusive_sum over 32 lanes plus a running base each lane computes identically. The cost is an 8x larger count table (256 * 8 * groups words, ~8 MB at 50M rows), which is noise next to the 400 MB the pass reads anyway.

Finding 3: the final sort was the bottleneck, and it was a blocking bug

After selection there are only ~200k candidates left to order, but argsort of 200k UInt64 measured 2.2-2.9 ms, the time of sorting 800k. The cause was elemsPerBlock = 4096 fixed: 200k rows is 49 threadgroups, 20k rows is five, on a GPU with 40 cores, and radix_scatter does an O(TG) rank loop per element that nothing else can overlap. Halving the block size until there are at least 64 blocks (inputs above ~256k rows are untouched, so the 50M argsort is unchanged) gave:

Input Before After
argsort of 20k 1.99 ms 0.41 ms
argsort of 200k 2.20 ms 1.34 ms
top_k(100) 5.6 ms 2.9 ms

The block-size change is most of the top_k(100) improvement. The rule as it stands in Sources/ArrowMetal/Kernels/Sort.swift:

func blockPlan(_ rows: Int) -> (elemsPerBlock: Int, blocks: Int) {
    var e = Swift.max(4096, ((rows + 127) / 128 + 255) / 256 * 256)
    while e > 256 && (rows + e - 1) / e < 64 { e >>= 1 }
    return (e, Swift.max(1, (rows + e - 1) / e))
}

Finding 4: refine on the compacted array, not the column

The selected bin is ~n/256 rows, which still dominates the final ordering. Running the same histogram + compaction again over the compacted candidates (a few hundred thousand keys, microseconds) shrinks it by another 256x. One extra round trip, and it takes the survivors under the 2048 pairs a single-threadgroup bitonic sort can order in one dispatch.

Finding 5: benchmark data hides skew

rng.integers(-(2**62), 2**62) spreads over only 128 of the 256 top-digit bins, so the candidate bin is n/128, not n/256. Worth remembering when reading a selection benchmark: the bin size, and therefore the final sort, depends entirely on the key distribution's top byte.

Finding 6: a float key's top byte is the exponent, so float columns are the skewed case

The top byte of a float64 key is the sign plus seven exponent bits, so a column of uniform doubles concentrates in a handful of bins rather than spreading over 256, and the bin holding the wanted rank can be a large fraction of the column. When it is over the compaction budget the search narrows another digit over the column instead, one more full pass, which is why the same 50M Float64 median varies from run to run depending on exactly where the rank lands, while Int64 keys are stable. Raising the budget so the big bin gets compacted instead is faster still, but a 25M-row bin needs 300 MB of scratch for a 400 MB column, and a median already 80x faster than pyarrow (4.61 ms against 368.88 ms at 50M rows, Benchmarks/results/full_matrix_2026-09-07-parallel.csv) is not worth a 75% memory overhead.

What shipped

  • The two-pass select with per-simdgroup sub-blocks, planned by TopK.radixPlan(n:) in Sources/ArrowMetal/Kernels/RadixSelect.swift; the single-dispatch bitonic limit is bitonicCap = 2048 in the same file.
  • The block-size rule blockPlan in Sources/ArrowMetal/Kernels/Sort.swift, quoted above.
  • The second refinement pass over the compacted candidates; the compaction budget left as it is, for the memory reason in Finding 6.
  • The median figures are the quantile(float64, 0.5) rows at 50M in Benchmarks/results/full_matrix_2026-09-07-parallel.csv.
Round 8 · 2026-09-06

A threadgroup atomic read that is not uniform (top_k)

TL;DR

  • One cell of a 13,000-case differential run diverged and then passed on every rerun. A stress test against a CPU oracle reproduced it at roughly 1 in 900 calls.
  • Every thread of topk_select read the threadgroup atomic count held itself with a relaxed load; one simdgroup out of eight occasionally saw a newer value, so the sentinel fill left holes of zeroed memory that read as (key 0, row 0) and sorted to the front.
  • Fix: thread 0 reads held once per chunk into a plain threadgroup variable between two barriers. The cost is below the noise floor: 3.11 / 3.31 / 3.38 ms with the fix against 3.22 / 3.30 / 4.37 ms without it for top_k(100 of 20M Int64).
  • Rule: never let a barrier-carrying branch, or a loop bound whose strides must tile a range, come from a per-thread atomic_load_explicit(..., memory_order_relaxed).

The setting

Symptom. One cell of a 13,000-case differential run diverged and then passed on every rerun: top_k / float64, 100,003 rows, 30% nulls, k = 17, descending. result[0] was row 0 where pyarrow says row 35254. A randomised stress against a CPU oracle (TopKTests.testStressAgainstCPUOracle) reproduced it at roughly 1 in 900 calls across every key type, both directions, n from 32k to 1M and k from 1 to 1024. The failure always looked the same: a run of zeros at the front of the result, the true answer after them.

Research steps

  1. A stress test comparing against a CPU oracle (not against argsort, which shares the sort keys), with pool-churning kernels in between; this is testStressAgainstCPUOracle in Tests/ArrowMetalTests/TopKTests.swift.
  2. Poison the candidate buffers before the dispatch, to prove the kernel really wrote those zeros rather than leaving stale bytes.
  3. Add a debug buffer carrying each threadgroup's held, arrival count and compaction count, which showed the holes were inside [0, c) and [c, cap) in simdgroup-sized runs.
  4. Instrument the kernel to count the holes per threadgroup.
  5. Fix, measure the cost, and check the other atomic_load_explicit sites for the same pattern.

Finding 1: a relaxed atomic load is not uniform across the threadgroup

Root cause. topk_select keeps a per-threadgroup candidate buffer in threadgroup memory with an atomic count held. Every thread read that count itself:

threadgroup_barrier(mem_flags::mem_threadgroup);
uint c = atomic_load_explicit(&held, memory_order_relaxed);   // Kernels/TopKSource.swift
...
for (uint i = c + lid; i < cap; i += TG) { bufKey[i] = keyMax; bufRow[i] = TK_NOROW; }

c bounds the range each thread pads with sentinels, and the union over lid covers [c, cap) only if every thread has the same c. It does not: on an M4 Max, one simdgroup out of eight occasionally comes back with a newer value than the rest. A relaxed atomic load is not ordered by the preceding barrier the way a plain threadgroup read is, so it can be satisfied late, after other threads have already run ahead and incremented held. The threads holding the larger c start their stride later and the slots they should have covered are never written. Instrumenting the kernel showed 7 to 63 such holes per threadgroup, clustered at 32 and 64: one and two simdgroups.

Finding 2: what a hole does to the answer

Those holes hold zeroed pool memory, which reads as (key 0, row 0). That pair is the minimum of the (key, row) order, so the bitonic sort moves it to the front of the buffer and it becomes the block's answer; the host then sees row 0 as the top candidate. Worse, if a block accumulates k or more holes the compaction sets its threshold to bufKey[k-1] = (0, 0), which nothing can beat, and the block is blind for the rest of the scan. The same read also decides if (c + TG > cap), a branch containing threadgroup_barrier, so a non-uniform c was undefined behaviour in its own right.

What shipped

  • Fix. Thread 0 reads held once per chunk into a plain threadgroup uint shared_c (clamped to cap) between two barriers, and every thread takes c from there; the branch and the fill ranges are now uniform by construction. The kernel as it stands in Sources/ArrowMetal/Kernels/TopKSource.swift:
threadgroup_barrier(mem_flags::mem_threadgroup);
if (lid == 0u) shared_c = min(atomic_load_explicit(&held, memory_order_relaxed), cap);
threadgroup_barrier(mem_flags::mem_threadgroup);
uint c = shared_c;
if (c + TG > cap) {
    for (uint i = c + lid; i < cap; i += TG) { bufKey[i] = \(keyMax); bufRow[i] = TK_NOROW; }
  • In addition, the buffer is filled with sentinels once at kernel entry, so a slot no one writes reads as "no row" and the host drops it instead of it masquerading as row 0.
  • Cost. One extra threadgroup_barrier per 256-row chunk, and one cap-element sentinel fill per block (2 to 8 strided stores per thread, once). Below the noise floor: top_k(100 of 20M Int64) on M4 Max runs 3.11 / 3.31 / 3.38 ms with the fix against 3.22 / 3.30 / 4.37 ms without it. The pass is still about one read per row.
  • Test. TopKTests.testStressAgainstCPUOracle, the stress against the CPU oracle.
  • Rule. In MSL, never let a value that decides a barrier-carrying branch, or a per-thread loop bound whose strides must tile a range, come from a per-thread atomic_load_explicit(..., memory_order_relaxed). Broadcast it through a plain threadgroup variable between barriers. The other atomic_load_explicit sites (SortSource, GroupBySource, JoinSource, StringExtraSource) were checked: each reads a slot the calling thread owns, so none of them tiles or branches on a shared count.
Round 8b · 2026-09-06

The differential matrix over the whole type surface

TL;DR

  • Extending python/tests/test_differential.py to every type ArrowMetal imports (45 columns, 181 operations, 33,156 cases at the time; 217 operations and 41,688 cases in the run recorded in docs/EVALUATION.md) turned up three bugs and one unreproduced crash in pyarrow 25.0.1, not in ArrowMetal.
  • Each has a workaround in the harness and a test that fails if a later pyarrow fixes it.
  • Two properties of the harness itself: time-of-day cases have to drop their null rows, and a 38-digit Decimal has to be read off as_tuple().digits.

The setting

The differential matrix compares every ArrowMetal operation against pyarrow over every imported type. Extending it to the whole type surface is what surfaced the pyarrow behaviours below. They are recorded here because the harness has to work around them, and each has a test that fails if a later pyarrow fixes it.

Research steps

  1. Extend the matrix to 45 columns and 181 operations, 33,156 cases (217 operations and 41,688 cases in the run recorded in docs/EVALUATION.md).
  2. Trace one segfault back from the faulting frame, Array.nbytes inside the harness's own array cache, to the preceding pc.year_month_day call, which cost an hour.
  3. Compare pc.utf8_normalize against Python's unicodedata and the Unicode annex for each form.
  4. Run pc.pairwise_diff on a sliced column whose values are not an arithmetic progression, then the same slice check on the boolean fill and replace kernels.
  5. Give each oracle a workaround, and pin each pyarrow behaviour with a test that fails when it changes.

Finding 1: a single unreproduced segfault after pc.year_month_day

A single unreproduced segfault was observed a few allocations after pc.year_month_day on a timestamp[s] array with nulls; it has not recurred. It landed in whatever unrelated call happened next: the faulting frame was Array.nbytes inside the harness's own array cache, which cost an hour to trace back. Out of caution the matrix does not use the two struct-valued temporal kernels as oracles: it compares iso_calendar and year_month_day field by field against pc.iso_year/ pc.iso_week/pc.day_of_week and pc.year/pc.month/pc.day, which is a stronger check anyway.

Finding 2: pc.utf8_normalize ignores its form option

pc.utf8_normalize ignores its form option: NFC and NFKC come back decomposed, so its NFC is NFD. Python's unicodedata and ArrowMetal agree with each other and with the Unicode annex; the matrix uses unicodedata as the oracle:

for form in ("NFC", "NFKC", "NFD", "NFKD"):
    got.append(arrow(x.utf8_normalize(form)))
    expected.append(pa.array([None if s is None else unicodedata.normalize(form, s)

Pinned by test_pyarrow_utf8_normalize_ignores_its_form_option.

Finding 3: pc.pairwise_diff ignores ArrowArray.offset

pc.pairwise_diff ignores ArrowArray.offset: on a sliced column it reads the values buffer from the start and answers with the wrong rows. Only visible when the values are not an arithmetic progression, which is why it hid for a while. The matrix hands that oracle a materialised copy while ArrowMetal still gets the slice, so the case remains a test of the offset handling:

def _materialised(src):
    """The same values with the Arrow offset folded away."""
    if src.offset == 0:
        return src
    return pa.concat_arrays([src.slice(0, 0), src])

Fixed upstream in pyarrow 26.0.0; the workaround stayed while 25.0.1 was the pinned version and was retired with its pinning test on 2026-10-15, when the matrix moved to 26.0.0 (Round 22).

Finding 4: the same offset bug on boolean columns

pc.fill_null_forward, pc.fill_null_backward and pc.replace_with_mask have the same offset bug on a boolean column (the values bitmap, not the validity one). Same mitigation.

Finding 5: pc.add on time-of-day validates the values under the validity bitmap

pc.add on a time32/time64 column validates the values under the validity bitmap, so a null row whose hidden value would leave [0, 86400) makes the oracle raise even though the row is null. The generator deliberately puts real numbers under the null bits, so the time-of-day cases have to drop their null rows rather than mask them.

Finding 6: a 38-digit Decimal and the default context

A Decimal that came out of a 38-digit column cannot be scaled with Decimal.scaleb or multiplied by 10 ** scale under the default decimal context; 28 digits of precision silently round it. Read the unscaled magnitude off as_tuple().digits instead:

def _unscaled_magnitude(value):
    return int("".join(str(d) for d in value.as_tuple().digits) or "0")

What changed

  • The oracles in python/tests/test_differential.py: field-by-field comparison for iso_calendar and year_month_day, unicodedata for normalisation, _materialised copies for pc.pairwise_diff and the boolean fill and replace kernels, dropped null rows for time-of-day addition, and _unscaled_magnitude for decimals.
  • The pinning tests in the same file: test_pyarrow_utf8_normalize_ignores_its_form_option, test_pyarrow_pairwise_diff_ignores_the_array_offset and test_pyarrow_boolean_fill_null_forward_ignores_the_array_offset; each fails when a later pyarrow changes the behaviour it pins. (They did, on 26.0.0, and were retired on 2026-10-15: Round 22.)
Round 7 · 2026-09-06

Strings and sort

TL;DR

  • Generated MSL written through a shell heredoc needs \(K) (one backslash) for Swift interpolation.
  • LSD radix sort with 8-bit digits, 4096-element blocks and a stable in-chunk ranking is correct across all types and sizes; descending order must invert keys, not reverse the ascending result.
  • MurmurHash3 x86_32 matches the reference vectors; four collisions among 100k 32-bit hashes is the birthday bound, not a bug.

The setting

The string kernels (hashing) and the LSD radix sort, with their MSL generated from Swift source written through a shell heredoc.

Research steps

  1. Generate MSL through a shell heredoc and check what reaches the Metal compiler.
  2. Run the LSD radix sort across all types and sizes, in both directions, and check ties.
  3. Check MurmurHash3 x86_32 against its reference vectors and count collisions among 100k hashes.

Finding 1: heredoc interpolation

Generated MSL written through a shell heredoc must use \(K) (one backslash) for Swift interpolation; a doubled backslash reaches the Metal compiler as literal text. Same newline rule as before for #define (Round 6, Finding 3).

Finding 2: the radix sort, and descending order

LSD radix sort with 8-bit digits, 4096-element blocks and a stable in-chunk ranking is correct across all types and sizes; descending order must invert keys rather than reverse the ascending result, or ties flip.

Finding 3: MurmurHash3 reference vectors

MurmurHash3 x86_32 reference vectors (seed 0):

Input Hash
"" 0
"a" 0x3c2569b2
"abc" 0xb3dd93fa
"hello" 0x248bfa47

Four collisions among 100k 32-bit hashes is normal (birthday bound), not a bug.

What shipped

  • The heredoc rule for generated MSL.
  • Key inversion for descending sorts in Sources/ArrowMetal/Kernels/Sort.swift.
  • The reference vectors above are checked in Tests/ArrowMetalTests/StringTests.swift, from "" -> 0 through "hello" -> 0x248bfa47.
Round 6 · 2026-09-06

Software IEEE-754 double on the GPU

TL;DR

  • Add, subtract and multiply on ulong with 3 guard bits and sticky are bit-exact against Swift's Double for 1M random pairs including subnormals, signed zeros, infinities and NaN.
  • A float-seeded Newton division was within 1 ulp only 93% of the time and wrong for subnormal inputs; restoring long division on the significands (57 quotient bits) replaced it.
  • Generated MSL fragments that start with a #define must begin with a newline, because Swift multi-line strings drop the final newline.
  • The Float64 GPU sum accumulates with d_add in tree order and differs from a sequential CPU sum only by normal floating-point reordering.

The setting

Float64 arithmetic implemented in MSL on ulong bit patterns, tested against Swift's Double.

Research steps

  1. Implement add, subtract and multiply with 3 guard bits and sticky; compare against Double on 1M random pairs including subnormals, signed zeros, infinities and NaN.
  2. Implement division as a float-seeded Newton iteration and measure how often it is within 1 ulp.
  3. Replace it with restoring long division on the significands, and test on ratios above and below 1.
  4. Fix the #define that glued to the previous line in the generated source.
  5. Sum Float64 on the GPU with d_add and combine the partials on the CPU.

Finding 1: add, subtract and multiply are bit-exact

Add, subtract and multiply implemented on ulong with 3 guard bits and sticky are bit-exact against Swift's Double for 1M random pairs including subnormals, signed zeros, infinities and NaN (d_finish handles normalisation, subnormal shift-with-sticky, round-to-nearest-even, and rounding carry).

Finding 2: division

A float-seeded Newton iteration was within 1 ulp only 93% of the time and wrong for subnormal inputs. Replaced with restoring long division on the significands (57 quotient bits). Two bugs on the way: one extra quotient bit shifted every result by 2x, and the restoring loop needs rem < mb before the first step (take the first quotient bit explicitly). Lesson: test division on ratios above and below 1.

Finding 3: a #define glued to the previous line

A preprocessor #define glued to the previous line (}#define) because Swift multi-line strings drop the final newline. Generated MSL fragments that start with a directive must begin with a newline.

Finding 4: the Float64 sum

Float64 sum on the GPU accumulates with d_add in tree order; per-threadgroup partials are combined on the CPU in Double. Results differ from a sequential CPU sum only by normal floating-point reordering.

What shipped

  • d_add, d_sub, d_mul, d_div and d_finish in Sources/ArrowMetal/Kernels/DoubleMath.swift.
  • Tests in Tests/ArrowMetalTests/DoubleMathTests.swift: testAddSubMulBitExact, testDivBitExact and testDoubleSumOnGPU.
  • The newline rule for generated fragments that start with a directive.
Round 5 · 2026-09-06

Pipeline creation on the paravirtual GPU, and lengths that stay on the device

TL;DR

  • The paravirtual GPU on GitHub runners fails makeComputePipelineState for arbitrary trivial kernels with no diagnostic while identical kernels pass in the same run. Mitigation: one retry, and GPU tests XCTSkip on devices whose name contains "Paravirtual".
  • Kernels now read their element count from a device buffer, so a filter followed by a sum is one round trip.

The setting

Round 4 left two things open: makeComputePipelineState failures on GitHub's paravirtual GPU, and the second round trip a sum() on a batched filter result paid to learn the filtered length.

Research steps

  1. Enable full compiler logs on the paravirtual GPU and compare the kernels that fail with those that pass in the same run.
  2. Add one retry on pipeline creation, and skip GPU tests on virtual devices.
  3. Move the element count into a device buffer and bind a pending filter's GPU-written total as that buffer.

Finding 1: the paravirtual GPU fails arbitrary trivial kernels

With full compiler logs enabled, the paravirtual GPU on GitHub runners fails makeComputePipelineState for arbitrary trivial kernels (bitmap_not, cast_kernel) with no diagnostic while identical kernels pass in the same run. It is the virtual Metal stack, not a construct. The retry in Sources/ArrowMetal/MetalContext.swift:

do { p = try device.makeComputePipelineState(function: fn) }
catch {
    // Virtualised GPUs (GitHub's "Apple Paravirtual device") fail pipeline creation sporadically; retry once.
    usleep(20_000)
    p = try device.makeComputePipelineState(function: fn)
}

Finding 2: lengths that stay on the device

Kernels now read their element count from a device buffer (device const uint* nPtr). A pending filter result binds its GPU-written total as that buffer, so compare / arithmetic / cast / bitmap ops / another filter / a reduction can consume it inside the same command buffer with worst-case dispatch sizes. Reductions still sync to read partials, but a filter followed by a sum is now one round trip.

What shipped

  • One retry on pipeline creation, quoted above.
  • GPU tests XCTSkip on devices whose name contains "Paravirtual" (ARROWMETAL_FORCE_GPU_TESTS=1 overrides): requireRealGPU() in Tests/ArrowMetalTests/TestSupport.swift. CI validates the build, interop and CPU paths; GPU correctness runs on real hardware.
  • device const uint* nPtr as the element count of every kernel, so pending lengths flow on the GPU.
Round 4 · 2026-09-06

The paravirtual GPU on GitHub runners

TL;DR

  • GitHub's macos-15 Apple silicon runners expose an "Apple Paravirtual device" GPU with 3 CPU cores, ~16 GB/s for a sum. Use CI for correctness only; never publish its numbers.
  • The paravirtual GPU failed makeComputePipelineState for kernels that pass on M4 Max; ARROWMETAL_DEBUG_SHADERS=1 now dumps the generated MSL and the compiler log on failure.
  • The ~120 µs per-call floor is the round trip; batching is what removes it, and a sum() on a batched filter still paid a second trip, which Round 5 removed.

The setting

The first CI runs on GitHub's hosted Apple silicon runners, after the Round 3 push, and the per-call latency of a single dispatch on M4 Max.

Research steps

  1. Measure a sum on the macos-15 runner's GPU.
  2. Read the pipeline creation failures on the runner for kernels that pass on M4 Max, and add a way to see the generated source and the compiler log.
  3. Try makeCommandBufferWithUnretainedReferences plus a spin-wait against the per-call latency.
  4. Count the round trips of a sum() on a batched filter result.

Finding 1: the runner GPU

GitHub's macos-15 Apple silicon runners expose an "Apple Paravirtual device" GPU with 3 CPU cores: ~16 GB/s for a sum. Use CI for correctness only; never publish its numbers.

Finding 2: pipeline creation fails on the runner

The paravirtual GPU failed makeComputePipelineState for kernels that pass on M4 Max (round 3 push). ARROWMETAL_DEBUG_SHADERS=1 now dumps generated MSL and the full compiler log on failure; CI sets it.

Finding 3: the per-call floor

makeCommandBufferWithUnretainedReferences plus a spin-wait did not measurably reduce per-call latency; the ~120 µs floor is the round trip. Batching is what works: chains pay it once.

Finding 4: a reduction on a batched filter

A sum() on a batched filter result still costs a second round trip because the reduction needs the filtered length on the CPU. Done in Round 5 above: kernels read n from a device buffer so pending lengths flow on the GPU.

What shipped

  • ARROWMETAL_DEBUG_SHADERS=1, read in Sources/ArrowMetal/MetalContext.swift; the CI job in .github/workflows/ci.yml runs ARROWMETAL_DEBUG_SHADERS=1 swift test.
  • The rule that CI numbers are never published.
Round 3 · 2026-09-06

Integer division by zero, and the group-by atomics

TL;DR

  • Metal integer division by zero returns an unspecified value; ArrowMetal defines it as 0 in both the GPU kernels and the CPU reference, and Int.min / -1 wraps.
  • Threadgroup-privatised group-by with 32-bit atomics reaches ~300 GB/s for up to 1024 keys, the same rate as a plain sum; device atomics at 100k keys halve it.
  • Crossing the C boundary from Python, and exporting a result back to pyarrow, cost nothing measurable; importing pyarrow buffers is one memcpy.

The setting

The arithmetic kernels, the group-by, and the Python binding's path across the C boundary.

Research steps

  1. Observe what Metal returns for integer division by zero, and define it the same way in the GPU kernels and the CPU reference.
  2. Measure the threadgroup-privatised group-by at up to 1024 keys and with device atomics at 100k keys.
  3. Time a call across the C boundary from Python, with and without exporting the result to pyarrow.
  4. Check the alignment of pyarrow's buffers against what a zero-copy import needs.

Finding 1: integer division by zero

Metal integer division by zero returns an unspecified value (observed 1 on M4 Max). ArrowMetal now defines it as 0 in both the GPU kernels and the CPU reference, and Int.min / -1 wraps instead of trapping. The generator in Sources/ArrowMetal/Kernels/KernelSource.swift:

/// Integer division by zero yields 0 (defined here, matching the CPU reference); overflow wraps.
if name == "div" && isInt {
    sclArray = "(b[i] == (\(T))0) ? (\(T))0 : a[i] / b[i]"

Finding 2: the group-by atomics

Threadgroup-privatised group-by with 32-bit atomics reaches ~300 GB/s for up to 1024 keys, the same rate as a plain sum: the atomics are not the bottleneck at that key count. Device atomics at 100k keys halve it.

Finding 3: the C boundary from Python

Crossing the C boundary from Python costs nothing measurable per call (ctypes overhead is ~10 µs); exporting a result back to pyarrow costs nothing measurable:

Call Time
without export 3.55 ms
with export 3.57 ms

Finding 4: importing pyarrow buffers

Importing pyarrow buffers is one memcpy: pyarrow's allocator is 64-byte aligned, not page aligned.

What shipped

  • Division by zero defined as 0 in the GPU kernels and the CPU reference; Int.min / -1 wraps.
  • The threadgroup-privatised group-by.
Ecosystem research · 2026-09-06

Ecosystem research

TL;DR

  • No existing Arrow library computes on Metal; the pieces that exist are types, IPC, device buffer wrappers and tensors.

What exists

  • apache/arrow-swift v21: types, IPC, Flight, C Data Interface. No compute kernels, nothing Metal.
  • arrow-nanoarrow device extension: C wrapper for ARROW_DEVICE_METAL buffers, no compute.
  • cuDF: CUDA only. MLX: unified-memory tensors, zero-copy MTLBuffer access, no nulls or columnar semantics.
  • A DuckDB extension with Metal aggregates exists (gpudb); it does not use Arrow buffers.

The device interface

  • The Arrow C Device Data Interface defines ARROW_DEVICE_METAL = 8 and expects MTLEvent* as sync_event.
Toolchain

Toolchain

TL;DR

  • Run tests with Xcode's DEVELOPER_DIR and always in release as well as debug.
  • Swift 6.3.3 miscompiles a withUnsafeBytes closure in a generic throws function under -O (swiftlang/swift#90477, open); MetalArray.init(_:) uses a plain loop instead.
  • The macos-15 runner's Swift 6.1 refuses closures Swift 6.3 accepts; release builds shorten object lifetimes; posix_memalign memory is not zero.

Running the tests

  • XCTest is not in the Command Line Tools. Run tests with DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer.
  • Always run swift test -c release.

Compilers

  • Swift 6.3.3 miscompiles a withUnsafeBytes closure inside a generic throws function under -O when the caller is in another module: the closure clobbers the error register around an Objective-C message send, so the function reports a phantom error and the caller crashes retaining it (swiftlang/swift#90477, open). MetalArray.init(_:) uses a plain element loop instead; the Metal-free reproducer is in UPSTREAM.md.
  • GitHub macos-15 runners come with Xcode 16.4 / Swift 6.1, which refuses to type-check dense closures that Swift 6.3 accepts. Keep test expressions simple.

Memory

  • Swift release builds shorten object lifetimes to last use: a raw pointer taken from a buffer object can outlive the object. Use withExtendedLifetime or the closure accessors.
  • posix_memalign memory is not zero for small blocks (recycled heap); only fresh mmap pages are zero.
Metal

What the M4 Max gives a kernel

TL;DR

  • 32 KB threadgroup memory, SIMD width 32, unified memory, no 64-bit atomic add from MSL.
  • Runtime makeLibrary(source:) needs no metal toolchain; makeBuffer(length:) buffers are not page aligned.
  • Memory-bound element-wise kernels run at ~375 GB/s against ~350 GB/s on the 16-core CPU; reductions and compaction favour the GPU by 2x to 3x.

The hardware

  • Apple M4 Max: 32 KB threadgroup memory, SIMD width 32, unified memory, no 64-bit atomic add from MSL (atomic_ulong fetch_add fails to compile; min and max do). 64-bit sums use split 32-bit atomics with carry.

The API

  • Runtime makeLibrary(source:) works without the metal toolchain. Errors carry line numbers of the generated source.
  • makeBuffer(length:) buffers are heap sub-allocated and not page aligned; bytesNoCopy requires page alignment of pointer and length.

Bandwidth

  • Memory-bound element-wise kernels run at ~375 GB/s on M4 Max once allocation overhead is removed; the 16-core CPU reaches ~350 GB/s on the same loops. Reductions and compaction favour the GPU by 2x to 3x.
  • Fusing the predicate into the filter's counting pass avoids materialising a boolean array.
Negative result

The resident GPU worker: why it does not work on Apple silicon today, and what took its place

Every unbatched ArrowMetal call is one command buffer, and a command-buffer round trip on an M4 Max costs about 65 µs of which the GPU runs for 1.7 (1.5 at best). Below roughly a million rows one CPU core wins, and below 100k the fixed cost is the whole story. The obvious escape is a resident worker: keep one compute dispatch alive whose threadgroups spin on a ring buffer of op descriptors in unified memory, so submitting an op is a store and collecting a result is a load. No encode, no commit, no completion notification.

It does not work on this platform. This document records the experiments, the numbers, and the latency work that replaced it.

Everything here was measured on an Apple M4 Max, macOS 26.6.2 (25G83), Xcode 26.6, release build.

1. What was attempted

                 shared MTLBuffer (.storageModeShared, page aligned)
   CPU  ──write op descriptor──►  ring[i] = { opcode, gpuAddress[], n, params }
                                  ring[i].state = READY          (release)
                                        │
   one long-running dispatch,  threadgroups spin:  while (ring[cursor].state != READY) ;
   never ends                            │        execute; ring[cursor].state = DONE
                                        ▼
   CPU  ◄──poll completion flag──  ring[i].state == DONE          (acquire)

Two things have to be true for this to beat 65 µs:

  1. a CPU store into shared memory must become visible to a running kernel, quickly;
  2. a store by that running kernel must become visible to the CPU, quickly.

Both were measured directly. Both fail.

2. Coherence: the crux, measured

Sources/ArrowMetal/Resident/ResidentMode.swift holds the in-tree reproduction (ResidentProbe), exercised by ResidentTests.testPersistentKernelCoherenceProbe. The method separates the two directions so neither measurement depends on the other:

  • CPU → GPU. The CPU raises a doorbell counter every 5 ms for 3 s (599 stores). The kernel logs every distinct value it observes into an output buffer. That log is read after the dispatch ends, where end-of-dispatch cache flushes make it visible unconditionally. So this direction is measured even when the other direction is completely broken.
  • GPU → CPU. The kernel writes a rising heartbeat. The CPU samples it while the kernel runs.

The doorbell and the heartbeat are 128 bytes apart. They must be: when they shared one 64-byte line, the GPU's eventual write-back clobbered the CPU's store, so the mailbox read back as zero after the kernel exited. That alone disqualifies a naive descriptor layout.

Every memory qualifier MSL offers was swept. 3 s window, 599 doorbells:

MSL qualifier on the shared buffer CPU stores the GPU saw first one seen after CPU first saw the heartbeat after
device uint* (plain) 0 of 599 never 227 ms
volatile device uint* 5 of 599 ~420 ms 380 ms
device coherent(device) uint* 3 of 599 ~815 ms 148 ms
volatile device coherent(device) uint* 2 of 599 ~1.36 s 76 ms
volatile device coherent(device) + threadgroup_barrier(mem_flags::mem_device) 4 of 599 ~990 ms 33 ms
atomic_load_explicit(…, memory_order_relaxed) 2 of 599 ~1.21 s 1.21 s
atomic_fetch_add_explicit(…, 0, relaxed) (device RMW) 0 of 599 never 990 ms
device RMW + mem_device fence 0 of 599 never never
coherent(device) + device RMW 0 of 599 never 1.99 s

Read that table twice. The best case is volatile + coherent(device) + a device fence, and it let four of 599 stores through, the first after about a second. Atomics — the tool the design calls for — are the worst: a device-scope read-modify-write never observed a CPU store at all.

The behaviour is consistent with visibility arriving only when the line is evicted from the GPU's cache, i.e. driven by unrelated memory traffic. Forcing evictions with a large device-memory sweep on every poll brings the latency down to about 5–6 ms, reliably — still two orders of magnitude worse than the 65 µs command buffer it is meant to replace, and it burns full memory bandwidth to do it.

There is no architectural fix available:

  • MSL exposes exactly two memory orders, memory_order_relaxed and memory_order_seq_cst, and seq_cst is not accepted for atomic_load_explicit on device address space (compile error: no matching function for call to 'atomic_load_explicit').
  • The only coherence scope the language has is coherent(device) — GPU-device scope. There is no system scope, so there is no way to express "coherent with the CPU".
  • MTLBuffer.didModifyRange does not apply: it is a managed-storage operation, and shared storage on Apple silicon has no such hint. There is no synchronize that can run mid-dispatch.

Conclusion: no MSL qualifier available today delivers the coherence a persistent spinning worker needs, so it cannot be built on this platform as it stands. The memory model simply does not promise CPU/GPU coherence inside a dispatch, and no qualifier delivers it in practice.

ResidentMode.available is false, MetalContext.setResidentMode(true) returns false, and am_resident_mode(1) returns 0. Nothing silently pretends otherwise.

3. The GPU watchdog: not the blocker

Worth recording, because it was expected to be the limit and is not.

Kernels that spun 2,000,000,000 iterations ran to completion uninterrupted for 65.4 s, 369.7 s, 381.2 s and 382.4 s, with MTLCommandBuffer.error == nil and status == .completed every time. No watchdog fired, no MTLCommandBufferError.timeout, no device reset, on a Mac with an attached display driving the desktop.

So the re-arm machinery a resident worker would have needed (bounded loop, relaunch from a completion handler) is unnecessary — but it is also moot. The GPU is fully occupied for the whole dispatch and unavailable to anything else, which makes a minutes-long spin a liability rather than a design.

4. Where the 65 µs goes

With the worker ruled out, the question becomes how much of ArrowMetal's per-call cost is Metal's and how much is ours. Per-call stages for an empty (nop) kernel, 500 calls, µs:

stage min median
makeCommandBufferWithUnretainedReferences 0.21 0.33
makeComputeCommandEncoder 1.33 1.71
setComputePipelineState + setBuffer + dispatchThreadgroups 0.25 0.42
endEncoding 0.00 0.12
commit 1.08 1.58
waitUntilCompleted 63.12 86.17
total round trip 67.54 90.67
of which driver work (kernelStartTime..kernelEndTime) 5.21 18.25
of which GPU execution (gpuStartTime..gpuEndTime) 1.50 1.71

Encoding costs 2.6 µs. The GPU runs for 1.7 µs. Everything else — about 60 µs — is submission and completion notification inside the driver, and it is not ours to remove.

(docs/BENCHMARKS.md round 4 quotes ~116 µs for the same empty round trip. These runs measure 67.5 µs min / 90.7 µs median over 500 warm calls in one process; the older figure was taken over 30 calls including the first ones. The floor is therefore lower than previously recorded, but the shape — GPU 1.7 µs, everything else driver — is the same.)

Wait strategies, same empty kernel:

strategy min median
waitUntilCompleted 68.62 77.83
spin on MTLCommandBuffer.status 61.33 79.75
spin 300 µs then waitUntilCompleted (what ArrowMetal did) 61.29 80.08
addCompletedHandler + DispatchSemaphore 67.33 76.50
encodeSignalEvent + CPU spin on MTLSharedEvent.signaledValue 56.17 65.54

An MTLSharedEvent's signaledValue is readable straight out of memory, so polling it skips the objc/driver path that MTLCommandBuffer.status goes through. That is 12 µs of median, for free.

Can the floor itself move?

Row min median CPU µs/op
shared-event spin, idle GPU 56.67 75.58 105
same, thread at QOS_CLASS_USER_INTERACTIVE 55.67 68.75 91
same, a second queue kept busy (GPU never idles) 29.50 68.12 116

Raising thread QoS is worth ~7 µs of median but is the caller's decision, not a library's, so ArrowMetal does not set it; do it in your own thread if you want it. Keeping the GPU warm improves the best case a lot and the median not at all.

Amortisation is the only real lever. Marginal cost per op when work is not waited on one at a time:

ops N separate command buffers, one wait N dispatches in one command buffer
1 68.8 µs 68.8 µs
2 38.4 µs/op 34.3 µs/op
5 22.3 µs/op 13.8 µs/op
10 15.6 µs/op 6.9 µs/op
20 11.5 µs/op 3.9 µs/op

Submission is about 11 µs; the ~57 µs remainder is the completion notification, paid once per wait. Ten dispatches in one command buffer complete in 69 µs total. That is what MetalContext.batch { } already does, and it is the only route below 20 µs per op on this hardware.

5. What changed

Three changes, all on the ordinary path. Nothing new is enabled by default that alters results.

  1. MTLSharedEvent completion wait (MetalContext.lowLatencyWait, on by default). Every synchronous command buffer signals a shared event; the CPU spins on signaledValue and falls back to waitUntilCompleted after spinMicroseconds. The event value is taken under commitLock together with the signal encode and the commit, so values are issued in commit order and signaledValue >= v really does mean "this command buffer finished" — without that lock a second thread could signal a higher value first and release a waiter whose own work had not run (ResidentTests.testConcurrentCallsAreNotReleasedEarly covers it).

  2. MSL source generated lazily. MetalContext.pipeline(source:function:cacheKey:) and Dispatch.pipeline now take the source as an @autoclosure, and the hot call sites pass the generator expression inline instead of binding it to a let first. Profiling a 1,000-row sum showed 20% of the call spent in CFStringFindWithOptionsAndLocale and _StringGuts.append — KernelSource.reductions rebuilding an MSL string for a kernel that was already compiled and cached, roughly 20 µs a call. On a cache hit the string is now never built. Applied to reductions, compare, filter, arithmetic, take and cast.

  3. A spin that polls instead of timing itself. The old spin called DispatchTime.now() on every iteration; the profile put 60% of the call's CPU samples inside dispatch_time → mach_absolute_time, against 4% reading MTLCommandBuffer.status. It now reads mach_absolute_time() directly and only once per 64 polls.

The 20% and 60% figures come from sample(1) on a tight col.sum() loop; throughput on that loop went from 108,781 to 168,190 iterations in 12 s (110 µs → 71 µs per op).

6. Measured per-op latency

Same standalone harness against the release library, median of 200, quiet machine. "before" is the library with lowLatencyWait = false and the eager MSL string build; "after" is 0.1.0. ARROWMETAL_LOW_LATENCY=0 reproduces the event-wait half only; the lazy MSL build has no runtime switch.

rows op before after Δ 1 CPU core 16 CPU cores
1,000 sum 114.4 88.4 −23% 0.1 0.5
1,000 compare 93.3 70.1 −25%
1,000 filter 92.2 81.6 −11% 0.5
1,000 multiply 81.7 69.7 −15%
1,000 add (array) 81.8 70.5 −14%
1,000 10 ops unbatched 820.0 695.5 −15%
1,000 10 ops batched 137.4 95.0 −31%
10,000 sum 101.2 69.1 −32% 0.6 1.0
10,000 compare 80.8 68.8 −15%
10,000 filter 92.7 81.6 −12% 6.3
10,000 10 ops batched 138.1 96.7 −30%
100,000 sum 101.2 68.5 −32% 7.2 8.2
100,000 compare 87.8 75.6 −14%
100,000 filter 101.9 86.7 −15% 192.2
100,000 10 ops batched 157.1 116.0 −26%
1,000,000 sum 126.8 97.0 −23% 78.8 74.5
1,000,000 compare 84.8 83.2 −2%
1,000,000 filter 118.1 107.8 −9% 2068.6
1,000,000 10 ops batched 370.1 330.7 −11%

Repeated three times interleaved (before, after, before, after, before, after) on a thermally loaded machine to rule out drift: absolute numbers rise about 30% across the board, the deltas hold — sum −16% to −24%, batched chain −16% to −19%, arithmetic −8% to −17%.

Reproduce in-tree:

DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
  ARROWMETAL_LATENCY_BENCH=1 swift test -c release --filter "ResidentTests/testLatencyBenchmark"
# ARROWMETAL_LOW_LATENCY=0 runs the same build with waitUntilCompleted, isolating the event half

The XCTest-hosted benchmark reads 15–30% higher than the standalone harness because of the test process; use it for relative comparisons.

Against the targets

  • ≤ 20 µs for one small op, synchronously, from Swift: not reachable. The platform floor for a single command-buffer round trip is 56 µs best case and ~65–69 µs typical, of which 1.7 µs is the GPU. There is nothing left in ArrowMetal to remove — encode is 2.6 µs.
  • A chain of 10 small ops in < 100 µs: reached. ctx.batch { } over ten 1,000-row ops is 95 µs, down from 137 µs. The raw Metal equivalent is 69 µs, so ArrowMetal now sits ~26 µs above the floor for a ten-op chain rather than ~68 µs above it.
  • The GPU/CPU crossover for sum is unchanged in shape and moved slightly: one core still wins below about 1M rows.

7. Power

There is no user-space GPU power counter without sudo powermetrics, which is not available in this environment, so CPU time (getrusage) is used as the proxy. 1,000-row sum, 2,000 calls:

configuration wall median µs CPU µs/op CPU per wall µs
lowLatencyWait, spinMicroseconds = 300 (default) 101.0 128.0 1.27
lowLatencyWait, spinMicroseconds = 0 109.4 31.6 0.29
waitUntilCompleted, spinMicroseconds = 300 110.6 136.8 1.24
waitUntilCompleted, spinMicroseconds = 0 109.3 30.7 0.28

Spinning costs slightly more than one full core per in-flight op (the excess over 1.0 is Metal's own helper threads). spinMicroseconds = 0 gives 4x of that CPU back for 8% more latency — the right setting for a throughput-oriented caller, and available as am_spin_microseconds(0) / arrowmetal.spin_microseconds(0). With spinning off the two wait strategies are identical, as expected: the event is never polled.

For scale, the resident worker it replaced would have burned one full CPU core plus one GPU threadgroup continuously and indefinitely — ResidentProbe measures 0.080 CPU-seconds over an 80 ms window, i.e. a core pinned, while the GPU is 100% occupied for the whole dispatch and unavailable to anything else. That cost would have been justified by a 20 µs op. It is not justified by a 5 ms one.

8. Limits and what is left

  • The 56 µs floor is the driver's submit + notify path. Nothing in ArrowMetal can touch it.
  • Everything below 20 µs per op comes from amortisation: batch { } (6.9 µs/op at ten dispatches) or batchAsync for pipelining (11.5 µs/op with 20 command buffers in flight). Small-op workloads should batch; the API already supports it in Swift, C and Python.
  • filter is still the furthest from the floor (~82 µs at 1,000 rows against ~69 µs for compare) because it encodes three dispatches and allocates.
  • Not attempted: MTLIndirectCommandBuffer; encoding is 2.6 µs of a 65 µs call.
  • If a future OS gives MSL a system-scope atomic or a documented CPU/GPU coherence guarantee inside a dispatch, ResidentProbe is the test to re-run first; ResidentTests prints a loud note rather than failing if both directions ever start working.