论文阅读笔记:HeavyKeeper: An Accurate Algorithm for Finding Top-k Elephant Flows
文章目录
这是杨仝老师课题组发表在TON2019(IEEE/ACM Transactions on Networking)上的一篇文章,聚焦于寻找top-k元素的应用场景,采用一种指数衰减计数策略(count-with-exponential-decay)将小流计数衰减为零从而为大流腾出空间,实现寻找top-k的目的。指数衰减计数思想来源于HeavyGuardian,但HeavyKeeper和HeavyGuardian适用的场景又不相同。HeavyKeeper仅适用于寻找top-k大象流的场景,HeavyGuardian则适用于五类任务场景。
Abstract
寻找top-k大象流是网络流量测量中的关键任务,在拥塞控制(congestion control)、异常检测(anomaly detection)和流量工程(traffic engineering)中有着广泛的应用。随着网络中线路速率的不断提高,设计准确、快速的大象流在线识别算法变得越来越具有挑战性。以前算法限制:在大的流量(heavy traffic)和小的片上存储器的约束下,现有的算法在实现精度方面受到严重限制。这些算法所采用的基本策略要么需要大量的空间开销来测量所有流的大小,要么在决定跟踪哪些流时产生显著的不准确性。我们的策略:我们采用了一种新的策略,称为指数衰减计数(count-with-exponential-decay),通过衰减主动去除小流量(small flows)来实现空间精度平衡,同时最小化对大流量(large flows)的影响,从而实现查找top-k大象流的高精度。此外,所提出的称为HeavyKeeper的算法对每个数据包的处理开销很小,且恒定,因此支持高线路速率(high line rates)。实验效果:HeavyKeeper算法在较小的内存容量下,准确率达到99.99%,与现有算法相比,误差平均降低了3个数量级左右。
问题:线路速率line rates到底是什么呢
1. Introduction
A. 背景和动机
研究场景:寻找最大的k个流(也被称作top-k elephant flows) 是一个基本的网络管理功能。流ID组成:流的ID通常定义为某些报头字段(如源IP地址、目的IP地址、源端口、目的端口、协议类型)的组合,流的大小定义为该流的数据包的数量。寻找大象流的意义:大象流贡献了大部分网络流量。许多管理应用程序都可以从有效地找到大象流的功能中受益,例如通过动态调度大象流来控制拥塞(congestion control by dynamically scheduling elephant flows)、网络容量规划(network capacity planning)、异常检测(anomaly detection)和转发表项的缓存(caching of forwarding table entries)。该功能不仅在网络测量(networking measurement)中具有重要意义,而且在数据挖掘(data mining)、信息检索(information retrieval)、数据库(databases)、安全(security)等网络之外的领域也有应用。
流大小分布:在真实的网络流量中,流大小(流中的数据包数量)的分布是高度倾斜的(skewed),即大多数是鼠流(mouse flows),而少数是大象流(elephant flows)。鼠流和大象流:大多数流很小,而少数流非常大。小的流通常被称为鼠标流(mouse flows),而大的流被称为大象流(elephant flows)。
研究场景:在高速网络中寻找top-k大象流(简写为top-k 流)是一项有挑战性的任务。准确记录不可能,近似记录提出:现代网络极高的线路速率使得几乎不可能准确地跟踪所有流量的信息。因此,文献中提出了近似方法(approximate methods),并得到了广泛的接受。片上、片外存储器空间和速度矛盾:为了跟上线路速率,这些算法预计将使用片上存储器(on-chip memory),如SRAM,其延迟约为1ns,而使用片外DRAM(off-chip DRAM)的延迟约为50ns。然而,片上存储器(on-chip memory)很小。更大的挑战是,保持每个包的处理开销小而恒定是被期望进行,这有助于流水线(pipelining)。
找top-k的两种策略:寻找top-k流量的传统解决方案遵循两种基本策略:计数所有(count-all)和承认所有计数一些(admit-all-count-some)。count-all策略依赖于一个sketch(例如CM sketch)来测量所有流的大小,同时使用最小堆(min-heap)来跟踪top-k流。对于每个传入的数据包,它将数据包记录在sketch中,并从sketch中检索数据包所属流 f i f_i fi 大小的估计值 n i ^ \hat{n_i} ni^ 。如果 n i ^ \hat{n_i} ni^ 大于最小堆中的最小流量,则用流 f i f_i fi 代替堆中的最小流量。由于需要一个大的sketch来计算所有流,这些解决方案的内存效率不高(not memory efficient)。
承认所有计数一些的策略(admit-all-count-some) 被Frequent、Lossy Counting、Space Saving和 CSS所采用。这些算法彼此相似。Space Saving 方法:为了节省内存,Space-Saving只维护一个名为Stream-Summary的数据结构,只计数一些流(例如m个流)。每个新流都将插入摘要(Summary)中,替换最小的现有流。新流的初始大小设为 n ^ m i n + 1 \hat{n}_{min}+1 n^min+1 ,其中 n ^ m i n \hat{n}_{min} n^min 为Summary中最小流的大小。通过在Summary中保留m个流,算法将报告其中最大的k个流,其中m>k。它假设每个新传入的流都是大象流,并在Summary中排除最小的流,为新流腾出空间。但是大多数流都是鼠标流。Space Saving 缺陷: 这样的假设导致巨大的误差,尤其是在较小的内存下(m的值有限)。
近来一些测量top-k方法: 除了上述两类寻找top-k流的算法外,最近有许多工作引入了新策略,我们将它们划分为第三类。**弹性草图(Elastic sketch)**使用投票来决定是否应该记录或删除一个流; HeavyGuardian 使用指数衰减策略来解决五个典型的测量任务; Cold Filter 使用两层过滤器来防止鼠标流进入某些数据结构(例如,Space-Saving, CM sketch); 计数器树(Counter Tree) 采用二维计数器共享策略,推导数学公式来估计流量大小。
B. 我们提出的解决方案
在本文中,我们提出了一种新的算法,HeavyKeeper,它使用了HeavyGuardian 中引入的类似策略,称为count-with-exponential-decay。它保留了所有大象流,同时大大减少了在鼠标流上浪费的空间。HeavyGuardian可以处理五种不同的任务,但不包括top-k大象流检测,而我们提出的算法HeavyKeeper只关注于寻找top-k大象流。HeavyKeeper使用多个数组,因此可以很好地扩展,而HeavyGuardian则不能。
与count-all不同,我们的策略只跟踪一小部分流量。与admit-all-count-some不同,我们不会自动承认新的流进入我们的数据结构,并且绝大多数鼠标流将被绕过。指数衰减思想:对于少数确实进入我们数据结构的鼠标流,它们将逐渐衰减,为真正的大象流腾出空间。在我们的数据结构中,流的衰减是不一致的(not uniform)。指数衰减(exponential decay) 的设计偏向于小流量,对大流量的影响较小。这种设计在小内存下可以很好地处理真实的流量轨迹。
2. 预先准备(Preliminaries)
A. 问题陈述(Problem Statement)
找top-k流指的是找最大的k个流。让 P = P 1 , P 2 , ⋯ , P n P=P_1,P_2,\cdots,P_n P=P1,P2,⋯,Pn 是一个有n个包的网络流(network stream)。每个包 P l ( 1 ≤ l ≤ N ) P_l(1 \leq l \leq N) Pl(1≤l≤N) 属于一个流 f i f_i fi , f i ∈ F = { f 1 , f 2 , ⋯ , f M } f_i \in F =\{f_1,f_2,\cdots,f_M\} fi∈F={f1,f2,⋯,fM} 并且 F F F 是流的集合。让 n i n_i ni 是 P P P 中流 f i f_i fi 的真实流大小。我们排序所有流 ( f 1 , f 2 , ⋯ , f M ) (f_1,f_2,\cdots,f_M) (f1,f2,⋯,fM) 以至于 n 1 ≥ n 2 ≥ ⋯ ≥ n M n_1 \geq n_2 \geq \cdots \geq n_M n1≥n2≥⋯≥nM 。
B. 以前的技术和限制(Prior Art and Limitations)
1)计数所有策略(Count-All Strategy) : count-all策略使用sketches(如CM sketch或Count sketch)来记录所有流的大小,并使用最小堆(min-heap) 来跟踪top-k流,包括流id及其流大小。以CM sketch为例。它在CM sketch中记录数据包,该sketch由计数器池组成。对于每个到达的数据包,它将数据包的流ID散列到d个计数器,并将这d个计数器增加1。d个计数器的最小值用作流的估计大小,用于更新最小堆(min-heap)。
存在问题:问题是所有流都是通过散列伪随机映射到相同的计数器池。每个计数器可以由多个流共享,从而记录所有这些流的大小总和。因此,一个小流可能被误视为大流,如果它的d个计数器被真正的大象流共享的话。
2)承认所有计数一些策略(Admit-All-Count-Some Strategy): 相当多的算法使用了admit-all- count-some策略,包括Frequent、Lossy Counting和Space-Saving。以Space-Saving为例 。它在一个名为Stream-Summary的数据结构中只计数一些流的大小,这导致搜索流或更新最小流的开销为0(1)。对于每个到达的数据包,如果其流ID不在Summary中,则该流将被允许进入Summary,替换最小的现有流。新流的初始大小设为 n ^ m i n + 1 \hat{n}_{min}+1 n^min+1 ,其中 n ^ m i n \hat{n}_{min} n^min 为替换前Summary中最小的流大小。最近提出了一个基于Space-Saving的工作CSS。它继承了上述策略,但通过使用TinyTable重新设计了Stream-Summary的数据结构,以减少内存使用。
承认所有计数一些策略(admit-all-count-some strategy) 是承认所有新的流,同时从Summary中排除最小的现有流量。为了使新流有机会留在Summary中,将其初始流大小设置为 n ^ m i n + 1 \hat{n}_{min}+1 n^min+1 。这种策略大大高估了流量的大小,我们在这里展示了一个例子。假设 n ^ m i n = 10000 \hat{n}_{min}=10000 n^min=10000 ,Summary已经满了。给定一个新流,它将直接用Summary中的最小大小替换该流,并将其大小设置为10,001。如果这个新的流是一个鼠标流,那么它在很大程度上被高估了。因此,大量的鼠标流将导致严重的高估错误。
3. HeavyKeeper的设计(The Design of HeavyKeeper)
A. 基本原理
我们的目标是使用一个小的哈希表来存储所有大象流。由于有大量的流,哈希表的每个桶将被许多流映射,我们的目标是仅存储其大小最大的流,这在使用小内存时不可能实现无错误。因此,我们利用一种称为指数衰减的概率方法(a probabilistic method called exponential-weakening decay) 。具体来说,当在散列桶中没有找到传入流时,我们以一定概率衰减流大小,随着流大小的增加,该概率呈指数递减。如果流大小衰减为0,则用新流替换原始流。这样,鼠标流可以很容易地衰减到0,而大象流可以很容易地在桶中保持稳定。两个缺陷:1)在很小的概率下,我们选择了错误的流量作为最大流量;2)由于衰减操作,报告的流大小可能被低估。解决:为了解决这些问题,我们使用具有不同哈希函数的多个哈希表。一个大象流可以存储在多个哈希表中,我们选择记录的最大大小,最小化流大小的误差。
B. HeavyKeeper结构
HeavyKeeper由d个数组组成,每个数组由w个桶bucket组成。每个桶由两个字段组成:指纹字段(fingerprint field)和计数器字段(counter field)。
fingerprint定义:一个流的fingerprint是特定函数生成的哈希值(例如,我们使用 h f ( . ) h_f(.) hf(.) 作为fingerprint哈希函数,则流 f j f_j fj 的fingerprint是 h f ( f j ) h_f(f_j) hf(fj) 。尽管流之间可能存在哈希冲突,但这种可能性非常小。例如,如果我们将fingerprint大小设置为16位,并且数组中有10000个桶,则指纹碰撞的概率为 1.52 ∗ 1 0 − 3 1.52*10^{-3} 1.52∗10−3 。简言之,fingerprint就是一个哈希值。
符号表示:我们使用 A j [ t ] A_j[t] Aj[t] 表示第 j t h j^{th} jth 个数组的 t t h t^{th} tth 个桶。使用 A j [ t ] . F P A_j[t].FP Aj[t].FP 和 A j [ t ] . C A_j[t].C Aj[t].C 分别表示它的fingerprint字段和counter字段。数组 A 1 , ⋯ , A d A_1,\cdots,A_d A1,⋯,Ad 分别与哈希函数 h 1 ( . ) ⋯ h d ( . ) h_1(.) \cdots h_d(.) h1(.)⋯hd(.) 相关联。这些d个哈希函数 h 1 ( . ) ⋯ h d ( . ) h_1(.) \cdots h_d(.) h1(.)⋯hd(.) 需要是两两相互独立。
1)插入:初始化所有的fingerprint字段是空(null),所有的计数器字段是0。对于每一个到来的包 P l P_l Pl 属于流 f i f_i fi , HeavyKeeper计算d个哈希函数,并且映射 f i f_i fi 到 d d d 个桶 A j [ h j ( f i ) ] ( 1 ≤ j ≤ d ) A_j[h_j(f_i)](1 \leq j \leq d) Aj[hj(fi)](1≤j≤d) (一个数组映射一个桶),为了方便我们简称为d个映射桶(d mapped buckets)。如图2,对于每个映射桶,HeavyKeeper应用不同的策略对于下面三种情况:
Case1 : 当 A j [ h j ( f i ) ] . C = 0 A_j[h_j(f_i)].C=0 Aj[hj(fi)].C=0 , 它意味着没有流映射到这个桶中,然后HeavyKeeper设置 A j [ h j ( f i ) ] . F P = F i A_j[h_j(f_i)].FP=F_i Aj[hj(fi)].FP=Fi 并且 A j [ h j ( f i ) ] . C = 1 A_j[h_j(f_i)].C=1 Aj[hj(fi)].C=1 , 其中 F i F_i Fi 代表 f i f_i fi 的fingerprint。
Case2: 当 A j [ h j ( f i ) ] . C > 0 A_j[h_j(f_i)].C>0 Aj[hj(fi)].C>0 并且 A j [ h j ( f i ) ] . F P = F i A_j[h_j(f_i)].FP=F_i Aj[hj(fi)].FP=Fi,它意味着 A j [ h j ( f i ) ] . C A_j[h_j(f_i)].C Aj[hj(fi)].C 可能是 f i f_i fi 的估计大小,这种情况下HeavyKeeper增加 A j [ h j ( f i ) ] . C A_j[h_j(f_i)].C Aj[hj(fi)].C 加1。
Case3: 当 A j [ h j ( f i ) ] . C > 0 A_j[h_j(f_i)].C>0 Aj[hj(fi)].C>0 并且 A j [ h j ( f i ) ] . F P ≠ F i A_j[h_j(f_i)].FP \neq F_i Aj[hj(fi)].FP=Fi ,它意味着 A j [ h j ( f i ) ] . C A_j[h_j(f_i)].C Aj[hj(fi)].C 不是 f i f_i fi 的估计大小。在这里,HeavyKeeper应用了指数衰减策略(exponential-weakening decay strategy) 对于这个桶。它以一个概率 P d e c a y P_{decay} Pdecay 衰减 A j [ h j ( f i ) ] . C A_j[h_j(f_i)].C Aj[hj(fi)].C 减一。在衰减后,如果 A j [ h j ( f i ) ] . C = 0 A_j[h_j(f_i)].C=0 Aj[hj(fi)].C=0 , HeavyKeeper用 F i F_i Fi 替换 A j [ h j ( f i ) ] . F P A_j[h_j(f_i)].FP Aj[hj(fi)].FP ,并且设置 A j [ h j ( f i ) ] . C A_j[h_j(f_i)].C Aj[hj(fi)].C 为1。因此,只要流被映射到bucket,它的计数器字段就永远不会为0。
注意,在任何时候,计数器的值都是非负的,因为衰减只发生在情况3中,而情况3仅在计数器的值大于0时发生。在情况3中,当计数器衰减到零时,新流被插入到该桶中,计数器立即被设置为1
2)查询 : 为了查询一个流 f i f_i fi 的大小,HeavyKeeper首先计算d个哈希函数去得到d个桶 A j [ h j ( f i ) ] ( 1 ≤ j ≤ d ) A_j[h_j(f_i)](1 \leq j \leq d) Aj[hj(fi)](1≤j≤d) ,在d个映射桶中,他选择那些fingerprint字段等于 F i F_i Fi 的桶。它然后报告那些桶中最大的计数器字段,即 m a x 1 ≤ j ≤ d { A j [ h j ( f i ) ] . C } max_{1 \leq j \leq d}\{A_j[h_j(f_i)].C\} max1≤j≤d{Aj[hj(fi)].C} , 其中 A j [ h j ( f i ) ] . F P = F i A_j[h_j(f_i)].FP=F_i Aj[hj(fi)].FP=Fi 。
为方便起见,对于 f i f_i fi 的d个映射桶,如果 A j [ h j ( f i ) ] . F P = F i A_j[h_j(f_i)].FP=F_i Aj[hj(fi)].FP=Fi ,我们说 f i f_i fi 保存(is held)在桶 A j [ h j ( f i ) ] A_j[h_j(f_i)] Aj[hj(fi)] 。忽略fingerprint碰撞的有限影响,我们证明每个流的报告大小(reported size)等于或小于实际流大小(real flow size)。如果流保持在(is held)没有映射的桶中,它报告它是一个鼠标流。如果一个流被保存在多个桶中,HeavyKeeper报告最大计数器字段(maximum counter field)。
3)衰减概率: 关键问题是如何选择一个函数来计算概率。根据我们在真实数据集和合成数据集上的实验结果,我们发现,只要参数设置合理,满足以下条件的函数都具有良好的性能:当前计数器域中的值越大,概率越小。最后我们选择指数函数
P
d
e
c
a
y
=
b
−
C
(
b
>
1
)
P_{decay}=b^{-C}(b>1)
Pdecay=b−C(b>1)
其中,
C
C
C 是当前计数器字段的值,b(b>1)并且b约等于1(即b=1.08)是一个预定义的指数计数。这是因为这个函数有下面特性:1)随着值的增大,概率缩减率逐渐增大,映射到[0,1]。2)当该值足够大时(例如50),概率接近于0,因此我们可以将概率视为0,从而加快我们算法的吞吐量。3)当值较小时(如3),记录的流几乎不可能是大象流,同时概率接近于1,正好符合这一条件。
因此,流的规模越大,其大小(size)越难以衰减。对于象流,它被保存在几个桶中,相应的计数器字段有规律地增加,而衰减的概率非常小。因此,大象流大小估计的错误率非常小。
注意: 我们的d个数组和d个两两独立哈希函数的数据结构可能与CM Sketch有一些相似之处。但相似之处仅限于此。CM sketch记录所有流量的大小;我们记录一小部分流的大小。CM sketch不存储流id;我们做了。CM sketch将每个流程的信息存储在d个计数器中;我们将每个流保持在一个桶中,而d-hashing 有助于找到一个空桶。CM sketch不必担心踢出现有的流来为新的流腾出空间的问题,这就是我们的指数延迟(exponential delay) 所做的。
示例:
如图一,给定一个属于流 f 3 f_3 f3 的传入数据包 P 5 P_5 P5 ,我们计算d个哈希函数以在每个数组中获得一个bucket。在第一个数组的映射桶中,fingerprint字段不等于 F 3 F_3 F3 ,计数器字段为21,因此我们将计数器字段从21衰减到20,概率为 1.0 8 − 21 1.08^{-21} 1.08−21 (假设b = 1.08)。
在第二个映射桶中,fingerprint字段也不是 F 3 F_3 F3 ,并且以 1.0 8 − 1 1.08^{-1} 1.08−1 的概率,我们将计数器字段从1衰减到0。如果计数器字段衰减为0,我们将fingerprint字段设置为 F 3 F_3 F3 ,并将计数器字段设置为1。在最后一个映射桶中,指纹字段是 F 3 F_3 F3 ,我们将计数器字段从7增加到8。
分析:HeavyKeeper使用fingerprint识别和保存大象流量。如果一个流大小较小的鼠标流被保存在一个桶中,那么它将很快被映射到这个桶的其他流所替换,因为有不同fingerprint的每个流都映射到这个桶会以一个高的概率衰减计数器字段(即 b − C → 1 b^{-C} \rightarrow 1 b−C→1 当 C C C 是小的)。如果象流保存在一个桶中,那么相应的计数器字段可以很容易地增加到一个大值,因为象流有许多传入数据包。而且,随着计数器字段的增大,衰减概率变得很小(当C较大时,衰减概率变为$b^{-C} \rightarrow 0 $)。因此,鼠标流很难在HeavyKeeper中保持很长时间,因此有很大的概率成为HeavyKeeper的过客。然而,大象流量在HeavyKeeper中可以保持稳定,并且大象流量的估计大小是准确的。
C. 找Top-k大象流的基本版本(Basic Version for Finding Top-k Elephant Flows)
为了找到top-k大象流,我们的基本版本只使用了一个HeavyKeeper和一个min-heap。最小堆用于存储top-k流的id和大小(size)。对于属于流 f i f_i fi 的每个传入数据包 P l P_l Pl ,我们首先将其插入HeavyKeeper。假设HeavyKeeper报告 f i f_i fi 的大小为 n i ^ \hat{n_i} ni^ 。如果 f i f_i fi 已经在最小堆中,我们用 m a x ( ( ^ n i ) , m i n _ h e a p [ f i ] ) max(\hat(n_i),min\_heap[f_i]) max((^ni),min_heap[fi]) 更新它的估计流大小,其中 m i n _ h e a p [ f i ] min\_heap[f_i] min_heap[fi] 是最小堆中记录的 f i f_i fi 的大小。否则,如果 n i ^ \hat{n_i} ni^ 大于最小堆根节点的最小流大小,则将根节点从最小堆中排除,并将大小为 n i ^ \hat{n_i} ni^ 的 f i f_i fi 插入最小堆中。要查询top-k流,我们只需报告最小堆中的k个流及其估计的流大小。
注意,在我们的实现中,我们使用了Stream-Summary而不是min-heap,因为min-heap和StreamSummary的功能是相似的,并且Stream-Summary可以实现0(1)的更新复杂度。为了更好地理解,我们在论文中使用最小堆来解释。
D. 优化(Optimizations)
在本节中,我们提出了进一步的优化方法,以避免意外误差,提高速度。为了方便,我们使用 n m i n n_{min} nmin 来表示最小堆中的最小流大小。
优化一:fingerprint冲突检测(Fingerprint Collisions Detection)
问题:假设在HeavyKeeper中有一个存放流 f i f_i fi 的桶,并且映射到同一桶的鼠标流 f j f_j fj 与 f i f_i fi 具有相同的fingerprint,即由于哈希冲突, F i = F j F_i=F_j Fi=Fj 。然后,鼠标流 f i f_i fi 也被保存在这个桶中,它的估计大小被大大高估了。在最坏的情况下,如果流 f i f_i fi 在所有d数组中都有fingerprint冲突,那么鼠标流 f i f_i fi 可能会被插入到最小堆中。它几乎不能被排出,因为它的大小被大大高估了。一个有效的解决方案是存储流的整个id,而不是使用fingerprint,这明确可以避免哈希冲突。然而,在实际的数据流中,流ID的位数通常非常大(例如,5tuple中超过100位),导致内存浪费。事实上,内存效率越高,算法的准确性就越高。我们的设计目标是找到一种在不增加记录比特数的情况下减轻哈希冲突的解决方案。因此,我们的解决方案是存储fingerprint而不是整个id。为了减少哈希冲突的影响,我们提出了一个基于以下定理的解决方案。
优化二:有选择性增加(Selective Increment)
问题:如果一个流 f i f_i fi 不在最小堆中,那么估计的流大小应该不大于 n m i n n_{min} nmin 。但是,由于fingerprint碰撞,可能存在一些映射的流 f i f_i fi 映射的桶,其中fingerprint字段为 f i f_i fi ,计数器字段大于 n m i n n_{min} nmin 。在这种情况下,流量 f i f_i fi 并不是这个桶中保存的流量,因此增加相应的计数器字段只会导致额外的错误。
解决方案:在这种情况下,我们不做任何改变,而不是增加或衰减相应的计数器字段。
4. 总结(Conclusion)
查找top-k大象流是网络流量测量的关键任务。现有的top-k流查找算法在流量速度大、内存占用小的情况下无法达到较高的查找精度。在本文中,我们提出了一种新的数据结构,称为HeavyKeeper,与以前的算法相比,它在top-k查询上实现了更高的精度,在流量大小估计上实现了更低的错误率。HeavyKeeper的关键思想是它智能地忽略了鼠标流,并通过使用指数弱化衰减策略(exponential-weakening decay strategy) 专注于记录大象流的信息。我们的评估证实,HeavyKeeper在寻找top-k大象流方面达到了99.99%的精度,同时与最先进的算法相比,估计流大小的错误率也降低了约3个数量级。我们已经在GitHub上发布了HeavyKeeper的源代码和所有相关算法。http://hadjieleftheriou.com/frequent-items