这里写目录标题
论文链接:https://ieeexplore.ieee.org/document/10005066
开源地址:https://github.com/wenjunpaper/CuckooCounter
0. 问题和关键见解
- 解决的问题:能够在真实网络数据流中进行频数估计和识别 Top-k 流的近似算法,并且能够同时保证速度、误差和(Top-k 流识别)精确度。
- 关键见解:这篇文章提出的 Cuckoo Counter 将一个流哈希到桶中,并利用 cuckoo hasing 的思想在发生溢出或冲突时去对流进行重新定位,从而充分利用内存。因此,这种替换策略有助于 Cuckoo Counter 在精确记录大象流的同时覆盖更多老鼠流,并保证了吞吐量。
1. 问题阐述
下面将简要地对频数估计和 Top-k 流估计这两个问题进行阐述。假设一个网络流 P = { p 1 , p 2 , . . . , p N } \{p_1,p_2,...,p_N\} {p1,p2,...,pN} 包含 N 个数据包。其中每个数据包仅属于某一个流,表示为 e。网络流 P 中的数据包可以被分类到 n 个不重叠的流:E = { e 1 , e 2 , . . . , e n } \{e_1,e_2,...,e_n\} {e1,e2,...,en}。流 e i e_i ei 中的数据包数量被称为 e i e_i ei 的频数(缩写为 e i . f e_i.f ei.f 或 f i f_i fi),因此我们有 ∑ f i = N ∑f_i=N ∑fi=N。
- 每流频数估计 (Per-flow Frequency Estimation):给定一个网络流 P,它提供每个流的(近似)频数,即 f 1 f_1 f1, f 2 f_2 f2,…, f n f_n fn。
- Top-k 流估计 (Top-k Estimation):给定一个整数 k 和一个网络流 P,它提供了一个包含频数最大的 k 个流(即大小最大的 k 个流)的列表,即 e 1 e_1 e1, e 2 e_2 e2,…, e k e_k ek。
2. 相关工作
- 基于草图的方案:该方案通常提供每个流的近似估计,比较适合于频数估计。这是因为草图有着对所有流进行快速更新的速度,但是一些草图的处理逻辑容易导致老鼠流和大象流之间的误分类。
- 基于计数器的方案:该方案大流估计方面具有更高的准确性,比较适合于 Top-k 流估计。这是因为它们的替换策略更倾向于保留计数器中的大流,但是这也导致其关于老鼠流的信息存储的比较少。
- 基于混合的方案:通过结合上述两种方案的思想,一些工作设计出同时适用于频数估计和 Top-k 流估计的方案。作者提到了 ASketch,如图 1 所示,该方案在现有草图上使用了一个额外的过滤器(其内部包含计数器)来提前对大象流进行聚合,而草图则用于处理分布的尾部(也就是小鼠流),有点类似缓存的思想。但是该方案在插入过程中会导致草图和过滤器之间产生过多的交换,从而大大降低了速度。
图 1:ASketch 结构
3. 框架设计
3.1 频数估计
3.1.1 数据结构
如图 2 所示,Cuckoo Counter 由两个数组 A1 和 A2 组成。假设桶的总数量为 m,而每个数组有 w 个桶,并且每个桶由具有不同大小的 B 个条目组成:
{
e
n
t
r
y
1
,
e
n
t
r
y
2
,
…
,
e
n
t
r
y
B
}
\{entry_1, entry_2, …, entry_B\}
{entry1,entry2,…,entryB}(
e
n
t
r
y
i
entry_i
entryi 的大小随
i
i
i 增加而增加)。条目是 Cuckoo Counter 的基本单位,而桶则是一次 Access 的基本单位。因此,一个桶的长度最好是机器字的整数倍(例如,64位,128位…),从而不会浪费内存访问。
每个条目由两部分组成,即 fingerprint 和 counter。fingerprint 用作流的标识,并且所有 fingerprint 必须占据相同大小的空间,以符合 Cuckoo hashing 的思想。counter 则用于记录 fingerprint 对应的流的频数。所有这些条目都可以用来估计和存储老鼠流,但大象流仅存储在大小为 entryB、entryB−1… 的这些条目中。而那些位于中间的条目则充当老鼠流和大象流之间计数的缓冲区,它们可以存放老鼠流和中等大小的流。
作者引入 partial-key cuckoo hashing 来根据流的 fingerprint 推导出该流的备用位置。对于属于流 e 的数据包p 来说,使用如下公式来计算出两个候选的桶:
h1(e) = hash(e), h2(e) = h1(e) ⊕ hash(e’s fingerprint)
上述公式中的 ⊕ ⊕ ⊕ (异或运算) 能够保证 h 1 ( e ) h_1(e) h1(e) 也可以根据 h 2 ( e ) h_2(e) h2(e) 和 e 的 fingerprint 计算出来,即 h1(e) = h2(e) ⊕ hash(e’s fingerprint)。因此,可以变成更一般化的公式,即通过该流的当前位置和 fingerprint 来计算其在另一个数组中的位置:
hanother(e) = hcurrent(e) ⊕ hash(e’s fingerprint)
由于 hash() 函数长度为 64 比特,比 m 大很多,所以后面用 h ~ i ( e ) \widetilde{h}_i(e) h i(e) 来指代 h i ( e ) % w h_i(e)\%w hi(e)%w(也就是需要进行取模运算),即流 e 映射到的上面数组或下面数组的桶的索引。
3.1.2 算法和操作
3.1.2.1 插入操作
下面使用 A i [ j ] [ k ] A_i[j][k] Ai[j][k] 来表示 a r r a y i [ b u c k e t j ] [ e n t r y k ] array_i[bucket_j][entry_k] arrayi[bucketj][entryk]。最初,所有条目都初始化为 0。当插入属于流 e 的数据包 p 时,我们首先通过哈希计算两个索引 h 1 ( e ) h_1(e) h1(e) 和 h 2 ( e ) h_2(e) h2(e) 来找到两个候选桶 A 1 [ h ~ 1 ( e ) ] A_1[\widetilde{h}_1(e)] A1[h 1(e)] 和 A 2 [ h ~ 2 ( e ) ] A_2[\widetilde{h}_2(e)] A2[h 2(e)]。然后扫描这两个桶中的所有条目 A 1 [ h ~ 1 ( e ) ] [ j ] A_1[\widetilde{h}_1(e)][j] A1[h 1(e)][j], A 2 [ h ~ 2 ( e ) ] [ j ] A_2[\widetilde{h}_2(e)][j] A2[h 2(e)][j],其中 1 ≤ j ≤ B。
- 如果这些条目中存在流 e,我们将相应条目的 counter 加 1。
- 如果流 e 是一个新流,那么我们检查
A
1
[
h
~
1
(
e
)
]
A_1[\widetilde{h}_1(e)]
A1[h
1(e)] 或
A
2
[
h
~
2
(
e
)
]
A_2[\widetilde{h}_2(e)]
A2[h
2(e)] 中是否有空的条目。
- 如果有,则将数据包插入到最先找到的空条目中,并将该条目的 counter 值设置为 1。
- 如果两个桶均没有空的条目,则在 A 1 [ h ~ 1 ( e ) ] [ 1 ] A_1[\widetilde{h}_1(e)][1] A1[h 1(e)][1] 或 A 2 [ h ~ 2 ( e ) ] [ 1 ] A_2[\widetilde{h}_2(e)][1] A2[h 2(e)][1] 中随机选择一个流 e’ 踢出,并将流 e 插入被踢出那个流所在的条目中。然后通过 partial-key cuckoo hashing 重新定位(计算备用桶)被踢出的流 e’。流 e’ 将被插入到另一个数组的相应桶(即备用桶)中。如果该桶中也没有空的条目,则位于该桶的 entry1 中的流 e’’ 将被踢出,并且流 e’ 将被插入该条目以替换e’。这个过程会一直持续下去,直到原始的流和被踢出的流插入成功,或者踢出的次数达到 maxloop。当踢出次数达到 maxloop 时,最后被踢出的流会被强制插入到对应的桶中,然后用自己的 fingerprint 替换 entry1 条目中的 fingerprint,该条目的 counter 则存储这两个 counter 中较小的值。
作者简单解释一下这里为什么取较小的 counter 值:由于 fingerprint 碰撞所产生的高估,使用 min() 操作所导致的低估可以在一定程度上抵消这部分误差。而如果这里取较大的 counter 值(即 max() 操作),则可以保持不存在低估错误这个良好的特性。
当流 e 所映射到的桶中没有空的条目时,仅随机选择 A 1 [ h ~ 1 ( e ) ] A_1[\widetilde{h}_1(e)] A1[h 1(e)] 或 A 2 [ h ~ 1 ( e ) ] A_2[\widetilde{h}_1(e)] A2[h 1(e)] 中的 entry1 来进行踢出或插入。作者确保 entry1 始终记录网络流中的那些老鼠流。当条目的 counter 溢出时,例如 entry1 中的 counter 值达到其上限时,则会扫描桶中其他具有更大 counter 的那些条目。
- 如果存在更大的条目,并且该条目的 counter 值小于溢出条目的 counter 值,则交换这两个条目。
- 否则,则检查另一个数组的备用桶中的那些条目,看看是否存在比溢出条目具有更大 counter 和更小的 counter 值的条目 Φ。然后我们将条目 Φ 中的原始流踢出并重新定位踢出的流。然后,我们将溢出条目中的流插入到条目 Φ 中。这样做只会将老鼠流的频数引入到其他条目中,而不会错误地将大象流的频数添加到老鼠流上,这尤其提高了大象流的频数估计的准确性。
3.1.2.2 查询操作
在查询流 e 时,首先通过 partial-key cuckoo hashing 计算出两个索引, h 1 ( e ) h_1(e) h1(e) 和 h 2 ( e ) h_2(e) h2(e)。然后,我们将流 e 的 fingerprint 与 A i [ h ~ i ( e ) ] [ j ] A_i[\widetilde{h}_i(e)][j] Ai[h i(e)][j] 中的这些条目的 fingerprint 进行匹配(i ∈ {1, 2},1 ≤ j ≤ B)。
- 如果匹配成功,则返回相应条目的 counter 值。
- 接着,如果这两个桶中至少有 1 个空条目,则返回 0。
- 否则,只返回 A i [ h ~ i ( e ) ] [ 1 ] . c o u n t e r A_i[\widetilde{h}_i(e)][1].counter Ai[h i(e)][1].counter 中的较小值。
通过上面查询的分析,作者再次简要解释为什么取 counter 的较小值:
- 对于存在于数据集中的流:经过实验结果表明,取较小值的平均绝对误差 AAE 表现更好。这是因为对于那些没有存储在 Cuckoo Counter 中的流来说,它们很大概率上是低频数的。
- 对于不存在于数据集中的流(查询这些流应该返回 0):显然,取较小值也是更好的。
3.1.2.3 删除操作
首先计算流 e 的两个索引, h 1 ( e ) h_1(e) h1(e) 和 h 2 ( e ) h_2(e) h2(e),并扫描 A i [ h ~ i ( e ) ] [ j ] A_i[\widetilde{h}_i(e)][j] Ai[h i(e)][j](i ∈ {1, 2},1 ≤ j ≤ B)中的条目。
- 如果这些条目中存在与流 e 相同的 fingerprint,则将相应的 counter 值减 1。
- 否则,我们将具有较大的 A i [ h ~ i ( e ) ] [ 1 ] A_i[\widetilde{h}_i(e)][1] Ai[h i(e)][1] 中的 counter 值减 1。原因与查询中相同。如果在执行完删除操作后,counter 值为0,则直接删除相应的无效 fingerprint。
3.2 Top-k 流估计
3.2.1 数据结构
Top-k 流估计所使用的数据结构与图 2 展示的相同,它由两个数组组成,每个数组有 w 个桶,每个桶中有多个不同大小的条目。为了报告 Top-k 个最频繁的流,作者添加了一个额外的堆,但是与其他工作中使用的最小堆不同,其使用
f
s
t
a
r
t
f_{start}
fstart 字段来记录某个流首次进入堆时的频数。通过添加这个字段,可以改进算法以过滤掉一些错误/假的 Top-k 个流。
如图 3 的左半部分所示,优化堆包含 ( 1 + ε ) k (1+ε)k (1+ε)k 个条目(其中 ε ε ε 是一个小数,例如 0.01),每个条目表示为 h e a p [ x ] ( 1 ≤ x ≤ ( 1 + ε ) k ) heap[x] (1 ≤ x ≤ (1+ε)k) heap[x](1≤x≤(1+ε)k),其中 k k k 是要跟踪的大象流的数量。每个条目包括三个部分:流 ID,起始频数和当前频数,分别表示为 h e a p [ x ] . I D heap[x].ID heap[x].ID, h e a p [ x ] . f s t a r t heap[x].f_{start} heap[x].fstart 和 h e a p [ x ] . f n o w heap[x].f_{now} heap[x].fnow。
3.2.2 算法和操作
3.2.2.2 更新
初始时,堆和 Cuckoo Counter 的所有条目都设置为 0。当插入属于流 e 的数据包 p 时,首先计算其 fingerprint,并通过 partial-key cuckoo hashing 将其插入到 Cuckoo Counter 中。完成插入后,可以得到流 e 的频数,记为 f f f(插入和查询类似,它们可以一起进行而不影响速度)。然后便开始更新堆。
- 如果流 e 在堆中,我们将相应的 f n o w f_{now} fnow 字段更新为 f f f。
- 如果流 e 不在堆中:
- 如果堆中存在空条目,则直接将流 e 的信息插入到对应的条目中(将 ID 设置为 e, f s t a r t f_{start} fstart 和 f n o w f_{now} fnow 设置为 f f f)。
- 如果堆中不存在空条目,则判断 f f f 是否大于堆中最小的 f n o w f_{now} fnow,如果大于的话,则使用流 e 的信息直接替换具有最小条目的流的信息(将 ID 设置为 e, f s t a r t f_{start} fstart 和 f n o w f_{now} fnow 设置为 f f f)即可;反之,则不做任何操作。
3.2.2.3 检测
检测 Top-k 个大象流的方法略有不同。首先计算堆中所有流的 fingerprint,然后重新遍历这个堆。当一个流 e 发生 fingerprint 冲突,并且其 ( f s t a r t − f n o w ) (f_{start} - f_{now}) (fstart−fnow) 值小于给定的阈值时,我们不报告这个流;否则,我们则报告该流。被过滤的流的数量不能超过 ε k εk εk。如果在遍历过程中被过滤的流的数量达到 ε k εk εk,那么我们将报告所有后续的流。
3.2.2.4 改进点阐述
作者在这里简要解释了一下为什么引入 f s t a r t f_{start} fstart 这种优化是有效的:因为 CC 存储带有 fingerprint 的流,不同流之间可能会发生哈希冲突。所以可能出现以下情况:
一个老鼠流 e 0 e_0 e0 和一个大象流 e 1 e_1 e1(假设我们正在寻找前 1000 个流, e 1 e_1 e1 排名第 500 名)被映射到同一个桶中,并且它们具有相同的 fingerprint,因此 CC 会错误地将 e 0 e_0 e0 分类为大象流。因此, e 0 e_0 e0 将进入堆,其 f s t a r t f_{start} fstart 和 f n o w f_{now} fnow 都被设置为一个较大值(即 e 1 e_1 e1 的近似频率),但是其 f n o w f_{now} fnow 字段几乎不会增长。稍后 e 1 e_1 e1 也将进入堆中。
所以说那些假的前 k 个流(例如 e 0 e_0 e0)具有以下特征:其 fingerprint 与堆中的某些其他流发生冲突,并且其 f s t a r t − f n o w f_{start} - f_{now} fstart−fnow 值非常小。我们测试了各种数据集,并发现堆中的那些假的前 k 个流确实具有这种特征,而真的前 k 个流的 f s t a r t − f n o w f_{start} - f_{now} fstart−fnow 值都很大。
因此,作者预先设置了一个阈值 Δ 来区分真的前 k 个流和假的前 k 个流之间的差异。如果堆中的一个流发生了 fingerprint 冲突,并且其 f s t a r t − f n o w < Δ f_{start} - f_{now} < Δ fstart−fnow<Δ,则我们将忽略它。由于一些流将被过滤掉,因此需要将堆的大小设置为 ( 1 + ε ) k (1+ε)k (1+ε)k,以确保最终能够得到 k 个流。在最佳情况下,可以过滤掉 ε k εk εk 个假的前 k 个流,从而将前 k 个流的估计精度提高了 ε k εk εk。同时,这种改变几乎不会增加额外的内存(因为 ε ≪ k ≪ ε \ll k \ll ε≪k≪ CC 中桶的数量)。
4. 结论
作者总结了 Cuckoo Counter 的三个关键思想:
(1)Memory efficient:每个桶中条目的大小经过精心设计,分别用于计算老鼠流和大象流的频数,可以有效处理服从倾斜分布的数据流,并提高内存利用率。
(2)High speed:当发生溢出时,Cuckoo Counter 尝试将流重新定位到同一个桶中更大的条目中,以 O(1) 的内存访问完成,从而可以在不牺牲更新性能的情况下将大象流和老鼠流重新定位到合适的条目中。
(3)High accuracy and high precision:当发生严重冲突时,Cuckoo Counter 利用 partial-key cuckoo hashing 将存储在最小条目中的流踢出,尽可能填满每个桶以提高内存利用率而不损失太多准确性,并且能够自然地保留更多的大象流。