Skip to main content

Welcome to a Grasp lesson

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

Lesson 16

External Merge Sort: Runs and Multi-way Merging

Recommended resources

Hello! Welcome back to our module on Query Execution.

In our last lesson, we explored how a database system executes a WHERE clause using either a full table scan or a more efficient index scan. You learned how the query optimizer makes a cost-based decision between these two strategies, weighing the benefits of an index against the potential cost of random I/O.

Today, we shift our focus from filtering data to sorting it. What happens when you issue a query with an ORDER BY clause on a table that's too large to fit in main memory? Standard in-memory sorting algorithms aren't enough.

By the end of this 60-minute lesson, you will be able to implement external merge sort, the fundamental disk-based sorting algorithm used in virtually all database systems. We will break this down into its two core components: creating initial sorted "runs" and then merging those runs into a final, sorted output. This algorithm is not only essential for handling ORDER BY, but as we'll see later, it's also a key building block for other complex operations like joins and aggregations.

1. The Core Problem: Sorting Data Larger than Memory

When you have a small array of data in C++ or Java, you can simply call a library function like std::sort or Arrays.sort(). These are highly optimized in-memory algorithms, like Introsort or Timsort, that assume all the data is readily accessible in RAM.

But what if you need to sort a 100 GB file with only 1 GB of RAM? You can't load the whole file. This is the domain of external memory algorithms, which are designed to minimize the primary bottleneck: slow disk I/O.

To set the stage, let's watch a brief introduction that explains why we need specialized algorithms for this scenario.

#11 - Sorting & Aggregation Algorithms ✸ dbt Database Talk (CMU Intro to Database Systems)

Professor Andy Pavlo from CMU introduces the context for disk-based query execution algorithms, highlighting why sorting is such a fundamental and challenging operation when data doesn't fit in memory.

Watch the video from 02:45 to 06:10. Focus on the core idea that we must design algorithms that work with intermediate results spilling to disk and that sorting is a key primitive used throughout the query execution engine.


2. The External Merge Sort Algorithm: A Two-Phase Approach

The solution to sorting huge files is a classic "divide and conquer" strategy called External Merge Sort. It breaks the problem into two distinct phases:

  1. Phase 1 (The Sort Phase): Read the large file in chunks, where each chunk is small enough to fit into memory. Sort each chunk individually using a fast in-memory algorithm and write the sorted chunk back to a temporary file on disk. Each of these temporary sorted files is called a run.
  2. Phase 2 (The Merge Phase): After creating all the runs, merge them into a single, globally sorted file. This is done by simultaneously reading the beginning of each run, repeatedly picking the smallest value among them, and writing it to the final output file.

This diagram provides a high-level visual of the entire process.

Two-Phase, Multiway Merge Sort Diagram
This diagram illustrates the two phases. In Phase 1, the large file is broken into memory-sized chunks (M) which are sorted to create runs. In Phase 2, all these runs are merged together to produce the final sorted output (N).

Let's walk through a simplified example to see how the data flows.

#11 - Sorting & Aggregation Algorithms ✸ dbt Database Talk (CMU Intro to Database Systems)

This segment from the same CMU lecture provides a clear, animated walkthrough of a simple two-way external merge sort. It shows how initial runs are created and then merged in successive passes.

Watch from 19:48 to 25:35. Pay attention to the two distinct stages: 'Pass 0' where individual pages are sorted to create runs of size 1, and the subsequent passes where pairs of runs are merged into larger runs.


3. Implementing Phase 1: Creating Sorted Runs

Let's dive into the implementation, which is surprisingly straightforward. Given your background in C++, you'll find the logic very familiar. The goal of this phase is to read the entire input file and write out a set of temporary, sorted files (the runs).

External Sorting

This article from algorithmica.org provides an excellent, practical C++ implementation of a two-pass external sort. We'll focus first on the code for Phase 1.

Please read the section titled 'Practical Implementation'. Focus on the first C++ code block. This code demonstrates exactly how to implement the run creation phase. Notice how it: Reads the input file in large chunks (M bytes at a time). Uses std::sort to sort the chunk in memory. Writes the sorted chunk to a new temporary file (part-XXX.bin).

The core logic of Phase 1 can be summarized in this pseudocode:

// Pseudocode for Phase 1: Creating Runs
buffer = allocate_memory(M_bytes);
input_file = open("large_file.dat");
run_count = 0;

while (!input_file.end_of_file()) {
    // 1. Read a chunk of data into the buffer
    bytes_read = input_file.read(buffer, M_bytes);

    // 2. Sort the buffer in-memory
    std::sort(buffer, buffer + bytes_read);

    // 3. Write the sorted buffer to a new temporary run file
    output_run_file = open("run_" + run_count + ".tmp");
    output_run_file.write(buffer, bytes_read);
    output_run_file.close();

    run_count++;
}

At the end of this phase, the single large, unsorted input file has been transformed into N/M smaller, individually sorted run files on disk, where N is the total file size and M is the memory buffer size.


4. Implementing Phase 2: The Multi-Way Merge

Now we have a collection of sorted runs. The next step is to merge them. While we could merge them two at a time (as in the simple video example), this would require multiple passes over the data. A much more efficient method is to perform a k-way merge, where we merge k runs simultaneously.

External Sort-Merge Algorithm Stage-2: Multi-way Merge Pass
This image shows the basic idea of a 5-way merge. The system reads a block from each of the 5 runs and merges them into a single output stream.

The key challenge in implementing a k-way merge is this: at each step, how do you efficiently find the smallest element among the current heads of all k runs?

A naive linear scan would take O(k) time for every single element we write to the output. We can do much better by using a min-heap data structure.

The algorithm for the merge phase is as follows:

  1. Open all k run files for reading.
  2. Create a min-heap (in C++, this is a std::priority_queue with a custom comparator).
  3. For each run, read the first element and push it onto the heap. It's helpful to store it as a pair: {value, run_index}.
  4. While the heap is not empty:
    a. Pop the minimum element {value, run_index} from the heap.
    b. Write value to the output file.
    c. Read the next element from the run file indicated by run_index.
    d. If that run is not exhausted, push the new element onto the heap.

This approach is highly efficient. The heap always contains exactly k elements (one from each run), and each pop/push operation takes only O(log k) time.

Let's return to the algorithmica.org article to see how this is implemented in C++.

External Sorting

This second part of the article shows a brilliant and practical implementation of the k-way merge using a standard C++ priority_queue as a min-heap.

Read the rest of the 'Practical Implementation' section, starting from 'What is left now is to merge them together.' Pay close attention to: The Pointer struct and how the < operator is overloaded to make the priority_queue a min-heap. How the heap is initialized with the first element from each run's buffer. The main while loop that pops the minimum, writes it to an output buffer, and pushes the next element from the corresponding run.

Test your understanding!

Suppose you have 1 GB of available memory for the merge phase, and you need to merge 500 sorted runs. You decide to use 1 MB input buffers for each of the runs being merged and a 10 MB output buffer.

Approximately how many runs (k) can you merge simultaneously in a single pass?

Show answer

You need one 1 MB buffer for each of the k input runs and one 10 MB buffer for the output. The total memory usage is k * 1 MB + 10 MB.

This must be less than or equal to your total available memory of 1 GB (1024 MB).

k * 1 + 10 <= 1024
k <= 1014

You can merge up to 1014 runs at once. Since you only have 500 runs, you can merge them all in a single pass. This is why a two-pass external sort (one for run creation, one for merging) is so common in practice.

An Advanced Optimization: Replacement Selection

The length of your initial runs is limited by the size of your memory buffer. However, a clever technique called Replacement Selection can often create runs that are, on average, twice the size of your available memory. This reduces the number of initial runs, which in turn can reduce the number of merge passes needed.

This is an advanced topic, but if you're curious about the algorithm, this video provides an excellent explanation.

Database Systems: Query Processing, Join Algorithms, External Merge Sort, Early Aggregation

Professor Jens Dittrich explains the beautiful algorithm of Replacement Selection, which uses a heap to create initial sorted runs that can be longer than main memory.

This is optional, but if you're interested, watch from 1:11:26 to 1:17:30. The core idea is to maintain a heap in memory. As you pull the minimum element off the heap and write it to the output run, you read the next element from the input. If the new element is larger than the one you just wrote, you can add it to the current heap; otherwise, you set it aside for the next run.


Conclusion

Congratulations! You've just walked through the design and implementation of one of the most fundamental algorithms in database systems. It's a perfect example of how systems are designed to overcome physical hardware limitations like memory size and disk speed.

Key Takeaways:

  • External Merge Sort is a two-phase algorithm for sorting datasets that are too large to fit in RAM.
  • Phase 1 (Sort): Create initial sorted runs by sorting memory-sized chunks of the input data and writing them to temporary files.
  • Phase 2 (Merge): Perform a k-way merge on the runs. A min-heap (std::priority_queue) is the ideal data structure to efficiently find the next smallest element across all runs.
  • The entire algorithm is designed to favor sequential disk I/O (reading and writing large blocks) and to minimize the number of passes over the data.

Preview of the Next Lesson

In this lesson, we focused on the implementation. In the next one, we will formally analyze the I/O cost of external merge sort, calculating the total number of disk reads and writes required. We'll also see how this powerful sorting primitive can be used to implement another relational operator: projection with duplicate elimination (the SELECT DISTINCT clause).