Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Parallel Scan: Foundations and Applications

Introduction

In our previous lesson, we explored the parallel reduction primitive, focusing on how a hierarchical, tree-based algorithm can be optimized to efficiently reduce a large array to a single value on a GPU. We saw that performance hinges on managing architectural details like warp divergence and shared memory bank conflicts.

This lesson builds directly on those concepts to tackle a related, but more general, data-parallel primitive. We will address the learning outcome: Describe the parallel scan (prefix sum) primitive and its application in data-parallel algorithms.

A scan, or prefix sum, takes an array of numbers and an associative operator (like addition) and returns an array of the same size. However, instead of just the final sum, it returns all the intermediate "running totals." This primitive is a fundamental building block for a surprisingly wide range of parallel algorithms that seem inherently sequential, such as stream filtering, sorting, and certain kinds of graph and tree operations.

We will examine the core challenge in parallelizing scan, explore classic algorithms that solve it, and see how it is used to enable other complex data-parallel tasks.

1. Defining Scan (Prefix Sum)

A scan operation takes an input array and a binary associative operator . There are two main variants:

  • Inclusive Scan: The output element at index is the reduction of all input elements up to and including .
  • Exclusive Scan: The output element at index is the reduction of all input elements before . The first element is the identity value for the operator .

For example, for the input [3 1 7 4] with the + operator (identity 0):

  • Inclusive scan: [3, 4, 11, 15]
  • Exclusive scan: [0, 3, 4, 11]

The two forms are easily convertible, so we will often just refer to "scan."

Chapter 39. Parallel Prefix Sum (Scan) with CUDA

The article 'Chapter 39. Parallel Prefix Sum (Scan) with CUDA' from GPU Gems 3 provides a clear definition of scan and the concept of work efficiency, which we will use to evaluate our parallel algorithms.

Read Section 39.1, 'Introduction', and 39.1.1, 'Sequential Scan and Work Efficiency'. Pay attention to the definitions of exclusive and inclusive scan and the definition of a work-efficient parallel algorithm.

A sequential implementation is straightforward, but it contains a critical data dependency: out[i] = out[i-1] + in[i]. This dependency makes a direct parallelization impossible, as each output depends on the previously computed one. We need a different approach.

COMP526 3-7 §3.6 Parallel primitives, Prefix sum

The following video from a course by Sebastian Wild explains this data dependency problem and introduces our first parallel approach.

Watch from 00:48 to 06:09. The video defines the problem and shows why the simple sequential loop is a 'disaster' for parallelization.

2. Parallel Scan Algorithms

As with reduction, the key to an efficient parallel scan is a tree-based approach. We will look at two main algorithms that represent a classic trade-off between simplicity and efficiency.

Algorithm 1: A Simple but Work-Inefficient Approach (Hillis & Steele)

The first parallel algorithm we'll consider is intuitive. It works in logarithmic steps. In each step d, every element k adds the value from an element 2^(d-1) positions away.

This is exactly the algorithm described in the next segment of the video. It's simple to understand but has a significant drawback.

COMP526 3-7 §3.6 Parallel primitives, Prefix sum

Let's continue with the video to see the first parallel algorithm. It's analogous to the naive parallel reduction we saw in the last lesson.

Watch from 10:40 to 18:20. Focus on how the algorithm works by doubling the 'jump' distance in each step. Also, pay close attention to the analysis of its time and work complexity: O(log n) time is great, but O(n log n) work is not work-efficient.

This algorithm achieves logarithmic time complexity, which is a massive improvement over the linear time of the sequential version. However, its work complexity of means it performs much more total work than the optimal . For large arrays, this extra factor of is very costly.

Algorithm 2: A Work-Efficient Approach (Blelloch Scan)

A more advanced algorithm, proposed by Guy Blelloch, achieves both logarithmic time and linear work, making it asymptotically optimal. It consists of two passes over the data, which can be visualized as traversing up and down a binary tree.

  1. Reduce (Up-Sweep) Phase: In this phase, we traverse from the leaves of the tree towards the root. Each parent node in the tree stores the sum of its children's values. This is essentially a parallel reduction, where we store all the intermediate partial sums along the way. At the end of this phase, the root node (the last element of the array) contains the sum of the entire array.

  2. Down-Sweep Phase: We traverse from the root back to the leaves. Starting by setting the root's value to the identity (0 for addition), we use the partial sums computed during the up-sweep to calculate the final prefix sums. At each node, we pass its current value to its left child. For the right child, we pass the sum of the parent's current value and the stored partial sum of the left child (from the up-sweep).

Parallel Prefix Algorithm Illustration
Illustration of the two-phase Blelloch scan algorithm. The up-sweep (left) computes partial sums up the tree. The down-sweep (right) uses these sums to propagate the correct prefixes down the tree, resulting in the final exclusive scan.

This two-pass method is more complex to reason about, but its work efficiency makes it the foundation of most high-performance scan implementations.

Blelloch Scan - Intro to Parallel Programming

The following video from Udacity provides a clear, step-by-step visualization of the Blelloch scan algorithm in action.

Watch from 0:00 to 04:06. Focus on understanding the distinct roles of the 'reduce' (up-sweep) and 'downsweep' phases and how the intermediate results from the first phase are used in the second.

The GPU Gems chapter on scan provides pseudocode for these two phases, which helps formalize the process shown in the video.

Chapter 39. Parallel Prefix Sum (Scan) with CUDA

To solidify your understanding, let's look at the pseudocode for the work-efficient algorithm.

Read Section 39.2.2, 'A Work-Efficient Parallel Scan'. Compare the pseudocode in 'Algorithm 3' (Up-Sweep) and 'Algorithm 4' (Down-Sweep) with the steps you saw in the video.

3. Implementation on a GPU: A Hierarchical Approach

Just like with parallel reduction, implementing scan on a GPU involves a hierarchical strategy that maps the algorithm to the GPU's architecture of warps, blocks, and grids.

  • Intra-Warp Scan: For the 32 threads within a warp, a simple O(w log w) Hillis-Steele style scan is often used. Because all threads in a warp execute in lockstep (SIMT), the work-inefficiency is less of a concern than the algorithmic simplicity and lack of synchronization overhead.

  • Intra-Block Scan: To scan across a thread block, we compose the results of intra-warp scans. Each warp computes its own scan. Then, the partial sum from each warp is collected, and a single warp scans these partial sums. Finally, these results are broadcast back to the other warps, which add the block-level prefix to their local results. This is a direct application of the "divide and conquer" strategy.

  • Grid-Level Scan (Large Arrays): To handle arrays larger than a single block can process, we use multiple kernel launches, just as we did for reduction.

    1. Divide the input array into chunks, one per block.
    2. Kernel 1: Each block scans its local chunk and writes the block's total sum to an auxiliary array.
    3. Kernel 2: A second kernel (often just one block) is launched to scan the auxiliary array of block sums.
    4. Kernel 3: A final kernel adds the scanned block sums to the corresponding chunks from the first step.

This multi-pass process is visualized effectively in the following image.

Multi-Pass Parallel Scan (Prefix Sum) Algorithm
This diagram illustrates how a large array is scanned. Each block computes a local scan and a total sum. The block sums are then scanned, and these results are added back to the local scans to produce the final global result.

The design of these hierarchical algorithms is a deep topic. For a more detailed perspective on how they are constructed, the following technical report is an excellent resource, though not required reading.

[PDF] Efficient Parallel Scan Algorithms for GPUs - Michael Garland

For those interested in the deeper design principles, the technical report 'Efficient Parallel Scan Algorithms for GPUs' details this hierarchical design.

This is an optional read. Skim Section 3, 'Designing an Efficient Scan'. It formally describes the intra-warp, intra-block, and global scan composition that we just outlined.

4. Applications of Parallel Scan

The true power of scan lies in its ability to convert seemingly sequential problems into parallel ones. By pre-calculating prefix sums, we can determine the output position of elements in parallel, eliminating data dependencies.

Stream Compaction (Filtering)

A canonical example is stream compaction. The goal is to take an input array and a predicate (a true/false condition) and produce a new array containing only the elements for which the predicate is true, in their original order.

A sequential approach would loop through the array, check the predicate, and copy the element to an output array using a counter. This is sequential because the output position of element i depends on how many preceding elements passed the predicate.

Scan solves this elegantly:

  1. Create a flag array: Generate a binary array where 1 represents an element that passes the predicate and 0 represents one that fails.
  2. Scan the flag array: Perform an exclusive scan on the flag array. The result for each element is the count of all preceding 1s. This resulting value is precisely the destination index in the compacted output array!
  3. Scatter: In parallel, each thread i checks if its element passed the predicate. If it did, it uses the index from the scanned flag array to write (scatter) its value to the correct position in the output array.

COMP526 3-7 §3.6 Parallel primitives, Prefix sum

The video we watched earlier concludes with a clear explanation of this exact application.

Watch from 33:16 to 38:47. This segment demonstrates how prefix sums are the 'key ingredient' to parallelize the stream compaction problem.

Other important applications of scan include:

  • Radix Sort: Scan is used within each step of a parallel radix sort to determine the new positions of elements based on their current bit values.
  • Summed-Area Tables: Used in image processing for fast box blurs, this is computed by performing a scan on all rows, followed by a scan on all columns.
  • Polynomial Evaluation: Scan can be used to evaluate polynomials in parallel.

Conclusion

In this lesson, we demystified the parallel scan primitive, a cornerstone of data-parallel programming. While its implementation is more intricate than that of reduction, the underlying principles are similar, relying on tree-based algorithms and a hierarchical mapping to the GPU architecture.

Key Takeaways:

  • Scan (Prefix Sum) is a primitive that computes the "running total" for an array, producing an output array of intermediate reductions.
  • Simple sequential implementations have strong data dependencies, making them unsuitable for parallel execution.
  • Work-efficient parallel scan algorithms like the Blelloch scan use a two-pass up-sweep (reduce) and down-sweep approach to achieve work and time.
  • Efficient GPU implementations use a hierarchical strategy (intra-warp, intra-block, grid-level) and require multiple kernel launches to process large arrays.
  • Scan is a powerful building block for other algorithms, most notably stream compaction, where it is used to calculate output addresses in parallel, breaking sequential dependencies.

Preview of the Next Lesson:

We have now covered two fundamental data-parallel primitives: reduction and scan. In the next lesson, we will shift our focus to a workload that is at the heart of nearly all modern deep learning: matrix multiplication (GEMM). We will see how this operation is parallelized on GPUs using a technique called tiling, which leverages shared memory to manage data reuse and maximize computational throughput.

Can't find a good explanation? Sign up and we'll make it for you

Sign up