土法炼钢 · 系统与基础设施

HyperLogLog:从概率计数到 Redis 实现的基数估计

文章导航

分类入口
algorithmsdatabase
标签入口
#hyperloglog#cardinality-estimation#loglog#flajolet-martin#hyperloglog-plus-plus#linear-counting#redis#pfcount#sketch#streaming

目录

用 Redis 的 PFADD / PFCOUNT 统计独立访客时,常听到三句话:一个 key 只占 12 KB;误差 0.81%;多个计数器可以随意合并。第一句和第三句成立。第二句最容易误读:0.81% 是 \(1.04/\sqrt{m}\) 给出的标准误差,大约只有三分之二的估计落在 \(\pm 0.81\%\) 以内;而且它只在基数远大于寄存器数时成立。小基数和中间过渡区的误差取决于用哪个估计公式,Redis 在 2.8.9、4.0、5.0 三个版本里用过三套不同的公式。

本文沿 Flajolet-Martin(1985)、LogLog(2003)、HyperLogLog(2007)、HyperLogLog++(2013)到 Ertl 估计器(2017)这条线推导,用同目录 reproduce/ 下的程序实测这些估计器在 \(10\) 到 \(4 \times 10^9\) 的基数上的偏差,再对照 Redis 7.2.5 的 src/hyperloglog.c 讲实现。

实验环境:Intel Core i9-12900K,WSL2 内核 6.6.87.2,GCC 16.1.1,程序用 taskset 绑定在单个核上。所有指标都是估计误差,与计时无关;随机种子固定,重跑结果逐字节一致。

一、问题:精确去重为什么贵

给定一个元素可以重复出现的多重集合(multiset)\(S\),基数(cardinality)是其中不同元素的个数:

\[ n = |\{x : x \in S\}|. \]

流式场景要求一遍扫描、任意时刻都能给出估计,且内存远小于 \(n\)。

精确方案都要记住”见过哪些元素”。哈希集合为每个不同元素存一份:一亿个 64 位 ID 光是原始数据就是 800 MB,还没算哈希表本身的开销。值域已知时可以用位图,32 位值域需要 \(2^{32}\) 位,即 512 MiB,与实际基数无关。

近似方案的目标是 \((1 \pm \varepsilon)\) 近似。理论上的最优空间已经知道:Kane、Nelson 和 Woodruff(PODS 2010)给出了用 \(O(\varepsilon^{-2} + \log n)\) 位、以 \(2/3\) 概率达到 \((1 \pm \varepsilon)\) 近似的算法,并称其空间最优。HyperLogLog 用 \(m\) 个寄存器、每个约 \(\log_2 \log_2 n\) 位,总空间 \(O(\varepsilon^{-2} \log \log n)\),渐近意义上不是最优,但常数小、实现简单、可以合并,这是它成为工业标准的原因(第八节再讨论”最优”之争)。

方案 空间 误差 能否合并
哈希集合 \(\Theta(n)\) 个元素 精确 能(集合并)
位图 值域大小 \(\lvert U \rvert\) 位 精确 能(按位或)
线性计数(Linear Counting) 与 \(n\) 同阶的位图 位图接近填满时发散 能(按位或)
HyperLogLog \(m\) 个 \(\lceil \log_2(q+2) \rceil\) 位寄存器 标准误差约 \(1.04/\sqrt{m}\) 能(逐寄存器取 max)

表中 \(q\) 是哈希值去掉桶索引后剩余的位数,第三节解释。线性计数的”发散”在第四节有实测。

二、比特模式:Flajolet-Martin 与随机平均

前导零的分布

把元素哈希成均匀随机的比特串 \(x\),记 \(\rho(x)\) 为最左边第一个 1 所在的位置(从 1 开始计),例如 \(\rho(1\cdots) = 1\)、\(\rho(0001\cdots) = 4\)。每一位独立地以 \(1/2\) 概率为 0,所以

\[ \Pr[\rho(x) \ge k] = 2^{-(k-1)}. \]

看到一个 \(\rho \ge k\) 的哈希值,大致说明已经见过 \(2^{k-1}\) 量级的不同元素。重复元素的哈希值相同,不会改变任何统计量,这是所有这类算法天然去重的原因。

FM85:位图与第一个 0

Flajolet 和 Martin 的 Probabilistic Counting Algorithms for Data Base Applications(JCSS 31(2), 1985;会议版 FOCS 1983)用的观测量不是”最大的 \(\rho\)“,而是一个位图:对每个元素把 BITMAP[ρ(h(x)) - 1] 置 1(论文里位置从 0 开始),最后取最左边的 0 的位置 \(R\)。论文 Theorem 3.A 证明

\[ \mathbb{E}[R] \approx \log_2(\varphi n), \qquad \varphi = 0.77351\ldots, \]

所以 \(2^R/\varphi\) 是 \(n\) 的估计。单个位图的 \(R\) 是整数,估计只能在 2 的幂附近跳动,误差以倍数计。

降低方差的直接办法是用 \(m\) 个独立哈希函数各维护一个位图再平均,但每个元素要算 \(m\) 次哈希。FM85 提出了随机平均(stochastic averaging):用 \(a = h(x) \bmod m\) 选一个位图,用剩下的位 \(\lfloor h(x)/m \rfloor\) 更新它,最后对 \(m\) 个 \(R\) 取平均。这就是 PCSA(Probabilistic Counting with Stochastic Averaging),每个元素只算一次哈希,标准误差约 \(0.78/\sqrt{m}\);论文表格给出 \(m = 64\) 时为 9.7%,\(m = 1024\) 时为 2.4%。

后来的 LogLog 和 HyperLogLog 都沿用”一次哈希、用一部分位选桶”的随机平均,改的是每个桶存什么和怎么把桶合成一个估计。下图是本文涉及的谱系,实线是算法上的直接继承,虚线是 Redis 在不同版本采用的估计公式:

flowchart LR
    FM["FM85 / PCSA<br/>bitmap per bucket<br/>0.78/sqrt(m)"] --> LL["LogLog 2003<br/>max rho per bucket<br/>1.30/sqrt(m)"]
    LL --> HLL["HyperLogLog 2007<br/>harmonic mean<br/>1.04/sqrt(m)"]
    HLL --> PP["HLL++ 2013<br/>64-bit hash, sparse,<br/>empirical bias table"]
    HLL --> ER["Ertl 2017<br/>improved raw and ML<br/>estimators"]
    HLL --> ULL["UltraLogLog 2024<br/>richer 8-bit registers"]
    HLL -.-> R28["Redis 2.8.9 - 3.2<br/>LC + polynomial fit"]
    R28 -.-> R40["Redis 4.0<br/>LogLog-Beta"]
    ER -.-> R50["Redis 5.0 - 7.2<br/>Ertl estimator"]
    R40 -.-> R50

三、LogLog 与 HyperLogLog:每个桶只留一个最大值

最大值只需要 \(\log \log n\) 位

对 \(\nu\) 个不同元素取 \(\rho\) 的最大值 \(M\),有

\[ \Pr[M < k] = \left(1 - 2^{-(k-1)}\right)^{\nu} \approx \exp\!\left(-\nu \, 2^{-(k-1)}\right). \]

\(k\) 比 \(\log_2 \nu\) 小几位时这个概率接近 0,大几位时接近 1,所以 \(M \approx \log_2 \nu\)。存 \(M\) 只需要 \(\log_2 \log_2 N_{\max}\) 位,这就是 LogLog 名字的来历。

Durand 和 Flajolet 的 LogLog(Loglog Counting of Large Cardinalities,ESA 2003,LNCS 2832)把哈希值的前 \(p\) 位当桶号 \(j\),\(m = 2^p\) 个寄存器 \(M[j]\) 各存本桶见过的最大 \(\rho\),估计值是

\[ E_{\text{LogLog}} = \alpha_m \, m \, 2^{\frac{1}{m}\sum_{j} M[j]}, \]

即对 \(2^{M[j]}\) 取几何平均。论文给出标准误差 \(\sqrt{\tfrac{1}{12}\ln^2 2 + \tfrac{1}{6}\pi^2}\,/\sqrt{m} \approx 1.30/\sqrt{m}\),\(m = 256\) 时约 8%,\(m = 1024\) 时约 4%。摘要里另有一个常被引用的例子:\(m = 2048\) 个 5 位寄存器、共 1.28 KB,可以数到 \(2^{27}\)(约一亿),误差通常小于 2.5%。这组数字属于论文中的改进版 SuperLogLog,不是基本的 LogLog。

论文自己指出了 LogLog 的弱点:\(M\) 的上尾满足 \(\Pr[M > \log_2 \nu + k] \approx 2^{-k}\),衰减得不够快;估计值是寄存器算术平均的指数,少数异常大的寄存器就能把估计拉高。SuperLogLog 的补救是截断规则:只保留最小的 \(70\%\)(\(\theta_0 = 0.7\))寄存器再平均,经验标准误差降到 \(1.05/\sqrt{m}\),代价是偏差和误差不再容易分析。

HyperLogLog:换成调和平均

Flajolet、Fusy、Gandouet 和 Meunier 的 HyperLogLog: the analysis of a near-optimal cardinality estimation algorithm(AofA 2007,DMTCS Proceedings AH,127–146)沿用 LogLog 的寄存器,只把合成方式换成调和平均。插入一个元素的过程如下图上半部分:

HyperLogLog 插入一个元素:上半部分按论文约定,64 位哈希的前 14 位 00101101001101 给出桶号 2893,其余位中第一个 1 在第 4 位,所以 rho 等于 4,寄存器 M[2893] 从 2 更新为 max(2, 4) = 4;下半部分是 Redis 7.2.5 的约定,桶号取低 14 位,从第 14 位往高位数 0 的个数,b14 到 b16 为 0、b17 为 1,所以 count 等于 4,最大为 51

所有元素处理完后,论文 Figure 2 的估计是

\[ E = \alpha_m \, m^2 \left(\sum_{j=0}^{m-1} 2^{-M[j]}\right)^{-1}, \qquad \alpha_m = \left(m \int_0^\infty \left(\log_2 \frac{2+u}{1+u}\right)^m du\right)^{-1}. \]

论文 Figure 3 给出的实用常数是 \(\alpha_{16} = 0.673\)、\(\alpha_{32} = 0.697\)、\(\alpha_{64} = 0.709\),\(m \ge 128\) 时 \(\alpha_m = 0.7213/(1 + 1.079/m)\);极限值 \(\alpha_\infty = 1/(2\ln 2) \approx 0.72135\)。

直觉。 每个桶大约分到 \(n/m\) 个元素,\(2^{M[j]}\) 与 \(n/m\) 同一量级。\(m \big/ \sum_j 2^{-M[j]}\) 是 \(2^{M[j]}\) 的调和平均,乘以 \(m\) 就与 \(n\) 同一量级。但 \(2^{M[j]}\) 是一组几何变量最大值的指数,系统性地偏大,\(m^2 \big/ \sum_j 2^{-M[j]}\) 大约是 \(n/\alpha_m\);乘上 \(\alpha_m \approx 0.72\) 正是为了消掉这个乘性偏差。

为什么调和平均更稳。 在 LogLog 里,一个寄存器多出 \(\Delta\),估计值乘以 \(2^{\Delta/m}\),\(\Delta\) 越大影响越大。在 HyperLogLog 里,每个寄存器对分母的贡献是 \(2^{-M[j]} \in (0, 1]\),一个寄存器哪怕变成无穷大,分母也只少了它原来那一项,大约是总和的 \(1/m\)。异常大的值被自动压住,而异常小的值(空桶贡献 1)会显著拉低估计,这正是小基数时要单独处理的原因。

误差:\(1.04/\sqrt{m}\) 的准确含义

论文 Theorem 1 的前提是”理想多重集合”:哈希值是 \([0,1]\) 上独立均匀的随机数。在此前提下,当 \(n \to \infty\):

\[ \beta_\infty = \sqrt{3 \ln 2 - 1} \approx 1.03896. \]

与 LogLog 的 \(1.30/\sqrt{m}\) 相比,达到同样精度只需 \((1.04/1.30)^2 \approx 64\%\) 的寄存器,论文摘要给出的也是 64%。

\(m = 2^{14} = 16384\) 时,\(1.04/\sqrt{16384} = 1.04/128 \approx 0.81\%\)。论文第 4 节说估计值近似服从正态分布,预期分别有 65%、95%、99% 的估计落在 \(\sigma\)、\(2\sigma\)、\(3\sigma\) 以内。实测(reproduce/accuracy.c,1000 次独立试验,\(n = 10^7\))三个种子下的比例是:

种子 \(\pm 1\sigma\) \(\pm 2\sigma\) \(\pm 3\sigma\) RMSE
1 67.2% 94.2% 99.3% 0.85%
2 68.8% 95.6% 100.0% 0.81%
3 68.2% 96.4% 99.9% 0.80%

所以”一亿 UV 的估计落在 9919 万到 10081 万之间”只对约三分之二的情况成立;要覆盖 95% 需要放宽到 \(\pm 1.6\%\) 左右。

12 KB 从哪来

论文假设 32 位哈希,\(p = 14\) 时剩下 \(q = 18\) 位,\(\rho \le q + 1 = 19\),5 位寄存器就够。HyperLogLog++ 和 Redis 都改用 64 位哈希:\(q = 50\),\(\rho \le 51\),需要 6 位。

\[ 16384 \times 6 \text{ bit} = 98304 \text{ bit} = 12288 \text{ B} = 12 \text{ KiB}. \]

每加一位哈希,可计数的范围翻倍,而寄存器宽度只随 \(\log_2(q+2)\) 增长,这是 64 位哈希几乎”免费”的原因(HLL++ 论文第 5.1 节)。

四、偏差:小基数、过渡区与大基数

Theorem 1 是 \(n \to \infty\) 的结论。寄存器初始化为 0 时,\(n = 0\) 的原始估计是 \(\alpha_m m \approx 0.7m\)(论文第 4 节),离 0 差得很远。实用算法都要在两端做修正,各代方案的差别主要就在这里。

2007 年论文的三段式

论文 Figure 3 的程序按原始估计 \(E\) 分三段:

线性计数来自 Whang、Vander-Zanden 和 Taylor(ACM TODS 15(2), 1990)。把 \(n\) 个球随机扔进 \(m\) 个桶,空桶数约为 \(m e^{-n/m}\),反解得 \(n \approx m \ln(m/V)\);寄存器是否为 0 恰好就是”桶是否为空”。大范围修正针对 32 位哈希的碰撞:论文假设 \(E\) 估计的是不同哈希值的个数,约为 \(2^{32}(1 - e^{-n/2^{32}})\),反解得到上式。

reproduce/estimators.h 里的实现(\(\mathit{hash\_bits} = 64\) 时第三段不可达):

/* reproduce/estimators.h:Flajolet et al. 2007, Figure 3 */
static inline double est_hll2007(const int *C, int m, int q, int hash_bits)
{
    double E = est_raw(C, m, q);
    if (E <= 2.5 * m) return C[0] != 0 ? est_linear(C, m) : E;
    if (hash_bits != 32) return E;
    double two32 = 4294967296.0;
    if (E <= two32 / 30.0) return E;
    return -two32 * log(1.0 - E / two32); /* NaN once E >= 2^32 */
}

所有估计器都只依赖寄存器直方图 \(C_k\)(值为 \(k\) 的寄存器个数),实验程序在插入过程中增量维护直方图,于是同一条插入序列上每个检查点都能同时算出所有估计器。

实测:64 位哈希,\(p = 14\)

实验设置:每次试验插入 \(10^7\) 个不同元素,在 \(1\) 到 \(10^7\) 之间按对数均匀取 130 个检查点(每十倍 20 个,取整后去重),共 1000 次独立试验。元素的哈希值用 splitmix64 生成(它是 64 位整数上的双射,用来近似理想哈希),每次试验的输入区间互不重叠。三个种子的结论一致,下面的图和表用种子 1。

p 等于 14、64 位哈希下各估计器的相对误差随真实基数的变化:上图是平均相对误差,原始估计在 2.5m 附近仍有约 2.7% 的正偏差,2007 年的三段式在切换点附近出现约 1.65% 的偏差峰,Redis 3.2 的多项式修正、Redis 4.0 的 LogLog-Beta 和 Ertl 估计器都接近 0;下图是均方根误差,线性计数在超过 2.5m 后迅速变大,三段式在 2.5m 附近达到约 2.2%,Ertl 估计器全程不超过约 0.86%,虚线是 1.04 除以根号 m 等于 0.81%

几个检查点的数值(平均相对误差 / 均方根误差,单位 %):

\(n\) \(n/m\) 原始估计 线性计数 2007 三段式 Ertl(Redis 5.0+)
1,000 0.06 +1130.50 / 1130.50 +0.01 / 0.58 +0.01 / 0.58 +0.01 / 0.58
10,000 0.61 +73.27 / 73.28 −0.01 / 0.62 −0.01 / 0.62 −0.01 / 0.60
39,811 2.43 +2.70 / 2.77 −0.04 / 0.90 +1.00 / 2.17 −0.01 / 0.68
44,668 2.73 +1.65 / 1.77 −0.02 / 0.95 +1.65 / 1.77 −0.00 / 0.69
63,096 3.85 +0.25 / 0.74 −0.01 / 1.28 +0.25 / 0.74 +0.02 / 0.71
125,893 7.68 +0.01 / 0.77 +0.62 / 5.26 +0.01 / 0.77 +0.02 / 0.77
\(10^7\) 610 +0.01 / 0.85 无定义 +0.01 / 0.85 +0.02 / 0.85

读法:

  1. 原始估计在小基数时完全不可用。 \(n = 1000\) 时平均高估 11 倍,就是那个 \(0.7m\) 的底。
  2. 线性计数在小基数时比 \(1.04/\sqrt{m}\) 更准(\(n \le 10^4\) 时 RMSE 约 0.6%),但位图快填满时发散:\(n/m = 7.7\) 时 RMSE 已达 5.26%,再往后空寄存器数变成 0,公式无定义。
  3. 三段式的切换点附近有一个误差峰。 论文第 4 节说,模拟显示 \(n = \frac{5}{2} m\) 时已经进入渐近区、“没有可检测的偏差”。实测不支持这句话:\(n = 2.73m\) 时原始估计仍有 +1.65% 的平均偏差,要到约 \(5m\) 才降到 0.1% 以下。HLL++ 论文 Figure 4 给出的也是同样的结论(“at that point, the raw estimate is still significantly biased”)。\(n = 2.43m\) 处 RMSE 达到 2.17%,是因为一部分试验的原始估计已越过 \(\frac{5}{2}m\)、用了有偏的原始估计,另一部分仍用线性计数,两种结果混在一起。
  4. Ertl 估计器全程平滑。 三个种子下平均偏差的绝对值都不超过 0.06%,RMSE 不超过 0.86%,过渡区反而比渐近区更准(约 0.68%)。

32 位哈希与大范围修正

论文的第三段只在 32 位哈希下有意义。把 accuracy.c 切到 32 位哈希(\(q = 18\)),10 次试验,每次插入到 \(4 \times 10^9\):

32 位哈希、p 等于 14 时各估计器的平均相对误差:原始估计在 1e9 以上逐渐低估,4e9 时约低估 14%;2007 年的大范围修正从 2 的 32 次方除以 30 开始起作用后反而越修越高,1e9 时高估约 12%,4e9 时高估约 71%;Ertl 估计器全程在正负 0.5% 以内
\(n\) 原始估计 2007 三段式(含大范围修正) Ertl
\(2.0 \times 10^8\) +0.06% +2.46% +0.13%
\(5.0 \times 10^8\) −0.69% +5.55% −0.37%
\(1.0 \times 10^9\) −1.20% +12.27% −0.02%
\(2.0 \times 10^9\) −4.65% +25.95% −0.35%
\(4.0 \times 10^9\) −14.26% +70.85% −0.18%

大范围修正让偏差反了方向,而且比不修正还大:\(10^9\) 时是原始估计的 10 倍,\(4 \times 10^9\) 时是 5 倍。Ertl 估计器在 \(10^6\) 以上的所有检查点上平均偏差都不超过 0.43%。原因在于它的前提不成立:\(n\) 个元素中哈希值相同的那些,写进的是同一个寄存器、同一个值,草图(sketch)的分布与”\(n\) 个独立的 32 位哈希值”完全相同,原始估计本来就在估计 \(n\),而不是不同哈希值的个数。原始估计在大基数时的低估来自寄存器在 \(q + 1 = 19\) 处截断,再套一次碰撞反解就重复修正了。Ertl(arXiv:1702.01284,第 3 节)得出了同样的结论:“it even makes the bias worse … instead of underestimating the cardinalities, they are now overestimated”,并指出所有寄存器都等于 \(q+1\) 时原始估计为 \(\alpha_m 2^{33} > 2^{32}\),修正公式根本无定义。

四种补救:经验表、多项式、LogLog-Beta 与 Ertl

过渡区的偏差是公认的问题,解法分成两类:拿模拟数据拟合偏差,或从模型推出新的估计器。

HyperLogLog++ 的经验偏差表。 Heule、Nunkesser 和 Hall(EDBT 2013)对每个精度 \(p\) 做大量模拟(每个基数 5000 个数据点),记录原始估计的平均值与偏差;估计时对 \(E \le 5m\) 用 \(k\) 近邻插值(\(k = 6\),论文脚注说这个选择”rather arbitrary”)求出偏差并减掉。线性计数与去偏估计的切换阈值也由实验确定,\(p = 14\) 时为 11500,并用线性计数的结果来判断落在哪一侧。论文 Figure 3 显示,\(p = 14\) 时去偏估计在约 18000 到 61000 之间误差更小。这套做法依赖 Google 公布的经验数据表,本文没有复现。

Redis 2.8.9 到 3.2 的多项式。 同样的思路,数据换成一个四次多项式:\(E < 2.5m\) 且有空寄存器时用线性计数,否则在 \(E < 72000\) 时按拟合出的百分比偏差修正(只对 \(m = 16384\) 生效)。实测它把三段式的偏差峰从 1.65% 压到 0.17% 以内,RMSE 最大 0.93%(三个种子)。

LogLog-Beta。 Qin、Kim 和 Tung(arXiv:1612.02284,预印本)用一个以空寄存器数 \(C_0\) 为自变量的函数 \(\beta(C_0)\) 修正分母:

\[ E = \frac{\alpha_m \, m \, (m - C_0)}{\beta(C_0) + \sum_j 2^{-M[j]}}, \]

\(\beta\) 是 \(\ln(C_0 + 1)\) 的七次多项式加一个线性项,系数由拟合得到,只用一个公式覆盖全部范围。Redis 4.0 采用了它。实测三个种子下平均偏差的绝对值不超过 0.08%。

Ertl 的改进原始估计器。 Otmar Ertl 的 New cardinality estimation algorithms for HyperLogLog sketches(arXiv:1702.01284,预印本)不拟合数据,而是在泊松模型下分析原始估计为什么在两端失效:值为 0 的寄存器和值为 \(q+1\) 的寄存器各自”截断”了信息,直接把它们当作普通寄存器代入调和平均就会有偏。他把这两类寄存器的贡献换成由模型推出的期望值,得到论文式 (32):

\[ \hat{n} = \frac{\alpha_\infty m^2}{m\,\sigma\!\left(\frac{C_0}{m}\right) + \sum_{k=1}^{q} C_k 2^{-k} + m\,\tau\!\left(1 - \frac{C_{q+1}}{m}\right) 2^{-q}}, \]

\[ \sigma(x) = x + \sum_{k=1}^{\infty} x^{2^k} 2^{k-1}, \qquad \tau(x) = \frac{1}{2}\left(-x + \sum_{k=1}^{\infty} x^{2^{-k}} 2^{-k}\right). \]

\(\sigma\) 的级数在 \([0, 1)\) 上二次收敛,几次迭代就停。Redis 的 hllTau() 用的是另一种写法 \(\frac{1}{3}\big(1 - x - \sum_{k \ge 1} (1 - x^{2^{-k}})^2 2^{-k}\big)\),数值上与上式一致(在 \(x = 0.1, 0.3, 0.5, 0.9, 0.99\) 处比较,差异在 \(10^{-16}\) 量级)。取 \(q = 0\) 时这个式子退化为近似的线性计数,所以它把”小范围改用线性计数”这一步也吸收进去了。Ertl 在同一篇文章里还给出了更精确的最大似然估计器,以及基于两个草图联合估计交集的方法。Redis 5.0 起改用式 (32),这就是上文实测里标为”Ertl(Redis 5.0+)“的曲线。

四种方案的共同点是都只看寄存器直方图,不改变插入过程和草图格式,所以 Redis 换估计器不需要迁移已有数据。

五、HyperLogLog++ 的稀疏表示

HLL++ 论文的另一项改进针对”大量小计数器”:在 Google 的 PowerDrill 里,一次查询常常同时维护许多 count distinct,大多数基数远小于 \(m\),每个都占满 \(6m\) 位很浪费。

稀疏表示。 只存非零寄存器的 \((\mathit{idx}, \rho(w))\) 对,把桶号放在高位、\(\rho\) 放在低位拼成一个整数,维护一个有序列表;新元素先放进一个无序的临时集合,临时集合达到稀疏表示上限的 25% 时排序并合并进列表,同桶号只保留最大的 \(\rho\)。列表用变长整数编码加差分编码压缩。稀疏表示超过 \(6m\) 位时转为密集数组。

更高的稀疏精度 \(p'\)。 稀疏阶段可以用更大的精度 \(p' > p\) 计算桶号。转换到密集表示时,桶号取 \(\mathit{idx}'\) 的高 \(p\) 位;\(\mathit{idx}'\) 中多出来的 \(p' - p\) 位如果有 1,就用它们算 \(\rho\),全为 0 时 \(\rho = \rho(w') + (p' - p)\)。论文固定 \(p' = 25\),因为 25 位桶号、6 位 \(\rho\) 和 1 位标志正好放进 32 位整数;而且稀疏阶段总是落在线性计数的范围内,只需要桶号,所以只有 \(p' - p\) 位全为 0 时才需要存 \(\rho(w')\),出现概率是 \(2^{p - p'}\)。

论文 Table 1 列出了 \(p = 14\) 时稀疏表示在达到 \(6m\) 位之前最多能存多少个不同桶号:只用有序列表是 3072 个,加变长编码 5043.91 个,再加差分编码 8366.45 个,再加上述哈希编码的 HLL++ 是 12107 个。结论部分称,\(p = 14\)、\(p' = 25\) 时,基数在约 12000 以下的平均误差降为原来的四分之一。

Redis 也有稀疏表示,但它是对 \(p = 14\) 的寄存器数组做游程编码,不提高精度(第七节)。

六、合并与集合运算

逐寄存器取 max 就是并集

两个 HyperLogLog 草图的合并:HLL(A) 的前 8 个寄存器是 3 0 5 1 2 0 4 1,HLL(B) 是 1 2 5 0 6 0 3 2,逐列取最大值得到 3 2 5 1 6 0 4 2,每一格标出取自 A、取自 B 或两者相等;右侧说明结果等于直接对 A 并 B 建的草图,满足交换律、结合律和幂等律,前提是 p、哈希函数和种子相同

寄存器 \(M_S[j]\) 是集合 \(S\) 中落入桶 \(j\) 的元素的 \(\rho\) 的最大值。\(A \cup B\) 中落入桶 \(j\) 的元素要么来自 \(A\)、要么来自 \(B\),所以

\[ M_{A \cup B}[j] = \max\left(M_A[j],\ M_B[j]\right). \]

这是一个等式,不是近似:合并后的草图与直接对 \(A \cup B\) 建的草图逐字节相同,估计值也就完全相同。reproduce/hll.c 的自测用 memcmp 验证了这一点,同时验证了重复插入不改变草图、合并满足交换律和幂等律:

/* reproduce/hll.c */
static void hll_merge(hll_t *dst, const hll_t *src)
{
    for (int j = 0; j < HLL_M; j++)
        if (src->reg[j] > dst->reg[j]) dst->reg[j] = src->reg[j];
}
ok   memcmp(&once, &many, sizeof once) == 0
ok   memcmp(&tmp, &u, sizeof u) == 0
ok   memcmp(&tmp, &u, sizeof u) == 0
ok   memcmp(&tmp, &u, sizeof u) == 0
ok   mx <= HLL_Q + 1
|A|=50071 (50000)  |B|=50007 (50000)  |A u B|=75159 (75000)  |A|+|B|-|A u B|=24919 (25000)

四行 ok 依次是:同一批元素插一次和插十次的草图相同;\(\mathrm{merge}(A, B)\) 等于直接建的 \(A \cup B\);\(\mathrm{merge}(B, A)\) 也等于它;再合并一次 \(A\) 不变。

逐元素取 max 构成一个半格(join-semilattice),合并满足交换律、结合律和幂等律,这正是 Shapiro 等人(SSS 2011)定义的基于状态的无冲突复制数据类型(CRDT)所要求的合并性质。工程上的含义是:分片各自计数、按小时预聚合再合并成天、消息重复投递导致同一份草图被合并两次,结果都一样。前提是所有草图用同样的 \(p\)、同一个哈希函数和同一个种子。

交集与差集只能间接估计

HyperLogLog 原生只支持并集。常见做法是容斥:

\[ |A \cap B| \approx \hat{n}_A + \hat{n}_B - \hat{n}_{A \cup B}. \]

上面的自测是一次这样的估计,得到 24919(真值 25000)。问题在于误差的量级由 \(|A \cup B|\) 决定:三个估计各自有约 \(0.81\%\) 的相对误差,相减后绝对误差仍是 \(|A \cup B|\) 的百分之一量级。按这个量级推算,如果交集只占并集的 1%,交集估计的相对误差就可能与交集本身同一数量级。Ertl 在论文引言里引用前人的结果指出,容斥在 Jaccard 指数小时”can become quite inaccurate”,并在第 5 节给出了基于两个草图的联合最大似然估计,模拟中比容斥好得多。需要精确处理交集时,保留样本的 KMV(k-minimum values)一类方法更合适。

HyperLogLog 也不支持删除:max 不可逆,无法从草图里减去一个元素。需要滑动窗口时,通常按时间片各建一个草图,查询时合并窗口内的时间片。

七、Redis 7.2.5 的实现

Redis 从 2.8.9 起提供 PFADD、PFCOUNT、PFMERGE。antirez 在发布博客里写道,命令前缀 PF 是为了纪念 Philippe Flajolet。以下源码均出自 Redis 7.2.5 的 src/hyperloglog.c,行为用本地编译的 7.2.5 redis-server 验证过。

参数与头部

/* Redis 7.2.5 src/hyperloglog.c(节选) */
#define HLL_P 14 /* The greater is P, the smaller the error. */
#define HLL_Q (64-HLL_P) /* The number of bits of the hash value used for
                            determining the number of leading zeros. */
#define HLL_REGISTERS (1<<HLL_P) /* With P=14, 16384 registers. */
#define HLL_BITS 6 /* Enough to count up to 63 leading zeroes. */
#define HLL_DENSE_SIZE (HLL_HDR_SIZE+((HLL_REGISTERS*HLL_BITS+7)/8))

HLL 就是一个 Redis 字符串,前 16 字节是 struct hllhdr:4 字节魔数 "HYLL"、1 字节编码(HLL_DENSE 或 HLL_SPARSE)、3 字节保留、8 字节小端的缓存基数。缓存基数最高字节的最高位为 1 表示缓存失效。所以密集表示是 \(16 + 12288 = 12304\) 字节。

哈希与 \(\rho\)

/* Redis 7.2.5 src/hyperloglog.c, hllPatLen()(删去了注释) */
    hash = MurmurHash64A(ele,elesize,0xadc83b19ULL);
    index = hash & HLL_P_MASK; /* Register index. */
    hash >>= HLL_P; /* Remove bits used to address the register. */
    hash |= ((uint64_t)1<<HLL_Q); /* Make sure the loop terminates
                                     and count will be <= Q+1. */
    bit = 1;
    count = 1; /* Initialized to 1 since we count the "00000...1" pattern. */
    while((hash & bit) == 0) {
        count++;
        bit <<= 1;
    }

哈希函数是 MurmurHash2 的 64 位版本 MurmurHash64A,源码注释说改成了与字节序无关,种子固定为 0xadc83b19。桶号取低 14 位,剩下 50 位从最低位往上数 0,第 50 位强制置 1,保证 count 不超过 51。这就是图”HyperLogLog 插入一个元素”下半部分的约定。

密集表示:6 位寄存器紧密排列

Redis 密集表示的位布局:4 个字节里依次排着寄存器 0 到 5,寄存器 0 占字节 0 的低 6 位,寄存器 1 的低 2 位在字节 0 的高 2 位、高 4 位在字节 1 的低 4 位,寄存器 2 跨字节 1 和 2,寄存器 3 占字节 2 的高 6 位;下方演示读寄存器 1 时 _byte 为 0、_fb 为 6,把 b0 右移 6 位与 b1 左移 2 位按位或再与 63
/* Redis 7.2.5 src/hyperloglog.c */
#define HLL_DENSE_GET_REGISTER(target,p,regnum) do { \
    uint8_t *_p = (uint8_t*) p; \
    unsigned long _byte = regnum*HLL_BITS/8; \
    unsigned long _fb = regnum*HLL_BITS&7; \
    unsigned long _fb8 = 8 - _fb; \
    unsigned long b0 = _p[_byte]; \
    unsigned long b1 = _p[_byte+1]; \
    target = ((b0 >> _fb) | (b1 << _fb8)) & HLL_REGISTER_MAX; \
} while(0)

寄存器 \(j\) 占字节流中的第 \(6j\) 到 \(6j+5\) 位,从每个字节的最低位开始用。宏无条件读取下一个字节,不做”是否跨字节”的分支;读最后一个寄存器时越过数组末尾的那个字节由 sds 字符串的结尾 '\0' 提供(源码注释)。

稀疏表示:游程编码

Redis 稀疏表示示例:寄存器 1000 为 2、1020 和 1021 为 3、其余为 0 时,编码为 XZERO:1000、VAL:2,1、ZERO:19、VAL:3,2、XZERO:15362 五个操作码,共 7 字节,每个操作码下标出二进制和十六进制;下方列出 ZERO、XZERO、VAL 三种操作码的格式和取值范围

新建的 HLL 总是稀疏的:createHLLObject() 写入一个 XZERO:16384,加上头部共 18 字节。三种操作码是:ZERO(00xxxxxx,1 到 64 个 0)、XZERO(01xxxxxx yyyyyyyy,1 到 16384 个 0)、VAL(1vvvvvxx,1 到 4 个值为 1 到 32 的寄存器)。上图的例子取自源码注释,五个操作码覆盖全部 16384 个寄存器,只用 7 字节。

hllSparseSet() 在两种情况下把 HLL 转为密集表示:新值大于 32(VAL 装不下),或者更新后的长度超过 server.hll_sparse_max_bytes。这个配置项 hll-sparse-max-bytes 在 config.c 里的默认值是 3000,比较的是包含 16 字节头部在内的字符串长度。reproduce/redis_check.py 对 7.2.5 实测:

项目 实测
空 HLL(PFADD key 不带元素)的 STRLEN 18 字节
转为密集时已插入的不同元素数(20 次试验) 最少 1650,中位数 1673,最多 1703
转换前最后一次的稀疏长度 2999 到 3000 字节
密集表示长度 12304 字节

源码注释里有一张 100 个样本平均的”基数与稀疏字节数”表:基数 1000 时 1882 字节,2000 时 3480 字节,与实测的转换点一致。redis.conf 的注释建议保持在 3000 左右;如果 CPU 不是瓶颈、数据集由大量基数在 0 到 15000 的 HLL 组成,可以调到约 10000,代价是稀疏编码下 PFADD 是 \(O(N)\) 的。

估计器与三次更换

/* Redis 7.2.5 src/hyperloglog.c, hllCount()(节选) */
    double z = m * hllTau((m-reghisto[HLL_Q+1])/(double)m);
    for (j = HLL_Q; j >= 1; --j) {
        z += reghisto[j];
        z *= 0.5;
    }
    z += m * hllSigma(reghisto[0]/(double)m);
    E = llroundl(HLL_ALPHA_INF*m*m/z);

循环是 Horner 形式:从 \(k = q\) 往下,每步加 \(C_k\) 再乘 \(1/2\),最后得到 \(\sum_{k=1}^{q} C_k 2^{-k} + m\,\tau(\cdot)\,2^{-q}\),再加 \(m\,\sigma(C_0/m)\),正是第四节的式 (32)。按 GitHub 上各版本 tag 的源码,Redis 的估计器经历了三版:

版本 估计器 本文实测(64 位哈希,三个种子)
2.8.9 到 3.2 \(\alpha_m = 0.7213/(1+1.079/m)\);\(E < 2.5m\) 用线性计数,\(E < 72000\) 用多项式修正;不做大范围修正 平均偏差绝对值不超过 0.17%,RMSE 不超过 0.93%
4.0 LogLog-Beta 平均偏差绝对值不超过 0.08%
5.0 到 7.2 Ertl 式 (32),hllSigma() / hllTau() 平均偏差绝对值不超过 0.06%,RMSE 不超过 0.86%

3.2 的源码注释说明了为什么不做大范围修正:用的是 64 位哈希和 6 位寄存器,要逼近 \(2^{64}/30\) 需要大得不现实的集合。

reproduce/hll.c 按同样的哈希、桶号、\(\rho\) 和估计器实现,对 elem:0 到 elem:N-1 这组输入,与真实的 PFCOUNT 在 9 个检查点上逐个相等:

\(N\) 1 10 100 1000 2000 5000 10000 100000 1000000
PFCOUNT 1 10 100 1005 1994 4973 9922 99904 990064

命令层面的细节

八、争论与开放问题

“接近最优”指的是什么

2007 年论文标题里的 near-optimal 有两层依据(论文第 4 节):\((1 \pm \varepsilon)\) 近似地数到 \(N\) 至少需要 \(\Omega(\log \log N)\) 位;对一大类基于顺序统计量的算法,Chassaing 和 Gérin 证明精度下界接近 \(1/\sqrt{m}\)。这是数量级上的最优。

之后的工作从两个方向说明常数还有空间:

所以”HyperLogLog 是最优的”只在数量级上成立。它至今仍是默认选择,靠的是实现简单、固定大小、合并就是逐字节取 max。Pettie 和 Wang 为 \(H_0/I_0\) 是可合并草图的最优 Fisher-Shannon 数给出了间接证据:他们定义了”可线性化”(linearizable)草图这一子类,证明其中没有成员能超过 \(H_0/I_0\),常用的可合并草图都属于这一类。对一般的可合并草图,这仍是开放问题。

估计器该靠经验数据还是靠模型

HLL++ 的经验偏差表、Redis 3.2 的多项式和 LogLog-Beta 都是拟合出来的,绑定具体精度:Redis 3.2 的多项式只在 \(m = 16384\) 时启用,HLL++ 为每个 \(p\) 单独建表。Ertl 的批评是这些方法”treat the symptom and not the cause”,他从泊松模型推出的式 (32) 不需要任何经验数据,对任意 \((p, q)\) 都适用。本文的实测与这个判断一致:实测的两种拟合方案中较好的 LogLog-Beta 偏差不超过 0.08%,式 (32) 不超过 0.06%,而且式 (32) 在 32 位哈希下同样有效(第四节)。HLL++ 的经验表没有复现,不在比较之列。

另一面是 2007 年论文自己的说法与数据不符:它说 \(n = \frac{5}{2}m\) 时已无可检测的偏差,HLL++ 的 Figure 4 和本文的实测都显示此时原始估计仍有 1% 到 3% 的偏差。三段式里的阈值 \(\frac{5}{2}m\) 定得太早;HLL++ 的偏差修正覆盖到 \(5m\),针对的正是这一段。

可合并性与方差

如果只有一条数据流、不需要合并,可以在插入时顺便维护估计值:每当一个寄存器被改写,就按它被改写的概率的倒数给计数加一个增量。这类鞅(martingale)估计器或 HIP(historic inverse probability)估计器见 Ting(KDD 2014)和 Cohen(PODS 2014)。代价是这个计数是插入过程的副产品,不能从寄存器状态里恢复;两个草图合并后,合并结果没有对应的计数可用。所以 UltraLogLog 论文把鞅估计限定在”non-distributed setting”,并报告在这种场景下 UltraLogLog 相对 HyperLogLog 可省 17% 空间。选哪一种取决于是否需要合并。

反例:对抗性输入

所有精度结论都假设哈希值独立均匀。Paterson 和 Raynal(HyperLogLog: Exponentially Bad in Adversarial Settings,IEEE EuroS&P 2022)证明,如果攻击者了解 HLL 的内部状态、又能在一定程度上选择被计数的输入,就能让估计值比真实基数小指数倍;他们分析了原始算法和 Redis 使用的 Ertl 版本,并给出一种可证明防止这种操纵的简单改法。Redis 的哈希函数和种子 0xadc83b19 都是公开的,所以用 PFCOUNT 统计攻击者可控的输入(例如扫描源地址、用户自报的 ID)时,不能把 0.81% 当作保证。

开放问题

九、工程上的注意事项

问题 后果 做法
把 0.81% 当误差上限 约三分之一的估计超出 \(\pm 0.81\%\)(第三节实测 67% 到 69% 落在 \(1\sigma\) 内) 按 \(2\sigma \approx 1.6\%\) 或 \(3\sigma \approx 2.4\%\) 设告警阈值
自己实现时照抄 2007 年三段式 基数在 \(2.5m\) 附近平均偏差约 1.65%,RMSE 约 2.2%;32 位哈希下大范围修正越修越偏 用 Ertl 式 (32),或至少改用 64 位哈希并去掉大范围修正
不同服务用不同哈希、种子或 \(p\) 生成草图 合并结果没有意义,而且不会报错 把哈希函数、种子、\(p\) 写进序列化格式并在合并前校验
同一个 ID 在不同服务里编码不同(整数与字符串、大小写、字节序) 同一元素被计为多个 在进入草图前统一规范化
用容斥估计很小的交集 误差量级由并集大小决定,交集越小相对误差越大 改用联合估计或 KMV 类样本草图
大量低基数 key 默认配置下约 1700 个不同元素就转为密集表示,每个 key 固定占 12304 字节 按 redis.conf 的说明,在 CPU 与内存之间权衡调高 hll-sparse-max-bytes
高频调用多 key PFCOUNT 每次都合并所有 key 的 16384 个寄存器,结果不缓存 定期用 PFMERGE 预合并,再对结果调用单 key PFCOUNT
统计攻击者可控的输入 估计可以被操纵得远小于真实值 不把公开哈希的 HLL 用于安全决策;确需使用时参考 Paterson 和 Raynal 的改法

十、参考资料

文档

源码

核心论文

其他论文

实验


系列导航: - 上一篇:Unicode 文本算法:UTF-8 编解码与验证、规范化、字素簇与双向文本安全 - 下一篇:Count-Min Sketch:点查询误差界、保守更新与生产实现

相关阅读: - Bloom Filter 家族:从位数组到 Ribbon,每键位数离下界还差多少 - 流式算法总论:数据流模型、频率矩下界与线性 sketch

读完这篇,下一步读什么

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

2026-04-27 · algorithms / database

数据库缓冲池替换:LRU-K、2Q 与生产级扫描保护

从数据库缓冲池的 fix/unfix、脏页和扫描污染出发,对照 LRU-K、2Q、CLOCK-Pro 的学术脉络,以及 PostgreSQL 16 与 InnoDB 8.0 的源码实现,用可复现 trace 比较命中率和元数据开销。

2026-04-18 · algorithms / database

B-tree 深度解剖:从磁盘 I/O 模型到 boltdb 源码

从 Bayer-McCreight 1972 推导树高与分裂摊还代价,用计数模拟器和 bbolt v1.5.0 实测:随机插入页填充约 69%,按键序插入由分裂点决定是 50% 还是近 100%;再对照 bbolt、PostgreSQL、SQLite 源码看删除、写时复制与 B-link 并发。


By .