Repository navigation
Share the tile steps among the scan kernels, and count findall's selections with them - #182
Merged
Merged
Conversation
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.
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
force-pushed
the
tb/dedup-scan
branch
from
October 25, 2026 06:47
b67e7b2 to
8e132d8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
dimsandfindalleach had their own copy, and the serial scan loop appeared four times (threaded scans, and the kernels that give a thread a whole slice).findallalso 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.jlthat all three kernels use, each kernel keeping its own operand and carry order (so floating-point results don't change), oneserial_scan!serves the host and the kernels, andfindallcounts its selections with the scans' tile reduction and only scatters in its own kernel, withInt32per-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:
@inbounds, so it kept its bounds checks: bitonic sorts are 11-13% faster on CUDA and 20-32% on AMD and Apple GPUs.(2, 10^7)scan alongdims=272% slower.Performance, as the geometric mean of new over old times (min of interleaved rounds): scans and
findall1.006 on an RTX 5080, 0.954 on an AMD iGPU, 1.005 on PoCL, 0.941 on an M1. Largefindalls 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,findallcalls 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.