coding4fun 词频统计的优化思路

在此次的coding4fun活动中已经有不少同窗分享了精彩的优化思路。个人思路其实大同小异,下面就挑一些于众不一样的地方分享吧:数组

第一个不一样点:数据结构

在结构上选择了简化版的Trie做为查找结构。简化版Trie的结构就是一颗n叉树,每一个节点对应一个状态。选择简化版Trie的缘由是它的树状结构很容易用CAS实现无锁并行,而相比hashtable没有hash冲突和rehash的问题,相比复杂Trie结构如Double-ArrayTrie又比较容易实现:并发

215212914.jpg

简化版Trie的一个重要参数就是树的宽度(W),对应每一层有多少个子节点,它等于存放子节点的数组大小。若是W越大,每一个节点占据的内存就越大,节点利用率就越低。Trie的每一层能够有不一样的W,在极限外推下,若是Trie树的第一层W很是大,保证绝大部分节点在第一层能放下,这个内存结构就与hashtable没有太大区别了:215305316.jpgide

对Trie的调优集中在节点利用率上。若是节点利用率越高,相同单词量的状况下数据结构占据的内存就越小(假设内存能彻底放下的话),CPU随机访问这些节点的cachehit就会越高。——短期内无法对树的查找复杂度O(logn)进行改造,因此只能设法下降数据结构的内存占用(工做集)。性能

Trie树的查找是先把输入拆分红一系列“状态”序列,而后根据这组状态序列在用“树”表示的状态迁移有向图中定位最终的节点。在简化的Trie结构中,“状态”的总数就决定了Trie树的宽度W。所以怎样把输入有效的拆分红“状态”或者说定位子节点index是一样重要的,例如输入字符串‘ABCDEFG’:测试

  1. 若是按原始char内码拆分,生成的子节点index序列是:{65,66,67,68,69,70,71},须要一棵W=256的Trie树存放;优化

  2. 若是只考虑字母,忽略大小写压缩一下,生成的子节点index序列是:{0,1,2,3,4,5,6},只须要一棵W=26的Trie树就能放下;线程

  3. 若是在上一条的基础下,按Bit逐位拆分每一个字母,则产生的index序列是:{0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,1,1,0,0,0,0,0,1,0,0,1,0,1,0,0,0,1,1,0,0},只须要W=2的Trie(它实际上是棵二叉树),可是查找路径会增长不少。blog

BTW其实这就是BitwiseTrie,不过第一Java下不能利用CPU指令优化位运算,第二BitwiseTrie更适合定长输入,因此测试性能并不太友好。排序

  1. 更加灵活(复杂)的拆分办法,在上述按Bit拆分的基础上,再合并相邻Bit增长宽度,下降查找路径。例如合并3位:{0,4,0,4,0,3,0,2,2,1,6,0},须要一个W=8的Trie存储。这个方法可让任意W值的Trie树接受任意的状态序列输入。

为了方便对不一样W和结构的Trie性能进行测试,我定义了一组Tries/Sequencer接口,由Tries接口维护“状态”数据结构,Sequencer产生“状态”的index序列。在所有的测试中我尝试了若干种不一样的Tries和Sequencer实现,最后发现按照方式2来最大限度的压缩index序列的AlphabetSequencer与最简的SimpleTries/ConcurrentTries(使用CAS并行插入节点)实际性能最好。这一块就很少介绍了,你们有兴趣能够阅读Gitlab的代码。

第二个不一样点:

与大部分同窗利用内存映射文件一次性把文件读到内存不一样,我在一开始就直接把文件平均分红若干个分区,让每一个工做线程单独扫描一个分区进行分词,这样能够实现彻底并行:

215354297.jpg

由于平均分区这个办法太简单,可能会出现尾部“半个单词”的问题,漏掉分区结尾的单词。解决尾部半个单词的办法很简单,写成伪代码是这样的:

If(不是从文件开头读取){

忽略分区的第一个单词;

}

读取和计算分区中的单词;

If(没有读到文件末尾){

继续向后多读一个单词,直到单词结束;

}

具体的并发执行过程以下图。其中并发加载是最慢的,消耗通常在3s以上,而并发排序通常是6x-8x毫秒:220256704.jpg

实际调优中发现,尽管多个线程并发访问相同的数据结构,可是由于单词总量不大(3w多),Trie树的绝大部分节点都是在运行的最初阶段插入的,CAS冲突消耗的时间不多(100-200ms级别),主要的消耗仍是在树查找与内存访问。

相关文章
相关标签/搜索