整数压缩:varint、Golomb、PForDelta 与 SIMD 位打包,倒排表里每个整数花几比特

倒排表(posting list)是一串递增的文档号。存差值(d-gap)而不是原值,是所有整数压缩方案的共同起点;分歧在后面:每个差值按比特、按字节、按 32 位字还是按 128 个一块来编码。按比特编码最省空间,按块编码解码最快,教程里常见的”varint 够用”“PForDelta 比 varint 快几倍”都只说了一半,且多半不给口径。

本文用同一套数据回答三个问题:

  1. 一个倒排表至少要多少比特?各类编码离这个下界差多少,差在哪里?
  2. 字节对齐、字对齐、块对齐分别用多少空间换来了什么?
  3. SIMD 位打包快在哪里?“垂直布局”为什么不改变大小却改变速度?

主要结论(数据全部来自第十节的复现程序):

  • 以 \(\log_2\binom{N}{n}\) 为参照,Golomb 码在伯努利合成表上最多只多 0.03 比特/整数;Elias-Fano 在 \(p\le 1/16\) 时稳定多约 0.55–0.6;每 128 个取最大位宽的 BP128 多约 1.5;varint 多 1.6–6 比特,稠密端被每字节 8 比特的地板卡住,稀疏端受 7 比特分组取整的影响。
  • 在真实语料 KJV 圣经(每行一个文档,只统计长度 \(n\ge 256\) 的倒排表)上,插值编码 4.46、Golomb 4.70、OptPFD 5.32、SIMD-FastPFor 5.70、BP128 6.74、varint 8.28、Stream VByte 10.11 比特/整数。
  • 同一批 128 个整数,按”水平”布局和”垂直”布局打包,字节数完全相同;在本机,通用的垂直 SSE2 解包循环比水平标量循环快 3–5 倍。
  • 解码速度只在同一次运行内比较相对顺序:Stream VByte 和 SIMD 位打包最快,Simple-9/16 最慢,相差 10 倍以上。

不展开的话题:熵编码器本身(见 83 篇)、时间序列的浮点与时间戳压缩(85 篇)、列存里的编码选择和 Parquet/ORC(86 篇)、Roaring 这类位图容器。

一、问题、下界与数据

倒排表与 d-gap

在包含 \(N\) 个文档的集合里,一个词出现在 \(n\) 个文档中,倒排表就是 \(n\) 个严格递增的文档号 \(0\le x_0<x_1<\dots<x_{n-1}<N\)。本文统一用

\[ g_i = x_i - x_{i-1} - 1,\qquad x_{-1} = -1 \]

作为差值,于是 \(g_i\ge 0\),而且 \(\sum_i (g_i+1) = x_{n-1}+1 \le N\)。有的系统用 \(d_i = x_i - x_{i-1}\ge 1\)(Lucene 就是这样),两者只差 1,第八节会提到这一差别在哪里有影响。

下界 \(\log_2\binom{N}{n}\)

如果只知道 \(N\) 和 \(n\),倒排表可能是 \(\binom{N}{n}\) 个子集中的任何一个,给每个子集一个定长编号需要 \(\lceil\log_2\binom{N}{n}\rceil\) 比特。这是最坏情况(也是均匀随机子集的平均情况)下的下界。\(n\ll N\) 时用 Stirling 公式可得

\[ \log_2\binom{N}{n} \approx n\left(\log_2\frac{N}{n} + \log_2 e\right),\qquad \log_2 e\approx 1.443 . \]

所以均匀稀疏的倒排表,每个整数至少要 \(\log_2(N/n)+1.44\) 比特。本文所有编码器都把 \(n\) 和 \(N\) 当作已知(真实系统把它们存在词典里),与这个下界的前提一致。

两点要注意:

  • 下界针对”只知道 \(N\) 和 \(n\)“的编码。真实倒排表不是均匀随机的,同一个词往往集中出现在相邻文档里(圣经里某卷书反复出现某个人名),能利用这种局部性的编码可以低于这条线。下文的插值编码就是例子。
  • 下界不是熵编码器能达到的”最好值”的全部解释。它只说明:一个编码如果比它多出 \(\Delta\) 比特/整数,这 \(\Delta\) 一定来自对齐、分块、参数或对分布的错误假设。

伯努利模型:几何分布的 gap

最常用的分析模型是:每个文档独立地以概率 \(p=n/N\) 包含该词。此时 gap 服从几何分布

\[ \Pr[g=j] = (1-p)^j p,\qquad j\ge 0, \]

均值 \((1-p)/p\)。几何分布是分析 Golomb 码、Elias-Fano 与块编码的公共语言。合成数据取 \(p=2^{-k}\),\(k=1,\dots,15\),每个 \(k\) 生成 4 条长 65536 的倒排表(种子 \(84\times 100+k\)),于是下界约为 \(k+1.44\) 比特/整数。

真实语料

真实数据取 Canterbury 语料库的 large 集合(有 sha256 校验)里的两个文本,把每一行当作一个文档,按 [a-z0-9]+ 切词并转小写:

大部分词只出现几次,这些短表对总比特数贡献不大,却会让块编码的头部开销和尾部处理主导结果。所以下文同时报告”全部表”和”\(n\ge 256\) 的表”两个口径;比较 FastPFor 库的编码器时只用后者,理由见第七节。

二、按比特编码:Elias、Golomb 与 Rice

一元码、γ 码与 δ 码

一元码(unary code)把 \(q\ge 0\) 写成 \(q\) 个 0 加一个 1,共 \(q+1\) 比特。Elias 在 1975 年的论文里以它为基础构造了几种”通用码”(universal code):对任何单调递减的分布,平均码长都不超过熵的常数倍(加常数),其中最常用的是 γ 码和 δ 码。对正整数 \(v\)(本文编码 \(v=g+1\)):

  • γ 码:先用一元码写 \(\lfloor\log_2 v\rfloor\)(即 \(\lfloor\log_2 v\rfloor\) 个 0 再加 \(v\) 的最高位 1),再写 \(v\) 的其余 \(\lfloor\log_2 v\rfloor\) 位,共 \(2\lfloor\log_2 v\rfloor+1\) 比特。
  • δ 码:把 γ 码的一元前缀换成对 \(\lfloor\log_2 v\rfloor+1\) 的 γ 码,共 \(\lfloor\log_2 v\rfloor + 2\lfloor\log_2(\lfloor\log_2 v\rfloor+1)\rfloor+1\) 比特。

γ 码对小整数很合适:\(v=1\) 只要 1 比特。但它的长度大约是 \(2\log_2 v\),平均 gap 为 \(2^k\) 时约 \(2k\) 比特,比下界 \(k+1.44\) 多出将近一倍。伯努利实验里 \(k=8\) 时 γ 码 14.41、δ 码 12.86,下界 9.46。圣经的长表很稠密(下界 4.65),γ 码反而有 5.03,比后文的 BP128 还省。

Golomb 码

Golomb(1966)为游程编码设计的码有一个参数 \(b\):把 \(g\) 写成商 \(q=\lfloor g/b\rfloor\) 的一元码,加余数 \(r=g\bmod b\) 的截断二进制码(truncated binary)。截断二进制码令 \(c=\lceil\log_2 b\rceil\),\(r<2^c-b\) 时用 \(c-1\) 比特,否则用 \(c\) 比特写 \(r+2^c-b\)。

Gallager 与 van Voorhis(1975)证明,几何分布下 Golomb 码是最优前缀码,最优参数是满足 \((1-p)^b+(1-p)^{b+1}\le 1<(1-p)^{b-1}+(1-p)^b\) 的 \(b\),闭式为

\[ b = \left\lceil \frac{\log(2-p)}{-\log(1-p)} \right\rceil \approx \frac{\ln 2}{p}\quad (p\to 0). \]

复现程序的 golomb_param 就是这个公式,\(p\) 取 \(n/N\),不需要额外存参数(\(n=1\)、\(N=1000\) 时得 \(b=693\),作为已知答案写进了单元测试)。

为什么 Golomb 码能贴着下界?伯努利模型下 \(n\) 个 gap 的熵是 \(n\cdot H(p)/p\),其中 \(H(p)=-p\log_2 p-(1-p)\log_2(1-p)\);因为 \(n/p\approx N\),这正是 \(N\cdot H(n/N)\),与 \(\log_2\binom{N}{n}\) 的一阶近似相同。最优前缀码与熵的差距在这里很小:实测 \(k=1,\dots,15\) 时 Golomb 码比下界多 \(-0.003\) 到 \(0.031\) 比特/整数。\(k=1\) 时略低于下界并不矛盾:下界是对全部子集的最坏情况,而 4 条合成表共用同一个 \(N\)(取其中最大的文档号加 1),其余几条的实际跨度更短。

Rice 码

Rice(1979)把 \(b\) 限制为 \(2^k\):余数就是低 \(k\) 位,不需要截断二进制码,编码解码都是移位和掩码。代价是参数只能取 2 的幂。复现程序为每条表穷举 \(k\) 取最短结果,并用 5 比特记录 \(k\)。实测 Rice 比 Golomb 多 0 到 0.07 比特/整数;圣经 \(n\ge 256\) 的表上 Rice 4.80、Golomb 4.70。

这类码的弱点在解码速度而不在大小:一元部分要逐位或用前导零计数找 1,每个 gap 的长度都依赖数据,分支难以预测,也无法用 SIMD 一次处理多个值。Google 在 1997–2001 年间的变迁(第八节)就是一个例子。

三、利用全局信息:插值编码与 Elias-Fano

插值编码

Moffat 与 Stuiver(2000)的二分插值编码(binary interpolative coding, BIC)不看 gap,而看位置:已知 \(x_{lo..hi-1}\) 全部落在 \([L,R]\) 内,中间那个元素 \(x_{mid}\) 必然满足

\[ L + (mid-lo) \;\le\; x_{mid} \;\le\; R - (hi-1-mid), \]

因为它左边还有 \(mid-lo\) 个、右边还有 \(hi-1-mid\) 个互不相同的元素。用截断二进制码在这个区间里写 \(x_{mid}\),再对左右两半递归。复现程序的编码器就是这几行:

static inline void bic_rec(bw_t *w, const uint32_t *x, size_t lo, size_t hi, uint64_t L, uint64_t R)
{
    while (lo < hi) {
        size_t mid = lo + (hi - lo) / 2;
        uint64_t low = L + (mid - lo), high = R - (hi - 1 - mid);
        put_truncbin(w, x[mid] - low, high - low + 1);
        bic_rec(w, x, lo, mid, L, (uint64_t)x[mid] - 1);
        lo = mid + 1; L = (uint64_t)x[mid] + 1;
    }
}

当一段文档号连续出现时,区间会收缩到只剩一种可能,high == low,截断二进制码写 0 比特。这就是它能低于 \(\log_2\binom{N}{n}\) 的原因:圣经 \(n\ge 256\) 的表上插值编码 4.46 比特/整数,下界 4.65,Golomb 4.70。反过来,在没有聚集的伯努利表上它比 Golomb 多约 0.3 比特(\(k=8\) 时 9.81 对 9.47),world192 的长表上也略输 Golomb(7.64 对 7.51)。代价是解码必须递归或维护显式栈,而且无法跳到中间某个位置直接解码。

Elias-Fano

Elias-Fano 表示来自 Elias(1974)和 Fano(1971)各自独立的工作,Vigna(2013)把它用到倒排索引上(quasi-succinct indices)后才在搜索引擎里流行起来。它把每个 \(x_i\) 拆成两部分:

  • 低 \(l=\lfloor\log_2(N/n)\rfloor\) 位,按原样紧密排列,共 \(nl\) 比特,第 \(i\) 个在偏移 \(il\) 处;
  • 高位 \(h_i = x_i \gg l\),写成一个位向量:第 \(i\) 个元素把第 \(h_i+i\) 位置 1。位向量长 \(n + (x_{n-1}\gg l)\) 比特,每个”桶”(相同的 \(h\))以一个 0 结束。
Elias-Fano 示例:8 个取值在 0 到 63 的文档号 3、4、7、13、14、15、21、43,取 l 等于 3。每个值拆成高位和低 3 位;低位段按固定宽度排成 24 比特;高位段是 13 比特的位向量 1110111010001,第 i 个元素把第 high_i 加 i 位置 1,0 表示一个桶结束,高位为 3 和 4 的桶为空。总长 37 比特,对比 log2 C(64,8) 约 32.04 比特和上界 40 比特

高位向量里 1 的个数是 \(n\),0 的个数是 \(x_{n-1}\gg l\le N/2^l<2n\),因此总长不超过

\[ n\left(2+\left\lfloor\log_2\frac{N}{n}\right\rfloor\right) \le n\left(2+\log_2\frac{N}{n}\right), \]

与下界的一阶近似 \(n(\log_2(N/n)+1.443)\) 只差每元素约 \(2-\log_2 e\approx 0.557\) 比特。实测完全符合:伯努利表 \(k\ge 6\) 时 Elias-Fano 比下界多 0.55–0.56 比特/整数。稠密端差得更多(\(k=1\) 时 3.00 对 2.00),因为 \(n\) 与 \(N\) 同量级时 Stirling 近似里的 \(\log_2 e\) 不再成立,\(p=1/2\) 时真实下界就是每个 posting 2 比特。

它的价值在于随机访问:在高位向量上建一个 select 索引,\(h_i = \mathrm{select}_1(i) - i\),于是第 \(i\) 个元素和”第一个 \(\ge t\) 的元素”(NextGEQ)都能在常数时间或近常数时间内求出,不需要从表头顺序解码。这是求交集时跳跃的基础。它不利用聚集:圣经长表上 5.32 比特/整数,比插值编码多近 0.9。Ottaviano 与 Venturini(2014)的分段 Elias-Fano(partitioned Elias-Fano)把表切成若干段、每段按自身范围选表示,正是为了补这一点;本文没有实现它。

四、按字节编码:varint、zigzag、group varint 与 Stream VByte

LEB128 varint 与 zigzag

最常见的 varint 是 LEB128 风格:每字节低 7 位放数据,最高位为 1 表示后面还有字节,低位组在前。protobuf 的编码文档用 300 作例子,编码为 AC 02;复现程序的单元测试核对了这个值。64 位整数最多 10 字节。

protobuf 的 int32/int64 把负数按 64 位补码写,任何负数都占 10 字节;sint32/sint64 先做 zigzag 变换

\[ \mathrm{zz}(v) = 2v \oplus (v \gg 31) \quad(\text{算术右移}), \]

把 \(0,-1,1,-2,2,\dots\) 映射到 \(0,1,2,3,4,\dots\),让绝对值小的负数也短。倒排表的 gap 都非负,不需要 zigzag;它在有正有负的差值上才有用(例如时间序列,见 85 篇)。

不是所有 varint 都是这个格式。SQLite 数据库文件里的 varint 是大端的,最长 9 字节,第 9 字节的 8 位全部是数据,这样 9 字节正好覆盖 64 位整数。两种格式互不兼容。

varint 的问题在两头:稠密表里 gap 几乎都小于 128,每个仍要 8 比特,伯努利 \(k=1\) 时多出下界 6 比特;解码时每个字节都要判断最高位,分支随数据变化。

group varint 与 Stream VByte

Jeff Dean 在 WSDM 2009 的主题报告里介绍了 Google 使用的 group varint:4 个 32 位整数一组,前面一个 tag 字节,每 2 位表示对应整数的字节数减 1,第一个整数占 tag 的最高两位;后面按顺序排列各整数的小端字节,一组 5 到 17 字节。解码时用 tag 查一张 256 项的表,一次得到 4 个长度和偏移,不再逐字节判断。Lucene 10.5.1 的 GroupVIntUtil 用的是同一种位序(flag >> 6 是第一个值的长度)。

Stream VByte(Lemire、Kurz、Rupp,2018)用同样的 2 位长度码,但把所有控制字节放在前面、所有数据字节放在后面,第一个值占控制字节的最低两位。这样解码器读到一个控制字节,就能用一次 SIMD 字节重排(x86 上是 pshufb)把后面 4 个值的字节放到各自的 32 位槽里,控制字节的读取也不再依赖数据字节的解析结果。

三种字节对齐格式编码同一组 gap 1、15、511、131071。LEB128 用 7 字节 01 0F FF 03 FF FF 07,每字节最高位表示是否还有后续;group varint 用 tag 字节 06(二进制 00 00 01 10,第一个值在最高两位)加 7 个小端数据字节;Stream VByte 把控制字节 90(二进制 10 01 00 00,第一个值在最低两位)放入单独的控制流,数据流是同样的 7 个字节

图中的例子(1、15、511、131071)就是 Dean 报告里的例子,tag 为 00000110。三种格式的数据字节数都是 7;区别在长度信息放在哪里。LEB128 把它摊进每个字节的最高位,group varint 和 Stream VByte 把它集中成每值 2 比特。于是:

  • 字节对齐格式有地板:varint 每整数至少 8 比特,group varint 和 Stream VByte 至少 10 比特。伯努利 \(k\le 4\) 时它们分别停在 8.00 和 10.00。
  • 值较大时 2 比特长度码更划算:\(k=15\) 时 gap 多在 \(2^{15}\) 到 \(2^{17}\) 之间,varint 要 3 字节,Stream VByte 多数只要 2 字节加 2 比特,结果是 20.81 对 19.01。
  • 圣经 \(n\ge 256\) 的表上 varint 8.28、group varint 10.10、Stream VByte 10.11 比特/整数(后两者只差 0.004,来自不足 4 个值的尾部的处理方式)。

复现程序里的 Stream VByte 编码器与 streamvbyte v3.0.0 的 streamvbyte_encode 在 2000 条随机表上逐字节一致,库的解码器也能读回它的输出。

同一方向上还有两种格式:Stepanov 等(CIKM 2011)的 varint-G8IU 把数据装进 8 字节的组,用一个描述字节标出每个值在哪里结束;Plaisance、Kurz、Lemire(2015,arXiv 预印本)的 Masked VByte 保持标准 LEB128 格式不变,用 SIMD 指令一次提取一批字节的最高位,再查表解码,摘要报告比标量 VByte 快 2 到 4 倍。格式不变意味着它可以直接替换已有系统的解码器。

五、按字和按块编码:Simple 家族、BP128 与 PFOR

字节对齐的地板是 8 比特。要在稠密表上低于它,又不回到逐比特解码,办法是把对齐单位放大:一个 32 位或 64 位字装若干个等宽值(Simple 家族),或者 128 个值共用一个位宽(位打包)。

Simple-9、Simple-16 与 Simple-8b

Anh 与 Moffat(2005)的 Simple-9 把一个 32 位字分成 4 位选择子(selector)和 28 位数据。28 位有 9 种等宽切法:\(28\times1\)、\(14\times2\)、\(9\times3\)、\(7\times4\)、\(5\times5\)、\(4\times7\)、\(3\times9\)、\(2\times14\)、\(1\times28\)。编码器贪心地选能装下最多后续值的那种;解码器按选择子跳到 9 个固定的展开函数之一,每个字只有一次分支,而不是每个值一次。浪费来自两处:28 不能被 5、9 整除(\(5\times5\) 空 3 位,\(3\times9\) 空 1 位),以及一个字里只要有一个大值,同字的小值都得跟着用大位宽。

Zhang、Long、Suel(WWW 2008)的 Simple-16 用满 16 个选择子,并允许同一个字里有两种位宽(例如前 7 个值各 2 位、后 14 个各 1 位)。Anh 与 Moffat(2010)的 Simple-8b 换成 64 位字,4 位选择子加 60 位数据,60 的因子多(1、2、3、4、5、6、10、12、15、20、30、60),空位更少;FastPFor v0.5.0 的实现还用两个选择子表示连续 240 个和 120 个 0。

实测(FastPFor v0.5.0 的实现):圣经 \(n\ge 256\) 的表上 Simple-16 5.30、Simple-8b 5.69、Simple-9 5.72 比特/整数,都比 varint 的 8.28 少 2.5 比特以上。稀疏端的情形相反:伯努利 \(k=15\) 时 gap 多数需要 15 到 17 位,超过 14 位就只能一个字装一个值,Simple-9 和 Simple-16 都是 28.45 比特/整数,比 varint 的 20.81 还多;Simple-8b 有 \(3\times20\) 这一档,是 20.34。

BP128:128 个值共用一个位宽

二进制打包(binary packing)把 gap 按 128 个一块,每块取

\[ b = \left\lceil \log_2\left(1+\max_{0\le j<128} g_j\right) \right\rceil , \]

用 1 字节记下 \(b\),再把 128 个值各用 \(b\) 位紧密排列,一块共 \(8+128b\) 比特,正好是 \(4b\) 个 32 位字。复现程序的 bp128 对不足 128 的尾部用 varint;FastPFor 的 simdbinarypacking 把 16 块的位宽装进 4 个字,尾部也用 varint。两者在伯努利表上的结果到小数点后三位一致(\(k=8\) 时都是 11.014)。

代价很好估:每个值都要付整块最大值的位宽。几何分布下 128 个 gap 的最大值期望约为 \(H_{128}/p\approx 5.4\cdot 2^k\)(\(H_{128}\) 是调和数),\(\log_2\) 约为 \(k+2.4\),所以多数块的位宽是 \(k+3\),少数是 \(k+2\)。\(k=8\) 时实测 11.01 比特/整数(其中位宽字节占 \(8/128=0.0625\)),比下界 9.46 多 1.55。这 1.5 比特左右的差距在 \(k\ge 3\) 时几乎不变,是 BP128 为”每块只有一个位宽、解码无分支”付出的固定价格。

真实表的问题更大:一个词在某处连续出现、又在别处隔很远出现,同一块里的 gap 跨几个数量级。圣经长表上 BP128 6.74 比特/整数,比 Golomb 多 2.0,比伯努利表上的差距还大。

PFOR:让少数大值出块

Zukowski、Héman、Nes、Boncz(ICDE 2006)的 PFOR(patched frame of reference)针对的正是”少数离群值把整块位宽拉高”:选一个让绝大多数值放得下的 \(b\),放不下的值作为例外(exception)另外存放。原始设计为了让解码循环无分支,在例外的槽位里存”到下一个例外的距离”,把例外串成链表;若两个例外相距超过 \(2^b\),链表就断了,只能把中间某个普通值也登记成例外,称为强制例外(compulsory exception)。例外本身以未压缩的原值存在块尾部的例外区。

后续工作都在改例外的存法:

  • NewPFD 与 OptPFD(Yan、Ding、Suel,WWW 2009):槽位里放值的低 \(b\) 位,例外的位置和高位部分另存,并用 Simple-16 压缩,没有了强制例外。NewPFD 取让例外不超过 10% 的最小 \(b\)(FastPFor 的实现里是常量 PFORDELTA_INVERSERATIO = 10),OptPFD 对每块试遍候选 \(b\),取压缩后最短的。
  • FastPFor(Lemire、Boytsov,SPE 2015):每块 128 或 256 个值,块头按字节存 \(b\)、例外个数和块内最大位宽 \(b_{\max}\),再存每个例外的位置(1 字节);例外的高位部分不留在块里,而是按宽度 \(b_{\max}-b\) 分组,攒满一页(默认 65536 个整数)后每组用该宽度统一位打包。\(b_{\max}-b=1\) 时高位必然是 1,不存。

复现程序的 pfor128 是 FastPFor 代价模型的简化版:例外位置和高位就近存在块后,\(b\) 取使下式最小的值:

\[ \mathrm{cost}(b) = 16 + 128\,b + [n_{\mathrm{exc}}>0]\bigl(8 + n_{\mathrm{exc}}\,(8 + b_{\max} - b)\bigr)\ \text{比特}, \]

其中 16 比特是 \(b\) 与例外个数,8 比特是 \(b_{\max}\),每个例外付 8 比特位置和 \(b_{\max}-b\) 比特高位。下图用 16 个值示意(按 16 个值的槽位算代价,把公式里的 128 换成 16):

PFOR 块示意:16 个 gap 中 21 和 130 远大于其余值。取 b 等于 3 时每个槽位存值的低 3 位,两个例外的槽位分别存 5 和 2;槽位之后是 3 字节头部(b、例外个数、最大位宽 8)、两个 1 字节的位置 6 和 11、两个 5 比特的高位 2 和 16。解码先无分支地解开 16 个槽位,再对每个例外把高位左移 3 位后或进去。右侧列出各 b 的代价:b 等于 2 时 154 比特,3 时 98,4 时 112,不用例外的 b 等于 8 时 144,所以选 3

解码分两步:先像 BP128 一样无分支地解开全部槽位,再遍历例外表补上高位。例外越少,第二步越短。

效果:伯努利 \(k=8\) 时 pfor128 10.31、FastPFor 库的 FastPFor-128 10.30、OptPFD 10.24 比特/整数,比 BP128 的 11.01 少约 0.7,离下界约 0.8。圣经长表上 OptPFD 5.32、pfor128 5.53、FastPFor-128 5.70、NewPFD 5.76,而 BP128 是 6.74。FastPFor 库里名为 pfor 的实现按 Zukowski 等人的原始思路对整个数组只选一个 \(b\)(从最多 64K 个值的样本估计,并按论文的公式计入强制例外),例外按 32 位原值计价,圣经长表上 7.27 比特/整数,反而不如 BP128。

BP128 一块 \(4b\) 个字,怎么把 128 个值摆进这些字,不影响大小,却决定了能不能用 SIMD 一次解 4 个值。

水平布局与垂直布局

水平布局(horizontal)是最直接的写法:第 \(i\) 个值放在第 \(ib\) 位。解码第 \(i\) 个值要算它在哪个字、移多少位、是否跨字,相邻两个值的移位量不同,只能一个一个做。

Lemire 与 Boytsov(SPE 2015)的 SIMD-BP128 用垂直布局(vertical):把 128 个值按 \(i\bmod 4\) 分到 4 条通道(lane),每条通道 32 个值,各自按水平方式装进 \(b\) 个字;然后把 4 条通道的第 \(k\) 个字交错存放在 out[4k+j]。一次 128 位加载就拿到 4 条通道的第 \(k\) 个字,而 4 条通道里的值在完全相同的位置跨越字边界,所以一次移位、一次与掩码就同时得到 4 个值。

BP128 的两种布局,同一批 128 个值、位宽 12,只画前两个 32 位字。上半是水平布局:第 i 个值从第 12i 位开始,v2 和 v5 在不同偏移处跨越字边界,每个值要单独移位,只能标量循环。下半是 SIMD-BP128 的垂直布局:4 条通道分别放第 0、4、8 号,第 1、5、9 号等值,每条通道的第 k 个字相邻存放,一次 128 位加载取到 4 条通道的同一个字;右移 0 位再与掩码得到 v0 到 v3,右移 12 位得到 v4 到 v7,右移 24 位并或上下一个字左移 8 位得到 v8 到 v11。两种布局都是 48 个字,布局改变速度而不改变大小

复现程序里的垂直解包是一个通用循环,对任意 \(b\) 都成立,每轮产生 4 个值:

static inline void unpack_v(const uint32_t *in, int b, uint32_t *out)
{
    if (b == 0) { memset(out, 0, 512); return; }
    const __m128i mask = _mm_set1_epi32((int)(uint32_t)lowmask(b));
    const __m128i *p = (const __m128i *)in;
    __m128i cur = _mm_loadu_si128(p++);
    int s = 0;
    for (int k = 0; k < 32; k++) {
        __m128i v = _mm_srl_epi32(cur, _mm_cvtsi32_si128(s));
        s += b;
        if (s >= 32) {
            s -= 32;
            if (k < 31) cur = _mm_loadu_si128(p++);
            if (s > 0) v = _mm_or_si128(v, _mm_sll_epi32(cur, _mm_cvtsi32_si128(b - s)));
        }
        _mm_storeu_si128((__m128i *)(out + 4 * k), _mm_and_si128(v, mask));
    }
}

FastPFor 的 simdbitpacking.cpp 走得更远:为 \(b=1,\dots,32\) 各生成一个完全展开的解包函数,移位量都是编译期常量,循环里的分支也没有了。

解包速度

实验 E3 在同一块核上交替计时两种布局:每个 \(b\) 取 512 块随机值(固定种子),每轮把每块解包 20 次,11 轮取中位数,整批独立重复 3 次再取中位数。测量期间机器上还有别的负载(系统 load average 约 1.4),数字只用来看相对趋势。

水平标量循环与垂直 SSE2 循环解包 128 个值的每整数耗时随位宽 b 的变化,b 从 1 到 32。水平布局在 0.81 到 1.06 纳秒之间,随 b 缓慢上升,但 b 等于 1、2、4、8、16、32 这些整除 32 的位宽都在约 0.81;垂直布局在 0.175 到 0.335 纳秒之间,同样随 b 上升,两者之比从 b 等于 1 时约 4.7 倍降到 b 等于 32 时约 3.1 倍

垂直 SSE2 循环比水平标量循环快 3.1 到 4.7 倍(\(b=1\) 时 0.175 对 0.816 纳秒/整数,\(b=31\) 时 0.335 对 1.039)。两条曲线都随 \(b\) 上升,因为要读的字变多、跨字的次数变多。水平曲线在 \(b=8,16,32\) 处跌回约 0.81,与 \(b\le 4\) 时持平:这些 \(b\) 都整除 32,没有值跨字,if (s + b > 32) 这个分支从不成立,预测器每次都猜对。

这个对比并不只是”布局”的差别,也是标量与 SIMD 的差别:水平布局同样可以用字节重排指令做 SIMD 解码,只是每组值的移位量不同,需要更多指令。它说明的是,SIMD-BP128 用”4 条通道同步跨字”这一约束换来了最简单的向量代码,而大小上一比特都没多付。

七、实测:每整数比特数

伯努利表:离下界多远

下图纵轴是每个编码比 \(\log_2\binom{N}{n}/n\) 多出的比特数,横轴是密度 \(p=2^{-k}\)。

两幅折线图,横轴是伯努利密度的 k,从 1 到 15,纵轴是每个 posting 比 log2 C(N,n)/n 多出的比特数。左图是字节和比特对齐的编码:varint 与 Stream VByte 在 k 小时多出 6 到 8 比特,之后下降并随 7 比特或 8 比特分组呈锯齿;Elias gamma 与 delta 的差距随 k 线性增长;Golomb 几乎贴着 0;插值编码约多 0.3;Elias-Fano 在 k 大于等于 6 后稳定在约 0.55。右图是按字和按块的编码:Simple-16 与 Simple-8b 在 k 不大于 4 时只多 0.7 到 1.1,之后随 k 上升并波动,Simple-16 在 k 等于 15 时多 12;BP128 稳定多约 1.56;Lucene 10.5 的文档块在 k 等于 1 时只多 0.15,其余比 BP128 略高约 0.1;PFOR-128、SIMD-FastPFor 与 OptPFD 在 0.8 左右

几个可以直接读出的规律:

  • 贴着下界的只有 Golomb(最多多 0.03)。它的参数由 \(n/N\) 算出,正好匹配伯努利模型;这也是它唯一的优势场景。
  • Elias-Fano 在 \(k\ge 6\) 时稳定多约 0.55,插值编码在 \(k\ge 3\) 时稳定多约 0.35,二者都不需要分布参数,只用到 \(n\) 和 \(N\)。
  • 块编码的差距几乎不随 \(k\) 变化:BP128 约 1.56,PFOR 系列约 0.8。它们付的是”一块一个位宽”的价格,与平均 gap 多大无关。
  • Simple 家族只在稠密端有竞争力:\(k=2,\dots,4\) 时 Simple-16 只多 0.7 左右,比 PFOR 系列还省;gap 变大后可选的切法变少,差距升到 2 到 12 比特。
  • 字节对齐编码的差距在稠密端最大(\(k=1\) 时 varint 多 6.0,Stream VByte 多 8.0),在稀疏端随 7 比特或 8 比特分组的取整来回摆动,最小时约 1.6(varint,\(k=6\) 附近)。γ 码的差距随 \(k\) 线性增长,\(k=15\) 时多 11.9。

真实倒排表

两幅横向条形图,分别是 bible.txt 和 world192.txt 中长度至少 256 的倒排表,每条表示一种编码的每 posting 比特数,橙色虚线是下界。bible 下界 4.65:插值编码 4.46、Golomb 4.70、Rice 4.80、Elias gamma 5.03、Simple-16 5.30、OptPFD 5.32、Elias-Fano 5.32、PFOR-128 5.53、Simple-8b 5.69、SIMD-FastPFor 5.70、Lucene 10.5 文档块 6.64、BP128 6.74、varint 8.28、Stream VByte 10.11。world192 下界 7.53:插值编码 7.64、Golomb 7.51、Rice 7.63、gamma 9.56、Simple-16 9.10、OptPFD 8.21、Elias-Fano 8.01、PFOR-128 8.46、Simple-8b 9.08、SIMD-FastPFor 8.73、Lucene 9.31、BP128 9.07、varint 10.36、Stream VByte 10.84

真实表与伯努利表有两点不同。第一,圣经表的聚集很强:插值编码 4.46 低于下界 4.65,γ 码(5.03)这种对小整数友好的码也排到了 BP128(6.74)前面,因为长表里大量 gap 是 0。第二,块编码之间的差距被放大:同一块里 gap 跨数量级时,BP128 的”一个位宽”代价最高,PFOR 的例外机制最有用,圣经表上 OptPFD 比 BP128 少 1.42 比特/整数,伯努利表上只少 0.77(\(k=8\))。

world192 的聚集弱一些,排名更接近伯努利表:Golomb 7.51 略低于下界 7.53,插值编码 7.64 反而落后。

为什么库编码器只看 \(n\ge 256\)

FastPFor 库的编码器按 32 位字计数,每条表至少有一个长度头;块编码对不足一块的尾部改用 varint。在只有几个 posting 的短表上,这些固定开销就是全部。圣经全部表上 Stream VByte 用复现程序计是 11.02,用库计是 11.92;Simple-9 的全部表 8.43,长表 5.72。表 E2 两种口径都给了,正文比较编码器本身时用长表。

读完这篇,下一步读什么

优先读同系列或同问题的下一篇,把单篇消费变成主题集群。

2026-05-24 · algorithms

编辑距离与模糊匹配:Wagner-Fischer、位并行与 Levenshtein 自动机

从 Wagner-Fischer 填表出发,讲清带状 DP、Myers 位并行、OSA 与真 Damerau 的区别,用可复现程序在 8 万词词典上对比 BK-tree、Trie 自动机与对称删除,并核对 Lucene、git 的实际实现与 SETH 下界。

2026-05-30 · algorithms

HNSW:分层小世界图的近似近邻搜索

从 NSW 到 HNSW,拆解随机层数、SEARCH-LAYER、启发式邻居选择与参数边界;对照 hnswlib、Faiss、Lucene、pgvector 源码默认值,并用可复现实验比较 simple 与 heuristic 邻居选择的召回成本。

2026-05-13 · algorithms / database

时序数据压缩:delta-of-delta、XOR 浮点编码与十进制数据上的失效

拆解 Gorilla 的时间戳 delta-of-delta 与浮点 XOR 编码,对照 Prometheus、InfluxDB、VictoriaMetrics 钉版本源码,用节点采集数据和 ALP 数据集实测每个值花多少比特,并说明 XOR 在十进制数据上失效的原因与 Chimp、Elf、ALP 的改法。

2026-05-14 · algorithms / database

列式压缩:轻量编码怎么选、Parquet 与 ORC 怎么写、编码数据上怎么算

对照 Parquet 2.11 规范与 Arrow、ORC 源码拆开字典、RLE/位打包混合、DELTA 与 RLEv2,讲清写入器何时放弃字典;在 TPC-H lineitem 上按字节比较 Parquet、ORC 与级联选择,并数出在编码数据上执行省下的工作。