Skip to content

Share the tile steps among the scan kernels, and count findall's selections with them - #182

Merged
maleadt merged 7 commits into
tb/dedup-reducefrom
tb/dedup-scan
Oct 25, 2026
Merged

maleadt merged 7 commits into
tb/dedup-reducefrom
tb/dedup-scan

Conversation

@maleadt

@maleadt maleadt commented Oct 25, 2026

Copy link
Copy Markdown
Member

The scan kernels each wrote out the same tile steps, CUB's BlockLoad / BlockScan / BlockStore: a striped load of the tile, a scan of each thread's run, a block-wide scan of the threads' totals, applying the prefix (inclusive or exclusive), and a striped store. The whole-array scan, the scan of tiles along dims and findall each had their own copy, and the serial scan loop appeared four times (threaded scans, and the kernels that give a thread a whole slice). findall also had its own count pass, which staged each tile in local memory, where the scans' tile reduction reads each thread's run straight from memory and uses sub-group scans.

Now the tile steps are device functions in block.jl that all three kernels use, each kernel keeping its own operand and carry order (so floating-point results don't change), one serial_scan! serves the host and the kernels, and findall counts its selections with the scans' tile reduction and only scatters in its own kernel, with Int32 per-thread counts that take half the local memory. Generated code of the scan kernels is identical to before on CUDA and AMD, except for operators without a neutral element, which run at the same speed.

Running whole-array scans through the slice machinery as a single slice was measured too and rejected: 3-27% slower from 10^4 elements up on an RTX 5080 (10^8 Float32s: 1794 vs 1418 µs); a comment says so.

Three older performance problems turned up and are fixed in their own commits:

  • The bitonic sort's compare-exchange didn't propagate @inbounds, so it kept its bounds checks: bitonic sorts are 11-13% faster on CUDA and 20-32% on AMD and Apple GPUs.
  • Kernels that only pass a function argument on aren't specialized on it by Julia, so CUDA.jl specialized nine reduction kernels and the merge sorts again on every launch (methodinstance re-specializes on every lookup when the dispatch cache holds a widened method instance GPUCompiler.jl#1060). They now name those argument types, which takes 2-6% off small reductions, and a test checks that no kernel's specialization is widened.
  • A comment records why the block scan keeps its bounds checks: eliding them raises the register count of the slice carry pass from 64 to 101 and makes a (2, 10^7) scan along dims=2 72% slower.

Performance, as the geometric mean of new over old times (min of interleaved rounds): scans and findall 1.006 on an RTX 5080, 0.954 on an AMD iGPU, 1.005 on PoCL, 0.941 on an M1. Large findalls with many selections are faster everywhere (10^7 elements, 50% selected: 181 → 177 µs on the 5080, 15.2 → 10.7 ms on the iGPU, 4.3 → 3.6 ms on the M1). One cost: on CUDA, findall calls of a few tens of microseconds take 1-2 µs longer, as the second kernel's construction does not infer with KernelAbstractions 0.9, which CUDA.jl still requires; with 0.10 it does, and those calls are faster on the other backends.

Tested on CPU threads, PoCL (also with AK's kernels on the host backend), CUDA (Julia 1.12 and 1.10), AMDGPU and Metal.

The whole-array tile kernel, the slice-tiles kernel and findall's block
kernel each wrote out the striped tile load, and the two scan kernels
also the scan of each thread's run, the application of the block's
prefix with the inclusive/exclusive trick and the striped store. These
are now device helpers in block.jl (`tile_load!`, `thread_scan!`,
`tile_prefix!`, `tile_store!`), split as in CUB's BlockLoad, ThreadScan
and BlockStore; the kernels call `block_exclusive_scan!` between them.

The kernels keep their floating-point grouping: the whole-array kernel
seeds the block scan with the block's start, while the slice kernel
scans from the seed and combines its carry last, which `tile_prefix!`
takes as an optional argument. They also keep the order of their
loads: reading the block's start before the thread scan held it in
registers across it and cost AMDGPU 8 more VGPRs. `tile_prefix!`
picks the operand before combining it with the prefix, as the slice
kernel did. The whole-array kernel combined both and picked a result,
which on an M1 takes 2-6% longer for operators without a neutral
element, and up to 8% longer in the slice kernel.

The helpers propagate the kernels' `@inbounds`, and the kernels name
the types of `op` and `load`, which they now only pass on: without,
Julia does not specialize on them, and CUDA.jl specializes the kernel
again on every launch, which made small scans 2-4% slower. On an RTX
5080 the PTX of the scan kernels is unchanged, but for the whole-array
kernel with `Optional` partial results, which runs as fast.
The threaded whole-array scan, the threaded scan along dims and the
rows and columns kernels each had their own copy of the sequential
inclusive/exclusive loop. `serial_scan!` now takes the source, its
load, an index map and the range to walk, so that all four call it;
it propagates the caller's `@inbounds`.
findall's count pass was a mode of its block kernel, which staged the
tile in local memory and scanned it to get each block's count. That is
`tile_aggregates_kernel!` with `+` over the mask's elements as `Int32`,
which reads each thread's run straight from memory and scans in
sub-groups where the backend can. The block kernel now only scatters,
scanning its tile's counts as `Int32` from zero and adding the block's
offset, so its per-thread counts take half the local memory.

Large findalls with many selections are 2-9% faster on an RTX 5080,
15-43% on an AMD iGPU and 14-39% on an M1. On CUDA, calls that take a
few tens of microseconds (small inputs, or a million elements with 1%
selected) take 1-2 us longer: the second kernel costs a construction,
which does not infer with KernelAbstractions 0.9 (that CUDA.jl
requires) and takes 0.5 us. A
function barrier on the block size lets it infer from
KernelAbstractions 0.10.
The audit proposed running ScanPrefixes' reduce-then-scan mode through
the slice machinery with a single slice, dropping prefixes_length.
Measured, the slice path is 3-27% slower from 1e4 elements on: its
chunks loop over tiles, and its first pass scans each tile rather than
only reducing it. The numbers now sit next to accumulate_1d_gpu!.
`compare_exchange!` was `@inline` but not `@propagate_inbounds`, so its
local- and global-memory accesses kept their bounds checks inside the
kernels' `@inbounds`. Without them, BitonicSort of 1e4 and 1e6 Float32s
takes 43.6 and 307 us instead of 50.3 and 344 us on an RTX 5080, and
20-32% less time on an AMD iGPU and an M1.
Propagating the kernels' `@inbounds` into `block_exclusive_scan!` made
the carry pass of the slice-tiles kernel use 101 instead of 64
registers on an RTX 5080, and scans along dims of few long slices up to
72% slower, while whole-array scans did not change.
@maleadt
maleadt added this pull request to stack #178 October 25, 2026 04:12
Julia does not specialize a method on a function argument it only
passes to another function, and CUDA.jl's launch then looks up the
kernel by its widened signature and specializes it again, on every
launch (about 150 ns). The reduction kernels, which hand `f` and `op`
to `seeded_ops`, and the merge-sort kernels, which hand `comp` to the
merge, received them that way. They now name the types of these
arguments, which takes 2-6% off small reductions on an RTX 5080.

A test runs every family's kernels and checks that none of their
specializations is widened.
@maleadt
maleadt merged commit 356ad8d into main Oct 25, 2026
34 of 35 checks passed
@maleadt
maleadt deleted the tb/dedup-scan branch October 25, 2026 09:19
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