From 21171b747c2731edad85c949e68b974b9027390e Mon Sep 17 00:00:00 2001 From: Jakob Nybo Andersen Date: Thu, 2 Apr 2026 19:10:11 +0200 Subject: [PATCH 1/3] Switch default storage to MemoryRef-based array Instead of backing default FixedSizeArray with Memory, create a new array type implemented as an immutable struct implementing MemoryRef, and no size. This allow the pointer to inline into the FixedSizeArray structure, instead of being behind a pointer dereference to the Memory object. By avoiding size information in the backing type, the size is not wastefully stored doubly in FSA. --- src/FixedSizeArray.jl | 6 +++ src/FixedSizeArrays.jl | 10 +++-- src/refarray.jl | 100 +++++++++++++++++++++++++++++++++++++++++ test/runtests.jl | 8 ++-- 4 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 src/refarray.jl diff --git a/src/FixedSizeArray.jl b/src/FixedSizeArray.jl index 0a621f7..6f37ca7 100644 --- a/src/FixedSizeArray.jl +++ b/src/FixedSizeArray.jl @@ -253,6 +253,12 @@ function with_stripped_type_parameters_unchecked(::TypeParametersElementType, :: s = GenericMemory{K, T, AS} where {T} Val{s}() end +if (@isdefined UnsafeRefArray) + function with_stripped_type_parameters_unchecked(::TypeParametersElementType, ::Type{<:UnsafeRefArray}) + s = UnsafeRefArray + Val{s}() + end +end # `Base.@assume_effects :consistent` is a workaround for: # https://github.com/JuliaLang/julia/issues/56966 diff --git a/src/FixedSizeArrays.jl b/src/FixedSizeArrays.jl index 5349208..af58c2c 100644 --- a/src/FixedSizeArrays.jl +++ b/src/FixedSizeArrays.jl @@ -5,11 +5,15 @@ using Collects: Collect, collect_as using LightBoundsErrors: checkbounds_lightboundserror using LightBoundsErrors: LightBoundsError as BoundsErrorLight -const default_underlying_storage_type = (@isdefined Memory) ? Memory : Vector +if (@isdefined Memory) + include("refarray.jl") +end + +const default_underlying_storage_type = (@isdefined Memory) ? UnsafeRefArray : Vector -const optional_memory = (@isdefined Memory) ? (Memory,) : () +const optional_memory = (@isdefined Memory) ? (UnsafeRefArray,) : () const optional_atomic_memory = (@isdefined AtomicMemory) ? (AtomicMemory,) : () -const optional_generic_memory = (@isdefined GenericMemory) ? (GenericMemory,) : () +const optional_generic_memory = (@isdefined GenericMemory) ? (UnsafeRefArray,) : () include("FixedSizeArray.jl") diff --git a/src/refarray.jl b/src/refarray.jl new file mode 100644 index 0000000..0e192e0 --- /dev/null +++ b/src/refarray.jl @@ -0,0 +1,100 @@ +# Internal type used as the default, heap-backed parent container for FixedSizeArray. +# This is used over Memory{T}, because a MemoryRef inlines into the parent struct, and +# contains the pointer. This way, it saves a memory indirection over Memory. +# This type does not boundscheck, hence the unsafety. The FSA wrapper will handle boundschecks. +struct UnsafeRefArray{T} <: DenseVector{T} + ref::MemoryRef{T} +end + +# TODO: Would be nice to be able to omit the check for n < 0 in the Memory +# constructor, but this is in the memorynew built-in and cannot be disabled +function UnsafeRefArray{T}(::UndefInitializer, n::Int) where T + mem = Memory{T}(undef, n) + UnsafeRefArray{T}(memoryref(mem)) +end + +UnsafeRefArray{T}(::UndefInitializer, n::Tuple{Int}) where T = UnsafeRefArray{T}(undef, @inbounds(n[1])) + +# This version introduced Base.memoryindex as public API +@static if VERSION < v"1.13.0-DEV.1289" + internal_memindex(x::MemoryRef) = Core.memoryrefoffset(x) +else + internal_memindex(x::MemoryRef) = Base.memoryindex(x) +end + +function Base.length(x::UnsafeRefArray) + length(parent(x.ref)) - internal_memindex(x.ref) + 1 +end + +Base.size(x::UnsafeRefArray) = (length(x),) + +Base.firstindex(::UnsafeRefArray) = 1 + +function Base.getindex(x::UnsafeRefArray, i::Int) + @inbounds(memoryref(x.ref, i)[]) +end + +function Base.setindex!(x::UnsafeRefArray{T}, v, i::Int) where T + vT = convert(T, v)::T + @inbounds(memoryref(x.ref, i)[] = vT) +end + +Base.IndexStyle(::Type{<:UnsafeRefArray}) = Base.IndexLinear() + +Base.elsize(::Type{UnsafeRefArray{T}}) where {T} = Base.elsize(Memory{T}) + +function Base.similar(A::UnsafeRefArray, ::Type{S}, dims::Dims) where S + UnsafeRefArray{S}(undef, dims) +end + +function Base.isassigned(x::UnsafeRefArray, i::Int) + @boundscheck checkbounds(Bool, x, i) || false + ref = @inbounds memoryref(x.ref, i) + isassigned(ref) +end + +function Base.copy(x::UnsafeRefArray) + len = length(x) + newmem = Memory{eltype(x)}(undef, len) + newref = memoryref(newmem) + @inbounds unsafe_copyto!(newref, x.ref, len) + typeof(x)(newref) +end + +function Base.unsafe_convert(::Type{Ptr{T}}, x::UnsafeRefArray) where T + Base.unsafe_convert(Ptr{T}, x.ref) +end + +@static if VERSION < v"1.12.0-DEV.966" + Base.dataids(x::UnsafeRefArray) = Base.dataids(x.ref.mem) +else + Base.dataids(x::UnsafeRefArray) = Base.dataids(parent(x.ref)) +end + +# Collects.jl interface for UnsafeRefArray + +function (c::Collect)(::Type{UnsafeRefArray{T}}, collection) where {T} + if collection isa UnsafeRefArray{T} + return copy(collection) + end + if (T isa Type) && Base.IteratorSize(collection) isa Union{Base.HasLength, Base.HasShape} + result = UnsafeRefArray{T}(undef, Int(length(collection))::Int) + copyto!(result, collection) + return result + end + vec = c(Vector{T}, collection) + result = UnsafeRefArray{T}(undef, length(vec)) + copyto!(result, vec) + result +end + +function (c::Collect)(::Type{UnsafeRefArray}, collection) + if collection isa UnsafeRefArray + return copy(collection) + end + vec = c(Vector, collection) + T = eltype(vec) + result = UnsafeRefArray{T}(undef, length(vec)) + copyto!(result, vec) + result +end diff --git a/test/runtests.jl b/test/runtests.jl index 198cc62..2bcf4a6 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,7 +1,7 @@ using Test using LinearAlgebra, Adapt using FixedSizeArrays -using FixedSizeArrays: checked_dims +using FixedSizeArrays: checked_dims, UnsafeRefArray using Collects: collect_as using OffsetArrays: OffsetArray using Random: Random @@ -137,7 +137,7 @@ end @testset "default underlying storage type" begin default = FixedSizeArrays.default_underlying_storage_type - @test default === (@isdefined(Memory) ? Memory : Vector) + @test default === (@isdefined(Memory) ? UnsafeRefArray : Vector) return_type = FixedSizeVector{Int,default{Int}} @test return_type === FixedSizeVectorDefault{Int} test_inferred(FixedSizeArray{Int}, return_type, (undef, 3)) @@ -223,7 +223,7 @@ end end @testset verbose=true "test sets for multiple supported storage types" begin - for storage_type ∈ (((@isdefined Memory) ? (Memory,) : ())..., Vector) + for storage_type ∈ (((@isdefined Memory) ? (UnsafeRefArray,) : ())..., Vector) FSV = fsv(storage_type) FSM = fsm(storage_type) FSA = fsa(storage_type) @@ -549,7 +549,7 @@ end (FSV, FSV), ( if @isdefined Memory - ((FSV, Memory), (Memory, FSV)) + ((FSV, UnsafeRefArray), (UnsafeRefArray, FSV)) else () end From d2d103e96dc43507cbbed0ba1610397afd49e97f Mon Sep 17 00:00:00 2001 From: Jakob Nybo Andersen Date: Thu, 2 Apr 2026 20:25:19 +0200 Subject: [PATCH 2/3] Fixup CI --- docs/src/usage.md | 14 +++++++------- ext/FixedSizeArraysRandomExt.jl | 16 ++++++++++++++++ src/FixedSizeArray.jl | 6 +++--- src/refarray.jl | 18 ++++++++++++------ test/runtests.jl | 30 +++++++++++++++++++++++++++++- 5 files changed, 67 insertions(+), 17 deletions(-) diff --git a/docs/src/usage.md b/docs/src/usage.md index 4081e6f..4b06116 100644 --- a/docs/src/usage.md +++ b/docs/src/usage.md @@ -24,12 +24,12 @@ julia> arr = [10 20; 30 14] 30 14 julia> FixedSizeArray(arr) # construct from an `AbstractArray` value -2×2 FixedSizeArray{Int64, 2, Memory{Int64}}: +2×2 FixedSizeArray{Int64, 2, FixedSizeArrays.UnsafeRefArray{Int64}}: 10 20 30 14 julia> FixedSizeArray{Float64}(arr) # construct from an `AbstractArray` value while converting element type -2×2 FixedSizeArray{Float64, 2, Memory{Float64}}: +2×2 FixedSizeArray{Float64, 2, FixedSizeArrays.UnsafeRefArray{Float64}}: 10.0 20.0 30.0 14.0 ``` @@ -45,7 +45,7 @@ julia> arr = transpose([10 20; 30 14]) 20 14 julia> adapt(FixedSizeArray, arr) -2×2 transpose(::FixedSizeArray{Int64, 2, Memory{Int64}}) with eltype Int64: +2×2 transpose(::FixedSizeArray{Int64, 2, FixedSizeArrays.UnsafeRefArray{Int64}}) with eltype Int64: 10 30 20 14 ``` @@ -68,7 +68,7 @@ The `FixedSizeArray{T,N,Mem}` type has three parameters: !!! note "Implementation details" - In Julia v1.11+, the default memory backend is the [`Memory{T}`](https://docs.julialang.org/en/v1/base/arrays/#Core.Memory) type. + In Julia v1.11+, the default memory backend is `UnsafeRefArray{T}`, an internal type that wraps a `MemoryRef{T}`. This allows the pointer to be inlined into the `FixedSizeArray` struct, saving a memory indirection compared to `Memory{T}`. Since Julia v1.10 does not have the `Memory` type, to make this package usable also on Julia v1.10 `Vector{T}` is used as memory backend, but many of the memory/performance optimizations enabled by this package will not be available in that version of Julia and in general `FixedSizeArrays.jl` does not provide significant improvements compared to `Base`'s `Array` for that specific version. To make it easier to refer to the concrete type `FixedSizeArray{T,N,Mem}` with the default memory backend, the following convenient aliases are available: @@ -88,17 +88,17 @@ julia> iter = (i for i ∈ 7:9 if i≠8); julia> using FixedSizeArrays, Collects julia> collect_as(FixedSizeArray, iter) # construct from an arbitrary iterator -2-element FixedSizeArray{Int64, 1, Memory{Int64}}: +2-element FixedSizeArray{Int64, 1, FixedSizeArrays.UnsafeRefArray{Int64}}: 7 9 julia> collect_as(FixedSizeArray{Float64}, iter) # construct from an arbitrary iterator while converting element type -2-element FixedSizeArray{Float64, 1, Memory{Float64}}: +2-element FixedSizeArray{Float64, 1, FixedSizeArrays.UnsafeRefArray{Float64}}: 7.0 9.0 julia> collect_as(FixedSizeVectorDefault, (3.14, -4.2, 2.68)) # construct from a tuple -3-element FixedSizeArray{Float64, 1, Memory{Float64}}: +3-element FixedSizeArray{Float64, 1, FixedSizeArrays.UnsafeRefArray{Float64}}: 3.14 -4.2 2.68 diff --git a/ext/FixedSizeArraysRandomExt.jl b/ext/FixedSizeArraysRandomExt.jl index de1d07f..051c8a9 100644 --- a/ext/FixedSizeArraysRandomExt.jl +++ b/ext/FixedSizeArraysRandomExt.jl @@ -21,4 +21,20 @@ if VERSION < v"1.11" end end +# UnsafeRefArray-backed: delegate rand! to the underlying Memory so that the +# specialized SIMD path in Random.XoshiroSimd is hit. +if @isdefined Memory + using FixedSizeArrays: UnsafeRefArray + + function Random.rand!(rng::Random.AbstractRNG, A::UnsafeRefArray{T}, sp::Random.Sampler) where {T} + Random.rand!(rng, parent(A), sp) + return A + end + + function Random.rand!(r::Random.MersenneTwister, A::UnsafeRefArray{Float64}, I::Random.SamplerTrivial{<:Random.FloatInterval{Float64}}) + Random.rand!(r, parent(A), I) + return A + end +end + end # module FixedSizeArraysRandomExt diff --git a/src/FixedSizeArray.jl b/src/FixedSizeArray.jl index 6f37ca7..9185a41 100644 --- a/src/FixedSizeArray.jl +++ b/src/FixedSizeArray.jl @@ -424,7 +424,7 @@ end FixedSizeArrayDefault{T,N}(undef, size1::Int, size2::Int, ...) FixedSizeArrayDefault{T,N}(array::AbstractArray) -Construct a [`FixedSizeArray`](@ref) with element type `T`, number of dimensions `N`, and the default memory backend (`Vector{T}` on Julia v1.10, `Memory{T}` on Julia v1.11+). +Construct a [`FixedSizeArray`](@ref) with element type `T`, number of dimensions `N`, and the default memory backend (`Vector{T}` on Julia v1.10, `UnsafeRefArray{T}` on Julia v1.11+). """ const FixedSizeArrayDefault = FixedSizeArray{T, N, default_underlying_storage_type{T}} where {T, N} """ @@ -432,7 +432,7 @@ const FixedSizeArrayDefault = FixedSizeArray{T, N, default_underlying_storage_ty FixedSizeVectorDefault{T}(undef, size1::Int) FixedSizeVectorDefault{T}(array::AbstractVector) -Construct a [`FixedSizeVector`](@ref) with element type `T`, and the default memory backend (`Vector{T}` on Julia v1.10, `Memory{T}` on Julia v1.11+). +Construct a [`FixedSizeVector`](@ref) with element type `T`, and the default memory backend (`Vector{T}` on Julia v1.10, `UnsafeRefArray{T}` on Julia v1.11+). """ const FixedSizeVectorDefault = FixedSizeArrayDefault{T, 1} where {T} """ @@ -440,6 +440,6 @@ const FixedSizeVectorDefault = FixedSizeArrayDefault{T, 1} where {T} FixedSizeMatrixDefault{T}(undef, size1::Int, size2::Int) FixedSizeMatrixDefault{T}(array::AbstractMatrix) -Construct a [`FixedSizeMatrix`](@ref) with element type `T`, and the default memory backend (`Vector{T}` on Julia v1.10, `Memory{T}` on Julia v1.11+). +Construct a [`FixedSizeMatrix`](@ref) with element type `T`, and the default memory backend (`Vector{T}` on Julia v1.10, `UnsafeRefArray{T}` on Julia v1.11+). """ const FixedSizeMatrixDefault = FixedSizeArrayDefault{T, 2} where {T} diff --git a/src/refarray.jl b/src/refarray.jl index 0e192e0..e6bc8b4 100644 --- a/src/refarray.jl +++ b/src/refarray.jl @@ -15,6 +15,12 @@ end UnsafeRefArray{T}(::UndefInitializer, n::Tuple{Int}) where T = UnsafeRefArray{T}(undef, @inbounds(n[1])) +@static if VERSION < v"1.12.0-DEV.966" + Base.parent(x::UnsafeRefArray) = x.ref.mem +else + Base.parent(x::UnsafeRefArray) = parent(x.ref) +end + # This version introduced Base.memoryindex as public API @static if VERSION < v"1.13.0-DEV.1289" internal_memindex(x::MemoryRef) = Core.memoryrefoffset(x) @@ -23,7 +29,11 @@ else end function Base.length(x::UnsafeRefArray) - length(parent(x.ref)) - internal_memindex(x.ref) + 1 + length(parent(x)) - internal_memindex(x.ref) + 1 +end + +function Base.checkbounds(A::UnsafeRefArray, is...) + checkbounds_lightboundserror(A, is...) end Base.size(x::UnsafeRefArray) = (length(x),) @@ -65,11 +75,7 @@ function Base.unsafe_convert(::Type{Ptr{T}}, x::UnsafeRefArray) where T Base.unsafe_convert(Ptr{T}, x.ref) end -@static if VERSION < v"1.12.0-DEV.966" - Base.dataids(x::UnsafeRefArray) = Base.dataids(x.ref.mem) -else - Base.dataids(x::UnsafeRefArray) = Base.dataids(parent(x.ref)) -end +Base.dataids(x::UnsafeRefArray) = Base.dataids(parent(x)) # Collects.jl interface for UnsafeRefArray diff --git a/test/runtests.jl b/test/runtests.jl index 2bcf4a6..c10712c 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,7 +1,10 @@ using Test using LinearAlgebra, Adapt using FixedSizeArrays -using FixedSizeArrays: checked_dims, UnsafeRefArray +using FixedSizeArrays: checked_dims +if @isdefined Memory + using FixedSizeArrays: UnsafeRefArray +end using Collects: collect_as using OffsetArrays: OffsetArray using Random: Random @@ -155,6 +158,31 @@ end test_inferred(FixedSizeVector{Int}, return_type, arr) end + (@isdefined Memory) && @testset "UnsafeRefArray" begin + @testset "tuple undef constructor" begin + a = UnsafeRefArray{Int}(undef, (5,)) + @test length(a) == 5 + end + @testset "similar" begin + a = UnsafeRefArray{Int}(undef, 3) + b = similar(a, Float64, (4,)) + @test b isa UnsafeRefArray{Float64} + @test length(b) == 4 + end + @testset "collect_as identity" begin + a = UnsafeRefArray{Int}(undef, 3) + a[1] = 1; a[2] = 2; a[3] = 3 + b = collect_as(UnsafeRefArray{Int}, a) + @test b isa UnsafeRefArray{Int} + @test b !== a + @test b[1] == 1 && b[2] == 2 && b[3] == 3 + c = collect_as(UnsafeRefArray, a) + @test c isa UnsafeRefArray{Int} + @test c !== a + @test c[1] == 1 && c[2] == 2 && c[3] == 3 + end + end + @testset "`undef` construction without element type" begin @test_throws MethodError FixedSizeVector(undef, 1) @test_throws MethodError FixedSizeMatrix(undef, 1, 1) From 2a62db81da63e422fe816d0c5055c3f907255577 Mon Sep 17 00:00:00 2001 From: Jakob Nybo Andersen Date: Thu, 2 Apr 2026 20:55:24 +0200 Subject: [PATCH 3/3] Reviewer comments --- src/refarray.jl | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/refarray.jl b/src/refarray.jl index e6bc8b4..f8814f2 100644 --- a/src/refarray.jl +++ b/src/refarray.jl @@ -28,15 +28,11 @@ else internal_memindex(x::MemoryRef) = Base.memoryindex(x) end -function Base.length(x::UnsafeRefArray) - length(parent(x)) - internal_memindex(x.ref) + 1 -end - function Base.checkbounds(A::UnsafeRefArray, is...) checkbounds_lightboundserror(A, is...) end -Base.size(x::UnsafeRefArray) = (length(x),) +Base.size(x::UnsafeRefArray) = ((length(parent(x)) - internal_memindex(x.ref) + 1),) Base.firstindex(::UnsafeRefArray) = 1