土法炼钢兴趣小组的算法知识备份

整数压缩:varint → PForDelta → SIMD-BP128

目录

搜索引擎的倒排索引(inverted index)可能是世界上最大的整数序列存储场景。 Google 在 2004 年的论文中提到,其索引包含数十亿文档, 而每个词项(term)对应的 posting list 就是一条单调递增的文档 ID 序列。 如何把这些整数序列尽可能小、解码尽可能快地存进磁盘或内存, 就是整数压缩(integer compression)这个领域三十年来一直在回答的问题。

本文从最经典的 varint 出发, 逐步深入到 PForDelta、SIMD-BP128、Roaring Bitmap 等现代算法, 最后给出一个可编译运行的 C 实现和工程经验总结。

一、为什么需要整数压缩

倒排索引:整数压缩最大的战场

一个搜索引擎的核心数据结构是倒排索引。假设语料库有 N 篇文档, 对每个词项 t,我们维护一条 posting list:

t = "compression" -> [3, 17, 42, 108, 256, 1024, ...]

列表中的元素是文档 ID,按升序排列。如果直接用 32 位整数存储, 每个 ID 占 4 字节。当 N 达到数十亿、词项数达到数百万时, 原始大小可以轻松超过 TB 级别。

压缩带来的收益

整数压缩的收益不仅仅是节省存储空间,更重要的是提升查询吞吐。 现代硬件的瓶颈往往在内存带宽而非 CPU 计算:

指标 未压缩 (32-bit) varint PForDelta SIMD-BP128
每整数字节数 4.00 1.5-2.0 0.8-1.2 0.5-1.0
解码速度 (Gints/s) N/A 0.3-0.5 1.0-2.0 4.0-8.0
缓存友好度 一般 极好

压缩后的数据更小,cache miss 更少, 即使加上解码开销,整体查询延迟往往更低。 这就是所谓的 “compression for speed” 范式。

整数压缩的分类

整数压缩算法大致可以分为以下几类:

  1. 字节对齐(byte-aligned):varint、Group Varint
  2. 位对齐(bit-aligned):Simple-9、Simple-16、Simple-8b
  3. 块编码(block-based):PForDelta、SIMD-BP128、FastPFOR
  4. 混合容器(hybrid container):Roaring Bitmap
  5. 预处理变换(pre-processing):delta encoding、zigzag encoding、frame-of-reference

它们之间并非互斥关系,实际系统中经常组合使用。

二、Delta 编码与 Zigzag 编码

在谈具体压缩算法之前,我们必须先理解两种基础的整数预处理变换。

Delta 编码

对于单调递增的文档 ID 序列,直接存储原始值非常浪费。 Delta 编码(差分编码)只存储相邻元素的差值:

原始序列:  [3,   17,  42,  108, 256, 1024]
delta 编码: [3,   14,  25,  66,  148, 768]

差值通常远小于原始值,所需的位宽(bit-width)也更小, 这为后续的位压缩(bit-packing)奠定了基础。

Delta 编码的逆操作是前缀和(prefix sum):

// delta decode = prefix sum
void delta_decode(uint32_t *data, size_t n) {
    for (size_t i = 1; i < n; i++) {
        data[i] += data[i - 1];
    }
}

Zigzag 编码

当整数可能为负数时(例如时序数据的差分值), 直接用 varint 编码会导致负数占用大量字节, 因为负数的补码表示高位全是 1。

Zigzag 编码将有符号整数映射到无符号整数:

 0 -> 0
-1 -> 1
 1 -> 2
-2 -> 3
 2 -> 4
...

实现非常简洁:

// zigzag encode: 将有符号整数映射到无符号整数
static inline uint32_t zigzag_encode(int32_t n) {
    return (uint32_t)((n << 1) ^ (n >> 31));
}

// zigzag decode: 逆映射
static inline int32_t zigzag_decode(uint32_t n) {
    return (int32_t)((n >> 1) ^ -(n & 1));
}

Protocol Buffers 的 sint32 / sint64 类型就使用了 zigzag 编码。

Frame-of-Reference(FOR)

Frame-of-Reference 是 delta 编码的推广。 对于一个块内的整数序列,先找出最小值 min_val 作为参考帧(frame), 然后只存储每个值与 min_val 的差:

原始:    [1000, 1003, 1005, 1002, 1007]
min_val: 1000
FOR:     [0,    3,    5,    2,    7]

FOR 之后的值域更小,bit-packing 更高效。 PForDelta 中的 “F” 和 “R” 就来自 Frame-of-Reference。

三、Varint:最经典的字节对齐编码

Varint(Variable-length Integer)是最广泛使用的整数压缩方案。 Google Protocol Buffers、Apache Thrift、SQLite 等系统都采用了 varint。

编码规则

每个字节的最高位(MSB)用作 continuation bit:

低 7 位存储实际数据,采用小端字节序。

值 = 300
二进制: 100101100
分组:   0000010  0101100
         ^高位    ^低位
编码字节: [0xAC, 0x02]
         10101100  00000010
         ^续       ^终

C 实现

#include <stdint.h>
#include <stddef.h>

// 编码一个 uint32,返回写入的字节数
size_t varint_encode_u32(uint8_t *buf, uint32_t value) {
    size_t i = 0;
    while (value >= 0x80) {
        buf[i++] = (uint8_t)(value | 0x80);
        value >>= 7;
    }
    buf[i++] = (uint8_t)value;
    return i;
}

// 解码一个 uint32,返回消耗的字节数
size_t varint_decode_u32(const uint8_t *buf, uint32_t *out) {
    uint32_t result = 0;
    size_t i = 0;
    uint32_t shift = 0;
    do {
        result |= (uint32_t)(buf[i] & 0x7F) << shift;
        shift += 7;
    } while (buf[i++] & 0x80);
    *out = result;
    return i;
}

Varint 的问题

Varint 编码简单直观,但有几个严重的性能问题:

  1. 逐字节处理:每个字节都需要检查 continuation bit,无法利用 SIMD
  2. 分支预测不友好:while 循环的次数取决于值的大小,分支预测器难以优化
  3. 无法随机访问:必须从头顺序解码,不能跳到第 k 个整数
  4. 解码速度慢:典型速度约 300-500 Mints/s,远低于内存带宽极限

四、Group Varint:Google 的字节对齐优化

Google 在 2009 年的论文 “Performance of Compressed Inverted List Caching” 中 提出了 Group Varint Encoding(GVE),专门解决 varint 的分支预测问题。

核心思想

将 4 个整数分为一组,用一个 tag byte 描述每个整数占用的字节数(1-4)。 tag byte 的 8 位分为 4 个 2-bit 字段:

tag byte: [len4-1][len3-1][len2-1][len1-1]
          高2位    次高2位  次低2位  低2位

len_i - 1 的值: 00=1字节, 01=2字节, 10=3字节, 11=4字节

这样一组 4 个整数最少需要 1 + 4 = 5 字节,最多 1 + 16 = 17 字节。

编码示例

4 个整数: [300, 5, 70000, 128]
字节数:    2    1    3      2
tag byte: (2-1)<<6 | (3-1)<<4 | (1-1)<<2 | (2-1)<<0
        = 01       | 10       | 00       | 01
        = 0x64

输出: [0x64] [0x2C, 0x01] [0x05] [0x70, 0x11, 0x01] [0x80, 0x00]
       tag     300          5       70000               128

解码实现

#include <string.h>

// 预计算表:tag -> 每个整数的长度
static uint8_t gv_len_table[256][4];

void gv_init_tables(void) {
    for (int tag = 0; tag < 256; tag++) {
        gv_len_table[tag][0] = ((tag >> 0) & 3) + 1;
        gv_len_table[tag][1] = ((tag >> 2) & 3) + 1;
        gv_len_table[tag][2] = ((tag >> 4) & 3) + 1;
        gv_len_table[tag][3] = ((tag >> 6) & 3) + 1;
    }
}

// 解码 4 个整数,返回消耗的总字节数
size_t gv_decode4(const uint8_t *in, uint32_t out[4]) {
    uint8_t tag = in[0];
    const uint8_t *p = in + 1;
    for (int i = 0; i < 4; i++) {
        uint8_t len = gv_len_table[tag][i];
        uint32_t val = 0;
        memcpy(&val, p, len);  // 小端机器上直接 memcpy
        out[i] = val;
        p += len;
    }
    return (size_t)(p - in);
}

性能分析

Group Varint 消除了 per-byte 的分支判断, 解码速度从 varint 的约 400 Mints/s 提升到约 800 Mints/s。 Google 的 Web Search 系统大量使用了这一编码。

但 Group Varint 仍然是字节对齐的, 对于值域很小的整数(例如 delta 值只需要 3-4 位), 它无法充分利用位级别的紧凑性。

五、Simple-8b 与 Simple-16

Simple-9

Simple-9(Anh & Moffat, 2005)是位对齐压缩的先驱。 它将 28 个有效位打包进一个 32 位字中, 高 4 位用作 selector,指定 28 位数据的分配方式:

selector | 整数个数 | 每整数位宽
---------|---------|----------
   0     |   28    |    1
   1     |   14    |    2
   2     |    9    |    3
   3     |    7    |    4
   4     |    5    |    5
   5     |    4    |    7
   6     |    3    |    9
   7     |    2    |   14
   8     |    1    |   28

Simple-8b

Simple-8b(Anh & Moffat, 2010)将思路扩展到 64 位字。 使用高 4 位作为 selector,低 60 位存储数据:

selector | 整数个数 | 每整数位宽
---------|---------|----------
   0     |  240    |    0   (all zeros)
   1     |   60    |    1
   2     |   30    |    2
   3     |   20    |    3
   4     |   15    |    4
   5     |   12    |    5
   6     |   10    |    6
   7     |    8    |    7 (+ 4 bits unused)
   8     |    7    |    8 (+ 4 bits unused)
   9     |    6    |   10
  10     |    5    |   12
  11     |    4    |   15
  12     |    3    |   20
  13     |    2    |   30
  14     |    1    |   60

Simple-8b 编码器

#include <stdint.h>

// Simple-8b selector 表
static const struct {
    int count;
    int bits;
} s8b_table[15] = {
    {240, 0}, {60, 1}, {30, 2}, {20, 3}, {15, 4},
    {12, 5},  {10, 6}, {8, 7},  {7, 8},  {6, 10},
    {5, 12},  {4, 15}, {3, 20}, {2, 30}, {1, 60}
};

// 尝试用 selector sel 编码 data 中的前若干个整数
// 成功返回编码的整数个数,失败返回 0
int s8b_try_pack(const uint32_t *data, int n, int sel, uint64_t *out) {
    int count = s8b_table[sel].count;
    int bits  = s8b_table[sel].bits;
    if (count > n) count = n;
    uint64_t mask = bits ? ((1ULL << bits) - 1) : 0;

    uint64_t word = (uint64_t)sel << 60;
    for (int i = 0; i < count; i++) {
        if (bits > 0 && data[i] > mask) return 0;
        word |= (uint64_t)data[i] << (i * bits);
    }
    *out = word;
    return count;
}

// 编码:选择能打包最多整数的 selector
int s8b_encode(const uint32_t *data, int n, uint64_t *out) {
    for (int sel = 0; sel < 15; sel++) {
        int packed = s8b_try_pack(data, n, sel, out);
        if (packed > 0) return packed;
    }
    return 0;  // should not happen for valid input
}

Simple-16

Simple-16(Zhang et al., 2008)使用 16 种不同的位宽分配方案, 与 Simple-9 共享 28 有效位的设计,但允许同一个字中混合不同位宽。 例如:

selector 4: 1 x 4-bit + 8 x 3-bit = 4 + 24 = 28 bits
selector 7: 1 x 10-bit + 2 x 9-bit = 10 + 18 = 28 bits

这种不均匀分配使得 Simple-16 在数据分布不均匀时压缩率优于 Simple-9。

六、PForDelta:块编码的里程碑

PForDelta(Patched Frame-of-Reference Delta)是 Zukowski et al. 2006 年提出的算法, 后来经过多次改进(NewPFD、OptPFD),成为倒排索引压缩的标杆。

算法思想

PForDelta 的核心观察是:在一个块(通常 128 或 256 个整数)中, 大多数 delta 值的位宽相似,只有少数异常值(exceptions)需要更多位。

算法步骤:

  1. 选择位宽 b:使得至少 90% 的值可以用 b 位表示
  2. 位打包:所有值用 b 位打包存储,异常值只存低 b 位
  3. 异常处理:异常值的完整值单独存储在 patch 表中

布局图解

PForDelta Layout

编码过程

#include <stdint.h>
#include <stdlib.h>
#include <string.h>

#define BLOCK_SIZE 128

typedef struct {
    uint8_t  bit_width;
    uint8_t  num_exceptions;
    uint32_t packed[BLOCK_SIZE];    // bit-packed base values
    uint8_t  exc_positions[BLOCK_SIZE];
    uint32_t exc_values[BLOCK_SIZE];
} pfd_block_t;

// 找到能覆盖 90% 值的最小位宽
static uint8_t find_bit_width(const uint32_t *deltas, int n) {
    // 计算每个位宽能覆盖的值的比例
    int counts[33] = {0};
    for (int i = 0; i < n; i++) {
        uint32_t v = deltas[i];
        int bits = 0;
        while (v > 0) { bits++; v >>= 1; }
        counts[bits]++;
    }

    int cumulative = 0;
    int threshold = (int)(n * 0.9);
    for (int b = 0; b <= 32; b++) {
        cumulative += counts[b];
        if (cumulative >= threshold) return (uint8_t)b;
    }
    return 32;
}

// PForDelta 编码一个块
void pfd_encode_block(const uint32_t *deltas, int n, pfd_block_t *blk) {
    blk->bit_width = find_bit_width(deltas, n);
    uint32_t mask = blk->bit_width < 32
                    ? (1U << blk->bit_width) - 1 : 0xFFFFFFFF;
    blk->num_exceptions = 0;

    for (int i = 0; i < n; i++) {
        blk->packed[i] = deltas[i] & mask;
        if (deltas[i] > mask) {
            blk->exc_positions[blk->num_exceptions] = (uint8_t)i;
            blk->exc_values[blk->num_exceptions]    = deltas[i];
            blk->num_exceptions++;
        }
    }
}

// PForDelta 解码一个块
void pfd_decode_block(const pfd_block_t *blk, uint32_t *out, int n) {
    // 第一步:复制 bit-packed 基础值
    memcpy(out, blk->packed, n * sizeof(uint32_t));

    // 第二步:用 exception 表修补
    for (int i = 0; i < blk->num_exceptions; i++) {
        out[blk->exc_positions[i]] = blk->exc_values[i];
    }
}

压缩率分析

假设 90% 的 delta 值可以用 b 位表示,10% 是异常:

每整数平均位数 = b + 0.1 * (32 + 8)
               = b + 4.0

当 b = 4 时,平均 8 bits/int = 1 byte/int
当 b = 8 时,平均 12 bits/int = 1.5 bytes/int

与 varint 的 1.5-2.0 bytes/int 相比,PForDelta 通常更紧凑。

变体:NewPFD 与 OptPFD

NewPFD(Yan et al., 2009)改进了异常值的存储方式, 使用链表结构将异常值嵌入主位流中,避免了额外的异常表开销。

OptPFD 进一步优化位宽选择,使用动态规划找到全局最优的位宽分配, 但编码速度较慢,适合静态索引的离线构建。

七、SIMD-BP128:向量化位打包

SIMD-BP128(Lemire & Boytsov, 2015)是目前解码速度最快的整数压缩算法之一, 其核心思想是将位打包(bit-packing)操作完全向量化。

设计原则

  1. 固定块大小 128:正好是 SSE 寄存器(128-bit)可以处理 4 个 32 位整数
  2. 统一位宽:同一块内所有整数使用相同位宽,无异常处理
  3. SIMD 友好的位布局:数据按照 SIMD lane 交错排列

位打包布局

传统的位打包是连续排列的:

传统:  [v0 的低b位][v1 的低b位][v2 的低b位]...

SIMD-BP128 按 4-lane 交错排列:

SIMD:  [v0_lane0][v4_lane0][v8_lane0]...[v0_lane1][v4_lane1]...

这样每次 SIMD load 可以同时获取 4 个值的同一位段。

解码速度

在现代 x86 CPU 上,SIMD-BP128 的解码速度可以达到:

这远远超过了内存带宽的限制, 意味着在大多数场景下,解码不再是瓶颈。

SIMD 位打包 C 实现

以下是一个完整的、可编译运行的 SIMD 友好位打包实现:

/*
 * simd_bitpack.c
 *
 * SIMD-friendly bit-packing / unpacking for 128-integer blocks.
 * Uses SSE2 intrinsics on x86; falls back to scalar on other platforms.
 *
 * Compile: gcc -O2 -msse2 -o simd_bitpack simd_bitpack.c
 * Run:     ./simd_bitpack
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <time.h>

#ifdef __SSE2__
#include <emmintrin.h>
#define USE_SIMD 1
#else
#define USE_SIMD 0
#endif

#define BLOCK_SIZE   128
#define MINI_BLOCK   32   /* SIMD processes 32 values at a time (8 x 128-bit) */
#define MAX_BITS     32

/* ------------------------------------------------------------------ */
/*  Scalar bit-pack: pack BLOCK_SIZE uint32s with uniform bit-width   */
/* ------------------------------------------------------------------ */
void scalar_pack(const uint32_t *in, uint8_t *out, int bit_width) {
    if (bit_width == 0) return;
    uint64_t buffer = 0;
    int bits_in_buf = 0;
    int out_pos = 0;
    uint32_t mask = (bit_width == 32) ? 0xFFFFFFFF : (1U << bit_width) - 1;

    for (int i = 0; i < BLOCK_SIZE; i++) {
        buffer |= ((uint64_t)(in[i] & mask)) << bits_in_buf;
        bits_in_buf += bit_width;
        while (bits_in_buf >= 8) {
            out[out_pos++] = (uint8_t)(buffer & 0xFF);
            buffer >>= 8;
            bits_in_buf -= 8;
        }
    }
    if (bits_in_buf > 0) {
        out[out_pos++] = (uint8_t)(buffer & 0xFF);
    }
}

/* ------------------------------------------------------------------ */
/*  Scalar bit-unpack                                                  */
/* ------------------------------------------------------------------ */
void scalar_unpack(const uint8_t *in, uint32_t *out, int bit_width) {
    if (bit_width == 0) {
        memset(out, 0, BLOCK_SIZE * sizeof(uint32_t));
        return;
    }
    uint64_t buffer = 0;
    int bits_in_buf = 0;
    int in_pos = 0;
    uint32_t mask = (bit_width == 32) ? 0xFFFFFFFF : (1U << bit_width) - 1;

    for (int i = 0; i < BLOCK_SIZE; i++) {
        while (bits_in_buf < bit_width) {
            buffer |= ((uint64_t)in[in_pos++]) << bits_in_buf;
            bits_in_buf += 8;
        }
        out[i] = (uint32_t)(buffer & mask);
        buffer >>= bit_width;
        bits_in_buf -= bit_width;
    }
}

/* ------------------------------------------------------------------ */
/*  SIMD-accelerated unpack (SSE2)                                     */
/*  Processes 4 integers at a time using 128-bit registers             */
/* ------------------------------------------------------------------ */
#if USE_SIMD
void simd_unpack(const uint8_t *in, uint32_t *out, int bit_width) {
    if (bit_width == 0) {
        __m128i zero = _mm_setzero_si128();
        for (int i = 0; i < BLOCK_SIZE; i += 4) {
            _mm_storeu_si128((__m128i *)(out + i), zero);
        }
        return;
    }
    if (bit_width == 32) {
        memcpy(out, in, BLOCK_SIZE * sizeof(uint32_t));
        return;
    }

    uint32_t mask = (1U << bit_width) - 1;
    __m128i vmask = _mm_set1_epi32((int)mask);
    uint64_t buffer = 0;
    int bits_in_buf = 0;
    int in_pos = 0;

    for (int i = 0; i < BLOCK_SIZE; i += 4) {
        uint32_t tmp[4];
        for (int j = 0; j < 4; j++) {
            while (bits_in_buf < bit_width) {
                buffer |= ((uint64_t)in[in_pos++]) << bits_in_buf;
                bits_in_buf += 8;
            }
            tmp[j] = (uint32_t)(buffer & mask);
            buffer >>= bit_width;
            bits_in_buf -= bit_width;
        }
        __m128i v = _mm_loadu_si128((const __m128i *)tmp);
        v = _mm_and_si128(v, vmask);
        _mm_storeu_si128((__m128i *)(out + i), v);
    }
}
#endif

/* ------------------------------------------------------------------ */
/*  Delta encode / decode                                              */
/* ------------------------------------------------------------------ */
void delta_encode(const uint32_t *in, uint32_t *out, int n) {
    out[0] = in[0];
    for (int i = 1; i < n; i++) {
        out[i] = in[i] - in[i - 1];
    }
}

void delta_decode(uint32_t *data, int n) {
    for (int i = 1; i < n; i++) {
        data[i] += data[i - 1];
    }
}

#if USE_SIMD
void delta_decode_simd(uint32_t *data, int n) {
    /* 利用 SIMD 做前缀和:分阶段累加 */
    /* 阶段1:4-wide 局部前缀和 */
    for (int i = 0; i < n; i += 4) {
        __m128i v = _mm_loadu_si128((const __m128i *)(data + i));
        /* shift-and-add pattern for prefix sum within 128-bit lane */
        v = _mm_add_epi32(v, _mm_slli_si128(v, 4));
        v = _mm_add_epi32(v, _mm_slli_si128(v, 8));
        _mm_storeu_si128((__m128i *)(data + i), v);
    }
    /* 阶段2:块间传播 */
    for (int i = 4; i < n; i += 4) {
        __m128i prev = _mm_set1_epi32((int)data[i - 1]);
        __m128i curr = _mm_loadu_si128((const __m128i *)(data + i));
        curr = _mm_add_epi32(curr, prev);
        _mm_storeu_si128((__m128i *)(data + i), curr);
    }
}
#endif

/* ------------------------------------------------------------------ */
/*  Compute required bit-width for a block                             */
/* ------------------------------------------------------------------ */
int compute_bit_width(const uint32_t *data, int n) {
    uint32_t max_val = 0;
    for (int i = 0; i < n; i++) {
        if (data[i] > max_val) max_val = data[i];
    }
    if (max_val == 0) return 0;
    int bits = 0;
    while (max_val > 0) {
        bits++;
        max_val >>= 1;
    }
    return bits;
}

/* ------------------------------------------------------------------ */
/*  Compressed block: header + packed data                             */
/* ------------------------------------------------------------------ */
typedef struct {
    uint8_t  bit_width;
    uint32_t first_value;
    uint8_t  packed_data[BLOCK_SIZE * 4]; /* max 128 * 32 / 8 = 512 */
    int      packed_bytes;
} compressed_block_t;

void compress_block(const uint32_t *in, compressed_block_t *blk) {
    uint32_t deltas[BLOCK_SIZE];
    delta_encode(in, deltas, BLOCK_SIZE);

    blk->first_value = deltas[0];
    blk->bit_width = (uint8_t)compute_bit_width(deltas + 1, BLOCK_SIZE - 1);
    blk->packed_bytes = (int)(((BLOCK_SIZE - 1) * blk->bit_width + 7) / 8);

    scalar_pack(deltas + 1, blk->packed_data, blk->bit_width);
}

void decompress_block(const compressed_block_t *blk, uint32_t *out) {
    out[0] = blk->first_value;
#if USE_SIMD
    simd_unpack(blk->packed_data, out + 1, blk->bit_width);
#else
    scalar_unpack(blk->packed_data, out + 1, blk->bit_width);
#endif
    delta_decode(out, BLOCK_SIZE);
}

/* ------------------------------------------------------------------ */
/*  Benchmark helper                                                   */
/* ------------------------------------------------------------------ */
static double time_diff_ms(struct timespec *start, struct timespec *end) {
    return (end->tv_sec - start->tv_sec) * 1000.0
         + (end->tv_nsec - start->tv_nsec) / 1e6;
}

/* ------------------------------------------------------------------ */
/*  Main: generate test data, compress, decompress, verify, benchmark  */
/* ------------------------------------------------------------------ */
int main(void) {
    printf("SIMD-friendly bit-packing demo (BLOCK_SIZE = %d)\n", BLOCK_SIZE);
    printf("SIMD support: %s\n\n", USE_SIMD ? "SSE2" : "scalar");

    /* 生成有序整数序列,模拟 posting list */
    uint32_t original[BLOCK_SIZE];
    uint32_t decoded[BLOCK_SIZE];
    srand(42);
    original[0] = (uint32_t)(rand() % 100);
    for (int i = 1; i < BLOCK_SIZE; i++) {
        original[i] = original[i - 1] + 1 + (uint32_t)(rand() % 20);
    }

    printf("Original (first 16): ");
    for (int i = 0; i < 16; i++) printf("%u ", original[i]);
    printf("...\n");

    /* 压缩 */
    compressed_block_t blk;
    compress_block(original, &blk);

    int uncompressed_bytes = BLOCK_SIZE * 4;
    int compressed_bytes = 1 + 4 + blk.packed_bytes;  /* header + data */
    printf("Bit-width: %d\n", blk.bit_width);
    printf("Compressed: %d bytes (from %d bytes, ratio: %.2fx)\n",
           compressed_bytes, uncompressed_bytes,
           (double)uncompressed_bytes / compressed_bytes);
    printf("Bits per integer: %.2f\n\n",
           (double)compressed_bytes * 8 / BLOCK_SIZE);

    /* 解压并验证 */
    decompress_block(&blk, decoded);
    int ok = 1;
    for (int i = 0; i < BLOCK_SIZE; i++) {
        if (original[i] != decoded[i]) { ok = 0; break; }
    }
    printf("Correctness: %s\n\n", ok ? "PASS" : "FAIL");

    /* 基准测试:解压速度 */
    const int ROUNDS = 1000000;
    struct timespec t0, t1;
    clock_gettime(CLOCK_MONOTONIC, &t0);
    for (int r = 0; r < ROUNDS; r++) {
        decompress_block(&blk, decoded);
    }
    clock_gettime(CLOCK_MONOTONIC, &t1);
    double ms = time_diff_ms(&t0, &t1);
    double total_ints = (double)ROUNDS * BLOCK_SIZE;
    double gints_per_sec = total_ints / (ms / 1000.0) / 1e9;
    printf("Benchmark: %d rounds, %.2f ms\n", ROUNDS, ms);
    printf("Throughput: %.2f Gints/s (%.2f billion integers per second)\n",
           gints_per_sec, gints_per_sec);

    return 0;
}

编译运行:

gcc -O2 -msse2 -o simd_bitpack simd_bitpack.c -lrt
./simd_bitpack

典型输出:

SIMD-friendly bit-packing demo (BLOCK_SIZE = 128)
SIMD support: SSE2

Original (first 16): 66 76 84 93 97 107 121 139 148 152 168 174 188 202 221 225 ...
Bit-width: 5
Compressed: 85 bytes (from 512 bytes, ratio: 6.02x)
Bits per integer: 5.31

Correctness: PASS

Benchmark: 1000000 rounds, 142.35 ms
Throughput: 0.90 Gints/s (0.90 billion integers per second)

八、Roaring Bitmap:混合容器

Roaring Bitmap(Chambi et al., 2016)是一种完全不同于位打包的整数集合压缩方案, 它在 Apache Lucene、Apache Spark、Redis、ClickHouse 等系统中广泛使用。

设计思想

Roaring 将 32 位整数空间划分为 65536 个桶(chunk), 每个桶覆盖一个高 16 位前缀,包含对应的低 16 位值。

对每个桶,根据其基数(cardinality)自动选择三种容器之一:

容器类型 适用场景 存储方式
Array Container 基数 < 4096 有序 uint16 数组
Bitmap Container 基数 >= 4096 8KB 位图
Run Container 连续区间多 RLE 编码的 (start, length) 对

容器选择的数学

为什么阈值是 4096 ?

Array:  cardinality * 2 bytes
Bitmap: 8192 bytes (constant)
交叉点: cardinality * 2 = 8192 => cardinality = 4096

核心操作伪代码

typedef struct {
    uint16_t high_bits;      // 高 16 位前缀
    uint8_t  container_type; // 0=array, 1=bitmap, 2=run
    union {
        struct {
            uint16_t *values;
            int       count;
        } array;
        struct {
            uint64_t bits[1024]; // 65536 / 64 = 1024
        } bitmap;
        struct {
            uint16_t *runs; // [start0, len0, start1, len1, ...]
            int       num_runs;
        } run;
    } data;
} roaring_container_t;

// 检查一个值是否存在
int roaring_contains(const roaring_container_t *c, uint16_t low) {
    switch (c->container_type) {
    case 0: { // array: 二分查找
        int lo = 0, hi = c->data.array.count - 1;
        while (lo <= hi) {
            int mid = (lo + hi) / 2;
            if (c->data.array.values[mid] == low) return 1;
            if (c->data.array.values[mid] < low) lo = mid + 1;
            else hi = mid - 1;
        }
        return 0;
    }
    case 1: // bitmap: 直接位查询
        return (c->data.bitmap.bits[low >> 6] >> (low & 63)) & 1;
    case 2: { // run: 二分查找区间
        for (int i = 0; i < c->data.run.num_runs; i++) {
            uint16_t start = c->data.run.runs[i * 2];
            uint16_t len   = c->data.run.runs[i * 2 + 1];
            if (low >= start && low <= start + len) return 1;
            if (low < start) break;
        }
        return 0;
    }
    }
    return 0;
}

Roaring 的集合运算

Roaring 的一大优势是集合运算(AND、OR、XOR)非常高效。 因为桶的高 16 位相同的容器才需要做运算, 而容器间的运算可以根据类型选择最优策略:

Array  AND Array  -> 归并式交集 O(min(m,n))
Array  AND Bitmap -> 逐个查位图 O(m)
Bitmap AND Bitmap -> 逐 word 位与 O(1024)

Lucene 从 5.0 版本开始使用 Roaring Bitmap 替代原来的位集合, 查询性能在稀疏场景下提升了数倍。

九、Benchmark:解码速度全景

以下是各算法在典型 x86-64 平台上的解码速度对比 (数据来自 Lemire et al. 的论文和 Daniel Lemire 的 GitHub 仓库):

算法              | 解码速度 (Mints/s) | bits/int | 随机访问
------------------|--------------------|----------|--------
Raw 32-bit        | memcpy limit       | 32.0     | O(1)
Varint            | 300 - 500          | 8 - 16   | O(n)
Group Varint      | 700 - 1000         | 8 - 16   | O(n)
Simple-8b         | 1000 - 1500        | 1 - 10   | O(n)
Simple-16         | 800 - 1200         | 1 - 10   | O(n)
PForDelta         | 1500 - 2500        | 3 - 12   | O(1)*
NewPFD            | 2000 - 3000        | 3 - 10   | O(1)*
SIMD-BP128 (SSE)  | 4000 - 6000        | 1 - 32   | O(1)*
SIMD-BP128 (AVX2) | 6000 - 10000       | 1 - 32   | O(1)*
Roaring Bitmap    | varies             | varies   | O(1)

*: 块内随机访问需要先解码整个块

影响解码速度的关键因素

  1. 分支数量:varint 每个字节一个分支,SIMD-BP128 整个块零分支
  2. SIMD 利用率:SIMD-BP128 和 Roaring 的 bitmap 运算可以充分利用向量指令
  3. 数据局部性:块编码天然具有良好的空间局部性
  4. 位宽分布:当数据分布均匀时,固定位宽方案(BP128)最优
  5. 异常值比例:异常值多时 PForDelta 退化,SIMD-BP128 的位宽被拉高

压缩率 vs 解码速度

在实践中,这两个指标往往需要权衡:

                       解码速度 (Gints/s)
                   0     2     4     6     8    10
                   |-----|-----|-----|-----|-----|
  Varint           |==                            | ~1.5 bytes/int
  Group Varint     |=====                         | ~1.5 bytes/int
  Simple-8b        |========                      | ~1.0 bytes/int
  PForDelta        |=============                 | ~0.9 bytes/int
  SIMD-BP128 SSE   |==========================    | ~0.8 bytes/int
  SIMD-BP128 AVX2  |=================================| ~0.8 bytes/int

在绝大多数场景下,SIMD-BP128 在压缩率和速度上都是最优选择。 只有在需要细粒度随机访问或集合运算时,Roaring Bitmap 才是更好的选择。

十、工业实践:谁在用什么

系统与算法对照表

系统 用途 采用的算法 备注
Google Web Search 倒排索引 Group Varint 2009 年论文公开
Apache Lucene 倒排索引 PForDelta + Roaring 从 4.x 到 8.x 持续演进
Elasticsearch 搜索引擎 同 Lucene 底层直接使用 Lucene
Apache Parquet 列存文件格式 Delta + RLE + Bit-Packing 针对不同列类型自动选择
Apache Arrow 内存列格式 Dictionary + RLE 侧重内存中的零拷贝
ClickHouse OLAP 数据库 Delta + Double-Delta + Gorilla + LZ4 针对时序场景深度优化
DuckDB 嵌入式 OLAP Chimp + Patas + ALP 浮点数压缩领域的新进展
RocksDB LSM-tree Varint (prefix compression) 键值对的前缀压缩
Protocol Buffers 序列化格式 Varint + Zigzag 最广泛的 varint 实现
Cap’n Proto 序列化格式 无压缩(固定宽度) 用空间换零解析开销
Redis Bitmap 操作 Roaring Bitmap (RedisBloom) 通过模块支持
Pilosa/FeatureBase 位图索引 Roaring Bitmap 整个引擎建立在 Roaring 之上

Lucene 的演进历程

Lucene 的整数压缩策略经历了几个关键阶段:

版本    | 算法                  | 改进点
--------|----------------------|----------------------------
1.x-3.x | VInt (varint变体)     | 最基础的字节对齐方案
4.0     | PForDelta             | 块编码,压缩率大幅提升
4.1     | FOR + patching        | 优化异常处理
5.0     | Roaring Bitmap        | filter cache 从 bitset 迁移
8.x     | PFOR + BM25 skip      | 跳表与压缩的协同优化
9.x     | 多级索引 + SIMD hint  | 进一步减少 posting list 遍历

Parquet 的列编码策略

Parquet 根据列的数据特征自动选择编码方案:

数据类型         | 编码方案
----------------|------------------------------------
整数(低基数)   | Dictionary + RLE
整数(高基数)   | Delta Binary Packed
布尔            | RLE
浮点            | Plain(未来可能引入 ALP)
字符串          | Dictionary + Plain
时间戳          | Delta + PLAIN

Delta Binary Packed 就是 Parquet 中的 PForDelta 变体, 将数据分为 mini-block(默认 32 个值), 每个 mini-block 使用独立的位宽。

十一、工程踩坑指南

在工程实践中,整数压缩有很多容易忽略的细节。 以下是我和同事们踩过的坑:

常见问题汇总

问题 症状 原因 解决方案
字节序 跨平台数据损坏 大端/小端机器混用 统一用小端或加 byte-swap
对齐 段错误或性能下降 SIMD load 要求 16/32 字节对齐 aligned_allocposix_memalign
溢出 解码后数值错误 delta decode 的前缀和溢出 uint32 用 uint64 做累加或分段检查
块尾部 越界读写 最后一个块不满 128 个整数 padding 到 128 或记录实际长度
异常值爆炸 PForDelta 压缩率骤降 数据中混入了 0xFFFFFFFF 或负数 数据清洗或换用 varint 回退
SIMD 版本 运行时崩溃 目标 CPU 不支持 AVX2 运行时检测 CPUID,动态分发
压缩 buffer 写越界 worst case 没有预留足够空间 输出缓冲区至少 128 * 4 + header 字节
编码/解码不对称 数据损坏 编码用了 delta,解码忘了 prefix sum 写清晰的 codec 接口,编码解码成对出现
稀疏 posting list 压缩率差 delta 值很大(文档很少) 对稀疏列表换用 Roaring Bitmap
并发解码 数据竞争 多线程共享解码 buffer 每线程独立 buffer 或用 thread-local
内存碎片 OOM 或性能退化 大量小块分配 使用 arena allocator,一次分配大块内存
Benchmark 误差 结果不可复现 CPU 频率波动、turbo boost 关闭 turbo boost,绑定 CPU 核心

选择算法的决策树

你的数据是什么样的?
|
|-- 单调递增的整数序列(posting list)
|   |-- 需要集合运算(AND/OR/XOR)?
|   |   |-- 是 --> Roaring Bitmap
|   |   |-- 否 --> 继续
|   |
|   |-- 有 SIMD 支持?
|   |   |-- 是 --> SIMD-BP128
|   |   |-- 否 --> PForDelta
|   |
|   |-- 简单性优先?
|       |-- 是 --> Group Varint 或 varint
|
|-- 有正有负的整数(时序差分)
|   |-- zigzag + delta + SIMD-BP128
|
|-- 稀疏的位集合
|   |-- Roaring Bitmap
|
|-- 序列化/RPC 场景
|   |-- varint(protobuf 风格)
|
|-- 列式存储
    |-- Delta Binary Packed(Parquet 风格)

十二、结语与个人观点

对整数压缩领域的几点看法

第一,varint 不会消亡。 尽管 SIMD-BP128 在解码速度上碾压 varint, 但 varint 的简单性、流式处理能力和广泛的生态支持, 使它在序列化协议、日志格式等场景中仍然是首选。 Protocol Buffers 不太可能在短期内放弃 varint。

第二,SIMD 是现代整数压缩的基座。 从 2015 年 Lemire 的论文开始, 几乎所有新的压缩算法都以 SIMD 为前提进行设计。 AVX-512 的 VPCOMPRESSD/VPEXPANDD 指令更是为位打包量身定制。 未来 ARM SVE 和 RISC-V V 扩展的普及会进一步推动这一趋势。

第三,Roaring Bitmap 重新定义了”整数集合”。 它不追求极致的压缩率,而是在压缩和计算之间找到了实用的平衡点。 当你需要对压缩后的数据做交集、并集等运算时, Roaring 几乎是唯一合理的选择。

第四,硬件在追赶软件。 Intel 的 QAT 加速器、NVIDIA 的 nvCOMP 库, 以及各种 FPGA 实现,正在把整数压缩推向硬件加速的方向。 当解码速度已经超过内存带宽时, 下一个瓶颈在 PCIe 和网络,这又催生了 CXL 和 SmartNIC 上的近数据计算。

第五,不要过早优化压缩方案。 在选择算法之前,先量化你的瓶颈。 如果系统的瓶颈在磁盘 I/O,那么高压缩率比快解码更重要; 如果瓶颈在 CPU,那么应该选择解码最快的方案; 如果瓶颈在内存带宽,那么压缩率和解码速度需要综合考量。

推荐阅读

  1. Lemire, D., & Boytsov, L. (2015). “Decoding billions of integers in milliseconds through vectorized bit packing.” Software: Practice and Experience.
  2. Zukowski, M., et al. (2006). “Super-scalar RAM-CPU cache compression.” ICDE.
  3. Chambi, S., et al. (2016). “Better bitmap performance with Roaring bitmaps.” Software: Practice and Experience.
  4. Dean, J. (2009). “Challenges in building large-scale information retrieval systems.” WSDM Keynote.
  5. Yan, H., et al. (2009). “Inverted index compression and query processing with optimized document ordering.” WWW.

代码仓库

整数压缩是一个看似简单实则精深的领域。 从 1970 年代的 Elias gamma coding 到今天的 SIMD-BP128, 五十年来算法的进化始终追随着硬件的演进。 理解这个领域,不仅有助于设计高性能搜索引擎和数据库, 也能帮助我们建立对”数据表示”更深层次的直觉。


上一篇: 算术编码与 ANS 下一篇: 时序数据压缩 相关阅读: - Bloom Filter 全家族 - 向量化哈希


By .