Skip to main content

Welcome to a Grasp lesson

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

Lesson 52

Bit-Packed Encoding for Small Integers

Recommended resources

Hello! Welcome to the next lesson in our module on Columnar Storage and Execution.

In our last lesson, you saw how to execute selection queries directly on Run-Length Encoded (RLE) data. We established that RLE is highly effective for compressing columns with long, consecutive runs of identical values.

However, many columns in a database don't have that property. Consider an age column or a day_of_the_week column. The values are small integers, but they are unlikely to appear in long, sorted runs. So, how can we compress this type of data?

This lesson addresses that exact question. Your learning outcome is to implement bit-packed encoding for compressing columns with small integer values. This is a classic, low-level technique that sits at the heart of many high-performance database systems.

1. The Core Idea: Stop Wasting Bits

Database systems and programming languages typically store integers in fixed-size types like a 32-bit or 64-bit integer. This is convenient, but often wasteful. If you have a column storing human ages, the maximum value might be around 120. To store the number 120, you only need 7 bits (), yet a 32-bit integer uses 32 bits, wasting the 25 leading zero-bits.

Bit-packed encoding, or bit packing, eliminates this waste. It determines the minimum number of bits required to store the largest value in a chunk of data and then stores every value in that chunk using exactly that many bits.

Bit-Packed Encoding Illustration
This diagram shows four small integers (2, 3, 1, 2) that each require only 2 bits. Instead of using a full 32-bit integer for each, bit packing concatenates their 2-bit representations into a single 8-bit byte, achieving significant space savings.

2. Why and How Bit Packing Works

The fundamental motivation for bit packing is to reduce the data size, which in turn minimizes I/O and allows more data to fit into CPU caches. To get a formal introduction from a database systems perspective, let's watch a segment from a CMU lecture.

F2023 #05 - Storage Models & Database Compression (CMU Intro to Database Systems)

This clip from the CMU Introduction to Database Systems course explains the motivation for bit packing and introduces a key challenge: what to do when a value is too large to fit the chosen bit-width.

Watch the section from 1:02:22 to 1:06:58. Pay attention to how the required bit-width is determined and the 'mostly encoding' technique for handling outlier values.

As the video explains, the process for a block of data is:

  1. Find the maximum value in the block.
  2. Calculate the required bit-width: . For example, if the max value is 100, you need bits.
  3. Pack all integers in the block using this fixed width, storing them contiguously in a byte array.

The "mostly encoding" trick mentioned is an important optimization for data with rare large values, but for our core implementation, we will assume all values fit within the calculated bit-width.


3. The Mechanics of Implementation

Implementing bit packing requires careful manipulation of bits using bitwise operators. Since you're proficient in C++, you'll be familiar with shifting (<<, >>), masking (&), and ORing (|). The main challenge is managing the bit-stream as it crosses byte boundaries.

A fantastic, detailed video walks through building a bit packer from scratch. Although the code is in JavaScript, the underlying logic of bit manipulation is universal and directly applicable to a C++ implementation.

Packing Bits For Compression

This video, 'Packing Bits For Compression', provides a detailed walkthrough of implementing a bit packer. It clearly separates the logic for packing a value that fits within a single byte from the more complex case where it spans multiple bytes.

Please watch the following two segments: The 'Simple Case' (17:02 - 30:05): This section covers the logic for when a value's bits can be written into the currently active byte without crossing a boundary. Focus on how bitwise OR and left-shift are used to place the value's bits correctly. The 'Complex Case' (30:05 - 40:37): This covers the crucial logic for handling values that span two or more bytes. Observe how the value is split and written iteratively.

The core algorithm demonstrated in the video involves maintaining two pointers:

  • byte_index: The current byte we are writing to in our output buffer.
  • bit_offset: The next available bit within the current byte (e.g., from 7 down to 0).

When packing a new value, the algorithm must:

  1. Determine if the new value's bits fit in the remaining space of the current byte.
  2. If yes (the simple case), shift the value into position and OR it with the current byte.
  3. If no (the complex case), fill the rest of the current byte with the top bits of the value, advance to the next byte, and then write the remaining bits of the value. This may repeat if the value spans multiple byte boundaries.

4. Bit Ordering and a C++ Perspective

Before writing code, a crucial design decision is the bit ordering. Do you pack bits starting from the Most Significant Bit (MSB) of a byte, or the Least Significant Bit (LSB)?

  • MSB-first: New values are packed from left to right in the byte. This corresponds naturally to big-endian byte order.
  • LSB-first: New values are packed from right to left. This corresponds naturally to little-endian byte order, which is used by most modern CPUs (like x86 and ARM).

The following blog post provides an expert-level discussion on this topic and is essential reading for anyone implementing bit-level I/O.

Reading bits in far too many ways (part 1) - The ryg blog

This article, 'Reading bits in far too many ways (part 1),' by Fabian Giesen is a classic text on the practicalities of bitstream manipulation. It clearly explains the difference between MSB-first and LSB-first packing.

Read the first section, 'Degrees of freedom,' which uses diagrams to explain MSB-first and LSB-first packing and how values span multiple bytes. Don't worry about the C++ code for now; focus on understanding the two conventions.

A Simple C++ Example: Packing Bools

To see this in a C++ context, let's look at a simplified version of bit packing: packing boolean values, where each value takes exactly 1 bit.

Packing Bools, Performance tests

The blog post 'Packing Bools, Performance tests' provides simple, manual C++ implementations for packing boolean values into a byte array.

Please read the two sections 'Manual version' and 'Improvement'. The first shows a straightforward loop building one byte at a time. The second shows a common optimization technique.

The first "manual version" is a great starting point for our general-purpose packer. It uses a shiftCounter (our bit_offset) and an OutByte variable to accumulate 8 bits before writing to the output array.

C++ Pseudocode for General Bit Packing

Now, let's synthesize these ideas into a C++ function that can pack integers of a given bit_width. This implementation will use an LSB-first strategy, which is common.

#include <vector>
#include <cstdint>
#include <cmath>

// A simplified bit packer for unsigned integers.
// Assumes all values fit within bit_width.
void bit_pack(const std::vector<uint32_t>& values, int bit_width, std::vector<uint8_t>& packed_output) {
    if (values.empty()) return;

    // Calculate the required output size in bytes.
    uint64_t total_bits = (uint64_t)values.size() * bit_width;
    size_t packed_size_bytes = (total_bits + 7) / 8;
    packed_output.assign(packed_size_bytes, 0);

    uint64_t bit_buffer = 0; // A 64-bit buffer to hold bits before they are flushed to bytes.
    int bits_in_buffer = 0;

    for (uint32_t value : values) {
        // Add the new value's bits to our buffer.
        // We cast value to uint64_t to ensure the left shift doesn't overflow if bits_in_buffer is large.
        bit_buffer |= ((uint64_t)value << bits_in_buffer);
        bits_in_buffer += bit_width;

        // Flush full bytes from the buffer to the output vector.
        while (bits_in_buffer >= 8) {
            packed_output.push_back(bit_buffer & 0xFF); // Write the lowest 8 bits.
            bit_buffer >>= 8;                           // Shift them out of the buffer.
            bits_in_buffer -= 8;
        }
    }

    // Flush any remaining bits.
    if (bits_in_buffer > 0) {
        packed_output.push_back(bit_buffer & 0xFF);
    }

    // The logic above writes bytes in reverse order of how they appear in the stream.
    // For a real implementation, you would write to an index in packed_output instead.
    // This simplified version using push_back is for clarity of the bit manipulation logic.
}

This implementation uses a bit_buffer (a 64-bit integer) to simplify the logic. Instead of managing byte boundaries manually for each value, we accumulate bits into this larger buffer and then flush completed bytes from it. This is a common and efficient pattern.

Test your understanding!

You need to pack the numbers [5, 3, 10] using a bit_width of 4. Using an LSB-first strategy, what would the binary representation of the first byte of the output be?

Hint:

  • 5 is 0101 in 4 bits.
  • 3 is 0011 in 4 bits.
  • 10 is 1010 in 4 bits.
Show answer
  1. The first value is 5 (0101). We pack it into the first 4 bits of the byte. The byte is now ....0101.
  2. The second value is 3 (0011). We pack it into the next 4 bits. The byte is now 00110101.
  3. The byte is now full. The first output byte is 00110101 (which is 53 in decimal).
  4. The next value, 10 (1010), would be packed starting in the next byte.

Conclusion

You have now learned the principles and implementation details of bit-packed encoding, a fundamental compression technique for columnar databases.

Key Takeaways:

  • Bit packing is ideal for columns of small integers that do not have long consecutive runs (unlike RLE).
  • It saves space by storing each number using the minimum number of bits required for the largest value in a block.
  • The implementation relies on careful bitwise manipulation (shifting, masking, and ORing) to tightly pack values into a byte stream.
  • A key design choice is bit-ordering (LSB-first vs. MSB-first), which has implications for endianness and implementation simplicity.
  • Using a larger bit buffer (e.g., a uint64_t) can simplify the code by decoupling the process of adding bits from the process of writing full bytes to the output.

Preview of the Next Lesson

We have now covered several powerful compression techniques that are applied at the column level (RLE, Dictionary Encoding, Bit Packing). But how are these compressed columns physically arranged on disk? In our next lesson, you will learn how to describe the PAX hybrid storage model as a compromise between row and column stores, which organizes data within a page to get the benefits of both worlds.