Skip to content

Support lazy arrays such as MappedArrays as read-only sources - #137

Merged
maleadt merged 1 commit into
mainfrom
tb/lazy-sources
Oct 27, 2026
Merged

maleadt merged 1 commit into
mainfrom
tb/lazy-sources

Conversation

@maleadt

@maleadt maleadt commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

AcceleratedKernels has always been meant to work on unmaterialised inputs: you can call AK.any over eachindex(x) instead of x, or sum a range on the GPU without allocating it. That works for ranges and CartesianIndices, but not for lazy arrays built on top of them, such as those from MappedArrays.jl. This PR makes those work, and supersedes #23, which was written against the old API with a positional backend.

What fails on main

Without a backend, every operation throws as soon as it sees a mapped array over a range. AK works out the backend by asking each array, and KernelAbstractions' get_backend follows parent down to the range, which it doesn't know about. A mapped array of several arrays has a tuple as its parent, which fails differently:

julia> AK.sum(mappedarray(abs2, 1:10))
ERROR: ArgumentError: Implement `KernelAbstractions.get_backend(::UnitRange{Int64})`

julia> AK.count(iseven, mappedarray(+, 1:10, 11:20))
ERROR: MethodError: no method matching get_backend(::Tuple{UnitRange{Int64}, UnitRange{Int64}})

If you pass backend=CUDABackend(), reductions, any/all/count, findall and searchsorted* already work, because such a mapped array is isbits and gets passed to the kernel as it is. The operations that allocate a result still fail, though. map, accumulate, reverse, sort and sortperm allocated it with similar(v), and a wrapper without its own similar method gets Base's fallback, which is a host Array. The kernel then receives a host array:

julia> AK.cumsum(mappedarray(abs2, 1:5); backend=CUDABackend())
ERROR: GPU compilation of MethodInstance for AcceleratedKernels.gpu__forindices_global!(...) failed
KernelError: passing non-bitstype argument
  .w is of type Vector{Int64} which is not isbits.

There were also copies that went through copy, copyto! or Array: sort copies its input, MergeSort's sortperm copies the keys, and small reductions finish on the host. For a wrapper of device memory, the generic fallbacks of those functions index the GPU array one element at a time, which is disallowed.

What this PR changes

  • Backend resolution. An array that wraps others now counts as the arrays it wraps. The wrapped arrays can be a single parent, or a tuple of them for a mapped array of several arrays. A lazy array over ranges or CartesianIndices therefore doesn't determine the backend, just as a reshaped range already didn't. A wrapper whose parents end in a single array with memory is still asked with get_backend (so its own method, if any, is used), and arrays that resolved before resolve the same way.
  • Allocating results. If the source wraps no memory, or only gets Base's similar fallback, results are allocated on the resolved backend. Everything else is still allocated with similar.
  • Copies. sort copies a wrapper whose results are allocated on the backend (see above) with a small AK kernel, because Base's copy would give a host Array. Every other wrapper keeps its own copy, so an OffsetArray's copy keeps its axes. Copies into existing buffers (MergeSort's sortperm keys) and to the host (small reductions) also use the kernel for wrappers that neither GPUArrays nor Base copies. GPUArrays handles its own wrappers of device arrays, and Base handles wrappers of host memory. A lazy array over ranges is also not evaluated on the host when the backend is a GPU, because its function may read device arrays that it captures. On the host backend, and for every other input, copies work as they did before.

The same calls on this branch (CUDA, RTX 5080):

julia> AK.sum(mappedarray(abs2, 1:10))
385

julia> AK.count(iseven, mappedarray(+, 1:10, 11:20))
10

julia> AK.cumsum(mappedarray(abs2, 1:5); backend=CUDABackend())
5-element CuArray{Int64, 1, CUDACore.DeviceMemory}:
  1   5  14  30  55

julia> AK.sort(mappedarray(x -> x % 7, 1:10); backend=CUDABackend())
10-element CuArray{Int64, 1, CUDACore.DeviceMemory}:
 0  1  1  2  2  3  3  4  5  6

julia> AK.reverse(mappedarray(I -> I[1] * I[2], CartesianIndices((2, 3))); backend=CUDABackend())
2×3 CuArray{Int64, 2, CUDACore.DeviceMemory}:
 6  4  2
 3  2  1

(Outputs are condensed onto one line.) Without a backend, the first two run on the host. The docs section on the backend keyword now describes wrappers and lazy arrays.

What still doesn't work: mappedarray(f, ::CuArray)

A lazy array that wraps device arrays has to be converted for the device before a kernel can use it. That conversion is done with Adapt.jl, and MappedArrays.jl doesn't define Adapt rules (it has no dependencies at all), so the CuArray inside isn't converted:

julia> AK.sum(mappedarray(abs2, CuArray(1:10)))
ERROR: KernelError: passing non-bitstype argument
Argument 3 to your kernel function is of type SubArray{Int64, 1, ReadonlyMappedArray{Int64, 1, CuArray{...}, typeof(abs2)}, ...}
  .parent is of type ReadonlyMappedArray{...} which is not isbits.

AK can't fix this generically, because Adapt has no structural fallback. Defining the rules from an AK extension would be type piracy, and those methods would clash with MappedArrays' own once it adds them. The fix belongs in MappedArrays.jl: an Adapt extension with an adapt_structure method for each mapped array type, adapting both the function and the wrapped arrays and keeping the element type. With that in place, the rest of this PR already handles such arrays. The tests demonstrate it with a small lazy wrapper type that does have an Adapt rule. With it, every operation works on a lazy array of the backend's arrays, with or without backend. Mapped arrays of host arrays already work on the host.

Tests

There is a new test/generic/lazy.jl, which adds MappedArrays and Adapt as test dependencies. It runs every read-only operation (reductions whole and along dims, mapreducedim!, any/all/count, findall with lazy sources and lazy items, accumulate/cumsum including dims, map/map!, reverse, sort, sortperm including MergeSort's key copy, and searchsortedfirst!/searchsortedlast! for both arguments) over:

  • mapped arrays of ranges, of CartesianIndices and of several ranges, with and without backend;
  • lazy arrays of the backend's arrays (including a view, nested wrappers, and one with its own similar), plus small reductions that finish on the host;
  • wrappers with their own copy: one without an Adapt rule, and one with offset axes whose sorted copy keeps them;
  • backend resolution cases, including a wrapper that defines its own get_backend, and mixed backends.

It also includes #23's range and mapped-array sums, and its CartesianIndices reduction, which reported an InvalidIRError and already works on main.

Run against main's src, lazy.jl has 18 errors out of 223 tests on the host. On this branch, the full suite passes on CPU, CUDA (RTX 5080) and OpenCL (PoCL) together (103,166 tests), and lazy.jl with sort and reduce also passes on Julia 1.10.

The change is additive: an input that resolved, allocated and copied correctly before still does. It doesn't touch anything GPUArrays relies on, so it could go into 0.5 or a later 0.5.x.

Supersedes #23. Thanks to @dstahlke for the original PR and the tests it is built on.

Backend resolution asked every array that is not a range, CartesianIndices or LinearIndices
(or a Base view, reshape or permutation of one) for its backend with KernelAbstractions'
`get_backend`, which recurses through `parent` and throws when it reaches a range or a tuple
of parents. So `AK.sum(mappedarray(abs2, 1:n))`, and every other operation on a
MappedArrays.jl array over ranges or Cartesian indices, threw "Implement get_backend"
without an explicit `backend`, and `mappedarray(+, A, B)` threw a MethodError.

With `backend` given, reductions, predicates, findall and searchsorted already worked on such
arrays, since an `isbits` mapped array is passed to kernels as it is. But the operations that
allocate a result (map, accumulate, reverse, sort, sortperm) used `similar(v)`, which for a
wrapper without its own `similar` is Base's host `Array`, so their kernels got a host array.
Copies of such wrappers (`sort`, the keys of the merge sort permutation, the host finish of
small reductions) went through `copy`, `copyto!` or `Array`, whose fallbacks scalar-index
device arrays.

Now a wrapper whose parents are not a single array with memory votes like the arrays it
wraps, so a wrapper of only ranges does not vote, as Base's wrappers of ranges already did
not; other arrays are still asked with `get_backend`. A wrapper that gets Base's `similar`
fallback, or wraps no memory, has its results allocated on `backend`, and is copied with a
kernel there; other wrappers keep their own `copy`, so an OffsetArray's copy keeps its axes.
Copies into existing arrays and to the host also use a kernel for wrappers that GPUArrays (its
wrappers of device arrays) and Base (wrappers of host memory) do not copy, since Base's
fallbacks index device memory element by element. Lazy arrays over ranges are not evaluated
on the host either, since their function may read device arrays it captures.

A lazy array of device arrays also needs an Adapt.jl rule for its type to reach kernels.
MappedArrays.jl has none, so `mappedarray(f, ::CuArray)` still only works on the host; that
rule belongs in MappedArrays.jl. The tests use a lazy wrapper with such a rule for the device
path.

Supersedes #23, which allocated the 1D reduction's scratch on the given backend instead of
with `similar(src)`, and whose range and mapped-array sums are among the tests.

Co-authored-by: Dan Stahlke <dan@stahlke.org>
@maleadt
maleadt marked this pull request as ready for review October 26, 2026 19:45
@maleadt
maleadt merged commit 6215609 into main Oct 27, 2026
35 checks passed
@maleadt
maleadt deleted the tb/lazy-sources branch October 27, 2026 06:30
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.

1 participant