在这次的coding4fun活动中已经有很多同学分享了精彩的优化思路。我的思路其实大同小异,下面就挑一些于众不同的地方分享吧:

第一个不同点:

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

215212914.jpg

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

对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(它其实是棵二叉树),但是查找路径会增加很多。

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级别),主要的消耗还是在树查找与内存访问。