Skip to content

Latest commit

 

History

History
566 lines (497 loc) · 140 KB

File metadata and controls

566 lines (497 loc) · 140 KB

Apache Arrow compute coverage

ArrowMetal 0.1.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.md does the complementary thing: one row per exact Arrow function name, all 307 of them, 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 Planned 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 8 0 1 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) 148 9 16 7 0 180
Arrow types (matrix below) 20 0 0 7 1 28

Interop uses a separate vocabulary and is counted apart: 8 in 0.1.0, 1 in 0.1.0 but unpublished, 1 partial, 2 planned (12 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.md: of Arrow v25's 307 compute function names, all 307 are reachable — 283 entirely on the GPU, 17 on the host, 7 with a stated limitation, and none missing.

The scope ArrowMetal 0.1.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 Arrowhash_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.
Planned Not implemented; a ROADMAP item covers it (linked in the note).

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.md, which has exactly one row per Arrow function name.

Aggregations — scalar

Arrow function Status Notes
sum GPU Kernels/Reductions.swift. Threadgroup partials, host finalise, no atomics. Integers accumulate in Int64/UInt64 and wrap; Float32 accumulates per thread in float and finalises in double, so the last ulp can differ from a strictly sequential double sum; Float64 uses a software IEEE-754 binary64 adder on the GPU (Kernels/DoubleMath.swift). 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 GPU sum divided by the valid count on the host (Reductions.swift).
min GPU NaN is skipped; all-NaN returns null, matching Arrow's min_max. Float64 reduces on order-preserving 64-bit keys.
max 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. 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 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 — and right/full outer joins are not implemented. 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) is an ArrowMetal extension rather than an Arrow function name, so it has no row of its own; it lives beside power in Kernels/Rounding.swift and is reachable as am_binary(op 5) and .modulo() / __mod__ in Python.

Arrow function Status Notes
add 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 As add.
multiply 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 extracts the root digit by digit in integers (54 restoring steps on a significand whose exponent has been made even), so there is no approximation left to be off by — 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 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 NdNo, numeric NdNlNo, 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. 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 az / AZ; 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: 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 hoursBetweennanosecondsBetween. 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. nanosecondday 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 and boolean conversions are CPU: floats format as 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, and parse with Swift's Double/Float initialiser; 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 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 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. 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 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 yet.
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 CPU The radix sort runs on the GPU over order-preserving keys and leaves the nulls, and every NaN, in one block after the values; a host-side stable partition of the int32 index array then moves that block to whichever end null_placement names. It is one O(length) pass against the sort's four or eight histogram + scan + scatter passes, so it does not dominate, and it keeps the nulls in their input order rather than the order the bytes under the validity bitmap happened to sort into — which is what Arrow's stable sort promises. at_end needs no move at all. NaN travels with the nulls, as it does in Arrow: at_start puts the nulls first, then the NaNs, then the values. This is the one part of the sort that is not a kernel.
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.

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.

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. Not implemented: divide, cast between decimal precisions, group-by, sort and is_in over decimal columns, and Arrow IPC (the writer rejects a decimal column with a message).
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 Planned ROADMAP → Types and interop lists utf8_view / binary_view import and export as open.
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 (complete dictionaries, isDelta = false). 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; IPC writing needs an explicit decode. 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 the row still open: ROADMAP → Types and interop.
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 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 (complete dictionaries only: a delta batch, a replacement for an id, or a batch carrying a different dictionary for a column is refused rather than silently mis-decoded). Compression, view types and run-end encoded columns are not written.
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, unpublished scripts/build_wheel.sh copies libArrowMetalC.dylib into the package and builds a macosx_*_arm64 wheel. It is not on PyPI yet: ROADMAP → Release mechanics.
Python: __arrow_c_device_array__ Planned Only __arrow_c_array__ is defined today. ROADMAP → Types and interop.
arrow-swift and MLX bridges, DuckDB/DataFusion UDF Planned ROADMAP → Integrations.

What ArrowMetal 0.1.0 claims, and what it does not

The claim. ArrowMetal 0.1.0 answers to all 307 of the Apache Arrow v25 compute function names — 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.md 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 (sumquantile, 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 saturating at 64 bits rather than at the target's width — 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 does not. 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, utf8_view / binary_view, 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.1.0. Read alongside ARROW_FUNCTIONS.md, ROADMAP.md, DESIGN.md and BENCHMARKS.md.