aare · feature/cuda_pixelhistogram
Building PixelHistogramCUDA
Design, current state and remaining steps for the GPU version of PixelHistogram (without pedestal tracking). It is also the handoff document: Start here and the Implementation reference are enough to continue in a new conversation.
Start here
- Goal: one histogram per detector pixel, filled on the GPU from batches of frames, with output identical to
PixelHistogramImpl. - Files:
include/aare/hist/PixelHistogramCUDA.hpp(class),include/aare/hist/pixelhistogram_kernel.cuh(kernels, namespaceaare::cuda::detail),include/aare/RAII_device.hpp(wrappers),include/aare/utils/cuda_check.cuh(CUDA_CHECK). Nothing is committed yet. - Current step: 6, CMake wiring and the first end-to-end tests against
PixelHistogramImpl. All code is written; the pipeline and fill kernel have never run end to end yet. - Settled decisions are listed under Decisions. Don't reopen them without a measurement.
- Branch:
feature/cuda_pixelhistogramis based onfeature/cuda_clusterfinderand pushed. The cluster finder merges intomainfirst.cuda_check.cuh, the base ofRAII_device.hppand theAARE_CUDACMake setup come from that branch; this branch only addsHostRegistration,AsyncDeviceBufferandEventtoRAII_device.hpp. Pick up cluster-finder updates by rebasing (then--force-with-lease) or merging; in both cases keep this branch'sRAII_device.hppon conflict. - Working agreement: the author writes the code; the assistant reviews, explains and gives hints rather than full solutions unless asked. Answers are direct, honest (including when something is wrong) and compact.
Steps 1, 3 and 4 are done and verified. Steps 2 and 5 are written and compile, but are only verified by step 6. Details in Steps.
How the pipeline works
Two streams, two device slots, one histogram that stays on the GPU for the whole run.
- host framescaller-owned, pinned once with
register_input_buffer - copy streamH2D of batch i into slot i mod 2
- compute streamone fill kernel per batch, in order
- d_histogram
uint32_tcounts, bin-major - values()transpose + cap, one D2H
Who waits for what
| Event | Recorded | Waited on by | Prevents |
|---|---|---|---|
copy_done[s] |
copy stream, after the H2D into slot s | compute stream, before the kernel on s | A kernel reading a half-copied slot |
kernel_done[s] |
compute stream, after the kernel on s | copy stream, before the next H2D into s | Overwriting a slot a kernel is still reading |
| stream order | n/a | each kernel after the previous one; values() after the last kernel |
Kernels racing on the histogram; reading an unfinished histogram |
Expected case: copy-bound
kernel_done wait costs nothing and the copy engine runs back to back. Throughput = copy time per batch.If the kernel were slower: compute-bound
Throughput is one batch per max(copy time, kernel time). A third slot would only absorb jitter, not make the slower stage faster.
Kernel design
Fill kernel: bin-major, one thread per pixel
- 1D grid, one thread per pixel. Histogramming never looks at neighbouring pixels, so a pixel is just a flat index
p. Consecutive threads take consecutive pixels, so each warp reads one contiguous chunk per frame. - Each thread loops over the batch's frames and increments its own pixel's bins.
- No atomics. Only one thread ever writes pixel p's bins, and kernels never overlap because they share one in-order stream.
| Accumulator layout | Warp increment, all same bin | Warp increment, all different bins | Hot set in L2 (160k px, ~10 hot bins) |
|---|---|---|---|
pixel-major [p][bin] | 32 separate sectors | 32 separate sectors | ~50 MB |
bin-major [bin][p] | 1 × 128 B line | 32 separate sectors | ~6 MB |
Bin-major is never worse, and much better when neighbouring pixels land in the same bin. What matters is how many distinct bins a warp hits, not how far apart they are.
No shared memory in the fill kernel
- It wouldn't be shared: each thread owns its pixel's bins, so it would just be a big private array per thread.
- 2 KB per pixel (1000 bins × 2 B) fits only ~50 pixels per SM, which collapses occupancy.
- Writing all bins back after each batch would move the whole histogram (~1.3 GB per batch).
- The 4090's 72 MB L2 already holds the hot bins: ~2 µs of L2 traffic per frame, against ~13 µs to copy one frame over PCIe.
Finalize kernel: transpose and cap Done
bin_to_pixel_majorturns the[nbins][npix]accumulator into[npix][nbins]StorageType, capping each count. One thread per output element: writes are coalesced, reads are strided but mostly served from cache.- It writes into a temporary buffer and only reads the accumulator, so
values()works mid-run and filling can continue afterwards. - Cost: roughly 2–4 ms, against ~13 ms for the 320 MB D2H that follows. A tiled version is deferred (see Decisions).
Implementation reference
Exact behaviour of the code as of 13 Oct 2026. Everything below is implemented.
Layout conventions
NDView/NDArrayare always C-order. Strides are computed from the shape (c_stridesinNDView.hpp); the last index moves fastest; neither class accepts custom strides.sub_view(first, last)slices axis 0 and stays contiguous.- Input batch:
NDView<AxisType, 3>of shape(n_frames, rows, cols). Withp = row × ncols + colandnpix = rows × cols, frame f, pixel p is atf × npix + p. - Device accumulator
d_histogram:npix × nbinsuint32_t, bin-major: count of (p, b) atb × npix + p. - API output: shape
(rows, cols, nbins)ofStorageType, pixel-major: (p, b) atp × nbins + b. Same as the CPU classes.
Class template and members
template <StorageType = uint16_t, DeviceStorageType = uint32_t, AxisType = float, int BLOCK_SIZE = 128> class PixelHistogramCUDA, in aare::cuda. Non-copyable, non-movable. NUM_SLOTS = 2 (compile-time).
| Member (declaration order) | Role |
|---|---|
nrows | Declared first: its initializer calls check_args, so validation runs before anything allocates. |
ncols, m_nbins, m_xmin, m_xmax, m_scale, m_batch_size | m_scale = nbins / (xmax − xmin) (bins per unit). |
m_next_batch, mutable m_processed_batches | Slot counter (submitted batches); flush() sets processed = submitted. |
m_registered_input | HostRegistration; destroyed after the streams have synchronised. |
d_histogram, d_frames_batch[NUM_SLOTS] | Accumulator and per-slot frame buffers (npix × batch_size AxisType). |
copy_done[NUM_SLOTS], kernel_done[NUM_SLOTS] | Per-slot events. |
copy_stream, compute_stream | Declared last so they're destroyed first; the Stream deleter synchronises before destroying, so nothing is freed under running work. |
Constructor
check_args(rows, cols, n_bins, xmin, xmax, batch_size)throwsstd::invalid_argumentunless rows, cols, bins ≥ 1,xmax > xmin(also rejects NaN) andbatch_size ≥ 1; returnsrows.- Body: create events in a loop;
cudaMemsetAsync(d_histogram, 0, d_histogram.bytes(), compute_stream)(on the compute stream so every kernel is ordered after it; plaincudaMemsetwould run on the legacy default stream, which non-blocking streams don't wait for); allocate the frame buffers in a loop.
Input registration
register_input_buffer(ptr, bytes): synchronise the copy stream → unregister the previous buffer explicitly (re-registering overlapping memory would fail withcudaErrorHostMemoryAlreadyRegistered) →HostRegistration(ptr, bytes).unregister_input_buffer(): synchronise the copy stream → unregister.HostRegistration::unregister()releases the pointer from itsunique_ptrbefore callingcudaHostUnregister, so a failure is never retried by the destructor.- Contract: the caller's dataset must outlive the registration. Unregistered (pageable) input still works, but copies don't overlap.
submit_batch(frames): exact sequence
- Validate:
shape(0) ≤ m_batch_size,shape(1) == nrows,shape(2) == ncols. slot = m_next_batch++ % NUM_SLOTS;n_frames = shape(0).- Copy stream:
cudaStreamWaitEvent(kernel_done[slot]). - Copy stream:
cudaMemcpyAsyncH2D ofnpix × n_frames × sizeof(AxisType)intod_frames_batch[slot]. - Copy stream:
cudaEventRecord(copy_done[slot]). - Compute stream:
cudaStreamWaitEvent(copy_done[slot]). - Compute stream:
fill_bin_major<<<ceil(npix / BLOCK_SIZE), BLOCK_SIZE>>>(frames, d_histogram, npix, nbins, xmin, xmax, scale, n_frames), thencudaGetLastError. - Compute stream:
cudaEventRecord(kernel_done[slot]).
Invariants: the host never waits. In the first round, waits on events never recorded are no-ops. All kernels run in order on one stream, which is what makes plain increments safe. n_frames is passed by value, so a partial last batch is safe. fill_histogram(frames) cuts frames into sub_views of at most m_batch_size along axis 0 and submits each. flush() const synchronises copy, then compute.
fill_bin_major
p = (size_t)blockIdx.x × blockDim.x + threadIdx.x; return ifp ≥ npix.- For
fin[0, n_frames):v = frames[f × npix + p]; skip (continue) unlessv ≥ xmin && v < xmax, written this way so NaN is skipped too;bin = min((size_t)((v − xmin) × scale), nbins − 1);++hist[bin × npix + p]. No saturation check: theuint32_tcounter can't realistically overflow, andvalues()caps. - Matches
PixelHistogramImpl::fill_flat_uncheckedoperation for operation. No FMA risk: there's no addition after the multiply. - No atomics, no shared memory, no
__syncthreads(). All indices insize_t; pointersconst/__restrict__.
values(out) const: exact sequence
- Check
out.shape() == (nrows, ncols, nbins); otherwise throwstd::invalid_argumentnaming the expected shape. The shape fully determines the layout (C-order), so nothing else needs checking. tmp = make_async_buffer<StorageType>(npix × nbins, compute_stream).bin_to_pixel_major<<<ceil(total / BLOCK_SIZE), BLOCK_SIZE, 0, compute>>>(d_histogram, tmp, npix, nbins, count_cap()), thencudaGetLastError.cudaMemcpyAsync(out.data(), tmp, total × sizeof(StorageType), D2H, compute_stream).cudaStreamSynchronize(compute_stream).tmpis freed withcudaFreeAsyncwhen it goes out of scope, also if aCUDA_CHECKthrows.
- Semantics: a snapshot of every batch submitted so far; the accumulator is never modified, so filling can continue afterwards. The caller needs no sync before calling.
values()allocates an uninitialisedNDArray(shape-only constructor), callsvalues(view)and returns it (NRVO, else a move).count_cap()(private,static constexpr): for integerStorageType, the smaller of its max andDeviceStorageType's max (compared asuintmax_t); for floating-point storage,DeviceStorageType's max. Capping once here equals the CPU's per-increment saturation, because counts only increase.bin_to_pixel_major: one thread per output elementt(64-bit index, return ift ≥ npix × nbins);pix = t / nbins,bin = t − pix × nbins;out[t] = (StorageType)min(in[bin × npix + pix], cap)with a ternary. Writes coalesced, reads strided (served mostly from cache).
values_pixel(row, col) const
- A debug/convenience call for inspecting one pixel; for many pixels, call
values()once. Throwsstd::invalid_argumentunlessrow < nrowsandcol < ncols. cudaMemcpy2DAsync(dst = host DeviceStorageType[nbins], dpitch = sizeof(DeviceStorageType), src = d_histogram + p, spitch = npix × sizeof(DeviceStorageType), width = sizeof(DeviceStorageType), height = nbins, D2H, compute_stream), then synchronise.- Cap each value at
count_cap()and convert into the returnedNDArray<StorageType, 1>in a host loop. (NDArray's expression constructor wouldn't do this: it needs an rvalue and truncates instead of capping.) - Cost: the copy engine moves one 4-byte piece per bin, so per byte it's slow (2D copies are always slower than 1D unless width = pitch). Fine for a single pixel.
bin_edges() / bin_centers()
Computed on demand, nothing stored: edges[i] = xmin + (i × (xmax − xmin)) / nbins for i in [0, nbins] (same formula as PixelHistogramImpl); centers[i] = (edges[i] + edges[i+1]) / 2.
RAII helpers (include/aare/RAII_device.hpp)
| Helper | Owns | Released by |
|---|---|---|
DeviceBuffer<T>(n) | cudaMalloc memory; get(), size(), bytes() | cudaFree |
HostRegistration(ptr, bytes) | A cudaHostRegister; explicit constructor; unregister() | cudaHostUnregister |
AsyncDeviceBuffer<T> via make_async_buffer<T>(n, stream) | cudaMallocAsync memory; a unique_ptr with a deleter that remembers the stream | cudaFreeAsync on that stream |
Stream via make_stream() | A cudaStreamNonBlocking stream | cudaStreamSynchronize, then destroy |
Event via make_event() | An event with DisableTiming | BlockingSync | cudaEventDestroy |
All deleters are noexcept and ignore errors; CUDA_CHECK (which throws) is only used outside destructors.
Decisions
Settled
| Choice | Instead of, and why |
|---|---|
| Two streams (copy / compute), two slots | One stream per slot: kernels from different slots would run at the same time and race on the histogram. |
One submit_batch queuing all six operations; internal slot counter | Separate public submit + process: only correct if the caller strictly alternates them. Submitting i and i + 2 before processing i silently counted batch i + 2 twice. |
| Frames copied as delivered, frame-major | Transposing frames to pixel-major: an extra pass, and it de-coalesces the kernel's reads. |
| One accumulator, resident on the GPU | Partial histograms per batch or per stream: counting is additive, so there's nothing to reduce. |
Bin-major uint32_t counts | Pixel-major: 32 sectors per warp whatever the data. StorageType counts: a saturation compare in the hot loop. Capping once in values() gives the same result as the CPU's per-increment saturation. |
| No atomics, no shared memory in the fill kernel | See Kernel design. |
| Input: caller owns the dataset; the class holds the registration | A class-owned host copy: wasted memory for data already in RAM. |
Output: caller-owned NDView; no host copy kept; not pinned by the class | A host histogram member ties 320 MB to the instance. Pinning per call costs about what it saves for a copy made once per run. |
| Convert and transpose on the device | D2H as uint32_t, then convert on the host: twice the PCIe bytes plus a slow host pass. |
| Bin edges computed on the host | A device copy: no kernel reads it. |
Open
- Input type (D1): frames are
floattoday. Rawuint16_twould halve PCIe traffic, the likely bottleneck. For the performance pass. - Batch size (D6): 64–256 frames should be enough (launch overhead ~5 µs vs ~13 µs per frame copy). Choose with nsys.
- Streaming from file: today the dataset must already be in host memory. If needed: a blocking submit, or a pinned staging ring that
MultiThreadedFileReaderwrites into directly.
Deferred optimisations
- Tiled transpose, only if the transpose shows up next to the D2H in nsys.
- A window of hot bins in shared memory (e.g. 32 ×
uint16_tper pixel, centred per pixel), only if the fill kernel turns out to be the bottleneck.
Tiled transpose: how it works
A 2D grid of 32 × 32 tiles: (npix + 31) / 32 pixel tiles in x, (nbins + 31) / 32 bin tiles in y. Each block has 32 × 8 threads, and each thread handles 4 rows of its tile. Read the tile with threadIdx.x walking pixels (coalesced in bin-major), store it in a [32][33] shared array, __syncthreads(), then write it with threadIdx.x walking bins (coalesced in pixel-major), capping at the write. The extra column avoids bank conflicts. Shared memory is what lets a thread write a value another thread read; a 2D tiling without it changes nothing about coalescing. The square tile matters: a 32 × 8 tile would write only 8 bins per pixel.
Steps
Each step ends with a passing test. Specs are in the reference.
-
RAII wrappers Done
All CUDA resources are owned by wrappers in
RAII_device.hpp; the class has no destructor. -
Host-side pipeline Written, untested
Constructor, input registration,
submit_batch,fill_histogram,flush(), bin edges and centres. Verified in step 6. -
Finalize kernel +
values()Done, verifiedChecked with a scratch nvcc test on the RTX 4090: a known pattern
count = bin × npix + pon 37 × 53 px and 45 bins (counts up to ~88 k), withuint16_tanduint32_tstorage, plus a 1 × 1 case and a run throughvalues();count_cap()checked at compile time for six types. Zero mismatches. Becomes a real Catch2 test in step 6. -
values_pixel()Done, verifiedcudaMemcpy2DAsyncgather, then cap and convert on the host. Scratch test on the known pattern (37 × 53 px, 45 bins, counts up to 88 244):values_pixel(r, c)equalsvalues()(r, c, :)for the four corners and two interior pixels, withuint16_tanduint32_tstorage. Zero mismatches. -
Fill kernel
fill_bin_majorWritten, untestedImplemented to the spec in the reference; the class compiles. Verified by step 6's comparison with the CPU class.
-
Tests and overlap Next
- CMake wiring: reuse the
AARE_CUDAoption and the test pattern already in this branch's base (seeClusterFinderCUDA_testimpl.cu/ClusterFinderCUDA.test.cpp). The kernel launch means only nvcc can compile files that include the header, so a.cutest implementation is called from the Catch2.test.cpp. - The tests listed under Testing and measuring.
- nsys shows H2D i+1 overlapping kernel i.
- CMake wiring: reuse the
-
Performance pass To do
One change at a time, behind the same tests:
uint16_tinput, batch size, block size, several frames loaded ahead per thread. Aim for throughput within ~10–20 % of measured H2D bandwidth. Once copy-bound, kernel speedups show up as nothing. -
Integration To do
Python binding (with
py::keep_aliveonregister_input_buffer), docs indocs/src/, aRELEASE.mdentry, the explicit source/test lists in CMake (see AGENTS.md). Done whenctestand pytest pass on a CUDA build and a non-CUDA build is unaffected.
Loose ends
Small items in the current files.
- hpp L202tidyThe "TODO: Needs transpose" above
values(out)is done. - hpp L240tidyA bare
// TODOunder the Option B comment, with nothing to do. - hpp L173tidyThe "let's not use shmem at first" comment inside the launch can go: the decision is final.
- cuda_check.cuhtidyNo newline at the end of the file, so compilers warn "backslash-newline at end of file". The file comes from the cluster-finder branch: fix it there.
Testing and measuring
Tests
- Known pattern written into
d_histogram(viaFRIEND_TESTfromtests/friend_test.hpp): checks the transpose and capping without the fill kernel. Use odd sizes so partial blocks are covered. - Exact match with
PixelHistogramImplon random frames, including values atxmin, just belowxmax, atxmaxand outside the range. - Pipeline cases: a partial last batch; several
fill_histogramcalls in a row;values()mid-run, then more filling. - Race test (a test of the tests): temporarily remove the
kernel_donewait and slow the kernel down. Results must become wrong; if they don't, the tests can't catch a synchronisation bug.
Measuring
- Ceiling: measure pinned H2D bandwidth (
nvbandwidthorbandwidthTest) and divide by the frame size to get the maximum frames/s. - Timeline: wrap submit,
values()and file reading in NVTX ranges, then:
nsys profile --trace=cuda,nvtx -o pixhist ./build/your_bench
nsys stats --report cuda_gpu_trace pixhist.nsys-rep
- Gaps on the copy row while kernels run: compute-bound, so optimise the kernel.
- Gaps on both rows: the host is the bottleneck (file reading, a hidden sync).
- Copies labelled "Pageable": the input isn't pinned, so nothing overlaps.
- A memcpy or memset on the default stream: something escaped your streams.
Pitfalls
- Default-stream callsPlain
cudaMemcpyandcudaMemsetrun on the legacy default stream, which streams created withcudaStreamNonBlockingdon't wait for. Keep every operation on your own streams. - Pageable memory
cudaMemcpyAsyncfrom unpinned memory works but is effectively synchronous. Nothing errors; you just lose the overlap. - Throwing in destructors
CUDA_CHECKthrows, and a throw from a destructor callsstd::terminate. Deleters ignore errors instead. - 32-bit index overflow
blockIdx.x × blockDim.xis computed in 32 bits; widen tosize_tfirst.bin × npixandframe × npixalso need 64 bits on large detectors. - Host constexpr in device code
std::numeric_limits<T>::max()needs nvcc's--expt-relaxed-constexprin a kernel. That's why the cap is computed on the host and passed in. - Kernel arguments vs pointersArguments are copied at launch, so passing the frame count by value is safe. Anything read through a pointer is read when the kernel runs.
Self-check
Answers are folded.
Why does the H2D into slot s wait on kernel_done[s], and not on the previous batch's kernel?
Only the kernel on slot s reads slot s. And since kernels run in order, kernel_done[s] firing means every earlier kernel has finished too.
Why do kernels never race on the histogram without atomics?
Each pixel belongs to one thread, and all kernels run one after another on the compute stream.
Why is bin-major never worse than pixel-major for the fill kernel?
In pixel-major, neighbouring threads are always nbins apart, so every warp increment touches 32 sectors. In bin-major that's only the worst case (all bins different); every shared bin reduces it.
Why is capping once at the end the same as saturating every increment?
Counts only increase, so both give min(n, max), as long as the uint32_t counter itself never wraps (4 × 10⁹ counts per bin).
What does the caller need to do before values()?
Nothing. values() queues the transpose and the D2H on the compute stream, after every kernel already submitted, and synchronises before returning.
Why does a third slot not raise throughput?
Throughput is set by the slower of copy and kernel. Extra slots only absorb jitter, such as one slow file read.