Prefer to read without ads? Become a member — from $10/month — and support the work. Already a member? Log in to read ad-free on this device.

13.3 CUDA Implementations

Designing Scan algorithms and studying circuit diagrams is instructive, but in order to implement Scan for CUDA, we need to map the algorithms onto registers, memory and addressing schemes, and correct synchronization.

The optimal CUDA implementation of Scan depends on the size of the scan being performed; different schemes are best for warp-sized scans, scans that can fit in shared memory, and scans that must spill to global memory. Because blocks cannot reliably exchange data through global memory, scans too large to fit in shared memory must perform multiple kernel invocations3.

Before examining special cases (such as scanning of predicates), we build Scan for CUDA from the bottom up. The warp scan is the primitive at the very bottom; a block scan is built on top of it, and three device-wide strategies on top of that:

13.3.1 Warp Scans

The primitive at the bottom of every Scan implementation in this chapter is the warp scan: an inclusive scan across the 32 threads of a warp. Because the threads of a warp execute together and can exchange data without barriers, warp scans are fast, and the block- and device-wide scans that follow all use one as their innermost step.

The warp scan is modeled on the Kogge-Stone circuit (Figure 13-7). Kogge-Stone circuits are work-inefficient – they perform many operations for their small depth – but at the warp level, where the hardware’s execution resources are available regardless whether the developer uses them, that inefficiency is free and the shallow depth wins.

Listing 13-3 gives the warp scan as a __device__ routine that operates on shared memory, the fastest way for threads to exchange data with one another. It is packaged not as a free function but as a policy: a struct, WarpScanShared, with one static method, inclusive(), that takes a thread’s value and a pointer into shared scratch and returns the scanned result. The reason for the struct, rather than a plain function, becomes clear once the second implementation is in hand.

struct WarpScanShared {                        // Kogge-Stone in shared, Volta-clean    static constexpr const char *name = "WarpScanShared";    template<class T>    static inline __device__ T    inclusive( T val, volatile T *scr )    {        const int lane = threadIdx.x & 31;        scr[0] = val;        T t = val;        #pragma unroll        for ( int offset = 1; offset < 32; offset <<= 1 ) {            if ( lane >= offset ) t += scr[-offset];            __syncwarp();            scr[0] = t;            __syncwarp();        }        return t;    }};
Listing 13-3. WarpScanShared: warp scan in shared memory (source on GitHub)

Older implementations went to some length to shave instructions off this shared-memory warp scan – for example, interleaving each warp’s data with a block of zeros so the lane-ID conditionals could be removed. Since SM 3.0 (2013), the warp shuffle instruction has made those tricks obsolete: it exchanges registers directly between the threads of a warp, with no shared memory at all. Its “up” and “down” variants implement scan and reverse scan.

Listing 13-4 gives the second policy, WarpScanShuffle. Each of its five doubling steps calls __shfl_up_sync() to fetch the partial sum from the lane offset positions below, and a lane-ID test guards the add so that the bottom lanes, which have no lower neighbor, keep their own value. The scan touches no shared memory and lives entirely in registers.

struct WarpScanShuffle {                        // register-only, __shfl_up_sync    static constexpr const char *name = "WarpScanShuffle";    template<class T>    static inline __device__ T    inclusive( T val, volatile T * /*unused*/ )    {        const int lane = threadIdx.x & 31;        #pragma unroll        for ( int offset = 1; offset < 32; offset <<= 1 ) {            T n = __shfl_up_sync( 0xffffffffu, val, offset );            if ( lane >= offset ) val += n;        }        return val;    }};
Listing 13-4. WarpScanShuffle: register-only warp scan (source on GitHub)

Listing 13-5 gives the block-wide scan, blockScanInclusive(), written once as a function template parameterized on the warp policy. It scans each warp with WarpPolicy::inclusive(), records each warp’s total in shared memory, scans those per-warp totals with a single shuffle pass, and adds each warp’s exclusive prefix back into its lane. Selecting the warp scan through a template type parameter – not a runtime flag or a function pointer – lets it inline with no indirection, and because a function template cannot be partially specialized, a policy type is the idiomatic way to choose among implementations of one algorithm. An exclusive scan needs no code of its own: warpScanExclusive() returns the inclusive result minus the thread’s own value.

// Block-wide inclusive scan, composed on the SAME policy -- the warp scan inlines// (no function pointer). scr: blockDim ints of shared, warp-contiguous so the// shared policy's neighbour reads stay within a warp's own run.template<class WarpPolicy, class T>inline __device__ TblockScanInclusive( T val, volatile T *scr ){    const int tid = threadIdx.x, lane = tid & 31, warpid = tid >> 5;    const int nwarps = blockDim.x >> 5;    __shared__ T warpTotals[32];     T v = WarpPolicy::template inclusive<T>( val, scr + tid );    if ( 31 == lane ) warpTotals[warpid] = v;    __syncthreads();    if ( 0 == warpid ) {                        // exclusive scan of the per-warp totals        T w = ( lane < nwarps ) ? warpTotals[lane] : (T)0, inc = w;        #pragma unroll        for ( int offset = 1; offset < 32; offset <<= 1 ) {            T n = __shfl_up_sync( 0xffffffffu, inc, offset );            if ( lane >= offset ) inc += n;        }        warpTotals[lane] = inc - w;    }    __syncthreads();    return v + warpTotals[warpid];}
Listing 13-5. blockScanInclusive(): block scan over a warp policy (source on GitHub)

13.3.2 Scan-then-Fan

The scan-then-fan approach uses a similar decomposition for global and shared memory. Figure 13-8 shows the approach used to scan a threadblock: a scan is performed on each 32-thread warp, and the reduction of that 32-element subarray is written to shared memory. A single warp then scans the array of partial sums; a single warp is sufficient because CUDA does not support threadblocks with more than 1024 threads. Finally, the base sums are fanned out to each warp’s output elements. Note that Figure 13-8 shows an inclusive scan being performed in step 2), so the first element of its output must be fanned out to the second warp, and so on.

Figure 13-8. Scan-then-fan (shared memory)

The code to implement this algorithm is given in Listing 13-6. The input array is assumed to have been loaded into shared memory already, and the parameters sharedPartials and idx specify the base address and index of the warp to scan, respectively. (In our first implementation, threadIdx.x is passed as the parameter idx.) Lines 9-13 implement Step 1 of Figure 13-8; lines 16-21 implement Step 2; and lines 31-45 implement Step 3. The output value written by this thread is returned to the caller, but used only if it happens to be the thread block’s reduction.

template<class T>inline __device__ TscanBlock( volatile T *sPartials ){    extern __shared__ T warpPartials[];    const int tid = threadIdx.x;    const int lane = tid & 31;    const int warpid = tid >> 5;     //    // Compute this thread's partial sum    //    T sum = scanWarp<T>( sPartials );    __syncthreads();     //    // Write each warp's reduction to shared memory    //     if ( lane == 31 ) {        warpPartials[16+warpid] = sum;    }    __syncthreads();     //    // Have one warp scan reductions    //    if ( warpid==0 ) {        scanWarp<T>( 16+warpPartials+tid );    }    __syncthreads();     //    // Fan out the exclusive scan element (obtained    // by the conditional and the decrement by 1)    // to this warp's pending output    //    if ( warpid > 0 ) {        sum += warpPartials[16+warpid-1];    }    __syncthreads();     //    // Write this thread's scan output    //    *sPartials = sum;    __syncthreads();     //    // The return value will only be used by caller if it    // contains the spine value (i.e. the reduction    // of the array we just scanned).    //    return sum;}
Listing 13-6. scanBlock(): Block portion of scan-then-fan for thread blocks (source on GitHub)

Figure 13-9 shows how this approach is adapted to global memory: a kernel scans b-element subarrays where b is the block size; the partial sums are written to global memory and another, 1-block kernel invocation scans these partial sums, which are then fanned into the final output in global memory.

Figure 13-9. Scan-then-fan (global memory)

Listing 13-7 gives the CUDA code for the scan kernel of Step 1 in Figure 13-9. It loops over the threadblocks to process, staging the input array into and out of shared memory. The kernel then optionally writes the spine value to global memory at the end. At the bottom level of the recursion, there is no need to record spine values, so the bWriteSpine template parameter enables the kernel to avoid dynamically checking the value of partialsOut.

template<class T, bool bWriteSpine>__global__ voidscanAndWritePartials(     T *out,     T *gPartials,     const T *in,     size_t N,     size_t numBlocks ){    extern volatile __shared__ T sPartials[];    const int tid = threadIdx.x;    volatile T *myShared = sPartials+tid;     for ( size_t iBlock = blockIdx.x;                  iBlock < numBlocks;                  iBlock += gridDim.x ) {        size_t index = iBlock*blockDim.x+tid;         *myShared = (index < N) ? in[index] : 0;        __syncthreads();         T sum = scanBlock( myShared );        __syncthreads();        if ( index < N ) {            out[index] = *myShared;        }        //        // write the spine value to global memory        //        if ( bWriteSpine && (threadIdx.x==(blockDim.x-1)) )        {            gPartials[iBlock] = sum;        }    }}
Listing 13-7. scanAndWritePartials() (source on GitHub)

Listing 13-8 gives the host function that uses Listings 13-6 and 13-7 to implement an inclusive scan on an array in global memory. Note that the function recurses for scans too large to perform in shared memory; the first conditional in the function serves both as the base case for the recursion and to short-circuit scans small enough to perform in shared memory alone, avoiding any need to allocate global memory. Note how the amount of shared memory needed by the kernel (b*sizeof(T)) is specified at kernel invocation time.

For larger scans, the function computes the number of partial sums needed \(\left\lceil \frac{N}{b} \right\rceil\), allocates global memory to hold them, and follows the pattern of Figure 13-9, writing partial sums to the global array for later use by the scanAndWritePartials() kernel of Listing 13-7.

Each level of recursion reduces the number of elements being processed by a factor of b, so for e.g. b=128 and N= 1048576, two levels of recursion are required: one of size 8192 and a second of size 64.

The partials array is transient scratch – allocated and freed within a single scanFan call, once per level of the recursion. scanFan draws it from the stream-ordered allocator of Section 5.2.3 (cudaMallocAsync/cudaFreeAsync) rather than cudaMalloc, so each allocate and free is ordered on the stream instead of synchronizing the device, and the blocks come from a pool that a repeated scan reuses rather than re-obtaining from the driver.

template<class T>voidscanFan( T *out, const T *in, size_t N, int b ){    cudaError_t status_cudart;     if ( N <= b ) {        scanAndWritePartials<T, false><<<1,b,b*sizeof(T)>>>(             out, 0, in, N, 1 );        return;    }     //    // device pointer to array of partial sums in global memory    //    T *gPartials = 0;     //    // ceil(N/b)    //    size_t numPartials = (N+b-1)/b;     //    // one thread block per b-element tile.  The kernels are    // grid-stride, so any block count within CUDA's limits would    // also work; one block per tile keeps the mapping simple.    //    size_t numBlocks = numPartials;     //    // The partials array is transient scratch: it lives only for the    // duration of this (recursive) call.  Allocate it stream-ordered from    // the default stream's memory pool so the driver can satisfy the request    // by recycling scratch freed at a shallower recursion level, rather than    // synchronizing the device on every cudaMalloc/cudaFree.    //    cuda(MallocAsync( &gPartials, numPartials*sizeof(T), 0 ) );     scanAndWritePartials<T, true><<<numBlocks,b,b*sizeof(T)>>>(        out, gPartials, in, N, numPartials );    scanFan<T>( gPartials, gPartials, numPartials, b );    scanAddBaseSums<T><<<numBlocks, b>>>( out, gPartials, N, numPartials );  Error_cudart:    if ( gPartials ) cudaFreeAsync( gPartials, 0 );}
Listing 13-8. scanFan() host function. (source on GitHub)

Listing 13-9 completes the picture with a very simple kernel to fan-out results from global memory to global memory.

template<class T>__global__ voidscanAddBaseSums(     T *out,     T *gBaseSums,     size_t N,     size_t numBlocks ){    const int tid = threadIdx.x;     T fan_value = 0;    for ( size_t iBlock = blockIdx.x;                  iBlock < numBlocks;                  iBlock += gridDim.x ) {        size_t index = iBlock*blockDim.x+tid;        if ( iBlock > 0 ) {            fan_value = gBaseSums[iBlock-1];        }        out[index] += fan_value;    }}
Listing 13-9. scanAddBaseSums() kernel (source on GitHub)

13.3.3 Reduce-then-Scan

At the highest level of recursion, the scan-then-fan strategy performs 4N global memory operations: the initial scan performs one read and one write, then the fan of Listing 13-9 performs another read and write. We can decrease the number of global memory operations by first computing only reductions on the input array, deferring the scanning to a second pass.

Figure 13-10 shows how this strategy works. As before, an array of \(\left\lceil \frac{N}{b} \right\rceil\) partial sums of the input is computed and scanned to compute an array of base sums; but instead of doing the scan in the first pass, the first pass computes only the reductions. The scan of the final output is then performed, adding the base sum along the way. This performs 3N global memory operations – one read per element in the reduction pass, then a read and a write in the scan pass – as against 4N for scan-then-fan.

Figure 13-10. Reduce-then-scan.

Merrill4 describes a refinement of this strategy that uses a small, fixed-size number of base sums. The algorithm is the same as Figure 13-10, except that the array of Step 2) is a relatively small, fixed size of perhaps a few hundred instead of \(\left\lceil \frac{N}{b} \right\rceil\) partial sums. The number of partial sums is the same as the number of thread blocks to use, both for the reduction pass and for the Scan pass. Listing 13-10 shows the code to compute these partial sums, which computes reductions for subarrays of size elementsPerPartial as opposed to the thread block size.

template<class T, int numThreads>__device__ voidscanReduceSubarray(     T *gPartials,     const T *in,     size_t iBlock,     size_t N,     int elementsPerPartial ){    extern volatile __shared__ T sPartials[];    const int tid = threadIdx.x;     size_t baseIndex = iBlock*elementsPerPartial;     T sum = 0;    for ( int i = tid; i < elementsPerPartial; i += blockDim.x ) {        size_t index = baseIndex+i;        if ( index < N )            sum += in[index];    }    sPartials[tid] = sum;    __syncthreads();     reduceBlock<T,numThreads>( &gPartials[iBlock], sPartials );} /* * Compute the reductions of each subarray of size * elementsPerPartial, and write them to gPartials. */template<class T, int numThreads>__global__ voidscanReduceSubarrays(     T *gPartials,     const T *in,     size_t N,     int elementsPerPartial ){    extern volatile __shared__ T sPartials[];     for ( int iBlock = blockIdx.x;           iBlock*elementsPerPartial < N;           iBlock += gridDim.x )    {        scanReduceSubarray<T,numThreads>(             gPartials,             in,             iBlock,             N,             elementsPerPartial );    }}
Listing 13-10. scanReduceSubarrays() (source on GitHub)

Listing 13-11 gives the Scan code, which has been modified to carry over each block’s sum as the Scan of that block is completed.

template<class T>__global__ voidscan2Level_kernel(     T *out,     const T *gBaseSums,     const T *in,     size_t N,     size_t elementsPerPartial ){    extern volatile __shared__ T sPartials[];    const int tid = threadIdx.x;    int sIndex = (threadIdx.x);         T base_sum = 0;    if ( blockIdx.x && gBaseSums ) {        base_sum = gBaseSums[blockIdx.x-1];    }    for ( size_t i = 0;                 i < elementsPerPartial;                 i += blockDim.x ) {        size_t index = blockIdx.x*elementsPerPartial + i + tid;        sPartials[sIndex] = (index < N) ? in[index] : 0;        __syncthreads();         scanBlock<T>( sPartials+sIndex );        __syncthreads();        if ( index < N ) {            out[index] = sPartials[sIndex]+base_sum;        }        __syncthreads();         // carry forward from this block to the next.        base_sum += sPartials[             (blockDim.x-1) ];        __syncthreads();    }}
Listing 13-11. scan2Level_kernel() (source on GitHub)

Listing 13-12 gives the host code for Merrill’s two-pass reduce-then-scan algorithm. Since the number of partials computed is small and never varies, the host code never has to allocate global memory in order to perform the scan – instead, we declare a __device__ array that is allocated at module load time:

__device__ int g_globalPartials[MAX_PARTIALS];

and obtain its address by calling cudaGetSymbolAddress():

    status = cudaGetSymbolAddress(
                (void **) &globalPartials,
                g_globalPartials );

The routine then computes the number of elements per partial and number of threadblocks to use and invokes the three (3) kernels needed to perform the computation.

template<class T>voidscan2Level( T *out, const T *in, size_t N, int b ){    int sBytes = ((b)*sizeof(T));     if ( N <= b ) {        return scan2Level_kernel<T><<<1,b,sBytes>>>(             out, 0, in, N, N );    }     cudaError_t status_cudart;    T *gPartials = 0;    cuda(GetSymbolAddress( (void **) &gPartials, g_globalPartials ));     {        //        // ceil(N/b) = number of partial sums to compute        //        size_t numPartials = (N+b-1)/b;         if ( numPartials > MAX_PARTIALS ) {            numPartials = MAX_PARTIALS;        }         //        // elementsPerPartial has to be a multiple of b        //         unsigned int elementsPerPartial = (N+numPartials-1)/numPartials;        elementsPerPartial = b * ((elementsPerPartial+b-1)/b);        numPartials = (N+elementsPerPartial-1)/elementsPerPartial;         //        // number of CUDA threadblocks to use.  The kernels are         // blocking agnostic, so we can clamp to any number within         // CUDA's limits and the code will work.        //        const size_t maxBlocks = MAX_PARTIALS;        size_t numBlocks = std::min( numPartials, maxBlocks );         scanReduceSubarrays<T>(             gPartials,             in,             N,             elementsPerPartial,             numBlocks,             b );        scan2Level_kernel<T><<<1,b,sBytes>>>(             gPartials,             0,             gPartials,             numPartials,             numPartials );        scan2Level_kernel<T><<<numBlocks,b,sBytes>>>(            out,             gPartials,             in,             N,             elementsPerPartial );    }Error_cudart:;}
Listing 13-12. scan2Level (source on GitHub)

13.3.4 Single-Pass Decoupled Look-Back

Both strategies so far make more than one pass over the input in global memory: scan-then-fan performs 4N operations and reduce-then-scan performs 3N. The extra passes exist because thread blocks cannot share their partial results without returning to the host to launch another kernel. If the blocks could hand their partial sums to one another while the kernel is still running, a single pass – one read and one write per element, 2N operations – would suffice.

Merrill and Garland’s decoupled look-back algorithm does exactly this, and it is the method that CUB and Thrust use to implement device-wide scan. One thread block processes one tile of b elements in a single pass. The blocks are ordered, and each computes the inclusive scan of its own tile in shared memory (using the warp-scan machinery of Section 13.3.1); the last element of that local scan is the tile’s aggregate. To turn its local scan into a global one, a tile needs the exclusive prefix – the sum of all elements in the tiles before it – and computing that prefix is what the look-back does.

The obstacle is the prefix dependency: a tile’s exclusive prefix is its immediate predecessor’s inclusive prefix, so the most literal implementation would have each tile wait for the one before it to finish, serializing the entire grid. The insight that breaks the serialization is that a tile can publish its aggregate – which it computes on its own, with no predecessor – long before it knows its own prefix. A waiting successor can then make progress by summing aggregates rather than blocking on a finished prefix. The two computations are thereby decoupled: prefixes propagate down the grid while aggregates are still being produced.

Each tile advertises its progress through a one-word status descriptor in global memory that moves through three states:

A tile computes its exclusive prefix by looking back over its predecessors, nearest first (Figure 13-11). The look-back is done cooperatively by one warp: the 32 lanes read the descriptors of the 32 nearest predecessors at once, a __ballot finds the nearest lane whose tile has published an inclusive prefix (state P), and a warp reduction sums the values from the frontier back to it – the aggregates of the tiles in between, plus that prefix, which already folds in everything before it. If no lane in the window holds a P, the warp sums all 32 aggregates and steps back another 32 predecessors. Because the walk stops at the first P – and prefixes are continually being filled in behind it – it is short in practice, usually a single 32-wide step.

Figure 13-11. Single-pass decoupled look-back. Tile 5 has published its aggregate (A) and looks back nearest-first, summing the aggregates of tiles 4 and 3 until it reaches tile 2’s already-computed inclusive prefix (P), which it adds before publishing its own prefix. Tiles ahead (X) have not started.

Two details make the descriptor cheap and the algorithm safe. First, the status flag and its 32-bit value are packed into a single 64-bit word, written and read with one atomic operation. Because a reader that observes the flag necessarily observes the matching value, no separate memory fence is needed between them. This costs one bit of generality: it assumes a 32-bit element type and a commutative, associative operator (here, integer addition). A 64-bit element type needs a two-word descriptor and an explicit fence, which is what CUB implements.

Second, a tile that waits on a predecessor must be certain that predecessor is resident and running, or the look-back could spin forever against a block the scheduler has not launched. The kernel therefore claims tile indices from a global atomic counter on entry rather than using blockIdx.x. A block that later waits on predecessor p claimed a larger index than p did, so p began executing first; the look-back cannot deadlock.

The descriptor’s encoding and its three status codes are given in Listing 13-13.

enum { SCAN_X = 0, SCAN_A = 1, SCAN_P = 2 };  // invalid / aggregate / prefix // 64-bit descriptor word. Deliberately unsigned long long, not uint64_t: CUDA's// 64-bit atomics (atomicOr/atomicExch) are overloaded only for unsigned long// long, and uint64_t is a distinct type (unsigned long on LP64) that won't bind.typedef unsigned long long scanStatus; __device__ __forceinline__ scanStatusscanPackStatus( uint32_t flag, int value ){    return ( (scanStatus) flag << 32 ) | (uint32_t) value;}
Listing 13-13. Status descriptor for decoupled look-back. (source on GitHub)

The kernel itself is given in Listing 13-14: each block claims a tile, inclusive-scans it in shared memory, and its first warp performs the cooperative look-back before the block writes its outputs – all in a single launch.

template<class T>__global__ voidscanDecoupledLookback_kernel(    T *out,    const T *in,    volatile scanStatus *status,  // one descriptor per tile (SCAN_X-initialized)    uint32_t *tileCounter,          // one global counter, 0-initialized    size_t N ){    extern __shared__ T s[];        // blockDim.x elements    __shared__ uint32_t s_tile;    __shared__ T s_exclusive;     //    // Claim a tile index from a global counter. A block that later waits on    // predecessor p is then guaranteed p is already resident and running (p    // claimed a smaller index earlier), so the look-back cannot deadlock.    //    if ( threadIdx.x == 0 ) {        s_tile = atomicAdd( tileCounter, 1 );    }    __syncthreads();    const uint32_t tile = s_tile;    const size_t base = (size_t) tile * blockDim.x;    const size_t gidx = base + threadIdx.x;     //    // Load the tile (zero-filling past the end) and inclusive-scan it in shared    // memory (Kogge-Stone). s[blockDim.x-1] then holds the tile's aggregate.    //    s[threadIdx.x] = ( gidx < N ) ? in[gidx] : (T) 0;    __syncthreads();    for ( int off = 1; off < blockDim.x; off <<= 1 ) {        T add = ( threadIdx.x >= off ) ? s[threadIdx.x - off] : (T) 0;        __syncthreads();        s[threadIdx.x] += add;        __syncthreads();    }    const T aggregate = s[blockDim.x - 1];     //    // Compute this tile's exclusive prefix. Tile 0 has no predecessors; every    // other tile advertises its aggregate, then warp 0 looks back cooperatively    // over 32 predecessors at a time.    //    if ( tile == 0 ) {        if ( threadIdx.x == 0 ) {            atomicExch( (scanStatus *) &status[tile],                        scanPackStatus( SCAN_P, aggregate ) );            s_exclusive = (T) 0;        }    }    else {        // Advertise our aggregate so successors can make progress without us.        if ( threadIdx.x == 0 )            atomicExch( (scanStatus *) &status[tile],                        scanPackStatus( SCAN_A, aggregate ) );         if ( threadIdx.x < 32 ) {            const int lane = threadIdx.x;            T exclusive = (T) 0;            int frontier = (int) tile - 1;      // nearest predecessor not yet consumed            for ( ;; ) {                // Lane L inspects predecessor (frontier - L): the 32 nearest                // predecessors at once, lane 0 == nearest.                const int pidx = frontier - lane;                uint32_t flag;                T val;                do {                            // re-read the window until no lane sees X                    if ( pidx >= 0 ) {                        scanStatus d = atomicOr( (scanStatus *) &status[pidx], 0ull );                        flag = (uint32_t) ( d >> 32 );                        val  = (T) (int) ( d & 0xffffffffu );                    } else {                        flag = SCAN_P;          // out of range: prefix 0, stops the walk                        val  = (T) 0;                    }                } while ( __any_sync( 0xffffffffu, flag == SCAN_X ) );                 // Nearest inclusive prefix in the window (lowest lane with P).                const uint32_t pmask = __ballot_sync( 0xffffffffu, flag == SCAN_P );                const int firstP = pmask ? ( __ffs( pmask ) - 1 ) : 32;                 // Sum lanes 0..firstP: aggregates up to it, plus its prefix.                T contrib = ( lane <= firstP ) ? val : (T) 0;                #pragma unroll                for ( int off = 16; off > 0; off >>= 1 )                    contrib += __shfl_xor_sync( 0xffffffffu, contrib, off );                exclusive += contrib;                 if ( firstP < 32 )              // reached a prefix -> done                    break;                frontier -= 32;                 // whole window was aggregates -> keep walking            }            if ( lane == 0 ) {                s_exclusive = exclusive;                atomicExch( (scanStatus *) &status[tile],                            scanPackStatus( SCAN_P, exclusive + aggregate ) );            }        }    }    __syncthreads();     //    // Add the exclusive prefix to the local inclusive scan and write the output.    //    if ( gidx < N ) {        out[gidx] = s_exclusive + s[threadIdx.x];    }}
Listing 13-14. scanDecoupledLookback_kernel() (source on GitHub)

Listing 13-15 gives the host function, which allocates the per-tile status array (zero-initialized to X) and the tile counter – both stream-ordered from the memory pool, as scanFan allocates its partials in Section 13.3.2 – then makes the one kernel launch.

template<class T>voidscanDecoupledLookback( T *out, const T *in, size_t N, int b ){    cudaError_t status_cudart;    scanStatus *gStatus = 0;    uint32_t *tileCounter = 0;     if ( N == 0 )        return;     uint32_t numTiles = (uint32_t) ( ( N + b - 1 ) / b );     //    // gStatus and tileCounter are transient per-call scratch.  Allocate and    // zero them stream-ordered on the default stream so repeated calls (e.g.    // a timing loop) recycle the same pool memory instead of paying for a    // synchronizing cudaMalloc/cudaFree on every invocation.    //    cuda(MallocAsync( &gStatus, numTiles * sizeof(scanStatus), 0 ) );    cuda(MemsetAsync( gStatus, 0, numTiles * sizeof(scanStatus), 0 ) );   // SCAN_X == 0    cuda(MallocAsync( &tileCounter, sizeof(uint32_t), 0 ) );    cuda(MemsetAsync( tileCounter, 0, sizeof(uint32_t), 0 ) );     scanDecoupledLookback_kernel<T><<<numTiles, b, b * sizeof(T)>>>(        out, in, gStatus, tileCounter, N ); Error_cudart:    if ( gStatus )     cudaFreeAsync( gStatus, 0 );    if ( tileCounter ) cudaFreeAsync( tileCounter, 0 );}
Listing 13-15. scanDecoupledLookback() host function. (source on GitHub)

Touching global memory only twice per element gives the algorithm the lowest memory traffic of the three, but the kernel above does not yet realize that: with one element per thread and a per-lane serial load, it is bound by low per-thread work rather than by bandwidth. Section 13.3.5 closes that gap.

13.3.5 Optimizing the Single-Pass Scan

The decoupled-look-back kernel of the previous section has the least memory traffic of any scan in this chapter, yet on large inputs it is slower than the reduce-then-scan of Section 13.3.3. The reason is not the algorithm but the implementation: one element per thread never gives the memory system enough in-flight work to reach peak bandwidth, and the Kogge-Stone block scan spends much of its time in __syncthreads().

The fix is to increase the amount of work per thread. Listing 13-16 gives an optimized kernel in which each thread sequentially scans a small contiguous chunk of IPT elements in registers; a warp-shuffle block scan (Section 13.3.1) then stitches the per-thread sums together, and the same cooperative tile look-back runs on top. Loads and stores are coalesced, the register scan has ample instruction-level parallelism, and the larger tile (blockDim.x × IPT elements) means far fewer tiles and far fewer look-backs.

template<class T, int IPT>__global__ voidscanDecoupledLookback2_kernel(    T *out, const T *in, volatile scanStatus *status, uint32_t *tileCounter, size_t N ){    const int B = blockDim.x;    extern __shared__ T s[];                 // B*IPT elements, logical order    __shared__ uint32_t s_tile;    __shared__ T s_base;    __shared__ T warpsum[32];     if ( threadIdx.x == 0 ) s_tile = atomicAdd( tileCounter, 1 );    __syncthreads();    const uint32_t tile = s_tile;    const size_t tileBase = (size_t) tile * B * IPT;     // Coalesced striped load; s[k] holds logical element k of the tile.    #pragma unroll    for ( int i = 0; i < IPT; i++ ) {        int k = i * B + threadIdx.x;        size_t g = tileBase + k;        s[k] = ( g < N ) ? in[g] : (T) 0;    }    __syncthreads();     // Per-thread sequential inclusive scan of a contiguous chunk (in registers).    T chunk[IPT];    const int c0 = threadIdx.x * IPT;    T run = (T) 0;    #pragma unroll    for ( int i = 0; i < IPT; i++ ) { run += s[c0 + i]; chunk[i] = run; }    const T threadSum = run;     // Block exclusive scan of threadSum via warp shuffles -> offset, aggregate.    const int lane = threadIdx.x & 31, wid = threadIdx.x >> 5, numWarps = B >> 5;    T x = threadSum;    #pragma unroll    for ( int off = 1; off < 32; off <<= 1 ) {        T y = __shfl_up_sync( 0xffffffffu, x, off );        if ( lane >= off ) x += y;    }    if ( lane == 31 ) warpsum[wid] = x;    __syncthreads();    if ( wid == 0 ) {        T w = ( lane < numWarps ) ? warpsum[lane] : (T) 0;        #pragma unroll        for ( int off = 1; off < 32; off <<= 1 ) {            T y = __shfl_up_sync( 0xffffffffu, w, off );            if ( lane >= off ) w += y;        }        if ( lane < numWarps ) warpsum[lane] = w;    }    __syncthreads();    const T warpOffset = ( wid == 0 ) ? (T) 0 : warpsum[wid - 1];    const T offset = ( x - threadSum ) + warpOffset;   // block-exclusive prefix    const T aggregate = warpsum[numWarps - 1];     scanCoopLookback<T>( status, tile, aggregate, s_base );    __syncthreads();     // Add tile prefix + block offset, store coalesced.    const T base = s_base + offset;    #pragma unroll    for ( int i = 0; i < IPT; i++ ) s[c0 + i] = chunk[i] + base;    __syncthreads();    #pragma unroll    for ( int i = 0; i < IPT; i++ ) {        int k = i * B + threadIdx.x;        size_t g = tileBase + k;        if ( g < N ) out[g] = s[k];    }}
Listing 13-16. scanDecoupledLookback2_kernel(): items-per-thread optimization. (source on GitHub)

Table 13-1 shows the effect, measured on an NVIDIA RTX 3060 (peak memory bandwidth ≈ 360 GB/s) scanning 64M 32-bit integers. Making the look-back cooperative lifts the teaching kernel from 80 to 125 GB/s; increasing the work to eight items per thread lifts it to 330 GB/s – matching CUB’s DeviceScan, which implements the same algorithm.

Implementation GB/s vs. CUB
Decoupled look-back, serial look-back 80 0.24×
Decoupled look-back, cooperative look-back 125 0.38×
Reduce-then-scan (Section 13.3.3) 177 0.53×
Decoupled look-back, 8 items/thread 330 1.00×
CUB DeviceScan::InclusiveSum 331 1.00×
Thrust inclusive_scan 316 0.95×

Table 13-1. Inclusive scan of 64M 32-bit integers on an RTX 3060.

The lesson is the one this chapter opened with: the decoupled-look-back algorithm is what lets a library reach peak bandwidth, but realizing that potential takes a carefully engineered implementation. For production scans, call CUB’s DeviceScan or Thrust’s inclusive_scan and exclusive_scan, which implement this algorithm with the full generality – arbitrary types and operators, 64-bit descriptors – and tuning elided here.


  1. With SM 3.5 hardware, dynamic parallelism can move most of the kernel launches to be “child grids” as opposed to kernel launches initiated by the host.↩︎

  2. Merrill, Duane and Andrew Grimshaw. “Parallel Scan for Stream Architectures.” Technical Report CS2009-14, Department of Computer Science, University of Virginia, December 2009.↩︎