Skip to content

Speed up small reductions and reductions of wide matrices along dims - #138

Merged
maleadt merged 2 commits into
mainfrom
tb/reduce-small
Oct 2, 2026
Merged

maleadt merged 2 commits into
mainfrom
tb/reduce-small

Conversation

@maleadt

@maleadt maleadt commented Oct 1, 2026

Copy link
Copy Markdown
Member

Part of #135.

GPUArrays is about to send every Base reduction of GPU arrays through AK (JuliaGPU/GPUArrays.jl#790), so AK's reductions have to be at least as fast as the back-ends' own kernels they replace. For small reductions along dims they were not: sum(A; dims=2) of a 1000×10 matrix took 17.9 µs against 4.5 µs for CUDA.jl's mapreducedim!. Reductions of wide matrices along dims=2 were also slower than CUDA.jl's. This PR fixes both.

Small reductions: host overhead

The kernels were not the problem. Almost all the time went into host code that ran unspecialized. Julia does not specialize a method on a function argument that it only passes on to other calls. AK's entry points passed f and op straight through, so everything below them was compiled for ::Function. Base.promote_op then ran type inference at run time, on every call, to find the accumulator type, and each call after it was a dynamic dispatch. The reduced and kept dimensions added more, stored as vectors of tuples of unknown length.

The first commit annotates the entry points with where {F, OP} and keeps the dimension segments in fixed-length tuples. It also adds a function barrier where mapreducedim! computes the reduced dimensions, whose number is only known at run time. Host time of mapreducedim!(identity, +, R, A; init=0f0) on a 1000×10 Float32 matrix drops from 13.9 µs to 3.7 µs. CUDA.jl's call takes 2.6 µs, about 2 µs of which is the kernel launch.

Wide matrices: a column kernel

A reduction into contiguous outputs along a longer, strided dimension, the row sums of a wide column-major matrix for example, used one block per output. Its threads read elements a whole column apart, so the loads of a warp hit as many cache lines as it has threads. At 1000×100000 it ran at a quarter of the memory bandwidth. With fewer than 32 outputs it used the multi-block shape, which reads the input the same way.

The second commit adds a kernel for this layout. Each block takes up to 32 consecutive outputs (8 to 31 outputs take the next power of two) and spreads its other threads along the reduction, so that a warp's loads at each step are consecutive elements. Each thread keeps 8 loads in flight. When there are too few outputs to fill the device, the reduction of each output is split across blocks, and a second pass combines the partial results, as the multi-block shape already does. The kernel runs at most 256 threads per block because Metal does not accept 1024 threads for it. It is chosen from columns_min_elements elements on, a new internal tuning value: 2^18 by default, and 2^22 on oneAPI, where the second launch costs tens of microseconds. It also needs at least 8 outputs. With 4, it was faster on CUDA but slower than the multi-block shape on Metal and oneAPI.

Numbers

RTX 5080, Float32, init=0f0, Julia 1.13, CUDA.jl 6.4.1. Minimum of 50 synchronized runs per variant, variants interleaved, best of 3 rounds (2 for wall clock). The GPU was shared with at most one other process, at up to 14% utilization. µs.

mapreducedim! against CUDA.jl's GPUArrays.mapreducedim!. For the smallest shapes the table shows wall-clock time of the synchronized call. With other work on the GPU, CUDA.@elapsed can hide part of the host overhead of these sub-10 µs calls:

shape dims CUDA.jl main this PR
1000×10 1 11.8 20.0 8.1
1000×10 2 6.8 19.9 7.5
1000×1000 1 17.8 23.1 10.6
1000×1000 2 23.8 21.7 9.6

Larger ones, CUDA.@elapsed:

shape dims CUDA.jl main this PR
300×10000 2 45.3 67.0 10.6
1000×10000 2 141.0 171.2 18.3
1000×100000 1 1404.9 486.2 467.5
1000×100000 2 1459.1 1815.2 454.6
5000×20000 2 1466.2 1849.9 460.6
100×1000000 2 1705.9 1657.2 460.1
32×3000000 2 1452.3 4374.9 424.7
16×6000000 2 1078.0 761.5 428.4
8×12000000 2 701.8 760.5 430.0
4×25000000 2 519.7 567.2 550.6

400 MB in 425-460 µs is close to the card's bandwidth. The 4×25000000 case keeps the multi-block shape.

On Metal and oneAPI, take the numbers below as indicative only. Both machines were busy with other jobs during every run: another session's Metal test suites on the M1, and a stream of CI jobs on the Iris Xe. Wall clock, minimum of 30 synchronized runs, best of 2 to 4 rounds, µs:

shape dims M1 main M1 this PR Iris Xe main Iris Xe this PR
1000×10 2 198 180 157 124
1000×1000 2 272 246 241 228
300×10000 2 652 478 638 721
1000×100000 2 14774 10958 141310 28672
5000×20000 2 21185 10238 136381 27853
32×3000000 2 9229 8907 53668 25317
16×6000000 2 16555 7398 38803 25002
8×12000000 2 8670 6987 28677 24919

On the Iris Xe, 300×10000 is below oneAPI's threshold and runs the same kernel as on main, so its difference is noise.

Compatibility

There are no API or contract changes. The new ReduceTuning field is internal, and the reduction tree changes only for the shapes that now take the column kernel. That can change floating-point rounding, which the reduction contract allows.

Tested on CUDA, OpenCL (PoCL), the CPU threads back-end, Metal, oneAPI, and AMDGPU (an APU, where the failures that main already has remain). The new tests cover the column kernel in one and two passes, without a neutral element, with init, folding into R, an R narrower than the accumulator, a reused workspace, and a strided view at an offset.

Julia does not specialize a method on a function argument that it only
passes on to other calls. The reductions' entry points passed `f` and
`op` straight through, so the host code below them ran unspecialized:
`Base.promote_op` ran inference at run time on every call to find the
accumulator type, and every call after it was a dynamic dispatch. The
dimension segments added more, as vectors of tuples of unknown length.

Annotate the entry points with `where {F, OP}`, keep the segments in
fixed-length padded tuples (dispatching the common layouts, one reduced
and at most one kept segment, statically), and put a function barrier
after the computation of the reduced dimensions in `mapreducedim!`.

Host time of `mapreducedim!(identity, +, R, A; init=0f0)` for a 1000×10
`Float32` matrix (`dims=2`), RTX 5080: 13.9 µs before, 3.7 µs after;
CUDA.jl's own kernel takes 2.6 µs, of which the launch is about 2 µs.
That was the whole gap for small reductions (wall-clock minimum of
synchronized runs, µs):

| shape     | dims | CUDA.jl | before | after |
|-----------|------|---------|--------|-------|
| 1000×10   | 1    | 11.8    | 20.0   | 8.1   |
| 1000×10   | 2    | 6.8     | 19.9   | 7.5   |
| 1000×1000 | 1    | 17.8    | 23.1   | 10.6  |
| 1000×1000 | 2    | 23.8    | 21.7   | 9.6   |
A reduction into contiguous outputs along a longer strided dimension,
such as `sum(A; dims=2)` of a wide column-major matrix, took the
`by_block` shape: one block per output, its threads reading elements a
row apart, so no two threads of a warp touched the same cache line. At
1000×100000 it ran at a quarter of the bandwidth, slower than CUDA.jl's
kernel; with few outputs it took `multigroup`, as poorly coalesced.

The column kernel gives each block up to 32 consecutive outputs (8 to 31
outputs take the next power of two) with the block's other threads as
lanes along the reduction, so a warp's loads of one step are consecutive
elements; each thread keeps 8 loads in flight, and when the outputs
alone do not fill the device, each output's reduction is split across
blocks and combined in a second pass, like `multigroup`. It runs blocks
of at most 256 threads, as Metal allows its pipeline fewer than 1024. It
is used from `columns_min_elements` elements, a new internal tuning
value: 2^18 by default, 2^22 on oneAPI, where a second launch costs tens
of microseconds; and from 8 outputs, as with 4 it lost to `multigroup`
on Metal and oneAPI.

`sum(A; dims=2)` of `Float32`, minimum of 50 synchronized runs
(`CUDA.@elapsed`), RTX 5080, µs:

| shape        | CUDA.jl | before | after |
|--------------|---------|--------|-------|
| 300×10000    | 45.3    | 67.0   | 10.6  |
| 1000×10000   | 141.0   | 171.2  | 18.3  |
| 1000×100000  | 1459.1  | 1815.2 | 454.6 |
| 5000×20000   | 1466.2  | 1849.9 | 460.6 |
| 100×1000000  | 1705.9  | 1657.2 | 460.1 |
| 32×3000000   | 1452.3  | 4374.9 | 424.7 |
| 16×6000000   | 1078.0  | 761.5  | 428.4 |
| 8×12000000   | 701.8   | 760.5  | 430.0 |
@maleadt
maleadt added this pull request to stack #141 October 1, 2026 14:26
@maleadt
maleadt merged commit aed7d57 into main Oct 2, 2026
37 of 39 checks passed
@maleadt
maleadt deleted the tb/reduce-small branch October 2, 2026 09:01
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