How CUDA CCCL works: Thrust, CUB and libcu++ in 39 graphs
CCCL — the CUDA Core Compute Libraries — is the standard library of GPU programming: Thrust, CUB and libcu++ in one repository, NVIDIA/cccl. Almost all of it is C++ templates that compile straight into your program.
Five parts, mostly graphs: 1 the map · 2 one call followed down to the hardware · 3 the other algorithms · 4 libcu++, Python and cudax · 5 the math underneath, in plain words. Every number is read from the source at CCCL 3.6.0, commit 676b94d.
Part 1 — The map
The whole of CCCL on one page. Three ways in at the top — Python, a C++ program, your own kernel — and they all funnel into CUB, which is built on libcu++, right above the CUDA runtime and the hardware. The C++ libraries are header-only templates compiled into your program; the Python and C layers ship as compiled libraries.
A few words carry the rest of the article. A kernel is a function the CPU launches on the GPU (the “device”). It runs as a grid of blocks; each block lands on one SM (streaming multiprocessor — an H100 has 132) and splits into warps of 32 threads, numbered lanes 0–31.
Enough hardware for the rest of the article, with numbers for an H100 SXM. A kernel launch is a grid of blocks; a block is split into warps of 32 threads that execute one instruction together. The closer data sits to a thread, the faster it is to reach.
To watch warps execute, diverge and coalesce, try the GPU SIMT simulator.
Five words used everywhere below. A reduce gives one total; a scan (prefix sum) gives every running total — inclusive counts the item itself, exclusive stops just before it. Most of CCCL is these operations, done by thousands of threads at once.
Part 2 — One call, all the way down
The dive follows one line:
thrust::device_vector<float> v(1 << 28, 1.0f); // 268,435,456 floats = 1 GiB on the GPUfloat sum = thrust::reduce(v.begin(), v.end()); // 268,435,456 — exactly
Seven hops from one line to one number. The pattern in the middle is CUB's signature: it is called twice — first to ask how much scratch memory it needs, then to run — and in between the caller allocates (here, Thrust). CUB's newer overloads take an execution environment instead and allocate for you, by default with cudaMallocAsync.
The layers inside CUB
CUB is layered like nesting dolls. The outer two run on the CPU; from the kernel inward everything runs on the GPU. The inner three are public API: a collective is one call that every thread of a block (or warp) makes together, so your own kernels can use exactly the pieces CUB builds its algorithms from.
Picking a tile shape
A tile is the chunk of the array one block handles at a time: threads × items per thread.
Tuning is data, not code. A policy selector maps (determinism, GPU generation, type, operator) to a tile shape. An H100 (compute capability 9.0) takes the default run_to_run branch and lands in its second box: 256 threads × 16 items (vector loads of 4 only when the input is a raw pointer). The switch itself is passed through CUB's newer overloads, e.g. cuda::execution::require(cuda::execution::determinism::gpu_to_gpu); why floats need it at all is Part 5 §2.
The numbers are measured, not guessed — tunings keep the benchmark verdict that chose them:
Read it as: 17 items per thread, 192 threads, vector loads of 22 = 4 (ipv is a power of two). Against the default it replaced: at least 6 % faster, 13 % on average, up to 20 %.
Cutting the work into tiles
Cutting one reduction up. A tile is the chunk a block handles in one go (threads × items per thread). The grid is capped at 5 × the blocks that fit on the GPU at once — the subscription factor — and each block strides through the array a tile at a time, so the input is read exactly once and every block ends with a single partial sum.
Inside one block
One block, level by level. Threads first fold every tile the block owns into registers; only then, once per block, come the warp and block steps. Each level talks through the cheapest channel that reaches its partners: not at all, register-to-register (shuffles), then shared memory. Striped loads mean each warp instruction reads 128 neighbouring bytes; 16-byte float4 loads happen only when the input is a raw pointer, which Thrust's iterators currently are not.
Getting the data in
Reduce reads striped data straight into registers. Scan and sort need each thread’s items side by side, so they pay for a transpose:
CUB calls these data arrangements. Striped is what memory wants — neighbouring threads touch neighbouring addresses — and blocked is what per-thread algorithms want. A sum doesn't care about order, so DeviceReduce loads striped straight into registers; scan and sort need blocked order, so they load striped and transpose through shared memory.
Part 3 — The other algorithms
A reduction only needs blocks to agree at the very end. Scan and sort need every block to know about the blocks before it — and that is where CCCL’s best ideas live.
Scan: decoupled look-back
A scan writes every running total (Part 1), so tile 7 cannot write a single output until it knows the sum of tiles 0–6.
Single-pass scan with decoupled look-back (Merrill & Garland). A tile publishes its own sum (A) the moment it has it, so a successor waits only for its neighbours' own sums, never for their full prefixes — that split is what "decoupled" means. It adds As until it meets a P; one warp checks 32 predecessors at a time. For 4-byte values, status and value share one 8-byte word, so a single store publishes both and no reader can see a flag without its value.
On Blackwell-class GPUs (compute capability 10.0 and up) a contiguous scan runs a newer, warp-specialized form of the same idea: separate groups of warps load, reduce, look ahead and store, pipelined through shared memory. Hopper uses it too when bit-exact float sums are requested; other GPUs and fancy iterators get the classic look-back above.
Radix sort: onesweep
Radix sort orders keys one 8-bit digit at a time, lowest digit first: count how many keys have each of the 256 digit values, turn the counts into start positions, copy every key to its slot. Part 5 §5 draws one pass.
Onesweep (Adinets & Merrill): one kernel builds every histogram up front, and each digit pass is then a single kernel, because decoupled look-back — this time over 256 counters per tile — replaces the older three kernels per digit (count, scan, scatter). Tiles are numbered with atomicAdd in the order blocks actually start, so a block only ever waits on tiles that are already running: the look-back cannot deadlock.
Merge sort
DeviceMergeSort works for any comparison — strings, tuples, custom types — at the price of ⌈log2(tiles)⌉ passes over memory. Part 5 §5 shows the binary search that splits each merge evenly.
The whole family
Six ideas cover almost everything. Decoupled look-back alone carries scan, select, partition, unique, reduce-by-key and run-length encoding — which is why Part 3 opened with it.
Part 4 — libcu++, Python and cudax
libcu++: the ground floor
libcu++ is two libraries in one namespace family. cuda::std:: is the ordinary standard library, made to compile in GPU code; cuda:: adds what only a GPU has. CUB and Thrust are written in both.A GPU has more than one meaning of "visible to others". libcu++ encodes it in the type — cuda::atomic<T, scope> — and each scope compiles to one PTX qualifier. The PTX shown is what libcu++'s inline-assembly backend writes; newer toolkits reach the same instruction through compiler builtins.Asynchronous copies turn "load, then compute" into a conveyor belt: while the math works on tile k, the copy for tile k + 1 is already in flight. With these numbers the belt finishes in 330 units instead of 480.
Python gets the same kernels as C++, compiled on demand. The C library (cccl.c.parallel) writes the CUB template instantiation as source text, NVRTC (NVIDIA's runtime compiler) compiles it, and nvJitLink fuses it with your operator. numba-cuda-mlir is a Python-to-GPU compiler; LTO-IR is half-compiled code that can still be optimised when it is linked.
cudax: tasks that find their own order
CUDASTF (in cudax) takes tasks in plain program order, reads what each one does to each piece of data, and derives the dependency graph from the classic hazards. It runs the graph on CUDA streams (in-order queues of GPU work) or hands it to the GPU in one piece as a CUDA graph.
Part 5 — The math, in simple words
Everything above rests on six ideas. Here they are one at a time, each drawn with its numbers.
1. Adding in parallel
The whole trick of parallel reduction. Because (a + b) + c = a + (b + c), the additions can be regrouped into pairs that happen at the same time. Same work, far fewer rounds.Doubling the data adds just one round, so the chain of steps that must wait for each other stays short even for a billion numbers. The time itself is set by memory (§3).shfl.sync.down lets a lane read another lane's register directly — no memory at all. Three steps collapse 8 values into lane 0; five collapse a whole warp. The grey lanes compute too, every step; their results are simply never used.
2. Floats round, so order matters
Every float addition rounds, so a different grouping can give different last bits. DeviceReduce's default keeps the grouping fixed for a given GPU (DeviceScan's default is not_guaranteed); gpu_to_gpu uses a reproducible accumulator that gives the same bits everywhere, at some cost.
It bites harder than it looks: add 1.0f to a running total one at a time and it stops at 16,777,216 (224) — from there every + 1 rounds back down. The one-liner in Part 2 stays exact because the only sums that grow past 224 are whole numbers of 4,096-float tiles, and multiples of 4,096 are still exact in a float.
3. Memory is the speed limit
A reduction does one addition per 4-byte number it reads; the GPU could do 20 FLOPs in the time one byte arrives. So CUB's job is not faster math — it is keeping the memory system saturated."Speed of light" in CUB's vocabulary: the time the bytes alone need. A good implementation lands close to these numbers; everything in Part 2 exists to get there.A warp's 32 loads are served in 32-byte sectors. Neighbouring addresses share sectors; spread them out and the same 128 useful bytes cost up to 8× the traffic. This is why CUB loads striped and uses vector loads.Why BlockExchange — the transpose from "Getting the data in" — pads its shared memory: without padding, 8 lanes queue on one bank and the access takes 8 rounds; one spare word every 32 shifts each group onto fresh banks.To stream at full bandwidth a GPU must keep this many bytes requested-but-not-yet-arrived. CUB turns it into a tuning knob: DeviceTransform grows items per thread until the target is met.Tunings are written in nominal 4-byte units and scaled to the real element size, keeping each thread's bytes in flight near constant — the memory system cares about bytes, not items. 1-byte types are capped at twice the nominal item count.
4. Prefix sums
A prefix sum ("scan") replaces each value with the total of everything up to it. Kogge–Stone does it in log2 n steps, which is ideal inside a warp where all 32 lanes run anyway.No single scan wins on both axes. The cheap-work algorithm is serial; the fast one does 9× the work. CUB uses each where it is cheapest.The same pattern as the reduction funnel in Part 2, now for scan: serial where it is free, parallel where it is cheap, and a single global pass on top. On Blackwell-class GPUs the warp-specialized lookahead kernel replaces the bottom three rows' plumbing, with the same idea.Why scan matters so much: it turns "which items survive?" into "where does each survivor go?". DeviceSelect, DevicePartition and Unique are this picture plus decoupled look-back.Reduce-then-scan reads the input twice. Chained scan reads it once but makes every tile wait for its predecessor. Decoupled look-back gets the 2n traffic without the queue — 1.5× less data than the classic approach.
5. Sorting by digits, and merging by diagonals
One pass of LSD radix sort is a counting sort: count each digit, scan the counts into starting positions, drop every key into its slot. Sorting 32-bit keys is four of these, 8 bits at a time.Radix sort only understands unsigned integers. IEEE floats store sign and magnitude, so CUB flips bits on the way in (and back on the way out) to make integer order equal numeric order.Radix sort's cost depends on the key's bits, not on n; merge sort pays one more pass per doubling. That is why Thrust sorts plain numbers with radix sort and keeps merge sort for custom comparisons; CUB offers both.Merge path (Odeh, Green et al.): merging is a walk from the top-left to the bottom-right corner. Cut the walk with evenly spaced diagonals and every thread gets the same amount of output; finding a cut costs one small binary search.
6. Histograms
A histogram is one line of arithmetic per sample. The engineering is in the counting: private per-block counters keep thousands of threads from fighting over the same global counter.
Cheat sheet
Pick the layer by how much control you need: Thrust for convenience, CUB when you manage memory and streams yourself or write your own kernels, libcu++ for the primitives underneath.
Sources
NVIDIA/cccl at commit 676b94d (CCCL 3.6.0) — especially cub/cub/agent/single_pass_scan_operators.cuh, cub/cub/device/dispatch/dispatch_reduce.cuh, the tuning tables in cub/cub/device/dispatch/tuning/, and thrust/thrust/system/cuda/detail/reduce.h.