Files
aare/docs/optimization_summary.md
T
2026-08-06 14:01:23 +02:00

13 KiB
Raw Blame History

ClusterFinder CUDA — Profiling, Bottleneck Analysis, and Optimization Roadmap

1. Setup

  • Hardware: RTX 4090 (Ada, sm_89), PCIe 4.0 x16
  • Workload: 50,000 frames of 400×400 uint16 detector data, 3×3 cluster finding
  • Tooling: Nsight Systems 2024.5.1 (system-wide timeline), Nsight Compute 2024.1.1 (per-kernel deep dive)
  • Profiler counter access unlocked via:
    /etc/modprobe.d/nvidia-profiler.conf:
        options nvidia NVreg_RestrictProfilingToAdminUsers=0
    
    Confirmed by cat /proc/driver/nvidia/params | grep -i profilRmProfilingAdminOnly: 0.

2. Bottlenecks Identified from Nsys Profiling

2.1 Overall wall-time vs. ideal floor

From the original Phase 4 run (50k frames, 5 streams, BATCH_SIZE=2000):

Resource Total time Phase 4 utilization
Kernel execution 824 ms 21.1%
H2D copy engine 746 ms 19.1%
D2H copy engine 235 ms 6.0%
Phase 4 wall time 3903 ms

These three engines are independent hardware resources. With perfect overlap, the wall-clock floor is max(824, 746, 235) ≈ 824 ms. Actual measured wall time: 3903 ms. The GPU was idle ~80% of the time. This is host-scheduling-limited, not GPU-compute-limited.

2.2 Specific causes

Excessive host-device synchronization. 110,029 cudaStreamSynchronize calls (~2.2 per frame) consuming 452 ms of host time. Each sync blocks the host scheduler thread and prevents queueing of subsequent stream commands.

Two D2H transfers per frame. 110,002 D2H operations vs. 55,001 kernel launches — a count-then-data pattern where the second D2H size depends on the first D2H's result, forcing a per-frame sync.

Round-barrier pattern in the launch loop. Code structure for round { launch 5 streams; sync 5 streams } forces stream 0 to wait for the slowest stream in each round. PCIe RX throughput visibly pulses with ~80 µs busy / ~40 µs idle in the timeline, confirming the H2D engine isn't continuously fed.

Redundant host memcpy. The call site copies into a pageable batch_buffer, then find_clusters_batched copies again into per-stream pinned buffers. ~1.25 GB of host-DRAM memcpy per batch, all on one CPU core, blocking the launch loop. This shows up in the timeline as ~5070 ms gaps between bursts where the GPU is fully idle.

Per-frame launch overhead at small kernel size. Each frame triggers ~18 µs of fixed CUDA API overhead (launch + 3 memcpys + 2 syncs), vs. only ~15 µs of actual kernel execution. The host scheduling cost dominates the kernel cost.

2.3 What was working

PCIe pinned-memory transfers measured at 23.7 GB/s sustained (~95% of PCIe 4.0 x16 practical peak), confirming cudaMallocHost allocations are truly pinned. The kernel itself is regular and well-behaved (4% stddev across 55k launches, no thermal throttling, no spills per ptxas output: 0 bytes spill stores, 0 bytes spill loads).


3. Three-Tier Optimization Plan

Tier 1 — Recover the missing 2 seconds (host-side fixes)

Largest expected impact. Targets the gap between 824 ms (ideal floor) and 3903 ms (measured) by fixing host scheduling and synchronization patterns.

  • Drop the round-barrier: continuous queueing across all streams, with a single sync per stream at end-of-batch
  • Collapse count-then-data D2H into a single fixed-size D2H: per-frame device output as one contiguous [count][clusters[max]] block, eliminating the per-frame sync dependency
  • Eliminate the redundant host memcpy: ClusterFinder owns one large pinned staging buffer, populated by a single bulk memcpy at the top of find_clusters_batched
  • Expected gain: Phase 4 wall time from 3903 ms → ~10001500 ms

Tier 2 — Per-launch overhead reduction

After Tier 1 closes the host-scheduling gap, the next bottleneck becomes the per-launch CUDA API cost.

  • True work coalescing: one kernel processes N frames via blockIdx.z, amortizing the 2.5 µs cudaLaunchKernel cost over N×. Requires kernel signature change and a decision on how to handle per-frame pedestal updates (likely: move pedestal update to a separate sparse kernel)
  • CUDA Graphs: capture the per-batch sequence (memset → H2D → kernel → D2H) and replay as a single command-stream submission
  • Expected gain: ~1.52× over Tier 1, approaching the H2D copy engine floor (~13.5 µs/frame, ~675 ms for 50k frames)

Tier 3 — Kernel internals

Worth doing only after Tier 1 and 2, and only after ncu --set full confirms which kernel-internal change matters.

  • Drop the per-pixel double-precision arithmetic for variance/threshold (Ada has 1/64 FP64 throughput; precompute thresholds as float during pedestal updates)
  • Pedestal update write-back traffic: most pixels are non-photons and write 3 × 8 bytes per pixel, dominating DRAM traffic. Either move to float, or defer pedestal updates to a separate kernel
  • Halo-loading divergence cleanup: replace the role-based corner-handling code with a uniform cooperative tile load
  • Block geometry: A/B test 32×8 vs 16×16 once everything else is locked in

4. What Was Implemented (Tier 1)

Changes to ClusterFinderCUDA.hpp

New constructor signature:

ClusterFinderCUDA(Shape<2> shape, COMPUTE_TYPE nSigma,
                  size_t max_clusters_per_frame,   // was: capacity (global)
                  int n_streams,
                  size_t staging_batch_capacity)    // new

The third argument's semantics changed from "global cluster buffer size per stream" to "tight upper bound per frame." This is now a real number used for the fixed-size D2H, not an opaque global capacity.

Per-frame device output as single block:

d_output layout: [uint32_t count][padding to ClusterType alignment][ClusterType clusters[max]]

Offset computed as (count_bytes + cluster_align - 1) & ~(cluster_align - 1) for power-of-two alignment safety.

Internal pinned staging buffer: one cudaMallocHost of staging_batch_capacity × image_bytes, owned for the lifetime of the ClusterFinder. The caller's NDView is bulk-copied into this buffer once per batch.

Continuous-queue launch loop: frames distributed round-robin across streams; per-stream nested loop queues all of a stream's frames back-to-back without intermediate syncs. Single cudaStreamSynchronize per stream at end of batch.

Lazy-grown pinned output pool: one slot per frame within a batch (n_frames × output_bytes_per_frame), so each frame's D2H targets its own pinned destination and frames don't overwrite each other before parsing.

Per-frame kernel event timing pool (restored after initial removal): one event pair per frame slot, allocated lazily up to staging_batch_capacity, enables accurate avg_kernel_time_ms() reporting without the aliasing problem that one-event-pair-per-stream would have in continuous-queue mode.

Changes to bindings (bind_ClusterFinderCUDA.hpp)

  • Constructor binding updated to the new 5-argument form with renamed max_clusters_per_frame and new staging_batch_capacity
  • steal_clusters() no longer takes the realloc_same_capacity bool argument
  • steal_clusters() lambda uses std::move to transfer ownership out of the internal reference (required because ClusterVector is move-only)
  • Re-added #include <pybind11/stl.h> for automatic std::vector return conversion
  • Added py::array::c_style | py::array::forcecast to all py::array_t parameters to enforce contiguity at the binding boundary

Changes to Python factory (ClusterFinderCUDA function)

  • Kwarg capacitymax_clusters_per_frame
  • New kwarg staging_batch_capacity (defaults to 2000)
  • Default n_streams raised from 1 to 4
  • Tighter default max_clusters_per_frame=2048 (was unbounded-feel with capacity=1024 that would have silently truncated)

Changes to test/benchmark code

  • Constructor call sites updated to new signature
  • steal_clusters(true)steal_clusters() (no bool argument)
  • Optional: out-parameter overload of find_clusters_batched to avoid per-batch result vector allocation in tight benchmark loops

Changes to Jupyter notebook

  • Two steal_clusters(realloc_same_capacity=...) calls in the non-batched branch updated to steal_clusters()
  • BATCHED branch unchanged

5. What Still Needs to Be Done

Immediate next step: validate Tier 1

Re-run the test binary and check:

  • Phase 4 wall time should drop from 3903 ms → ~10001500 ms
  • cudaStreamSynchronize count in nsys stats should drop from 110k → ~n_batches × n_streams (~125 for 25 batches × 5 streams)
  • avg_kernel_time_ms() from the restored event pool should report ~0.015 ms (15 µs)
  • Per-frame wall time should land between 2030 µs (vs. old 78 µs)

If Phase 4 is significantly above ~1500 ms, the next thing to investigate in nsys is whether the host bulk-memcpy at the top of find_clusters_batched is on the critical path — visible as a gap at the start of each batch in the timeline.

Tier 1.5 — overlap host-memcpy with GPU execution

The current implementation runs the bulk host-memcpy on the calling thread, blocking the launch loop. To hide this cost:

  • Worker-thread approach: dedicate one host thread to filling staging buffer N+1 while the GPU consumes batch N. Requires a ping-pong (or larger) staging buffer.
  • Ping-pong staging: two pinned staging buffers, alternating between them per batch
  • Cost: adds threading complexity, but the bulk memcpy (~50 ms per 2000-frame batch) is currently on the critical path; hiding it could close the remaining gap to the kernel floor

Tier 2 — Work coalescing and CUDA Graphs

Pending Tier 1 validation. The two pieces:

Multi-frame kernel via blockIdx.z:

  • New kernel signature accepting [n_frames, nrows, ncols] input and per-frame output blocks
  • Resolve the pedestal-update race: simplest option is to disable the in-kernel pedestal update and run a separate sparse update kernel once per batch (small mathematical difference from current frame-by-frame behavior, likely negligible for slowly-drifting pedestals)
  • Estimated gain: collapses 2000 launches per batch into ~510 launches; saves ~120 ms of host overhead across Phase 4

CUDA Graph capture: once the per-batch sequence is regular (same operations on same buffers each time), capture once and replay. ~1.52× on launch-bound workloads. Synergizes with the multi-frame kernel because both reduce per-frame host overhead.

Tier 3 — Kernel internal optimization

Requires ncu --set full profiling run on a single kernel launch to prioritize:

  • Source counters → confirm 0 register spills
  • Memory Workload Analysis → L1 hit rate (current 3×3 stencil should benefit from shared memory; check whether it actually does)
  • Warp State Statistics → identify dominant stall reason (Long Scoreboard = DRAM-bound; Short Scoreboard = shared-memory-bound; Wait = barrier-bound)
  • GPU Speed of Light / Roofline → confirm kernel is memory-bound (expected) and how far from the DRAM ceiling

Then prioritize the kernel-internal changes (FP64 reduction, pedestal update offload, halo-load cleanup, block geometry) based on which one the profiler indicates as binding.

Open correctness issue (independent of perf work)

The original Phase 5 comparison reported 13.7M data_mismatch clusters and ~0.5M each of CPU-only / GPU-only out of 69M total. This is a kernel-vs-CPU algorithmic difference, not a performance issue, but worth tracing before further kernel optimization. Likely candidates:

  • Tie-breaking rule between adjacent local maxima (CPU and GPU may disagree on which pixel "wins" when two neighbors have equal values)
  • Off-by-one in cluster window indexing at frame boundaries
  • Difference in whether the central pixel must be a strict max (>) vs. ≥ max (>=)
  • The kernel's pedestal-update timing (CPU updates per-pixel sequentially as it walks the frame; GPU updates the entire frame's pedestal in parallel after the cluster decisions)

Profile a correct kernel, not a fast wrong one.


6. Reference Numbers

For future "did we improve?" comparisons:

Metric Original Tier 1 target Tier 2 target Theoretical floor
Phase 4 wall time 3903 ms ~1200 ms ~700 ms 675 ms (H2D-bound)
Per-frame wall 78 µs 24 µs 14 µs 13.5 µs
Per-frame kernel 15 µs 15 µs 15 µs 15 µs
cudaStreamSynchronize count 110,029 ~125 ~25
cudaLaunchKernel count 55,001 55,001 ~125
H2D bandwidth 23.7 GB/s 23.7 GB/s ~25 GB/s 26 GB/s (PCIe 4.0 x16 pinned)

The H2D engine is the hard floor on this hardware. To go below 13.5 µs/frame requires either reducing PCIe payload (uint8 input → 2× faster) or doing more compute per frame transferred (currently the simplest finder; nothing else to amortize over).