Hello! Welcome back to our module on Columnar Storage and Execution.
In our last lesson, you learned the "how" and "why" of dictionary encoding, a cornerstone of columnar database compression. We saw how to transform a column of potentially large, variable-length values into a compact, fixed-length integer attribute vector and a corresponding dictionary.
Today, we'll focus on the payoff. Your learning outcome is to execute an aggregate query (e.g., SUM, COUNT) on a dictionary-encoded column without full decompression. You'll discover how database systems exploit the encoded format to run analytical queries much faster than if they had to work with the original data.
By the end of this lesson, you will understand:
- The principle of "late materialization" and operating on compressed data.
- How simple aggregates like
COUNT(DISTINCT)can be answered almost instantly. - How
GROUP BYaggregations can be performed efficiently on integer codes. - How modern query engines use specialized logic to optimize these operations.
Recap: The Power of Codes
Just to refresh, we take a column and split it into two parts:
- The Dictionary: Stores each unique value just once.
- The Attribute Vector: Contains small integer codes representing the original values.
The goal today is to keep the data in this efficient, encoded form for as long as possible during query execution, avoiding the expensive cost of decompression (materialization).
The First Big Win: Aggregates on the Dictionary Itself
Let's start with a simple but powerful optimization. Consider this common analytical query:
SELECT COUNT(DISTINCT country) FROM sales;
On uncompressed data, the system would need to scan the entire country column, put all the values into a hash set to find the unique ones, and then count them. If the table has a billion rows, this is a lot of work.
With dictionary encoding, how can we do better?
Test your understanding!
Given that you have a dictionary containing all unique country names from the sales table, how would you satisfy the COUNT(DISTINCT country) query without ever looking at the main column data (the attribute vector)?
Show answer
You can simply count the number of entries in the dictionary. Since the dictionary, by definition, stores each unique value exactly once, its size is the answer to the query. This reduces a full column scan to a single, quick lookup of the dictionary's size.
This is a massive performance gain. The database can answer the query by looking at a tiny data structure (the dictionary) instead of scanning billions of values. The video segment below touches on this and other query processing benefits.
05 - Columnar Databases & Compression (CMU Intro to Database Systems / Fall 2022)
Let's watch a segment from the CMU Intro to Database Systems course that discusses how queries can operate directly on dictionary-encoded data.
Watch from 01:10:56 to 01:17:15. Focus on how a query can be answered by looking at the dictionary alone (like for DISTINCT) and how predicates can be rewritten to operate on the integer codes instead of the original strings.
As the video shows, even for selection predicates, the system can first look up the value in the dictionary, get its code, and then perform a highly efficient scan over the integer attribute vector.
/filters:no_upscale()/articles/columnar-databases-and-vectorization/en/resources/6fig8-1527227891641.png)
The Main Event: GROUP BY Aggregations
Now for the most common and interesting case: a GROUP BY query.
SELECT country, COUNT(*)
FROM sales
GROUP BY country;
A standard hash aggregate operator works by building a hash table. For each row, it hashes the country value and uses it to find the right slot in the hash table to update the running COUNT.
The Naive Way on Compressed Data:
- For each row, read the integer code from the attribute vector.
- Use the code to look up the original string value (e.g., "France") in the dictionary.
- Hash the string "France".
- Use this hash to find the entry in the aggregation hash table and increment the count.
This involves decompressing the value for every single row, defeating the purpose of our compression!
The Optimized Way: Aggregate on Codes
Since each unique country name already has a unique integer code, we can use these codes directly as the keys in our aggregation hash table.
- Create a hash table where keys are integers (our dictionary codes).
- For each row, read the integer code from the attribute vector.
- Use this code directly to find the entry in the aggregation hash table and increment the count.
- After processing all rows, the hash table contains entries like
{code: count}, for example{4: 150210}. - Only now, at the very end, do we iterate through the few unique keys in our hash table and use them to look up the original country names in the dictionary to produce the final result.
This technique of delaying decompression is called late materialization. We performed the entire aggregation—the most expensive part of the query—using the small, fixed-length integer codes, which is much faster for the CPU than processing and hashing strings.
A Real-World Implementation: DuckDB's Compressed Aggregation
This isn't just a theoretical idea. High-performance analytical databases like DuckDB have highly specialized logic for this. To see a concrete example, let's look at a technical paper describing an optimization in DuckDB.
Unified String Dictionary in DuckDB
This Master's thesis describes the string dictionary implementation in DuckDB. We'll look at two key parts: a general description of hash aggregation and a specific optimization that operates on compressed data.
First, read the short section '2.2 Hash aggregate' to understand the standard algorithm. Then, read '2.3.4 TryAddCompressedGroups'. This section describes a specific optimization that executes aggregation directly on the dictionary's unique values, avoiding the need to process every single value in the column. It's a perfect real-world example of our learning outcome.
The TryAddCompressedGroups optimization is a beautiful piece of engineering. Instead of hashing every value from the column, it iterates through the much smaller set of unique values in a block's dictionary and uses those to probe the aggregation hash table. This is a direct implementation of the principle we've been discussing.
How Query Engines Access Encoded Data
From a programming perspective, you might wonder how an engineer implementing a query operator would interact with this data. You don't want every function to have complex if/else logic for every possible encoding.
Modern execution frameworks, like Facebook's Velox, provide an abstraction for this, often called a DecodedVector. This is a crucial component that makes it easier to write high-performance, encoding-aware code.
A DecodedVector takes any vector—which could be flat, constant, or have multiple layers of dictionary encoding—and gives the programmer a simplified view:
- A pointer to the underlying base vector (the raw values).
- An array of indices mapping the current selection of rows to that base vector.
- A combined nulls buffer.
This allows an aggregate operator to efficiently access the data it needs without needing to recursively "peel" the layers of dictionary encoding itself.
Dictionary Encoding — Velox documentation
The Velox documentation provides an excellent, applied look at how to work with dictionary-encoded data efficiently. It shows the difference between a naive access pattern and an optimized one using DecodedVector.
Read the sections 'DecodedVector' and 'Optimizations'. The C++ code example in the 'Optimizations' section for calculating a sum is particularly relevant. Notice how it checks isFlatMapping() and isConstantMapping() to dispatch to the fastest possible code path. This is exactly how a high-performance engine avoids the overhead of decompression when it can.
The example of the optimized sum calculation in the Velox documentation is key. It demonstrates that a well-written query engine doesn't just use one method. It inspects the encoding and properties of the data (is it flat?, are all values the same?, does it have nulls?) and chooses the most efficient algorithm for that specific case. This is a fundamental design principle in high-performance database execution.
Conclusion
You've now seen the real power of dictionary encoding. It's not just about saving disk space; it's about enabling a whole new class of query processing optimizations that are essential for the speed of modern analytical databases.
Key Takeaways:
- By operating directly on dictionary codes, systems can avoid the high cost of decompressing data row-by-row. This principle is known as late materialization.
- Simple aggregates like
COUNT(DISTINCT)can often be answered by only inspecting the dictionary, bypassing the main column data entirely. - For complex
GROUP BYaggregates, the hash table can be built using the integer codes as keys, which is significantly more CPU-efficient than using the original values. The full values are materialized only for the final, much smaller, result set. - Modern execution frameworks like Velox use abstractions like
DecodedVectorand specialized code paths to make it possible to write efficient, encoding-aware query operators.
Preview of the Next Lesson
Dictionary encoding is powerful, but it's just one tool in the compression toolbox. Another technique, particularly effective for sorted or low-cardinality data, is Run-Length Encoding (RLE). In our next lesson, you will implement run-length encoding for compressing sorted or low-cardinality columns, adding another fundamental compression algorithm to your knowledge base.