Skip to content

Rework the host API: algorithms as values, primitive contracts, workspaces - #133

Merged
maleadt merged 8 commits into
mainfrom
tb/api
Sep 30, 2026
Merged

maleadt merged 8 commits into
mainfrom
tb/api

Conversation

@maleadt

@maleadt maleadt commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

This PR reworks how AcceleratedKernels' operations are called, how they choose an algorithm, and what they promise about their results. It breaks almost every call site, so it is meant for AK 0.5.

Fixes #134.

Why

AK's operations grew one at a time, and it shows in their interfaces. Some algorithms carry their settings, while others take loose keywords on the operation (block_size, items_per_thread, switch_below, max_tasks, ...), which some algorithms silently ignore. temp is scratch memory in one function and the destination in another. The backend is an optional trailing positional argument, which makes mapreduce(f, op, A, B, ...) ambiguous. Reductions require init, need a neutral element for every operator, and return AK's neutral for empty inputs.

That makes AK hard to build on. GPUArrays wants to implement sort!, mapreduce, accumulate and findall for every GPU array on top of AK (JuliaGPU/GPUArrays.jl#790), so that CUDA.jl, AMDGPU.jl, Metal.jl, oneAPI.jl and OpenCL.jl can drop their own copies of these kernels. For that, AK needs one calling convention and primitives whose behaviour is written down.

So this PR gives every algorithmic operation (sorting, reductions, scans, findall, any/all) the same shape:

AK.op(data...; backend=<derived>, alg=Auto(), <the operation's keywords>, workspace=nothing)

The launch wrappers (foreachindex, foraxes, map!, reverse! and the batched searches) take the same backend keyword, plus their launch settings (block_size, max_tasks, min_elems) as keywords.

It also draws a line between AK and GPUArrays. AK provides primitives with contracts of their own, modelled on CUB's rather than on Base's. GPUArrays builds Base's API on top of them, and decides everything that exists only because Base says so: result types, what an empty reduction returns, index types.

What it looks like

Most calls just drop the positional backend:

AK.sort!(v)                                     # AK picks the algorithm and its settings
AK.sum(m; dims=1)
AK.accumulate!(+, v; init=0)
AK.findall(x -> x > 0, v)
AK.foreachindex(1:n; backend=get_backend(x)) do i ... end

To control the algorithm, pass one. Algorithms are values that carry their settings, and fields left out are filled in for the device:

AK.sort!(v; alg=AK.RadixSort(block_size=512))   # this algorithm, with this setting
AK.sort!(v; alg=AK.Auto(stable=false))          # a requirement, not an algorithm

An explicit algorithm is used as given or rejected. It is never silently replaced, and bad settings fail on the host before any data is touched:

julia> AK.sort!(v; alg=AK.RadixSort(block_size=3))
ERROR: ArgumentError: RadixSort: `block_size` must be a positive power of two, got 3

Reductions no longer need init or a neutral element, and closures and tuple accumulators work:

julia> AK.reduce(+, CuArray([1, 2]); init=10)            # init is applied exactly once
13

julia> AK.mapreduce(x -> (x, 1), (a, b) -> (a[1] + b[1], a[2] + b[2]), CuArray(Float32[1, 2, 3]))
(6.0f0, 3)

julia> AK.reduce(+, CuArray(Int[]))
ERROR: ArgumentError: reducing over an empty collection is not allowed without `init`; pass `init`

julia> AK.sum(CuArray(Int[]))                           # sum and prod give zero and one
0

julia> AK.sum(CuArray(Int8[100, 100]))                  # partial results widen, as Base's add_sum does
200

A new mapreducedim! reduces into an existing array, which is what GPUArrays needs for sum!, maximum! and friends:

julia> R = CuArray(zeros(Float32, 1, 2));

julia> AK.mapreducedim!(abs2, +, R, CuArray(Float32[1 2; 3 4]); overwrite=true)
1×2 CuArray{Float32, 2, CUDACore.DeviceMemory}:
 10.0  20.0

By default, scans run in the destination's type (or wider), not in the source's:

julia> AK.accumulate!(*, CuArray(zeros(Int, 4)), CuArray(fill(0x10, 4)))
4-element CuArray{Int64, 1, CUDACore.DeviceMemory}:
    16
   256
  4096
 65536

findall selects items: by default the keys, but an array of values works too, so A[mask] takes a single pass:

julia> m = CuArray(Float32[0.2 0.9; 0.7 0.1]);

julia> AK.findall(x -> x > 0.5, m)
2-element CuArray{CartesianIndex{2}, 1, CUDACore.DeviceMemory}:
 CartesianIndex(2, 1)
 CartesianIndex(1, 2)

julia> AK.findall(x -> x > 0.5, m; items=m)
2-element CuArray{Float32, 1, CUDACore.DeviceMemory}:
 0.7
 0.9

The backend comes from the arrays. Ranges and other lazy collections have no backend, so they run on the host unless you say otherwise:

julia> AK.findall(isodd, 1:10)
5-element Vector{Int64}: ...

julia> AK.findall(isodd, 1:10; backend=CUDABackend())
5-element CuArray{Int64, 1, CUDACore.DeviceMemory}: ...

Scratch memory can be allocated once and reused. This replaces every temp* keyword:

julia> AK.workspace_size(AK.sum, v)                     # 10^6 Float32s on CUDA
(partials = (Float32, (3908,)),)

julia> ws = AK.workspace(AK.sort!, v);                  # same arguments as the call

julia> AK.sort!(v; workspace=ws);                       # allocates no scratch memory on the device

Design

Each family has a few algorithm structs:

Operation GPU algorithms Host algorithm
sort!, sortperm!, sort_by_key! MergeSort, RadixSort, BitonicSort CPUThreads.SampleSort
reduce, mapreduce, mapreducedim! BlockReduce CPUThreads.Partitioned
accumulate! ScanPrefixes, DecoupledLookback, SliceScan CPUThreads.Partitioned
findall ScanScatter CPUThreads.Partitioned
any, all ConcurrentWrite, ViaReduce CPUThreads.Partitioned

Auto(), the default, picks one for the device, and Auto never reads array contents. The values it fills in come from one internal tuning per family (sort_tuning(backend, T) and friends), which a backend extension can specialise per device. The default tunings reproduce the settings AK uses today, so calls without explicit settings launch the same kernels as before.

Tunings only affect speed. Correctness facts about a backend are separate capabilities: whether it runs AK's kernels at all, or has the forward progress that DecoupledLookback needs. These are declared once per backend and checked for Auto and explicit algorithms alike, so no tuning can enable something unsafe.

The host is an algorithm rather than a flag. On host arrays, Auto picks the CPUThreads algorithm (Julia threads), as before. prefer_threads is gone, and so are the max_tasks/min_elems keywords on the algorithmic operations; they are fields of the CPUThreads algorithms now. With KernelAbstractions 0.10, whose host backend runs kernels through PoCL, an explicit GPU algorithm also runs AK's kernels on host arrays (AK.sort!(v; alg=AK.MergeSort())). A new CI job tests this.

A workspace records the backend, device, resolved algorithm and buffer sizes. A call that would need anything else throws an ArgumentError rather than using the wrong buffers. That includes Auto picking another algorithm for another length, and a workspace whose buffers alias the call's arrays. On the host, the threaded algorithms still allocate small per-task bookkeeping.

The contracts

AK keeps Base's function names, since the functions take Base's arguments, but it does not promise Base-identical results. The docstrings have the details, and a new "Differences from Base" documentation page lists the main semantic differences. In short:

  • Operators. Reductions need an associative and commutative op; scans need an associative one, and keep element order, so matrix products work. A reduction's combination order depends on the algorithm and its settings, but is fixed for a given setting, shape and device.
  • init is optional. When given, it is applied exactly once, as op(init, partial), and init = nothing is an initial value like any other.
  • Empty inputs. An empty reduction without init is an ArgumentError, also along dims for every output whose slice is empty. sum and prod give zero and one, and count has init=0. An empty scan writes nothing.
  • Neutral elements only seed partial results, and never appear in a result. AK uses GPUArraysCore.neutral_element where one is known. Otherwise each partial result starts from its first element, so closures and tuple accumulators such as findmin's need no neutral element.
  • One accumulator type. Partial results have one type, and are converted to the destination's element type only when stored. That type is acctype when given; otherwise it is the type the fold of op settles on, starting from init's type (whole arrays), eltype(R) (mapreducedim!) or eltype(dst) joined with init's type (scans). An acctype narrower than that is the caller's choice.
  • findall selects items. The k-th element of A selects the k-th element of items, which defaults to keys(A). Positions are ordinal, so offset axes work.
  • Predicates must return a Bool. Where inference shows one cannot, any, all and findall fail on the host with an ArgumentError rather than in a kernel.
  • Sorting is stable by default. Auto(stable=true) only picks an unstable algorithm where equal elements are indistinguishable (integers, Bool and Char under the default ordering, but not floats, whose NaNs can differ).
  • Settings that used to be ignored are now errors, e.g. BlockReduce(items_per_thread=...) along dims, or ScanPrefixes along dims.

Back-end fixes this relies on

Testing this on every back-end turned up bugs outside AK. They are fixed upstream rather than worked around here:

oneAPI needed two decisions. oneAPI.jl calls AK's scans with 64-thread blocks, because larger blocks once gave wrong results. That no longer reproduces (2,400 random scans with 64 to 512 threads on an Iris Xe were all correct), so AK's oneAPI extension does not carry it over and uses the default scan tuning. On main, the extension also makes any/all default to the reduction, because some Intel GPUs were reported to hang when many threads write one location. An Iris Xe runs ConcurrentWrite fine (900 calls over up to 10^8 elements), but we don't know which devices were affected, so Auto keeps picking ViaReduce on oneAPI, through its predicate tuning. An explicit ConcurrentWrite still runs there, as on main.

Testing

Every commit passes the test suite on its own, with the released back-ends it requires: CUDACore 6.4.1, GPUArrays 11.5.15, KernelAbstractions 0.9.43, OpenCL.jl 0.10.12 with pocl_jll 7.2.0+3 (the local machine has an RTX 5080). The host-backend columns use KernelAbstractions main with SPIRVIntrinsics 1.1.4 and SPIRV_LLVM_Backend_jll 23.1.1+2:

Commit threads, CUDA and PoCL, Julia 1.13.1 AK's kernels on KernelAbstractions 0.10's host backend, 1.13.1 same, 1.10
1 310f105 152,649 57,514 57,514
2 5ffbdb5 153,978 57,960 57,960
3 e6de24d 154,133 58,025 58,025
4 b8082d5 154,597 58,182 58,180
5 489bbfc 154,744 58,231 58,229
6 473d42c 155,345 58,404 58,402
7 135188c 157,291 58,917 58,915
8 0bdbd3f 158,016 59,142 59,140

At the head, the suite also passes with the released back-ends on Metal (Apple M1, Metal.jl 1.11.1; Julia 1.13: 51,548, 1.10: 51,546), on an Intel Iris Xe with oneAPI.jl 2.9.2 (51,543 and 51,541) and with OpenCL on Intel's driver (51,541 and 51,539). The CUDA CI job, run locally with Pkg.test on Julia 1.13.1, passes (56,932). AMDGPU is left to CI.

The bits-union tests run where the arrays can hold bits unions: CUDA, AMDGPU, host arrays and KernelAbstractions 0.10's host backend. Metal.jl's, oneAPI.jl's and OpenCL.jl's arrays cannot hold them yet.

Before merging

  • Bump the version to 0.5.0.

Follow-ups, all of which fit behind this API: measured sorting thresholds and per-device tunings; faster small and whole-array reductions (#135); an order-preserving reduction and a sequential scan for non-commutative or non-associative operators; device-side work such as sub-group collectives, onesweep and segmented radix sort.

Reviewing

Each of the eight commits passes the tests on its own. Commit 1 introduces algorithm selection and moves sorting onto it; it carries most of the design. Commits 4 and 6 define the reduction and scan contracts, and are easiest to read through their docstrings (mapreducedim!, accumulate!) and docs/src/api/differences.md. Commits 2, 3, 5 and 7 convert the remaining operations (7 also gives findall its items), and 8 replaces the scratch keywords with workspaces.

Migration guide: every changed or removed entry point
Old New
positional backend (every operation) backend= keyword; usually omit it
prefer_threads (every operation) removed: the algorithmic operations take alg=AK.CPUThreads.… (or a kernel algorithm on KA 0.10); the launch wrappers always use Julia threads on the host
max_tasks, min_elems on algorithmic operations alg=AK.CPUThreads.SampleSort(; max_tasks, min_elems) / CPUThreads.Partitioned(...)
block_size, items_per_thread, switch_below on algorithmic operations fields of the algorithm: alg=AK.BlockReduce(block_size=512)
temp, temp_flags, temp_ix, temp_v, temp_keys, temp_values, temp_bools, MapReduce(temp=…) workspace=AK.workspace(op, args...; kwargs...)
foreachindex(f, itr, backend; …), foraxes(f, itr, dims, backend; …) foreachindex(f, itr; backend, block_size, max_tasks, min_elems), foraxes(f, itr, dims; …)
map!(f, dst, src, backend; …), map(f, src, backend; …) map!(f, dst, src; backend, …) (returns dst), map(f, src; …)
reverse!(v, backend; …), reverse(v, backend; …) reverse!(v; backend, dims, …), reverse!(dst, src; …), reverse(v; …)
searchsortedfirst!(ix, v, x, backend; rev::Bool) (returned nothing) searchsortedfirst!(ix, v, xs; backend, lt, by, rev, order) (returns ix); likewise searchsortedlast!
searchsortedfirst(v, x), searchsortedlast(v, x) (batched, allocating) removed (Base's names with other semantics): searchsortedfirst!(similar(xs, Int), v, xs)
sort!(v, backend; alg=nothing, block_size, …), sort(v, …) sort!(v; backend, alg=Auto(), dims, lt, by, rev, order, workspace), sort(v; …)
sortperm!(ix, v, backend; …), sortperm(v, …) sortperm!(ix, v; …) (always overwrites ix), sortperm(v; …)
merge_sort!(v; block_size), merge_sort(v; …) sort!(v; alg=AK.MergeSort(block_size=…)), sort(v; alg=…)
merge_sortperm!(ix, v; …), merge_sortperm(v; …) sortperm!(ix, v; alg=AK.MergeSort()), sortperm(v; alg=…)
merge_sortperm_lowmem!(ix, v; …), merge_sortperm_lowmem(v; …) sortperm!(ix, v; alg=AK.MergeSort(lowmem=true)), sortperm(v; alg=…)
merge_sortperm!(ix, v; inplace=true) (sorted v as well) ix .= 1:length(v); sort_by_key!(v, ix)
merge_sort_by_key!(k, v; …), merge_sort_by_key(k, v; …) sort_by_key!(k, v; …), sort_by_key!(copy(k), copy(v); …)
bitonic_sort!(v; …) sort!(v; alg=AK.BitonicSort())
sample_sort!(v; …), sample_sortperm!(ix, v; …) sort!(v; alg=AK.CPUThreads.SampleSort()), sortperm!(ix, v; alg=…)
alg=AK.SampleSort() alg=AK.CPUThreads.SampleSort()
AK.bitonic_defaults(backend) AK.sort_tuning(backend, T) (internal, like every tuning)
reduce(op, v, backend; init, neutral=…, block_size, items_per_thread, switch_below, temp) reduce(op, v; init=<optional>, neutral=nothing, acctype=nothing, dims, alg, workspace); an empty input needs init
mapreduce(f, op, v, backend; …), mapreduce(f, op, a, b, …, backend; …) mapreduce(f, op, v, vs...; backend, …)
reduce(op, v; dims, temp=dst) (into dst) mapreducedim!(identity, op, dst, v; overwrite=true)
sum(v; init=zero(T)), prod, maximum, minimum, count same names; add_sum/mul_prod; init optional (count: init=0); results have the accumulator type; sum/prod of empty inputs give zero/one
accumulate!(op, v, backend; init, block_size, items_per_thread, alg, temp, temp_flags) accumulate!(op, v; backend, init=<optional>, dims, inclusive, acctype, alg, workspace); accumulate!(op, dst, src; …)
accumulate(op, v, backend; …), cumsum(v, backend; …), cumprod accumulate(op, v; …), cumsum(v; …), cumprod(v; …)
AccumulateAlgorithm, field-less ScanPrefixes()/DecoupledLookback() ScanAlgorithm; the structs have block_size/items_per_thread fields; SliceScan for dims
findall(v, backend; alg=ScanScatter(block_size=256, items_per_thread=16), max_tasks, …) findall(v; items=keys(v), backend, alg=Auto(), workspace); ScanScatter fields default from the tuning
any(pred, v, backend; alg=ConcurrentWrite(), block_size, …), all any(pred, v; backend, alg=Auto(), workspace); ConcurrentWrite(block_size)
MapReduce(temp, switch_below), PredicatesAlgorithm ViaReduce(reduce::ReduceAlgorithm), PredicateAlgorithm
AK.neutral_element GPUArraysCore.neutral_element (AK's name refers to the same function, but is not public)
default_items_per_thread, default_scan_items_per_thread, _radix_defaults the families' tunings (reduce_tuning, scan_tuning, sort_tuning)

@maleadt maleadt changed the title Rework the host API: algorithms as values, Base semantics, workspaces Rework the host API: algorithms as values, primitive contracts, workspaces Sep 24, 2026
@maleadt
maleadt force-pushed the tb/api branch 2 times, most recently from e7b4e09 to 5d78328 Compare September 29, 2026 21:09
The foundation of the host-API restructure, and the first family to use it.

Algorithms are values that carry their settings: `alg=Auto()` lets AK choose,
an explicit algorithm is used as given or rejected with an `ArgumentError`.
`Auto(; stable=true)` carries requirements; with `stable=true` it chooses an
unstable algorithm only where equal elements are bitwise identical.

Choices can differ per device. `sort_tuning(backend, T)` returns a
`SortTuning` of thresholds and settings, and `_resolve_sort` selects,
fills unset fields and checks the result against the backend's capabilities
(`_runs_kernels`, `_runs_threads`). The default tuning selects the same
algorithms with the same settings as before; only merge sort's `by` keys are
now computed by AK's `map!` (see below).

`backend` becomes a keyword, derived from all array arguments, which must
agree; ranges and other non-arrays do not count, and without any array the
host backend is used.

The threaded CPU sort becomes `CPUThreads.SampleSort`, chosen by `Auto` on
the host backend. On KernelAbstractions 0.10 the host backend also runs AK's
kernels, so explicit kernel algorithms work on host arrays there; the
`--cpu-ka` test configuration tests that, in a new CI job.

Sorting entry points:
- `sort!`, `sort`, `sortperm!`, `sortperm` take `backend` and `alg` keywords;
  `prefer_threads`, `max_tasks`, `min_elems` and the loose `block_size` are
  gone (they are algorithm fields now), as is `alg=nothing`.
- `sort_by_key!` replaces `merge_sort_by_key!`/`merge_sort_by_key`, with
  `alg` and a threaded host implementation.
- `merge_sort!`, `merge_sort`, `merge_sortperm!`, `merge_sortperm`,
  `merge_sortperm_lowmem!`, `merge_sortperm_lowmem`, `bitonic_sort!`,
  `sample_sort!`, `sample_sortperm!`, `bitonic_defaults` and `SampleSort`
  are removed; the algorithms are reached through `alg`.
- `MergeSort` gains a `block_size` field.
- `RadixSort` settings are bounded (`block_size <= 1024`,
  `items_per_thread <= 64`), so its local-memory checks cannot overflow.
- Merge sort computes `by` keys with `map!` into an array of the keys' type
  instead of broadcasting, which made a `BitArray` of `Bool` keys that
  kernels cannot take.
- Nested operations (radix sort's key-range reduction, the index and key
  initialisation of merge sort) receive the resolved backend.
- The threaded sort and sort permutation of an N-d array without `dims` no
  longer fail for small inputs (`Base.sort!` needs `dims` for matrices).
- Merge sort is tested on reshaped views and on `Union{Missing, Int32}`
  arrays, which `main` failed to compile on CUDA. The views need
  KernelAbstractions 0.9.43, which rebuilds them in `@Const`
  (JuliaGPU/KernelAbstractions.jl#794), so the compat bound rises; the bits
  unions need CUDA.jl's cached loads of them (JuliaGPU/CUDA.jl#3297), so the
  CUDACore bound rises to 6.4.1. On
  KernelAbstractions 0.10's POCL backend (`--cpu-ka`), they need
  SPIRVIntrinsics 1.1.4, which allocates local memory of bits unions
  (JuliaGPU/OpenCL.jl#516), and SPIRV_LLVM_Backend_jll 23.1.1+2, whose fix of
  pointers extracted from aggregates sorting by key needs
  (llvm/llvm-project#227599).
`foreachindex`, `foraxes`, `map!`, `map`, `reverse!`, `reverse`,
`searchsortedfirst!` and `searchsortedlast!` take `backend` as a keyword,
derived from their arrays (the destination first); a loop over a range with
no backend runs on the host. On the host backend they always run on Julia
threads, and `prefer_threads` is gone. Their launch settings, `block_size`,
`max_tasks` and `min_elems`, are explicit keywords, checked on every backend.

- `map!` and the batched searches return their destination (the searches
  returned `nothing`).
- The batched searches take `rev::Union{Nothing,Bool}=nothing` and `order`,
  like every other ordering-taking operation.
- The allocating `searchsortedfirst(v, xs)` and `searchsortedlast(v, xs)` are
  removed: Base has these names with different semantics (a vector `x` is
  one value), so `AK.searchsortedfirst` is now Base's.

Internal callers pass their backend to the internal `_foreachindex`.
`reduce` and `mapreduce` take `backend` and `alg` keywords, like sorting.
The GPU tree reduction becomes `BlockReduce(block_size, items_per_thread,
switch_below)` and the threaded host reduction `CPUThreads.Partitioned(
max_tasks, min_elems)`, which scans, `findall` and the predicates will share.
`Auto` picks `Partitioned` on the host backend and `BlockReduce` elsewhere.
`reduce_tuning(backend, T)` returns a `ReduceTuning` whose values fill unset
fields; its defaults are the settings used so far, so every call launches
the same kernels as before. The number of blocks a reduction along `dims`
aims for (`TARGET_BLOCKS`) is a tuning value too, `target_blocks`.

The reduction contract is unchanged here: `init` is still required, and
results take its type. The next commit changes that.

- The positional `backend` and the loose `block_size`, `items_per_thread`,
  `switch_below`, `max_tasks`, `min_elems` and `prefer_threads` keywords are
  gone from `reduce`, `mapreduce`, `sum`, `prod`, `maximum`, `minimum` and
  `count`; the convenience reductions forward `alg`.
- `BlockReduce` with an explicit `items_per_thread` or `switch_below` along
  `dims` is an `ArgumentError`; the settings were silently ignored there.
- Resolution rejects whole-array `BlockReduce` tiles
  (`block_size * items_per_thread`) of one element, which never finish, and
  of more than `typemax(Int32)` elements. A tuning's `target_blocks` must be
  positive.
- Base's views, reshapes and permutations of ranges and other backend-free
  values do not determine the backend either. Otherwise `get_backend` would
  throw for a `Broadcasted` over reshaped ranges, which the host reduction
  has accepted so far.
- Radix sort's key-range reduction and the predicates' reduction path pass
  their backend and algorithm through the new keywords.
- The domain and capability checks shared by all families move next to the
  algorithm types.
Reductions get a contract of their own, modelled on CUB's rather than on
Base's, so that a front-end such as GPUArrays can build Base's API on it
and decide Base's rules (empty results, result types) itself. `op` must be
associative and commutative, as for every GPU reduction.

`mapreducedim!(f, op, R, A; init, neutral, overwrite, acctype, alg)`
reduces into an existing array: the dimensions where `R` has size 1 are
reduced, and each output whose slice is not empty becomes
`op(init, partial)` with `init` (applied once), `partial` with
`overwrite=true`, and `op(R[i], partial)` otherwise, folding as
`Base.mapreducedim!` does. An output whose slice is empty is set to `init`,
or not written. The allocating reductions along `dims` are built on it.

`init` is optional, and `init = nothing` is an initial value; the
keyword's default is an internal sentinel. Without `init`, an empty
reduction is an `ArgumentError`, of a whole array and along `dims` of any
output whose slice is empty. `sum` and `prod` give zero and one of the
accumulator type instead (not as an `init`, so that a sum of `-0.0`s stays
`-0.0`), and `count` keeps its `init=0`.

Partial results have one accumulator type and are converted to the
destination's element type only when stored: `acctype` when it is given,
else the type the fold of `op` settles on from `init`'s type (or
`eltype(R)`) and the mapped elements. Every element is also a one-element
partial result (`Base.reduce_first(op, x)`), so that type is joined in too,
and `sum(Int8[...])` accumulates in `Int`. Results have that type, with
`op(init, partial)` converted to it: scalar results, arrays along `dims`
also with an `init` (not `typeof(init)`), and a single element
(`AK.reduce((a, b) -> a + b, [true]) === 1`). An `acctype` is rejected only
when a partial result cannot convert to it at all, or `op` cannot combine
it (whatever the input); otherwise an `op` that inference shows always
throws is an error only where something is combined.

`neutral` only seeds partial results. When it is not given, AK uses
GPUArraysCore's `neutral_element(op, T)` where one is defined; for any other
operator, each partial result starts from its first element
(`Base.mapreduce_first`), so anonymous operators and tuple accumulators need
no neutral element. The same kernels serve both cases: without a neutral
element, they reduce `(value, valid)` lanes through wrappers of `f` and `op`
that the kernels build on the device.

A new documentation page lists where these rules differ from Base's.

- `sum` and `prod` use Base's `add_sum` and `mul_prod`, so small integers
  are summed as `Int`; `count` requires `f` to return a `Bool`.
- `temp` no longer doubles as the destination of reductions along `dims`;
  it is scratch for whole-array reductions only, with the accumulator
  element type.
- `BlockReduce` requires a bits-type accumulator and says so, instead of
  failing in the compiler.
- Every kernel shape is tested with a reshaped view and a
  `Union{Missing, Bool}` source, which failed to compile on CUDA before the
  fixes in KernelAbstractions 0.9.43 and CUDA.jl (#134).
- `neutral_element` comes from GPUArraysCore 0.2.1; AK's copy is gone.
`accumulate!`, `accumulate`, `cumsum` and `cumprod` take `backend` and `alg`
keywords, like sorting and reductions. The whole-array GPU scans
`ScanPrefixes` and `DecoupledLookback` carry `block_size` and
`items_per_thread`; the per-slice kernels used along `dims` become an
algorithm of their own, `SliceScan(block_size)`; the threaded host scan is
`CPUThreads.Partitioned`. `Auto` picks `Partitioned` on the host backend,
`SliceScan` along `dims`, and `ScanPrefixes` for whole arrays, or
`DecoupledLookback` where the backend supports it and the tuning prefers it
(no default tuning does, so every call launches the same kernels as before).

`scan_tuning(backend, T)` returns a `ScanTuning`. Its `items_per_thread`
default is derived, as before, from the effective block size and a
local-memory budget, so an explicit `block_size=1024` gets a matching
default. oneAPI gets the default tuning too: oneAPI.jl passes 64-thread
blocks on every call, to avoid wrong results with larger blocks, which no
longer reproduce (thousands of random scans with 128, 256 and 512 threads
are correct on an Iris Xe).

`DecoupledLookback` needs a device-scope fence, atomics and forward progress
between blocks. That is a capability, `_supports_lookback`, which the CUDA
and AMDGPU extensions declare; elsewhere an explicit `DecoupledLookback` is
an `ArgumentError`, as is `ScanPrefixes` or `DecoupledLookback` along `dims`
(they were silently replaced by the per-slice kernels) and `SliceScan` without
`dims`.

- The positional `backend` and the loose `block_size`, `items_per_thread`,
  `max_tasks`, `min_elems` and `prefer_threads` keywords are gone; the
  `AccumulateAlgorithm` supertype is now `ScanAlgorithm`.
- `temp` and `temp_flags` with `dims` are an `ArgumentError`; they were
  ignored.
- `accumulate!(op, dst, src)` with `dst === src` no longer copies.
- The scan contract is unchanged here: `init` is still required. The next
  commit changes that.
Scans get a contract of their own, like the reductions of commit 4: `op`
must be associative (not commutative), elements keep their order, and a
front-end decides Base's rules itself.

The running value has one type and is converted to the destination's
element type only when stored: `acctype` when it is given, else the
accumulator type of commit 4's reductions, starting from the destination's
element type joined with `init`'s type, as `mapreducedim!` starts from
`eltype(R)`. So with the usual operators and no `acctype`, a scan does not
run in a type narrower than its destination: `accumulate!(*, zeros(Int, 4), fill(0x10,
4))` gives `[16, 256, 4096, 65536]`, and `Float32`s scanned into a
`Float64` array are summed in `Float64`. Elements enter as one-element reductions (`Base.reduce_first`):
`AK.accumulate(*, ['a', 'b'])` gives strings. Only where the running type
differs from the destination's element type does the scan run in a scratch
array of that type. `accumulate` allocates the fold type from `init`'s type
and the elements (`accumulate(+, Int8[1, 2]; init=0)` is an `Int` vector),
or `acctype`.

`init` is optional. Without it, an inclusive scan starts from the first
element; with it, `init` is applied as `op(init, x)` to the first element
of each slice (or seeds the kernels directly when it has the running type),
so `init = nothing` is an initial value. A `dims` beyond the array's
dimensions makes every slice one element long: inclusive scans apply `init`
to every element, and exclusive ones fill every element with their seed. A
scan that never calls `op` only copies. An exclusive scan starts from
`init`, or from the neutral element of `op` without it; when none is known
that is an `ArgumentError`.

`neutral` defaults to GPUArraysCore's `neutral_element` where one is defined
(`-0.0` for floating-point sums, which unlike `0.0` is an exact identity).
For other operators every scan kernel, and the threaded host scan, keeps its
partial results as `(value, valid)` lanes, like the reductions of commit 4,
so inclusive scans need no neutral element: anonymous operators, and
non-commutative ones such as matrix products, just work. Elements are lifted
into lanes as they are loaded and lowered as they are stored; an empty lane
is never stored.

`cumsum` and `cumprod` use Base's `add_sum` and `mul_prod`, so
`AK.cumsum(Int8[...])` is an `Int` vector. The documentation's differences
from Base gain the scans' rows.

- `accumulate!(op, dst, src)` requires `dst` to be `src` or not to overlap
  it, and to have its axes.
- `temp` requires a known neutral element, like the reductions' `temp`, and
  has the running type.
- Scans along `dims` step through the array with the strides of its linear
  indices rather than `strides(v)`, which differ for wrappers such as a
  `PermutedDimsArray` or a strided view: those were scanned wrongly before.
- `accumulate`, `cumsum` and `cumprod` of an input without a backend (a
  range) allocate their result on the `backend` given.
`findall`, `any` and `all` take `backend` and `alg` keywords, like the other
families, which completes the calling convention: no operation takes a
positional backend or `prefer_threads` any more.

`findall` has `ScanScatter(block_size, items_per_thread)` on GPUs and
`CPUThreads.Partitioned` on the host, filled from `findall_tuning`. `any`
and `all` have `ConcurrentWrite(block_size)`, which stores one flag from
many threads, and `ViaReduce(reduce)`, a reduction with `|` or `&` through a
`ReduceAlgorithm` (it was `MapReduce(temp, switch_below)`, whose fields were
the reduction's), filled from `predicate_tuning`. Every predicate algorithm
requires the predicate to return a `Bool`; the reduction used to accept
integers. Where inference shows the predicate returns something else, the
call throws an `ArgumentError` before launching (except for an empty array,
where the predicate is never called), rather than failing in the kernel,
which GPU backends report without Base's `TypeError`.
`findall` checks its predicate, or its non-`Bool`
values, the same way. `Auto` picks `ConcurrentWrite`, or `ViaReduce()`
where the tuning prefers it.

`findall` selects items rather than computing indices by Base's rules: the
new `items` keyword (default `keys(A)`) is any array of `A`'s length, and
the `k`-th element of `A` selects its `k`-th element. `keys(A)` gives
`Int`s for vectors and `CartesianIndex`es otherwise, now also for the
predicate form of a 0-dimensional array; `LinearIndices(A)` gives linear
indices; an array of values selects them, so `findall(mask; items=A)` is
`A[mask]` in one pass. Positions are ordinal, so offset axes work.

oneAPI's extension redefined `AK.any` and `AK.all` to default to the
reduction, because some Intel GPUs were reported to hang on concurrent
writes to one location. It now sets that default through the tuning
(`prefer_concurrent_write=false`), so an explicit `ConcurrentWrite` runs
there as on every GPU; 900 such calls over up to 10^8 elements, all of
which match, ran correctly on an Iris Xe.

- The positional `backend` and the loose `max_tasks`, `min_elems`,
  `block_size` and `prefer_threads` keywords are gone, and so are
  `use_gpu_algorithm` and the test harness's `prefer_threads` global.
- `FindallAlgorithm` and `PredicateAlgorithm` (was `PredicatesAlgorithm`) are
  `Algorithm`s, documented with the other families.
- `findall`'s mask lives on the resolved backend, so a range with an
  explicit GPU backend works.
- `any` of a `Union{Missing, Bool}` array, and `any` or `findall` of a
  reshaped view, are tested; they failed to compile on CUDA before the
  fixes in KernelAbstractions 0.9.43 and CUDA.jl.
Every operation that needs scratch memory takes a `workspace` keyword
instead of its `temp`, `temp_flags`, `temp_ix`, `temp_keys`, `temp_values`
and `temp_bools` keywords, which are gone. `AK.workspace(op, args...;
kwargs...)` allocates the scratch of the call `op(args...; kwargs...)`, and
`AK.workspace_size` returns it as a `NamedTuple` of `(eltype, dims)` pairs
without allocating:

    ws = AK.workspace(AK.sort!, v)
    AK.sort!(v; workspace=ws)        # allocates no scratch memory

Each operation now plans its scratch in one function, `_plan`, which
resolves the algorithm and lists the buffers the implementation uses,
including those of the operations it calls (radix sort's key-range
reduction and histogram scan, findall's scan of its counts, the predicates'
reduction). The implementation takes its buffers from the plan, allocated
or from the workspace, and allocates no scratch of its own, so the query
and the call cannot disagree. Merge and radix sorts, sample sort, whole
and dimensional reductions, scans (including the scratch array of a scan
whose destination has another element type), findall and the predicates
have plans; `workspace` works for `sort`, `sortperm`, `accumulate`,
`cumsum`, `sum` and the other allocating and convenience forms too. A plan
takes the operation's keywords, including those that change the buffers:
`acctype` sets the type of a reduction's partial results and of a scan's
scratch array, and `findall`'s `items` is checked against the input. The
empty `sum` and `prod`, which do not reduce, check their workspace too.

A `Workspace` records the backend, the device, the resolved algorithm and
those of the operations it calls, and the buffer sizes. A call checks all of
them, and that no buffer aliases its arrays (including the arrays of a fused
or `Broadcasted` source, and the input of an allocating `sort`), and throws
an `ArgumentError` otherwise: `Auto()` may resolve differently for another
length, so a workspace made for one call is only accepted by calls that plan
the same scratch.

The workspace covers device memory, with two exceptions. On the host, the
threaded algorithms keep their small per-task bookkeeping, and
`CPUThreads.SampleSort` leaves `Base.sort!` to allocate the scratch of its
per-task sorts and of the slices of a sort along `dims` (its serial path
takes the workspace's buffer). Before Julia 1.12, a reduction of several
arrays or of a `Broadcasted` object still materializes it; the plan and the
workspace checks see the original arrays, and materializing happens only
when the reduction runs.

- The reduction's launch-shape decision along `dims` moves into a function
  that the plan and the kernel dispatch share.
- `DecoupledLookback`'s flags and every other buffer come from the plan;
  none needs initialising between calls.
- Buffer requirements carry their element type in the type domain, and the
  setup of reductions and scans carries types as `Val`s, so results still
  infer.
- A `Broadcasted` reduction source below `switch_below` is evaluated on the
  host from host copies of its arrays, instead of being materialized on the
  device.
- Docs: a "Scratch memory" page, and the sorting and performance pages use
  workspaces.
@maleadt
maleadt marked this pull request as ready for review September 30, 2026 13:54
@maleadt
maleadt merged commit 4fecd3e into main Sep 30, 2026
38 of 39 checks passed
@maleadt
maleadt deleted the tb/api branch September 30, 2026 19:37
@maleadt
maleadt restored the tb/api branch September 30, 2026 19:40
@maleadt
maleadt deleted the tb/api branch September 30, 2026 19:40
@anicusan

anicusan commented Oct 1, 2026

Copy link
Copy Markdown
Member

I very much like this direction - however it seems that it's not a full generalisation of the algorithms and their specific requirements (temporaries, block / thread sizes, etc.); in particular, why hardcode the stability of sorting into the Auto included for all algorithms? In the future there may be other such "global" settings for families of algorithms (e.g. commutativity for reductions). Wouldn't separating the abstract Algorithm for each family - SortAlgorithm, AccumulateAlgorithm, AutoSortAlgorithm etc. - be a more complete generalisation, and make it more future-proof?

@maleadt

maleadt commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

Full generalization wasn't really the goal, just a step towards a more principled API instead of loose keywords and ad-hoc arguments. It's still 0.5, but good enough to start integrating into GPUArrays.

Some of what you mention is already there: there are families (SortAlgorithm, ReduceAlgorithm, ScanAlgorithm), block/thread sizes are fields of the algorithms, and temporaries go through workspace. Stability is on Auto because the sorting algorithms themselves have fixed stability (only BitonicSort is unstable), so it only matters when AK makes the choice. Commutativity is different: it's a property of op that explicit algorithms need too, so I'd put it on the call rather than on Auto.

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.

Kernels fail to compile for sources with a bits-union element type (Union{Missing, Bool})

2 participants