Skip to content

Allow a GenericMemoryRef memory backend - #212

Open
vchuravy wants to merge 1 commit into
mainfrom
vc/memoryref-backend
Open

vchuravy wants to merge 1 commit into
mainfrom
vc/memoryref-backend

Conversation

@vchuravy

Copy link
Copy Markdown
Contributor

Requested by Valentin

Before: wrapping a Vector without copying (wrap(FixedSizeArray, v, dims)) gives FixedSizeArray{T,N,Vector{T}}, whose every access loads the data pointer and the Memory from the mutable Vector. In loops with conditional accesses LLVM cannot hoist these loads; under Enzyme.jl this made the augmented forward pass cache the (shadow) Memory on every inner iteration (e.g. 9.3 MB vs 1.9 MB of GC-allocated tape for a Trixi.jl flux-differencing VJP in 3D). Wrapping v.ref.mem instead is not an option, since the memory can be longer than the vector (after resize!) and the vector can start at an offset.

After: on Julia v1.11 and later, the memory backend can be a GenericMemoryRef (e.g. FixedSizeArray{T,N,MemoryRef{T}}). The array stores the reference directly and its own size gives the length, so the data pointer is a field of the immutable FixedSizeArray.

v = rand(10)
a = FixedSizeArrays.wrap(FixedSizeArray{Float64,2,MemoryRef{Float64}}, v, (2, 5))  # no copy
b = FixedSizeArrays.wrap(FixedSizeArray, Base.memoryref(v.ref.mem, 3), (2, 2))     # at an offset

How:

  • The type bound of Mem is Union{DenseVector{T}, GenericMemoryRef{<:Any,T}} on v1.11+ (unchanged on v1.10). Storage access goes through small internal hooks; the reference implementations are in src/memoryref.jl and use memoryrefnew(ref, i, false) + memoryrefget/memoryrefset!/memoryref_isassigned with the default access order (so AtomicMemory works). Bounds are checked against the FixedSizeArray's size.
  • wrap(FixedSizeArray, ref, size) requires at least prod(size) elements from ref on; wrap(FixedSizeArray{T,N,MemoryRef{T}} where {T,N}, v::Vector, size) wraps v.ref (exact length).
  • parent returns the reference and parentindices are 1:length relative to it; dataids are those of the memory, so the array and the wrapped Vector report mightalias.
  • similar, broadcasting, copy and collect_as allocate a new Memory and keep the reference backend; copyto! between references uses unsafe_copyto!; the Random and MPI extensions use a view of the memory.
  • Caveat: generic code that recurses into parent(A) expecting an AbstractArray needs a method for this backend (e.g. KernelAbstractions.get_backend). As for a wrapped Vector, the array keeps the old memory alive after resize! of the vector, so it stays memory/GC safe but may no longer alias it; wrap again after resizing (documented in wrap).
  • New testset "GenericMemoryRef backend" (858 tests: offsets after popfirst!, references shorter than their memory, empty arrays, String elements, aliasing with the Vector, the resize contract, GC with write barriers, overlapping copyto!, broadcasting into an aliased destination, MPI pointers, AtomicMemory, inference and no allocations), plus MemoryRef in the per-backend test loops. The full test suite passes locally on Julia 1.11.9 and 1.12.7.

In Trixi.jl (local prototype), backing the solution arrays with this backend reduces the GC-allocated Enzyme tape of an rhs! VJP from 0.67 MB to 11 KB (2D) and from 1.86 MB to 26 KB (3D) compared to Array, with identical gradients and bit-identical AMR results. Differentiating it with Enzyme.jl needs EnzymeAD/Enzyme.jl#3835 (make_zero of a MemoryRef).

🤖 Generated with Claude Code

@vchuravy vchuravy self-assigned this Oct 14, 2026
@codecov

codecov Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.49624% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.57%. Comparing base (fc90003) to head (19b939f).

Files with missing lines Patch % Lines
ext/FixedSizeArraysRandomExt.jl 66.66% 1 Missing ⚠️
src/memoryref.jl 98.76% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #212      +/-   ##
==========================================
+ Coverage   94.42%   95.57%   +1.14%     
==========================================
  Files           8        9       +1     
  Lines         323      429     +106     
==========================================
+ Hits          305      410     +105     
- Misses         18       19       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread test/runtests.jl
Comment thread src/memoryref.jl Outdated
@giordano

Copy link
Copy Markdown
Collaborator

I still have to review this in details (unlikely to have much time before this evening), but in general very happy about moving in this direction!

@giordano

Copy link
Copy Markdown
Collaborator

Does this change the default backend?

@vchuravy

vchuravy commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor Author

Currently it does not. Making it the default would be a breaking change.

@vchuravy vchuravy removed their assignment Oct 20, 2026
@vchuravy
vchuravy changed the base branch from main to vc/fixed-size-array-dims October 20, 2026 09:18
@vchuravy
vchuravy added this pull request to stack #216 October 20, 2026 09:19
@vchuravy
vchuravy force-pushed the vc/memoryref-backend branch from 861d7e0 to 2d3e931 Compare October 20, 2026 10:36
Base automatically changed from vc/fixed-size-array-dims to main October 20, 2026 20:56
@giordano
giordano force-pushed the vc/memoryref-backend branch from 2d3e931 to 3df638c Compare October 20, 2026 20:56
giordano pushed a commit that referenced this pull request Oct 21, 2026
`static_size_linear_index` now computes `i + n1 * ((j - 1) + n2 * (k - 1))` instead of
the sum of strided terms `1 + (i - 1) + n1 * (j - 1) + (n1 * n2) * (k - 1)`, which needs
fewer integer operations. In the flux differencing volume integral of Trixi.jl (2D,
4096 elements, 1 thread), this is 3.5% faster with a `Vector` backend (2553 to 2464 us)
and 2.5% faster with a `MemoryRef` backend (#212).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5z6TVTAFfLGSFafUoLzuo
@vchuravy
vchuravy force-pushed the vc/memoryref-backend branch from 3df638c to 5e9682f Compare October 21, 2026 09:36
giordano pushed a commit that referenced this pull request Oct 21, 2026
* Compute the linear index of Cartesian indexing in Horner form

`static_size_linear_index` now computes `i + n1 * ((j - 1) + n2 * (k - 1))` instead of
the sum of strided terms `1 + (i - 1) + n1 * (j - 1) + (n1 * n2) * (k - 1)`, which needs
fewer integer operations. In the flux differencing volume integral of Trixi.jl (2D,
4096 elements, 1 thread), this is 3.5% faster with a `Vector` backend (2553 to 2464 us)
and 2.5% faster with a `MemoryRef` backend (#212).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D5z6TVTAFfLGSFafUoLzuo

* Add tests and a CHANGELOG entry for the Horner-form linear index

- Test Cartesian `getindex`/`setindex!` against `LinearIndices` for 1-5
  dimensions with odd sizes, size-1 dimensions, `Memory`/`Vector`
  backends, a static first dimension, and out-of-bounds indices
  (ported from #206).
- FileCheck test: all elements `u[v, i, j, k]` along the first dimension
  share one offset, with no multiplication after the first load.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@giordano
giordano force-pushed the vc/memoryref-backend branch from 5e9682f to 0a2d3db Compare October 23, 2026 01:26
Comment thread CHANGELOG.md
* Static dimensions: `FixedSizeArray{T,N,Mem}` is now an alias for `FixedSizeArrayDims{T,N,NTuple{N,Int},Mem}`, where the new public (but not exported) type `FixedSizeArrays.FixedSizeArrayDims{T,N,D,Mem}` is an `N`-dimensional array over a memory backend `Mem<:DenseVector{T}` whose dimensions are a tuple of type `D` of `Int`s, `Val`s and `StaticInt`s from [Static.jl](https://github.com/SciML/Static.jl) (e.g. `(Val(5), Val(4), n)` or `(static(5), static(4), n)`), so that any dimension can be a compile-time constant while the others are set at runtime. Linear indices, strides and sizes are computed at compile time as far as the dimensions are static. An array with static dimensions can be constructed with `FixedSizeArrays.wrap(FixedSizeArrayDims, parent, dims)`, with `collect_as`, or with the constructors of `FixedSizeArrayDims` (`undef` or converting from another array); the dimensions given to a `FixedSizeArray` constructor are converted to `Int`s. It supports the strided arrays interface, `similar`, `copy`, `fill!`, `copyto!`, broadcasting (returning `FixedSizeArray`s), and the package extensions with MPI.jl, Adapt.jl (adapting the memory backend for any memory backend), and the new package extension with [StaticArrayInterface.jl](https://github.com/JuliaArrays/StaticArrayInterface.jl), which provides static sizes and strides to e.g. StrideArraysCore.jl, LoopVectorization.jl and Polyester.jl. `Val` dimensions need no other package, while `StaticInt` dimensions are available with the new package extension with Static.jl, which is a weak dependency.
* `FixedSizeArrays.wrap` now accepts at most one dimension given as `:`, which is computed from the length of the parent.
* With StaticArrayInterface.jl, `StaticArrayInterface.device` of a `FixedSizeArray` is now the one of its memory backend, e.g. a GPU memory backend is not treated as a CPU pointer by LoopVectorization.jl.
* On Julia v1.11 and later, the memory backend of a `FixedSizeArray` can be a `GenericMemoryRef` (e.g. `FixedSizeArray{T,N,MemoryRef{T}}`, also with static dimensions, e.g. `FixedSizeArrayDims{T,N,D,MemoryRef{T}}`), a reference to an element in a `Memory`, which can be at an offset and does not need to have exactly the length of the array. `FixedSizeArrays.wrap(FixedSizeArray, ref, size)` wraps a reference, `FixedSizeArrays.wrap(FixedSizeArray{T,N,MemoryRef{T}} where {T,N}, v::Vector, size)` the reference of a `Vector`. Unlike a `Vector` backend, the data pointer is a field of the immutable `FixedSizeArray` and need not be reloaded from a mutable object at every access.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder: move this entry to a different version section, when we're ready to move on with this PR

@giordano giordano left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caveat: generic code that recurses into parent(A) expecting an AbstractArray needs a method for this backend (e.g. KernelAbstractions.get_backend). As for a wrapped Vector, the array keeps the old memory alive after resize! of the vector, so it stays memory/GC safe but may no longer alias it; wrap again after resizing (documented in wrap).

This is probably the more problematic point of this PR, there's quite a bit of workarounds to make this kinda work.

We should also have FileCheck tests, including ensuring that memory loads are hoisted in loops with conditional access

Comment thread src/memoryref.jl
Comment on lines +123 to +132
Base.@propagate_inbounds function copyto_memoryref!(dst, doff::Int, src, soff::Int, n::Int)
n < 0 && throw_copyto_negative(n)
iszero(n) && return dst
@boundscheck checkbounds(dst, doff:(doff + n - 1))
@boundscheck checkbounds(src, soff:(soff + n - 1))
dr = maybe_memoryref(dst)
sr = maybe_memoryref(src)
unsafe_copyto!(element_ref(dr, doff), element_ref(sr, soff), n)
dst
end

@giordano giordano Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can write past the end of dst inside @inbounds:

julia> using FixedSizeArrays

julia> mem = Memory{Float64}(zeros(6));

julia> a = FixedSizeArrays.wrap(FixedSizeArray, memoryref(mem), (2,));  # the first 2 elements of `mem`

julia> b = FixedSizeArray{Float64,1,MemoryRef{Float64}}(ones(4));

julia> f!(a, b) = @inbounds copyto!(a, b)
f! (generic function with 1 method)

julia> f!(a, b)
2-element FixedSizeVector{Float64, MemoryRef{Float64}}:
 1.0
 1.0

julia> mem
6-element Memory{Float64}:
 1.0
 1.0
 1.0
 1.0
 0.0
 0.0

Comment thread src/memoryref.jl
Comment on lines +170 to +172
function wrap_backend(::Type{<:(FixedSizeArrayDims{T, N, D, <:MemRef{T}} where {T, N, D})}, parent::Vector)
getfield(parent, :ref)
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should there be a method to wrap a Memory (with memoryrefnew(mem))?

Comment thread src/memoryref.jl
storage_array_type(::Type{R}) where {R <: GenericMemoryRef} = memory_type(R)


storage_dataids(ref::GenericMemoryRef) = Base.dataids(getfield(ref, :mem))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be aware of the offsets, so that non-overlapping slices of the same memory don't alias?

Comment on lines +26 to +30
function adapt_backend(to, ref::MemoryRef, a::FixedSizeArrayDims)
v = Base.wrap(Array, ref, length(a))
adapted = adapt(to, v)
adapted === v ? ref : adapted
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means that the backend type may not be preserved?

# support for the storage type is used: since its first element is the first element of
# the array, the pointer to it is the pointer to the array, and the result of `cconvert`,
# which `ccall` keeps rooted, keeps the memory alive.
Base.cconvert(::Type{MPIPtr}, a::FixedSizeArrayDims) = Base.cconvert(MPIPtr, underlying_storage(a))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need this method at all with MPI.jl@0.20.28?

Comment thread src/memoryref.jl
Comment on lines +134 to +139
# use the fast path only if one side has a `GenericMemoryRef` backend and both sides are
# backed by a memory reference with the same element type and memory kind
function copyto_fast_path(dst, src)
dr = maybe_memoryref(dst)
sr = maybe_memoryref(src)
(dr isa MemoryRef) && (sr isa MemoryRef) && (eltype(dr) === eltype(sr))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The check contradicts the comment that this should apply to GenericMemoryRef?

Rebased onto the static dimensions of #213 (`FixedSizeArrayDims`), so that arrays with
static dimensions can have a `GenericMemoryRef` backend too, e.g.
`FixedSizeArrayDims{T,N,D,MemoryRef{T}}`:

- `wrap` of a reference accepts static dimensions and a `:` dimension, which is computed
  from the elements of the memory from the reference on.
- `wrap` of a `Vector` with a type asking for a reference backend (e.g.
  `FixedSizeArrayDims{T,N,D,MemoryRef{T}} where {T,N,D}`) wraps the reference of the
  `Vector`.
- `similar`, `copy`, `fill!`, `isassigned` and Cartesian indexing with static strides go
  through the storage hooks.
- StaticArrayInterface.jl: the `device` of a reference backend is the one of its memory,
  so that LoopVectorization.jl accepts these arrays.
- Adapt.jl: for static dimensions, a reference backend is adapted as a `Vector` sharing
  its elements, and kept if adapting does not change it.
- Tests: the per-backend test sets with `MemoryRef` as in the original PR (except
  the `wrap` and static dimensions test sets, which assume a parent of exactly the length
  of the array), plus a new test set for static dimensions with a reference backend.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@giordano
giordano force-pushed the vc/memoryref-backend branch from 0a2d3db to 19b939f Compare October 24, 2026 19:35

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants