Parallel Sample Sort Foundations and Shared-Memory Solvers
Sorting is a fundamental building block across computational science, databases, and scientific computing. While algorithms like quicksort and mergesort work exceptionally well in sequential computing, parallelizing them efficiently across multi-core CPUs and distributed clusters presents non-trivial challenges in load balancing and communication.
In this lesson, we explore Sample Sort—a parallel generalization of bucket sort designed to handle unknown and non-uniform data distributions—and implement scalable shared-memory versions using OpenMP and Pthreads.
7.15 From Bucket Sort to Sample Sort
Section titled “7.15 From Bucket Sort to Sample Sort”1. Bucket Sort (Uniform Distribution Assumption)
Section titled “1. Bucket Sort (Uniform Distribution Assumption)”In classical Bucket Sort, we are given keys with a known uniform distribution over an interval . We partition into equal-width buckets of width :
Keys are routed to buckets using direct arithmetic (), sorted locally, and concatenated. If keys are uniformly distributed, each bucket receives approximately elements, yielding an average time complexity.
However, if the distribution is non-uniform (e.g., highly skewed or clustered), one bucket may receive the vast majority of keys while others remain empty, destroying parallel load balance and degrading performance to .
2. Sample Sort (Unknown Distributions)
Section titled “2. Sample Sort (Unknown Distributions)”Sample Sort eliminates the uniform distribution requirement by taking a statistical sample of keys () from the original dataset to estimate its actual distribution.
flowchart LR
subgraph SampleSortFlow["Figure 7.10: Five Phases of Sample Sort"]
direction LR
P1["1. Sample Selection<br/>Choose s keys from list"] --> P2["2. Sort Sample and Splitters<br/>Extract b - 1 splitters e_i"]
P2 --> P3["3. Build Mapping<br/>Count items per bucket<br/>Prefix sums"]
P3 --> P4["4. Route Keys to Buckets<br/>Push or Pull data"]
P4 --> P5["5. Local Sort and Concatenate<br/>Final sorted output"]
endSplitter Selection Workflow:
Section titled “Splitter Selection Workflow:”- Sort the sample keys:
- Select splitters spaced evenly by intervals of keys:
- Define the bucket ranges:
Because the splitters reflect the empirical density of the keys, each bucket receives approximately equal numbers of elements, guaranteeing balanced parallel workloads regardless of the underlying distribution!
Concrete Splitter Example
Section titled “Concrete Splitter Example”Suppose we want buckets from a sorted sample of keys:
The sample is partitioned into 4 groups of elements each:
- Group 0:
- Group 1:
- Group 2:
- Group 3:
The splitters separating these groups are:
The 4 resulting bucket intervals are:
7.16 Mapping Functions and Prefix Sums
Section titled “7.16 Mapping Functions and Prefix Sums”To distribute keys into buckets, we can choose between two fundamental strategies:
Strategy 1: Dynamic Reallocation (realloc)
Section titled “Strategy 1: Dynamic Reallocation (realloc)”Each bucket begins with capacity . When a bucket overflows, its capacity is doubled using realloc(). While simple to code, repeated calls to realloc() cause memory fragmentation, thread lock contention, and expensive data copies.
Strategy 2: Pre-Allocated Mapping via Exclusive Prefix Sums
Section titled “Strategy 2: Pre-Allocated Mapping via Exclusive Prefix Sums”Instead of dynamic reallocation, we construct exact bucket offsets in advance using exclusive prefix sums, allowing all keys to be placed into a single pre-allocated contiguous array without reallocation!
Mathematical Anatomy of Prefix Sums
Section titled “Mathematical Anatomy of Prefix Sums”Given an array of numbers :
- Inclusive Prefix Sum: Each output element includes the corresponding input element:
- Exclusive Prefix Sum: Each output element sums only the strictly preceding elements, beginning with :
For input array :
- Inclusive:
- Exclusive:
Step-by-Step Mapping Matrix Trace
Section titled “Step-by-Step Mapping Matrix Trace”Consider a dataset with elements divided into sublists of 6 keys each, with splitters :
Sublist 0: {15, 35, 77, 83, 86, 93}Sublist 1: {21, 27, 49, 62, 86, 92}Sublist 2: {26, 26, 40, 59, 63, 90}Sublist 3: {11, 29, 36, 67, 68, 72}Step 1: Count Matrix (from_to_cts[sublist][bucket])
Section titled “Step 1: Count Matrix (from_to_cts[sublist][bucket])”We count how many elements from each sublist fall into each bucket:
Step 2: Bucket Offsets within Sublists (bkt_starts_in_slists)
Section titled “Step 2: Bucket Offsets within Sublists (bkt_starts_in_slists)”Row-wise exclusive prefix sums of from_to_cts determine the starting index of each bucket within each sublist:
Step 3: Sublist Offsets within Buckets (slist_starts_in_bkts)
Section titled “Step 3: Sublist Offsets within Buckets (slist_starts_in_bkts)”Column-wise exclusive prefix sums of from_to_cts determine where each sublist’s elements begin inside each bucket:
Step 4: Global Bucket Starting Positions (bkt_starts)
Section titled “Step 4: Global Bucket Starting Positions (bkt_starts)”Exclusive prefix sums of the bucket sizes vector determine exact starting offsets in the output array:
With these tables, every thread moves its keys directly into non-overlapping memory locations with zero lock contention and zero memory reallocations!
7.17 OpenMP Implementations & Benchmarks
Section titled “7.17 OpenMP Implementations & Benchmarks”Version 1: Master Sampling with Thread-Local Reallocation
Section titled “Version 1: Master Sampling with Thread-Local Reallocation”In the first OpenMP implementation:
- Master thread randomly selects and sorts sample keys, computing splitters.
- Inside
#pragma omp parallel, each thread “pulls” all matching keys from the entire input array into its private bucket, callingrealloc()as needed. - Each thread sorts its bucket with
qsort(). #pragma omp barriersynchronizes all threads.- Threads write their sorted buckets back to the global array.
Table 7.10: Version 1 OpenMP Run-times ( integers)
Section titled “Table 7.10: Version 1 OpenMP Run-times (n=224=16,777,216n = 2^{24} = 16,777,216n=224=16,777,216 integers)”| Threads | Run-time | Speedup vs. 1 Thread |
|---|---|---|
| 1 | ||
| 2 | ||
| 4 | ||
| 8 | ||
| 16 | ||
| 32 | ||
| 64 | ||
Sequential qsort | — |
Bottleneck Analysis: Performance plateaus beyond 8 threads because every thread scans the entire 16-million-element input list, duplicating memory reads times and saturating memory bus bandwidth.
Version 2: Fully Parallel Deterministic Sampling & Prefix Mapping
Section titled “Version 2: Fully Parallel Deterministic Sampling & Prefix Mapping”To achieve linear scalability, Version 2 eliminates duplicated memory scans:
- Parallel In-Place Sort: Each thread sorts only its own sublist.
- Parallel Deterministic Sampling: Each thread takes evenly spaced keys from its sorted sublist.
- Thread 0 sorts the tiny sample () and extracts splitters.
- Each thread fills its row of
from_to_ctsusing binary search against the splitters ( instead of linear scans). - Fast prefix sums allocate contiguous bucket slices.
- Threads merge and push keys directly to destination buckets without reallocation.
Table 7.11: Version 2 OpenMP Run-times ( integers)
Section titled “Table 7.11: Version 2 OpenMP Run-times (n=224=16,777,216n = 2^{24} = 16,777,216n=224=16,777,216 integers)”| Threads | Version 1 Run-time | Version 2 Run-time | Speedup vs. Serial qsort () |
|---|---|---|---|
| 1 | |||
| 2 | |||
| 4 | |||
| 8 | |||
| 16 | |||
| 32 | |||
| 64 |
By eliminating redundant scans and memory reallocations, Version 2 scales smoothly to 64 cores, sorting 16.7 million integers in just seconds—a speedup over optimized sequential qsort!
7.18 Parallelizing Sample Sort with Pthreads
Section titled “7.18 Parallelizing Sample Sort with Pthreads”The Pthreads implementation mirrors OpenMP Version 2, replacing pragma directives with explicit thread creation (pthread_create) and synchronization barriers:
Table 7.12 & 7.13: Pthreads Sample Sort Performance ()
Section titled “Table 7.12 & 7.13: Pthreads Sample Sort Performance (n=224n = 2^{24}n=224)”| Threads | Version 1 (Pthreads) | Version 2 (Pthreads) | Version 2 (OpenMP) |
|---|---|---|---|
| 1 | |||
| 2 | |||
| 4 | |||
| 8 | |||
| 16 | |||
| 32 | |||
| 64 |
The benchmark results confirm that Pthreads and OpenMP deliver virtually identical performance when using equivalent algorithmic designs. OpenMP offers significantly cleaner and more maintainable code through compiler directives, while Pthreads grants low-level control when non-standard synchronization patterns are required.