搜索引擎的倒排索引(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” 范式。
整数压缩的分类
整数压缩算法大致可以分为以下几类:
- 字节对齐(byte-aligned):varint、Group Varint
- 位对齐(bit-aligned):Simple-9、Simple-16、Simple-8b
- 块编码(block-based):PForDelta、SIMD-BP128、FastPFOR
- 混合容器(hybrid container):Roaring Bitmap
- 预处理变换(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:
- MSB = 1:后面还有更多字节
- MSB = 0:这是最后一个字节
低 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 编码简单直观,但有几个严重的性能问题:
- 逐字节处理:每个字节都需要检查 continuation bit,无法利用 SIMD
- 分支预测不友好:while 循环的次数取决于值的大小,分支预测器难以优化
- 无法随机访问:必须从头顺序解码,不能跳到第 k 个整数
- 解码速度慢:典型速度约 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)需要更多位。
算法步骤:
- 选择位宽 b:使得至少 90% 的值可以用 b 位表示
- 位打包:所有值用 b 位打包存储,异常值只存低 b 位
- 异常处理:异常值的完整值单独存储在 patch 表中
布局图解
编码过程
#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)操作完全向量化。
设计原则
- 固定块大小 128:正好是 SSE 寄存器(128-bit)可以处理 4 个 32 位整数
- 统一位宽:同一块内所有整数使用相同位宽,无异常处理
- 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 的解码速度可以达到:
- SSE2:约 4 Gints/s(40 亿整数每秒)
- AVX2:约 8 Gints/s
- AVX-512:约 12 Gints/s
这远远超过了内存带宽的限制, 意味着在大多数场景下,解码不再是瓶颈。
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 Container:每个元素 2 字节,4096 个元素 = 8192 字节
- Bitmap Container:固定 8192 字节(65536 bits)
- 当基数恰好为 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)
*: 块内随机访问需要先解码整个块
影响解码速度的关键因素
- 分支数量:varint 每个字节一个分支,SIMD-BP128 整个块零分支
- SIMD 利用率:SIMD-BP128 和 Roaring 的 bitmap 运算可以充分利用向量指令
- 数据局部性:块编码天然具有良好的空间局部性
- 位宽分布:当数据分布均匀时,固定位宽方案(BP128)最优
- 异常值比例:异常值多时 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_alloc 或
posix_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,那么应该选择解码最快的方案; 如果瓶颈在内存带宽,那么压缩率和解码速度需要综合考量。
推荐阅读
- Lemire, D., & Boytsov, L. (2015). “Decoding billions of integers in milliseconds through vectorized bit packing.” Software: Practice and Experience.
- Zukowski, M., et al. (2006). “Super-scalar RAM-CPU cache compression.” ICDE.
- Chambi, S., et al. (2016). “Better bitmap performance with Roaring bitmaps.” Software: Practice and Experience.
- Dean, J. (2009). “Challenges in building large-scale information retrieval systems.” WSDM Keynote.
- Yan, H., et al. (2009). “Inverted index compression and query processing with optimized document ordering.” WWW.
代码仓库
- lemire/simdcomp – SIMD-BP128 的 C 参考实现
- RoaringBitmap/CRoaring – Roaring Bitmap 的 C/C++ 实现
- lemire/FastPFor – 多种整数压缩算法的 C++ 集合
- lemire/streamvbyte – Stream VByte 编码
整数压缩是一个看似简单实则精深的领域。 从 1970 年代的 Elias gamma coding 到今天的 SIMD-BP128, 五十年来算法的进化始终追随着硬件的演进。 理解这个领域,不仅有助于设计高性能搜索引擎和数据库, 也能帮助我们建立对”数据表示”更深层次的直觉。
上一篇: 算术编码与 ANS 下一篇: 时序数据压缩 相关阅读: - Bloom Filter 全家族 - 向量化哈希