A CUDA C++ exercise in profiler-driven GPU optimisation.
The problem: given a segmented image where each pixel stores an integer object ID, find the axis-aligned bounding box (AABB) of every object.
The repository starts with a deliberately naive implementation — one thread per pixel, four global atomics per non-background pixel — and is intended to be progressively optimised using Nsight Systems and Nsight Compute, with each iteration guided by profiler data.
pixel-aabb/
generate_seg.cpp -- generates the segmentation PNG (run once)
pixel_aabb.cu -- CUDA kernel + host driver (the thing to optimise)
vis.h -- visualisation helpers (colourise, draw_rect)
palette.h -- shared colour palette
stb_impl.cpp -- single TU that provides stb_image implementations
CMakeLists.txt
| Requirement | Tested version |
|---|---|
| CMake | 3.18 or later |
| CUDA Toolkit | 12.x |
| C++ compiler | MSVC 2022 (Windows) / GCC 11+ (Linux) |
| Git | any recent version (used by FetchContent to download stb) |
stb_image and stb_image_write are downloaded automatically by CMake on
the first configure — no manual steps required.
# From the repo root (CMake 3.24+ detects your GPU automatically):
cmake -S . -B build
cmake --build build --config ReleaseThe default CMAKE_CUDA_ARCHITECTURES=native compiles for the GPU installed in
the build machine. To target a different GPU (e.g. for cross-compilation), pass
the architecture explicitly:
cmake -S . -B build -DCMAKE_CUDA_ARCHITECTURES=75GPU architecture values:
| Value | GPU family |
|---|---|
| 75 | Turing (RTX 20xx, GTX 16xx) |
| 86 | Ampere (RTX 30xx) |
| 89 | Ada (RTX 40xx) |
| 90 | Hopper (H100) |
To find your GPU's compute capability:
nvidia-smi --query-gpu=compute_cap --format=csv,noheaderNote: Compiling for the wrong architecture produces a binary that silently runs zero kernel threads — all AABB bounds will remain at
INT_MAX. Always verify the architecture matches your GPU before concluding the kernel is broken.
Binaries are written to build/Release/ on Windows or build/ on Linux.
A pre-generated segmentation image is included at assets/segmentation.png.
The absolute path to that file is baked into the binary at build time, so the
executable works from any working directory:
build/Release/pixel_aabb.exe # Windows
build/pixel_aabb # LinuxTo use a different image, pass the path explicitly:
./pixel_aabb path/to/other.pngpixel_aabb accepts any 8-bit grayscale PNG where pixel values are object IDs
(0 = background). It prints a table of AABBs and writes result.png —
the colourised segmentation with white bounding-box overlays — to the current
working directory.
generate_seg is only needed if you want to modify the scene (edit SCENE[]
in generate_seg.cpp) or produce a fresh segmentation.png. Run it from the
repo root so the output lands in assets/:
cd assets
../build/Release/generate_seg # Windows
../build/generate_seg # LinuxIt writes two files:
| File | Description |
|---|---|
segmentation.png |
Grayscale PNG — pixel value = object ID. |
segmentation_preview.png |
Colourised RGB PNG — open this to inspect the scene. |
Both open natively in Windows Photos (double-click).
Example output:
Loaded segmentation.png (1024x1024)
Launch config: grid(64, 64) block(16, 16) = 1048576 total threads
Kernel time: 0.0521 ms
ID x0 y0 x1 y1 width height
---------------------------------------------
[ 1] 60 90 239 209 180 120
[ 2] 270 45 489 194 220 150
...
Saved result.png
The kernel is compiled with -lineinfo so profiler output maps back to source
lines.
Nsight Systems (timeline, CPU/GPU overlap, memory transfers):
nsys profile --trace=cuda ./pixel_aabb
nsys-ui report1.nsys-repNsight Compute (per-kernel hardware metrics):
ncu --set full -o profile ./pixel_aabb
ncu-ui profile.ncu-repKey metrics to examine on the naive baseline:
l2_global_atomic_store_transactions— reveals the contention cost of the global atomics.sm_active_cycles / sm_elapsed_cycles— low ratio indicates the SMs are stalling, likely on atomic serialisation.dram_read_transactions— baseline reads IDs asint(4 bytes/pixel); switching touint8_tshould reduce this by 4x.
Each step should be validated against the profiler before moving to the next.
- Per-block shared-memory reduction — threads within a block reduce into shared memory first, then one thread performs the global atomic. Eliminates most contention.
- uint8_t input — store object IDs as one byte per pixel instead of four, reducing global memory read traffic by 4x.
- Warp-level reduction — use
__reduce_min_sync/__reduce_max_sync(sm_80+) to reduce within a warp before touching shared memory. - Coalescing and layout experiments — investigate whether a structure-of-arrays layout for the bounds or a different thread-block shape improves L2 hit rate.