From 79c2d3214ff635e1fbfa4c3d0a6b753a5d7b7632 Mon Sep 17 00:00:00 2001 From: Kristoffer Carlsson Date: Wed, 12 Aug 2026 18:29:34 +0200 Subject: [PATCH 1/8] Reuse sort scratch space across key groups Recursive lexicographic refinement allocated a fresh merge-sort scratch vector for every subgroup. Thread one workspace through the recursion instead. MWE: benchmark/sortperm_recursive.jl (100,000 rows, 6 Float64 columns, cardinality 16; Julia 1.12.6, minimum of 20 samples). Before: 12.874 ms, 2.87 MiB, 34,473 allocations After: 12.340 ms, 1.62 MiB, 11 allocations --- benchmark/sortperm_recursive.jl | 21 +++++++++++++++++++++ src/sort.jl | 5 ++--- 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 benchmark/sortperm_recursive.jl diff --git a/benchmark/sortperm_recursive.jl b/benchmark/sortperm_recursive.jl new file mode 100644 index 00000000..4515b7c1 --- /dev/null +++ b/benchmark/sortperm_recursive.jl @@ -0,0 +1,21 @@ +using BenchmarkTools +using Random +using StructArrays + +function nested_table(::Type{T}; n=100_000, width=6, cardinality=16, seed=1234) where {T} + rng = MersenneTwister(seed) + columns = ntuple(_ -> T.(rand(rng, 1:cardinality, n)), width) + return StructArray(columns) +end + +table = nested_table(Float64) +@assert issorted(table[sortperm(table)]) + +trial = @benchmark sortperm($table) samples=20 evals=1 seconds=60 +estimate = minimum(trial) +println( + "Float64 keys: ", + BenchmarkTools.prettytime(estimate.time), ", ", + BenchmarkTools.prettymemory(estimate.memory), ", ", + estimate.allocs, " allocations", +) diff --git a/src/sort.jl b/src/sort.jl index 64316d91..8dc817d2 100644 --- a/src/sort.jl +++ b/src/sort.jl @@ -72,8 +72,7 @@ forward_vec(::Ordering) = nothing # Methods from IndexedTables to refine sorting: # # assuming x[p] is sorted, sort by remaining columns where x[p] is constant -function refine_perm!(p, cols, c, x, y′, lo, hi) - temp = similar(p, 0) +function refine_perm!(p, cols, c, x, y′, lo, hi, temp=similar(p, 0)) order = Perm(Forward, y′) y = something(forward_vec(order), y′) nc = length(cols) @@ -83,7 +82,7 @@ function refine_perm!(p, cols, c, x, y′, lo, hi) sort_sub_by!(p, i, i1, y, order, temp) if c < nc-1 z = cols[c+2] - refine_perm!(p, cols, c+1, y, z, i, i1) + refine_perm!(p, cols, c+1, y, z, i, i1, temp) end end end From 8c8f36fc88840bc45c6291a3ca889ee6482bdc6f Mon Sep 17 00:00:00 2001 From: Kristoffer Carlsson Date: Wed, 12 Aug 2026 18:30:41 +0200 Subject: [PATCH 2/8] Reuse counting-sort workspace across key groups Integer-key refinement allocated and filled a new histogram for every subgroup. Keep one histogram alongside the merge-sort scratch vector and resize/fill it in place. MWE: benchmark/sortperm_recursive.jl (100,000 rows, 6 Int columns, cardinality 16; Julia 1.12.6, minimum of 40 samples; baseline is the preceding commit). Before: 2.356 ms, 2.06 MiB, 17,504 allocations After: 2.206 ms, 960.53 KiB, 12 allocations --- benchmark/sortperm_recursive.jl | 22 ++++++++++++---------- src/sort.jl | 17 +++++++++-------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/benchmark/sortperm_recursive.jl b/benchmark/sortperm_recursive.jl index 4515b7c1..395f544b 100644 --- a/benchmark/sortperm_recursive.jl +++ b/benchmark/sortperm_recursive.jl @@ -8,14 +8,16 @@ function nested_table(::Type{T}; n=100_000, width=6, cardinality=16, seed=1234) return StructArray(columns) end -table = nested_table(Float64) -@assert issorted(table[sortperm(table)]) +for T in (Float64, Int) + table = nested_table(T) + @assert issorted(table[sortperm(table)]) -trial = @benchmark sortperm($table) samples=20 evals=1 seconds=60 -estimate = minimum(trial) -println( - "Float64 keys: ", - BenchmarkTools.prettytime(estimate.time), ", ", - BenchmarkTools.prettymemory(estimate.memory), ", ", - estimate.allocs, " allocations", -) + trial = @benchmark sortperm($table) samples=20 evals=1 seconds=60 + estimate = minimum(trial) + println( + T, " keys: ", + BenchmarkTools.prettytime(estimate.time), ", ", + BenchmarkTools.prettymemory(estimate.memory), ", ", + estimate.allocs, " allocations", + ) +end diff --git a/src/sort.jl b/src/sort.jl index 8dc817d2..cc8df7a8 100644 --- a/src/sort.jl +++ b/src/sort.jl @@ -72,29 +72,29 @@ forward_vec(::Ordering) = nothing # Methods from IndexedTables to refine sorting: # # assuming x[p] is sorted, sort by remaining columns where x[p] is constant -function refine_perm!(p, cols, c, x, y′, lo, hi, temp=similar(p, 0)) +function refine_perm!(p, cols, c, x, y′, lo, hi, temp=similar(p, 0), counts=Int[]) order = Perm(Forward, y′) y = something(forward_vec(order), y′) nc = length(cols) for idxs in GroupPerm(x, p, lo:hi) i, i1 = extrema(idxs) if i1 > i - sort_sub_by!(p, i, i1, y, order, temp) + sort_sub_by!(p, i, i1, y, order, temp, counts) if c < nc-1 z = cols[c+2] - refine_perm!(p, cols, c+1, y, z, i, i1, temp) + refine_perm!(p, cols, c+1, y, z, i, i1, temp, counts) end end end end # sort the values in v[i0:i1] in place, by array `by` -Base.@noinline function sort_sub_by!(v, i0, i1, by, order, temp) +Base.@noinline function sort_sub_by!(v, i0, i1, by, order, temp, counts=Int[]) empty!(temp) sort!(v, i0, i1, MergeSort, order, temp) end -Base.@noinline function sort_sub_by!(v, i0, i1, by::AbstractVector{T}, order, temp) where T<:Integer +Base.@noinline function sort_sub_by!(v, i0, i1, by::AbstractVector{T}, order, temp, counts=Int[]) where T<:Integer min = max = by[v[i0]] @inbounds for i = i0+1:i1 val = by[v[i]] @@ -107,7 +107,7 @@ Base.@noinline function sort_sub_by!(v, i0, i1, by::AbstractVector{T}, order, te rangelen = max-min+1 n = i1-i0+1 if rangelen <= n - sort_int_range_sub_by!(v, i0-1, n, by, rangelen, min, temp) + sort_int_range_sub_by!(v, i0-1, n, by, rangelen, min, temp, counts) else empty!(temp) sort!(v, i0, i1, MergeSort, order, temp) @@ -116,10 +116,11 @@ Base.@noinline function sort_sub_by!(v, i0, i1, by::AbstractVector{T}, order, te end # in-place counting sort of x[ioffs+1:ioffs+n] by values in `by` -function sort_int_range_sub_by!(x, ioffs, n, by, rangelen, minval, temp) +function sort_int_range_sub_by!(x, ioffs, n, by, rangelen, minval, temp, where=Int[]) offs = 1 - minval - where = fill(0, rangelen+1) + resize!(where, rangelen+1) + fill!(where, 0) where[1] = 1 @inbounds for i = 1:n where[by[x[i+ioffs]] + offs + 1] += 1 From dd8c50a57eaec619c26526f1f700ba7059af8d53 Mon Sep 17 00:00:00 2001 From: Kristoffer Carlsson Date: Wed, 12 Aug 2026 18:35:19 +0200 Subject: [PATCH 3/8] Preallocate components for known-length row appends The generic Tables fallback pushed rows one at a time without reserving component capacity. Apply sizehint! when the iterator reports a length, while retaining the existing row-wise conversion path. MWE: benchmark/append_rows.jl (1,000,000 NamedTuple rows, 4 columns; Julia 1.12.6, minimum of 10 samples). Before: 9.555 ms, 115.14 MiB, 120 allocations After: 8.034 ms, 30.56 MiB, 8 allocations --- benchmark/append_rows.jl | 19 +++++++++++++++++++ src/tables.jl | 11 +++++++++++ 2 files changed, 30 insertions(+) create mode 100644 benchmark/append_rows.jl diff --git a/benchmark/append_rows.jl b/benchmark/append_rows.jl new file mode 100644 index 00000000..a12dde4f --- /dev/null +++ b/benchmark/append_rows.jl @@ -0,0 +1,19 @@ +using BenchmarkTools +using StructArrays + +n = 1_000_000 +rows = [(a=i, b=2i, c=3.0i, d=4.0i) for i in 1:n] +base = StructArray((a=Int[], b=Int[], c=Float64[], d=Float64[])) + +probe = copy(base) +append!(probe, rows) +@assert probe == rows + +trial = @benchmark append!(dest, $rows) setup=(dest=copy($base)) samples=10 evals=1 seconds=60 +estimate = minimum(trial) +println( + "append! rows: ", + BenchmarkTools.prettytime(estimate.time), ", ", + BenchmarkTools.prettymemory(estimate.memory), ", ", + estimate.allocs, " allocations", +) diff --git a/src/tables.jl b/src/tables.jl index 432a75b2..58c79277 100644 --- a/src/tables.jl +++ b/src/tables.jl @@ -31,6 +31,16 @@ end try_compatible_columns(rows::StructArray{T}, s::StructArray{T}) where {T} = Tables.columntable(rows) try_compatible_columns(rows::StructArray{R}, s::StructArray{S}) where {R,S} = nothing +function _prepare_rows!(s, rows, ::typeof(push!)) + _sizehint_rows!(s, rows, Base.IteratorSize(rows)) +end +_prepare_rows!(s, rows, ::typeof(pushfirst!)) = s + +function _sizehint_rows!(s, rows, ::Union{Base.HasLength, Base.HasShape}) + sizehint!(s, length(s) + length(rows)) +end +_sizehint_rows!(s, rows, ::Any) = s + for (f, g) in zip((:append!, :prepend!), (:push!, :pushfirst!)) @eval function Base.$f(s::StructVector, rows) table = try_compatible_columns(rows, s) @@ -42,6 +52,7 @@ for (f, g) in zip((:append!, :prepend!), (:push!, :pushfirst!)) else # Otherwise, fallback to a generic implementation expecting # that `rows` is an iterator: + _prepare_rows!(s, rows, $g) return foldl($g, rows; init = s) end end From 3ef2c079004ac6778174795126e2985d39f51a65 Mon Sep 17 00:00:00 2001 From: Kristoffer Carlsson Date: Wed, 12 Aug 2026 18:37:50 +0200 Subject: [PATCH 4/8] Avoid temporary axes tuples in wide constructors Shape validation mapped axes over every component before reducing the results. For homogeneous concrete component tuples, validate in a type-stable loop and return early on a mismatch. MWE: benchmark/wide_constructor.jl (128 Vector{Float64} columns; Julia 1.12.6, minimum BenchmarkTools estimate). Before: 1.983 us, 1.23 KiB, 3 allocations After: 56.233 ns, 0 bytes, 0 allocations --- benchmark/wide_constructor.jl | 19 +++++++++++++++++++ src/utils.jl | 22 +++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 benchmark/wide_constructor.jl diff --git a/benchmark/wide_constructor.jl b/benchmark/wide_constructor.jl new file mode 100644 index 00000000..f0e7c37a --- /dev/null +++ b/benchmark/wide_constructor.jl @@ -0,0 +1,19 @@ +using BenchmarkTools +using StructArrays + +function construct_wide(columns::NTuple{N, Vector{Float64}}) where {N} + StructArray{NTuple{N, Float64}}(columns) +end + +columns = ntuple(_ -> rand(1), 128) +probe = construct_wide(columns) +@assert size(probe) == (1,) + +trial = @benchmark construct_wide($columns) +estimate = minimum(trial) +println( + "128-column constructor: ", + BenchmarkTools.prettytime(estimate.time), ", ", + BenchmarkTools.prettymemory(estimate.memory), ", ", + estimate.allocs, " allocations", +) diff --git a/src/utils.jl b/src/utils.jl index 00458e1b..6546560d 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -199,8 +199,28 @@ maybe_convert_elt(::Type{T}, vals::NamedTuple) where T = T<:NamedTuple ? convert Compute the unique value that `f` takes on each `component ∈ components`. If not all values are equal, return `nothing`. Otherwise, return the unique value. """ -function findconsistentvalue(f::F, cols::Tup) where F +function _findconsistentvalue(f, cols) val = f(first(cols)) isconsistent = all(map(isequal(val) ∘ f, values(cols))) return ifelse(isconsistent, val, nothing) end + +findconsistentvalue(f::F, cols::NamedTuple) where {F} = _findconsistentvalue(f, cols) + +function findconsistentvalue(f::F, cols::T) where {F, T<:Tuple} + if @generated + types = fieldtypes(T) + if length(types) > 32 && all(==(types[1]), types) && isconcretetype(types[1]) + return quote + val = f(first(cols)) + for col in cols + isequal(val, f(col)) || return nothing + end + return val + end + end + return :(_findconsistentvalue(f, cols)) + else + return _findconsistentvalue(f, cols) + end +end From 3d04987e6d8dec1913b6a494aa5ca12116c3ff34 Mon Sep 17 00:00:00 2001 From: Kristoffer Carlsson Date: Wed, 12 Aug 2026 18:38:40 +0200 Subject: [PATCH 5/8] Infer homogeneous column eltypes without tuple mapping Wide homogeneous component tuples already encode their shared array type. Derive the row tuple type directly instead of mapping eltype and splatting a large temporary tuple; retain the fallback for abstract and heterogeneous components. MWE: benchmark/wide_constructor.jl (inferred 128-column Vector{Float64} StructArray; Julia 1.12.6, minimum BenchmarkTools estimate; baseline is the preceding commit). Before: 4.244 us, 4.61 KiB, 13 allocations After: 56.233 ns, 0 bytes, 0 allocations --- benchmark/wide_constructor.jl | 4 +--- src/utils.jl | 13 ++++++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/benchmark/wide_constructor.jl b/benchmark/wide_constructor.jl index f0e7c37a..3c50c475 100644 --- a/benchmark/wide_constructor.jl +++ b/benchmark/wide_constructor.jl @@ -1,9 +1,7 @@ using BenchmarkTools using StructArrays -function construct_wide(columns::NTuple{N, Vector{Float64}}) where {N} - StructArray{NTuple{N, Float64}}(columns) -end +construct_wide(columns) = StructArray(columns) columns = ntuple(_ -> rand(1), 128) probe = construct_wide(columns) diff --git a/src/utils.jl b/src/utils.jl index 6546560d..e347a0ea 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -3,7 +3,18 @@ argtail(_, args...) = args split_tuple_type(T) = fieldtype(T, 1), Tuple{argtail(T.parameters...)...} eltypes(nt::NamedTuple{names}) where {names} = NamedTuple{names, eltypes(values(nt))} -eltypes(t::Tuple) = Tuple{map(eltype, t)...} +_eltypes(t::Tuple) = Tuple{map(eltype, t)...} +function eltypes(t::T) where {T<:Tuple} + if @generated + types = fieldtypes(T) + if !isempty(types) && all(==(types[1]), types) && isconcretetype(types[1]) && types[1] <: AbstractArray + return :(NTuple{$(length(types)), $(eltype(types[1]))}) + end + return :(_eltypes(t)) + else + return _eltypes(t) + end +end alwaysfalse(t) = false From 6ea02ff4808c8c252d49109cdd222e2818dcbdae Mon Sep 17 00:00:00 2001 From: Kristoffer Carlsson Date: Wed, 12 Aug 2026 18:40:04 +0200 Subject: [PATCH 6/8] Keep wide homogeneous row grouping type-stable Recursive Tuple-tail comparison crosses Julia's specialization limit for wide StructArrays and boxes each comparison. Compare homogeneous concrete component tuples with a type-stable loop while retaining recursive dispatch for heterogeneous tuples. MWE: benchmark/group_wide.jl (100,000 rows, 64 Int columns, cardinality 16; Julia 1.12.6, minimum of 10 samples). Before: 365.092 ms, 355.32 MiB, 698,520 allocations After: 728.416 us, 0 bytes, 0 allocations --- benchmark/group_wide.jl | 26 ++++++++++++++++++++++++++ src/sort.jl | 20 ++++++++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 benchmark/group_wide.jl diff --git a/benchmark/group_wide.jl b/benchmark/group_wide.jl new file mode 100644 index 00000000..7484061c --- /dev/null +++ b/benchmark/group_wide.jl @@ -0,0 +1,26 @@ +using BenchmarkTools +using Random +using StructArrays + +function countgroups(keys, permutation) + count = 0 + for _ in StructArrays.GroupPerm(keys, permutation) + count += 1 + end + return count +end + +rng = MersenneTwister(42) +columns = ntuple(_ -> rand(rng, 1:16, 100_000), 64) +table = StructArray(columns) +permutation = sortperm(table) +@assert countgroups(table, permutation) == length(unique(table)) + +trial = @benchmark countgroups($table, $permutation) samples=10 evals=1 seconds=60 +estimate = minimum(trial) +println( + "64-column grouping: ", + BenchmarkTools.prettytime(estimate.time), ", ", + BenchmarkTools.prettymemory(estimate.memory), ", ", + estimate.allocs, " allocations", +) diff --git a/src/sort.jl b/src/sort.jl index cc8df7a8..278b43e3 100644 --- a/src/sort.jl +++ b/src/sort.jl @@ -35,8 +35,24 @@ Base.eltype(::Type{<:GroupPerm}) = UnitRange{Int} return eq end -roweq(t::Tuple{}, i, j) = true -roweq(t::Tuple, i, j) = roweq(t[1], i, j) ? roweq(tail(t), i, j) : false +_roweq(t::Tuple{}, i, j) = true +_roweq(t::Tuple, i, j) = roweq(t[1], i, j) ? _roweq(tail(t), i, j) : false +function roweq(t::T, i, j) where {T<:Tuple} + if @generated + types = fieldtypes(T) + if length(types) > 32 && all(==(types[1]), types) && isconcretetype(types[1]) + return quote + for col in t + roweq(col, i, j) || return false + end + return true + end + end + return :(_roweq(t, i, j)) + else + return _roweq(t, i, j) + end +end roweq(s::StructArray, i, j) = roweq(Tuple(components(s)), i, j) function uniquesorted(keys, perm=sortperm(keys)) From 27ae387ce5f45c35aeffc3db453208a35961acc0 Mon Sep 17 00:00:00 2001 From: Kristoffer Carlsson Date: Wed, 12 Aug 2026 18:41:48 +0200 Subject: [PATCH 7/8] Avoid splatting wide Tuple rows during indexing Scalar getindex already materializes a tuple of component values. Tuple and NamedTuple row types can construct directly from it, avoiding the specialization cliff from a wide vararg splat while preserving their conversions. MWE: benchmark/wide_getindex.jl (128 Float64 columns, 100 rows; Julia 1.12.6, minimum BenchmarkTools estimate). Before: 2.509 us, 4.16 KiB, 131 allocations After: 54.795 ns, 0 bytes, 0 allocations --- benchmark/wide_getindex.jl | 15 +++++++++++++++ src/structarray.jl | 6 ++++++ 2 files changed, 21 insertions(+) create mode 100644 benchmark/wide_getindex.jl diff --git a/benchmark/wide_getindex.jl b/benchmark/wide_getindex.jl new file mode 100644 index 00000000..7d23924d --- /dev/null +++ b/benchmark/wide_getindex.jl @@ -0,0 +1,15 @@ +using BenchmarkTools +using StructArrays + +table = StructArray(ntuple(_ -> rand(100), 128)) +probe = table[50] +@assert probe == ntuple(i -> components(table)[i][50], 128) + +trial = @benchmark $table[50] +estimate = minimum(trial) +println( + "128-column getindex: ", + BenchmarkTools.prettytime(estimate.time), ", ", + BenchmarkTools.prettymemory(estimate.memory), ", ", + estimate.allocs, " allocations", +) diff --git a/src/structarray.jl b/src/structarray.jl index eba837be..1da2c828 100644 --- a/src/structarray.jl +++ b/src/structarray.jl @@ -350,6 +350,12 @@ Base.@propagate_inbounds function _getindex(x::StructArray{T}, I::Vararg{Int}) w return createinstance(T, get_ith(cols, I...)...) end +Base.@propagate_inbounds function _getindex(x::StructArray{T}, I::Vararg{Int}) where {T<:Tup} + cols = components(x) + @boundscheck checkbounds(x, I...) + return T(get_ith(cols, I...)) +end + @inline function _getindex(s::StructArray{T}, I...) where {T} @boundscheck checkbounds(s, I...) StructArray{T}(map(v -> @inbounds(getindex(v, I...)), components(s))) From 2e6e71cdcfea8698ced1bc2c2c4e730e06eb77d0 Mon Sep 17 00:00:00 2001 From: Kristoffer Carlsson Date: Wed, 12 Aug 2026 18:44:30 +0200 Subject: [PATCH 8/8] Keep wide NamedTuple shape checks type-stable NamedTuple component collections hit the same wide-tuple specialization limit during axes validation. Above that limit, use the encoded homogeneous value-tuple type to select the allocation-free loop; retain the unrolled path for narrow static arrays and the generic fallback for heterogeneous or abstract fields. MWE: benchmark/wide_constructor.jl (inferred 128-column named StructArray; Julia 1.12.6, minimum BenchmarkTools estimate). Before: 3.869 us, 3.36 KiB, 5 allocations After: 79.459 ns, 0 bytes, 0 allocations --- benchmark/wide_constructor.jl | 3 ++- src/utils.jl | 20 ++++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/benchmark/wide_constructor.jl b/benchmark/wide_constructor.jl index 3c50c475..3c477ae6 100644 --- a/benchmark/wide_constructor.jl +++ b/benchmark/wide_constructor.jl @@ -3,7 +3,8 @@ using StructArrays construct_wide(columns) = StructArray(columns) -columns = ntuple(_ -> rand(1), 128) +names = ntuple(i -> Symbol(:x, i), 128) +columns = NamedTuple{names}(ntuple(_ -> rand(1), 128)) probe = construct_wide(columns) @assert size(probe) == (1,) diff --git a/src/utils.jl b/src/utils.jl index e347a0ea..e8865fde 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -216,8 +216,6 @@ function _findconsistentvalue(f, cols) return ifelse(isconsistent, val, nothing) end -findconsistentvalue(f::F, cols::NamedTuple) where {F} = _findconsistentvalue(f, cols) - function findconsistentvalue(f::F, cols::T) where {F, T<:Tuple} if @generated types = fieldtypes(T) @@ -235,3 +233,21 @@ function findconsistentvalue(f::F, cols::T) where {F, T<:Tuple} return _findconsistentvalue(f, cols) end end + +function findconsistentvalue(f::F, cols::T) where {F, T<:NamedTuple} + if @generated + types = fieldtypes(T) + if length(types) > 32 && all(==(types[1]), types) && isconcretetype(types[1]) + return quote + val = f(first(cols)) + for col in cols + isequal(val, f(col)) || return nothing + end + return val + end + end + return :(_findconsistentvalue(f, cols)) + else + return _findconsistentvalue(f, cols) + end +end