Skip to content

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.


1. Bucket Sort (Uniform Distribution Assumption)

Section titled “1. Bucket Sort (Uniform Distribution Assumption)”

In classical Bucket Sort, we are given nn keys with a known uniform distribution over an interval [c,d)[c, d). We partition [c,d)[c, d) into bb equal-width buckets of width h=(d−c)/bh = (d - c) / b:

[c,c+h),[c+h,c+2h),…,[c+(b−1)h,d)[c, c + h), \quad [c + h, c + 2h), \quad \dots, \quad [c + (b - 1)h, d)

Keys are routed to buckets using direct arithmetic (i=⌊(x−c)/h⌋i = \lfloor (x - c) / h \rfloor), sorted locally, and concatenated. If keys are uniformly distributed, each bucket receives approximately n/bn / b elements, yielding an average O(n)O(n) 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 O(n2)O(n^2).


Sample Sort eliminates the uniform distribution requirement by taking a statistical sample of ss keys (b≤s≤nb \le s \le n) 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"]
  end
  1. Sort the ss sample keys: a0≤a1≤a2≤⋯≤as−1a_0 \le a_1 \le a_2 \le \dots \le a_{s-1}
  2. Select b−1b - 1 splitters spaced evenly by intervals of s/bs / b keys: e1,e2,…,eb−1e_1, e_2, \dots, e_{b-1}
  3. Define the bb bucket ranges: [−∞,e1),[e1,e2),…,[eb−1,∞)[-\infty, e_1), \quad [e_1, e_2), \quad \dots, \quad [e_{b-1}, \infty)

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!


Suppose we want b=4b = 4 buckets from a sorted sample of s=12s = 12 keys:

{2,10,23,25,29,38,40,49,53,60,62,67}\{2, 10, 23, 25, 29, 38, 40, 49, 53, 60, 62, 67\}

The sample is partitioned into 4 groups of s/b=3s / b = 3 elements each:

  • Group 0: {2,10,23}\{2, 10, 23\}
  • Group 1: {25,29,38}\{25, 29, 38\}
  • Group 2: {40,49,53}\{40, 49, 53\}
  • Group 3: {60,62,67}\{60, 62, 67\}

The splitters separating these groups are:

e1=23+252=24,e2=38+402=39,e3=53+602=57e_1 = \dfrac{23 + 25}{2} = 24, \quad e_2 = \dfrac{38 + 40}{2} = 39, \quad e_3 = \dfrac{53 + 60}{2} = 57

The 4 resulting bucket intervals are:

[−∞,24),[24,39),[39,57),[57,∞)[-\infty, 24), \quad [24, 39), \quad [39, 57), \quad [57, \infty)


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 n/bn / b. 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!


Given an array of nn numbers {x0,x1,…,xn−1}\{x_0, x_1, \dots, x_{n-1}\}:

  • Inclusive Prefix Sum: Each output element includes the corresponding input element: incl[i]=∑k=0ixk  ⟹  {x0,x0+x1,…,∑k=0n−1xk}\text{incl}[i] = \sum_{k=0}^{i} x_k \implies \{x_0, x_0 + x_1, \dots, \sum_{k=0}^{n-1} x_k\}
  • Exclusive Prefix Sum: Each output element sums only the strictly preceding elements, beginning with 00: excl[0]=0,excl[i]=∑k=0i−1xk(i≥1)\text{excl}[0] = 0, \quad \text{excl}[i] = \sum_{k=0}^{i-1} x_k \quad (i \ge 1)

For input array {5,1,6,2,4}\{5, 1, 6, 2, 4\}:

  • Inclusive: {5,6,12,14,18}\{5, 6, 12, 14, 18\}
  • Exclusive: {0,5,6,12,14}\{0, 5, 6, 12, 14\}

Consider a dataset with n=24n = 24 elements divided into b=4b = 4 sublists of 6 keys each, with splitters {−∞,45,75,91,∞}\{-\infty, 45, 75, 91, \infty\}:

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:

from_to_cts=[2031221132103300]\text{from\_to\_cts} = \begin{bmatrix} 2 & 0 & 3 & 1 \\ 2 & 2 & 1 & 1 \\ 3 & 2 & 1 & 0 \\ 3 & 3 & 0 & 0 \end{bmatrix}

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:

bkt_starts_in_slists=[0225024503560366]\text{bkt\_starts\_in\_slists} = \begin{bmatrix} 0 & 2 & 2 & 5 \\ 0 & 2 & 4 & 5 \\ 0 & 3 & 5 & 6 \\ 0 & 3 & 6 & 6 \end{bmatrix}

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:

slist_starts_in_bkts=[0000203142427452],Bucket Sizes=[10752]\text{slist\_starts\_in\_bkts} = \begin{bmatrix} 0 & 0 & 0 & 0 \\ 2 & 0 & 3 & 1 \\ 4 & 2 & 4 & 2 \\ 7 & 4 & 5 & 2 \end{bmatrix}, \quad \text{Bucket Sizes} = \begin{bmatrix} 10 & 7 & 5 & 2 \end{bmatrix}

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 {10,7,5,2}\{10, 7, 5, 2\} determine exact starting offsets in the output array:

bkt_starts=[0101722]\text{bkt\_starts} = \begin{bmatrix} 0 & 10 & 17 & 22 \end{bmatrix}

With these tables, every thread moves its keys directly into non-overlapping memory locations with zero lock contention and zero memory reallocations!


Version 1: Master Sampling with Thread-Local Reallocation

Section titled “Version 1: Master Sampling with Thread-Local Reallocation”

In the first OpenMP implementation:

  1. Master thread randomly selects and sorts sample keys, computing splitters.
  2. Inside #pragma omp parallel, each thread “pulls” all matching keys from the entire input array into its private bucket, calling realloc() as needed.
  3. Each thread sorts its bucket with qsort().
  4. #pragma omp barrier synchronizes all threads.
  5. Threads write their sorted buckets back to the global array.

Table 7.10: Version 1 OpenMP Run-times (n=224=16,777,216n = 2^{24} = 16,777,216 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)”
ThreadsRun-timeSpeedup vs. 1 Thread
13.54 s3.54\,\text{s}1.00×1.00\times
22.29 s2.29\,\text{s}1.55×1.55\times
41.69 s1.69\,\text{s}2.09×2.09\times
81.34 s1.34\,\text{s}2.64×2.64\times
161.40 s1.40\,\text{s}2.53×2.53\times
321.36 s1.36\,\text{s}2.60×2.60\times
641.33 s1.33\,\text{s}2.66×2.66\times
Sequential qsort2.59 s2.59\,\text{s}—

Bottleneck Analysis: Performance plateaus beyond 8 threads because every thread scans the entire 16-million-element input list, duplicating memory reads pp 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:

  1. Parallel In-Place Sort: Each thread sorts only its own n/pn / p sublist.
  2. Parallel Deterministic Sampling: Each thread takes s/ps / p evenly spaced keys from its sorted sublist.
  3. Thread 0 sorts the tiny sample (s≪ns \ll n) and extracts splitters.
  4. Each thread fills its row of from_to_cts using binary search against the splitters (O(log⁡b)O(\log b) instead of O(n)O(n) linear scans).
  5. Fast prefix sums allocate contiguous bucket slices.
  6. Threads merge and push keys directly to destination buckets without reallocation.

Table 7.11: Version 2 OpenMP Run-times (n=224=16,777,216n = 2^{24} = 16,777,216 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)”
ThreadsVersion 1 Run-timeVersion 2 Run-timeSpeedup vs. Serial qsort (2.59 s2.59\,\text{s})
13.54 s3.54\,\text{s}2.65 s2.65\,\text{s}0.98×0.98\times
22.29 s2.29\,\text{s}1.33 s1.33\,\text{s}1.95×1.95\times
41.69 s1.69\,\text{s}1.01 s1.01\,\text{s}2.56×2.56\times
81.34 s1.34\,\text{s}0.35 s0.35\,\text{s}7.40×7.40\times
161.40 s1.40\,\text{s}0.21 s0.21\,\text{s}12.33×12.33\times
321.36 s1.36\,\text{s}0.15 s0.15\,\text{s}17.27×17.27\times
641.33 s1.33\,\text{s}0.13 s0.13\,\text{s}19.92×19.92\times

By eliminating redundant scans and memory reallocations, Version 2 scales smoothly to 64 cores, sorting 16.7 million integers in just 0.130.13 seconds—a 20×20\times 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 (n=224n = 2^{24})

Section titled “Table 7.12 & 7.13: Pthreads Sample Sort Performance (n=224n = 2^{24}n=224)”
ThreadsVersion 1 (Pthreads)Version 2 (Pthreads)Version 2 (OpenMP)
13.55 s3.55\,\text{s}2.65 s2.65\,\text{s}2.65 s2.65\,\text{s}
22.31 s2.31\,\text{s}1.36 s1.36\,\text{s}1.33 s1.33\,\text{s}
41.71 s1.71\,\text{s}1.08 s1.08\,\text{s}1.01 s1.01\,\text{s}
81.48 s1.48\,\text{s}0.35 s0.35\,\text{s}0.35 s0.35\,\text{s}
161.45 s1.45\,\text{s}0.20 s0.20\,\text{s}0.21 s0.21\,\text{s}
321.28 s1.28\,\text{s}0.14 s0.14\,\text{s}0.15 s0.15\,\text{s}
641.24 s1.24\,\text{s}0.12 s0.12\,\text{s}0.13 s0.13\,\text{s}

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.