Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 63

Parallel Hash Join Optimization

Recommended resources

Hello! Welcome back to our exploration of parallel and distributed databases.

In our last lesson, we established the core concepts of inter-operator (pipelined) and intra-operator (partitioned) parallelism. We saw how the Exchange operator is the key to moving data between nodes to enable parallel query processing. Now, it's time to apply these powerful primitives to one of the most critical and performance-sensitive operations in any analytical database: the join.

Today's lesson is dedicated to the parallel hash join. By the end of this session, you will be able to design and analyze a parallel hash join algorithm, including the crucial aspects of data shuffling and handling data skew. This is a perfect case study of how the abstract concepts of parallel execution translate into a complex, high-performance algorithm.

A Quick Review: The Single-Node Hash Join

Before we parallelize the algorithm, let's briefly recall how a basic hash join works on a single machine. If you need a more detailed refresher, the previous module on Query Execution covered this in depth.

The algorithm has two main phases:

  1. Build Phase: Scan the smaller (outer) relation, and build an in-memory hash table using the join key. For example, in R JOIN S, if R is smaller, we'd build a hash table on R.
  2. Probe Phase: Scan the larger (inner) relation. For each tuple, hash its join key and probe the hash table to find matching tuples from the build relation.

This is highly efficient when the hash table for the outer relation fits in memory. But what if the data is distributed across dozens or hundreds of nodes in a shared-nothing cluster? Let's find out.


The Core of Parallel Hash Join: Partition and Conquer

The fundamental strategy for a parallel hash join in a shared-nothing architecture is to use the Exchange (or Shuffle) operator we learned about previously. The goal is simple: ensure that all tuples from both relations that could possibly join (i.e., they have the same join key) end up on the same worker node.

This is achieved through hash partitioning on the join key:

  1. Shuffle Phase: All worker nodes scan their local partitions of both relations, R and S. For each tuple, they apply a common hash function to the join attribute, h(join_key), and send the tuple to the worker node determined by the hash value (e.g., worker_id = h(join_key) % num_workers).
  2. Local Join Phase: After the shuffle is complete, every worker node i holds a partition of R (let's call it Ri) and a partition of S (Si). Crucially, all tuples in Ri and Si that can join are guaranteed to be on the same node. Each worker can then perform a standard single-node hash join on its local Ri and Si partitions, completely in parallel with all other workers.

This "partition-first" or "shuffle-then-join" approach is a classic parallel database algorithm.

Parallel Hash Join Algorithm Stages
This diagram illustrates the "partition-first" approach. Data from initial partitions of R and S are hash-partitioned (shuffled) based on the join key. Each resulting pair of hash partitions (e.g., R's hash partition 0 and S's hash partition 0) can then be joined independently.

While this is the classic model, modern systems have evolved different architectures to implement this idea. Let's look at two prominent designs.


Architecture 1: The "No-Partition" Shared Hash Table

One popular and surprisingly effective design, used by systems like PostgreSQL, is to have all workers collaborate on building a single, large hash table in shared memory.

Parallel Hash Joins in PostgreSQL Explained

This technical blog post from an EnterpriseDB developer explains how PostgreSQL implements its parallel hash join. It provides an excellent, practical look at the shared hash table design and its performance benefits.

Please read the first two main sections: 'A simple example' and the visualization section that follows it. Focus on the stylized execution timelines that compare the non-parallel, partially parallel, and fully parallel (shared hash table) approaches. Note the role of the 'barrier'.

As you read, the key phases of this design are:

  1. Shared Build: All worker processes scan the build relation in parallel and concurrently insert tuples into one giant, shared hash table. This requires thread-safe data structures and synchronization (latches) to manage concurrent writes.
  2. Synchronization Barrier: A critical step. All workers must wait until every worker has finished scanning the build relation. This ensures the shared hash table is complete before anyone starts probing it.
  3. Shared Probe: After the barrier, all workers scan their partitions of the probe relation in parallel and perform lookups (probes) against the now-complete shared hash table.

This "no-partition" design is simple and avoids the overhead of physically writing out intermediate partitions to disk or memory buffers.


Architecture 2: The Radix-Partition Join

The second major design is a more explicit "partition-first" strategy, often called a Radix Join. This approach focuses on breaking down the relations into many small partitions that are optimized for cache performance.

11 - Parallel Hash Join Algorithms (CMU Advanced Databases / Spring 2023)

The following video from CMU's Advanced Databases course gives a detailed breakdown of the phases of a parallel hash join, focusing on the Radix partitioning strategy. This provides a great contrast to the shared-table approach.

Please watch from 20:26 to 38:01. The lecturer will walk through: The three phases (Partition, Build, Probe). Explicit Partitioning: The goal of breaking data into chunks for cores and NUMA regions. Non-blocking vs. Blocking Partitioning: A high-level comparison. Radix Partitioning: The core of this section. Pay close attention to how histograms and prefix sums are used to coordinate where threads write their output partitions without locking. This is the data shuffling mechanism.

The key idea behind Radix partitioning is a multi-pass approach to data shuffling:

  1. Histogram Pass: In the first pass, workers scan the build relation and create local histograms based on a few bits of the hashed join key (the "radix"). These histograms count how many tuples fall into each radix partition.
  2. Prefix Sum & Shuffle Pass: The local histograms are aggregated to compute a global prefix sum. This tells each worker the exact offset in a global buffer where it should write its tuples for each radix partition. In the second pass, workers rescan the relation, and using the pre-computed offsets, write tuples into the correct partitions without any locking. This entire process is then repeated for the probe relation.
  3. Local Join Phase: Once both relations are partitioned, workers can process pairs of (R, S) radix partitions, which are now small enough to fit nicely in CPU caches, and perform the final joins.

The Elephant in the Room: Data Skew

Both parallel join strategies rely on the hash function to distribute work evenly. But what happens if the data itself is not evenly distributed?

Data skew is when certain join key values appear far more frequently than others. For example, in a customers table, the country_code 'USA' might appear millions of time, while 'LUX' appears only a few thousand.

A naive hash partition (h(key) % N) will send all tuples for 'USA' to the same worker. This worker becomes a bottleneck, overloaded with data, while other workers finish quickly and sit idle. This is called redistribution skew, and it can completely undermine the benefits of parallelism.

So, how do we handle it? The classic paper "Practical Skew Handling in Parallel Joins" by DeWitt et al. provides the foundational techniques.

Practical Skew Handling in Parallel Joins

The paper itself is a dense academic read, but its core ideas are fundamental to building robust parallel systems. I've summarized the most important technique for you below.

This is for your reference; you don't need to read the PDF. I'm citing it as the source for the explanation that follows. Focus on my summary of Virtual Processor Partitioning.

A Key Technique: Virtual Processor Partitioning

Instead of creating a number of partitions equal to the number of workers (N), we create many more partitions, say 100 * N. We can think of these as "virtual" partitions or fine-grained buckets.

The process is:

  1. Partition both relations R and S into a large number of virtual partitions based on the join key. A heavy-hitter key might still dominate one or more virtual partitions, but each virtual partition is now a much smaller unit of work.
  2. Distribute these virtual partitions to the actual worker nodes for processing. The simplest way is round-robin, where virtual partition i is assigned to worker i % N.

Because each worker now gets a mix of many small virtual partitions, the total workload tends to average out, even in the presence of skew. The large amount of work from a "heavy" virtual partition is balanced by the small amounts of work from many "light" ones.

Interestingly, the "no-partition" shared hash table approach we discussed first has a natural advantage here. As highlighted in the Postgres blog, academic research shows that this simple algorithm is very competitive and can even outperform complex partitioning algorithms when data is skewed, precisely because all workers are contributing to all keys, so no single worker gets stuck with a "hot" key.

Test your understanding!

Imagine you are designing a parallel hash join for two large tables, events and users, joined on user_id. You discover that a small number of "power users" generate the vast majority of events, leading to severe data skew on user_id.

If you use a classic "partition-first" parallel join, what problem will you face? How could Virtual Processor Partitioning help mitigate it?

Show answer
  1. The Problem: With a classic "partition-first" approach that uses, for example, hash(user_id) % num_workers, all events and user data for a single power user will be sent to the same worker node. This node will be overwhelmed with data, taking much longer to complete its local join than any other node. This worker becomes the bottleneck, and the overall query performance will be poor, defeating the purpose of parallelism.

  2. How Virtual Processor Partitioning Helps: By partitioning into a much larger number of virtual partitions (e.g., hash(user_id) % (num_workers * 100)), the data for a single power user might still fill a few virtual partitions completely. However, these individual virtual partitions are small units of work. When they are distributed to the physical workers in a round-robin fashion, the "heavy" partitions are spread out. Each worker gets some heavy partitions and many light ones, leading to a much more balanced total workload across all workers.


Conclusion

We've delved into the design of the parallel hash join, a cornerstone of modern data warehouses and analytical engines. It's a fantastic real-world application of intra-operator parallelism and the Exchange operator.

Key Takeaways:

  • Parallel hash joins work by shuffling both relations on the join key, ensuring matching tuples are co-located on the same worker node for a local join.
  • There are two primary architectural patterns:
    • Partition-First (e.g., Radix Join): Explicitly partition data into many small, independent chunks, then join them. This can be complex but offers good cache performance and a clear path for out-of-memory processing.
    • Shared Hash Table: Have all workers build a single, concurrent hash table. This is simpler and has proven to be very robust against data skew, a critical real-world problem.
  • Data skew is a major performance killer in parallel joins. It can be handled with techniques like Virtual Processor Partitioning to achieve finer-grained load balancing.

Preview of the Next Lesson

So far, our discussions of transactions, concurrency, and recovery have been in the context of a single database instance. But what happens when a transaction needs to modify data on multiple nodes in our distributed system? How can we guarantee atomicity—that the transaction either commits everywhere or aborts everywhere—especially when nodes can fail?

In the next lesson, we will tackle this challenge head-on by examining the classic algorithm for distributed transaction coordination: the two-phase commit (2PC) protocol.