URL Top 100

100GB url 文件,使用 1GB 内存计算出出现次数 top100 的 url 和出现的次数。

关键词

TOP N、分治、最小堆、多线程、mmap、BKDRHash、crc32

原理

本题目整体使用 TOP N 问题的解题方法,使用 map-reduce 的**解决。

第一步将URL分类放到k个不同的哈希桶。时间复杂度:O(n),空间复杂度:O(n)。

第二步在不同的线程下统计不同哈希桶下的各个URL的数量,并放入最小堆。 限制最小堆容量为100,URL数量比最小的数更小则不放入堆中。 时间复杂度:O(n),空间复杂度:O(1)。

第三轮,合并不同哈希桶产生的最小堆。最后得到的最小堆依次输出即为TOP 100 的倒序排列。

实现

第一步:分类

partition.c 中,我使用 mmap 实现了100G文件直接映射到内存。

传统使用 fread 的方式,实际上是依靠不断调用系统调用 read 实现的。 在用户态和系统态之间频繁切换,带来了更多的资源消耗。

使用 mmap 优势在于:

  • 一次映射就够,直接用指针就可以操作;
  • 完全不需要考虑内存是否充足问题,内存页的分配由内核负责,不会占用过多的内存空间;
  • 文件I/O在内核中完成,读的速度更快。

实际运行时,大多数情况下只占用 308KB 的物理内存。

在遍历过程中,循环只是不断寻找换行符 \n。 当找到 \n 时,则可以计算当前URL的字符串的末尾、大小以及下一个URL的起点。

我使用了特别制作的 BKDRHash 函数,直接用 mmap 内存中的 URL 计算 Hash 值。 本部分大约占用 partition 部分约 30% 的计算资源,因此可以考虑使用其他计算量更小的Hash算法。

对于桶文件(bucket file),我一样用 mmap 的实现方法。 但是,与前者不同的是,桶文件映射的内存空间不仅需要读权限,也提供写权限。 我对全部的桶文件,提供一个默认的尺寸,阈值为 待处理URL文件尺寸 / 桶的个数。 当然,为了便于内存管理,该阈值是以页的大小 PAGE_SIZE 的倍数。 通过 ftruncate 函数,很方便生成指定大小的文件。 写URL到桶文件的实现,其实就是 memcpy 从原文件的映射空间复制到桶文件的映射空间。 同时也带来一个好处,分隔符变为了 \0,更方便下一轮使用 strlen 计算URL长度,更方便从桶文件中抽出 URL。 针对已经写超过阈值的情况,在初始版本中将使用 fwrite (带缓冲区的写)实现,后面有更好的实现办法。

第一步可以实现多线程,由于时间原因,在后期改进中增加思路。

第二步:哈希分治法统计

在第一步中,我们已经形成了 k 个包含了全部URL的桶文件。

由于第一步中使用哈希函数将不同的URL分别放到不同的桶中。 因此,每一个桶中的URL绝对不会在其他桶文件中出现,直接统计这个桶中的URL的数目就已经是最终的个数了。

统计时需要使用 hashmap 实现 KV 存储。Key 为 URL,Value 为当前该URL已经出现的次数。 在实际使用中,我选了一个公有领域的 hashmap 实现。它使用 crc32 计算Hash,当出现碰撞时使用链表遍历。 每匹配到一个URL,就获取该URL的已经出现的次数。加1后再放回到 hashmap。 为了减少 hash 的计算,当该 key 已经被找到时,get 函数将会直接返回存储节点,这样在 put 的时候就不需要重新寻找该节点了。

每个桶完成统计后,将会遍历整个 hashmap,将全部URL和次数对放入限制容量为100的最小堆。 最小堆满容量后,插入新的KV,将只会记录当前V比最小堆中最小的数还大的情况,确保最小堆中的全部成员都是当前桶的TOP 100。

第三部:合并多个最小堆

原理很简单,就是遍历各个最小堆的全部的成员,插入到一个新的限制容量为100的最小堆。

为了节省资源,实际使用第一个桶内存在数目的最小堆作为基准。 其他桶的最小堆最终都睡插入到基准的最小堆中。

该处理非常快,多次测试仅在1s之内完成。

性能测试

Device:

  • CPU: 2.8 GHz Intel Core i7
  • Disk: APPLE SSD AP0512M
  • Go version: go version go1.12.9 darwin/amd64

ONE thread, 100G:

partition time: 1571243 ms (26min 11s)
reduce time: 3132900 ms (52min 12s)
merge time: 0ms

分析与结论

partition

通过 DTrace 性能分析可以知道:

  • partition 阶段有大约 30% 的时间在计算Hash,约 15% 的时间进行内存复制,少量时间计算 fwrite
  • 当某个桶溢出时,fwrite 的时间占比大幅增加,影响写的速度;
  • 未使用多线程,计算跟不上读写。

因此,可以改进的部分有多线程、Hash 算法和桶满了之后分配新的内存空间。

reduce

通过 DTrace 性能分析可以知道:

  • hashmap 中的 crc32 算法占用 reduce 的 58% - 99% 的时间,由于 hashmap_get 和 hashmap_put 都需要重复计算;
  • 未使用多线程,计算跟不上读写。

因此,可以改进的部分有多线程、Hashmap的实现。

merge

最小堆的合并基本不占时间。根据 8:2 原则,可以忽略优化。

未来工作

partition 和 reduce 均使用多线程

通过测试结果和分析的结论可以看出,实际上计算的效率不及读写的效率。 由于目前只是单核计算,所以可以通过多线程的方式实现更高效的计算。

最容易实现多线程的是 reduce,每一个桶都是独立的统计单位,可以用单独的线程跑 traversing 函数。

partition 多线程实现稍微复杂,可以通过提前计算不同的分片的起始点和终点,并行计算不同分片的 partition。

hashmap

最直接可以优化的部分是 get 和 put 重复计算 Hash 的问题。

【已实现】在 get 阶段可以直接返回底层存储的节点指针,在 put 阶段直接修改节点的值,即可减少目前一半的时间。

其次,利用多线程计算不同桶数据的问题。

Hash

目前,partition 使用的 BKDRHash 消耗的时间比 crc32 更多。能否使用 crc32 替代 BKDRHash?还需要继续优化。

贡献

感谢 warmwaffles 提供的 hashmap 实现

感谢 armon 实现的 c-minheap-indirect