基于 SIMD 指令的 PFOR-DELTA 解压和查找

PFOR-DELTA 是一种经典的有序整数数列压缩算法,被广泛使用在搜索、推荐引擎的倒排索引和召回队列压缩中。PFOR-DELTA 的具体算法这里就不展开了,不了解的同学可以参考它的原始论文《Super-Scalar RAM-CPU Cache Compression》或者做一些搜索工作。

朴素的 PFOR-DELTA 解压主要是逐个对 frame 中的 bitpack 整数进行解压,对朴素 PFOR-DELTA 的优化主要包括对齐的内存访问aligned memory access, 先按4/8字节偏移读取,然后再移位取得 bitpack)和循环展开loop unrolling, 由每次循环解压一个整数转成每次循环解压 N 个整数以减少分支判断和访存次数)。通用的 PFOR-DELTA 函数库,往往采取这两种优化方法。

本文主要介绍在目前的先进 CPU 架构下,如何利用 SIMD 指令(如 SSE, AVX) 加速 PFOR-DELTA 解压和查找。中文互联网上与之相关的有一篇阿里搜索和推荐团队的文章《索引压缩算法New PForDelta简介以及使用SIMD技术的优化》,但该文章缺乏算法细节且其收益表明对 SIMD 指令的应用并不高效。与该文类似,为简化计,下文主要以不带异常段的 PFOR-DELTA 为例来说明算法细节。

基于 SIMD 的 bit unpacking

PFOR-DELTA 的每个分块中,都是以固定位宽压缩的整数。例如小于 32 的整数,都可以用 5 位来存储,相比于原来的 32 位存储,大大减少了数据的存储空间。但是在使用的时候,我们又必须将压缩的数展开到 32 位,才能进计算和比较操作,将一个数从压缩的位宽展开到使用的位宽,叫做 bit unpacking

对单独一个 bitpack 的整数来说,展开是非常容易的,通过简单的移位、AND 操作即可完成。但是 SIMD 指令主要提升的是并行化,考虑到 frame 宽度不同,存在各种对齐问题,如何同时进行多个数的 bit unpacking 并不是一件非常直观的事。

下面以 frame 宽度为 9,即每个整数用 9 bit 表示,来详细说明基于现代 CPU 广泛支持的 128位 SIMD 指令的 bit unpacking 算法。256/512 位的 SIMD 算法可依此推演。

如上图所示,如果我们从数组开头加载 128 bit 的数据,其中将会包括 14 个完整的 9 bit 整数,以及多读的 2 个 bit。

为了将每个 9 bit 展开成 32 bit,在只有 128 位寄存器的情况下,我们只能 4 个 4 个地展开。首先需要将头 4 个 9 bit 整数移动到寄存器 32 位偏移的位置。Intel 提供了 _mm_shuffle_epi8 intrinsic 可以根据重整参数重新排布 128 位寄存器的内容,但可惜的是它最小粒度只能按 byte 重整,这也就意味着我们必须将 9 bit 整数所在的整个 2 byte 移动到 32 位偏移的位置。对应的代码是:

// 考虑到 litten endian,实际上 shufkey 内容为:
// 00018080 01028080 02038080 03048080
__m128i shufkey = {0x8080020180800100, 0x8080040380800302};
v = _mm_shuffle_epi8(v, shufkey);

这时候,128 位寄存器中的每个 32 位槽位中都包含一个 9 bit 整数,但可惜的是它都包含了前后数字的一些冗余 bit。下面这张图,说明了如何用 SIMD 指令消除这些冗余 bit。

前面说过,对一个整数做 unpack 可以完全用移位和 AND mask 来实现,对多个整数依然如此。但由于各槽位中 9 bit 整数的对齐不同,移位 bit 数也不同。例如:第一个槽位右移 0 位,第二个槽位右移 2 位,第三个操作右移 2 位,第四个槽位右移 3 位。

可是 SSE 的移位指令只能支持各槽位同等位数的移位,无法支持这样各槽位不同位数的移位。我们不得不用代价更高昂的其它指令来实现分槽位不同移位,先用基于 2 次幂的向量乘法实现左移对齐 9 bit 整数,然后再统一右移,统一 mask。不使用 2 次幂除法的原因是除法的成本更高。对应到代码是:

// 通过向量乘法实现各 DW 槽位中 9 bit 整数左移对齐。当然参数可以换成常数。
v = _mm_mullo_epi32(v, _mm_set_epi32(8, 4, 2, 1));
// 所有 DW 右移 3 位
v = _mm_srli_epi32(3);
// 通过 and mask 掉 9 bit 之外多余的 bit。当然参数可以换成常数。
v = _mm_and_si128(v, _mm_set1_epi32(0x1ff));

这样,我们就实现了 9 bit 整数的 unpacking。上述算法具备通用性,对于其它的 frame 宽度,只是 shuffle、移位、乘法、mask 的参数不同,处理过程并无区别。

认真的读者可能会有疑问:第一张图为什么做了两遍 shuffle?这是考虑到了奇数位 bit packing 的特殊性。

对于偶数位(2,4,6,8)的 bit packing,一次解压 4 个整数,解压的位宽是可以被 8 整除的,所以每次解压都可以从字节边界开始,所有算法参数都相同;对于奇数位(3, 5, 7, 9),一次解压 4 个整数,解压的位宽是不能被 8 整除的,所以第二轮解压不能从字节边界开始,第二轮解压的算法参数与第一轮不同。但解压 8 个整数后,位宽就对齐到字节边界了。所以说:frame 位宽为偶数的 SIMD unpacking,循环 block 可以是 4*位宽;但 frame 位宽为奇数的 SIMD unpacking,循环 block 只能是 8*位宽。如果 AVX 指令可用,可以使用 256 位的 SIMD 指令,能大大缓解这个问题。

不失其一般性,可以看到我们只用了 4 条 SIMD 计算指令,就完成了 4 个整数的 bit unpacking,其效率较逐一 unpacking 高了很多,并且可以推及到 256 位和 512 位的情形得到更高加速比。

本节内容主要参考了论文《SIMD-Scan: Ultra Fast in-Memory Table Scan using on-Chip Vector Processing Units》。

基于 SIMD 的 delta 计算

在 PFOR-DELTA 算法中,每个整数其实是 delta,需要把所有前序的整数加起来才是真正所要的数据。参考上节,我们虽然解压出来了 {v0, v1, v2, v3},但实际上需要的却是 {v0, v0+v1, v0+v1+v2, v0+v1+v2+v3}

将 __m128i 转成一个数组,然后再循环相加是比较简单的思路。但我们也可以直接用三条 SIMD 指令来实现 delta 计算:

// {v0, v1, v2, v3} + {0, 0, v0, v1} = {v0, v1, v0+v2, v1+v3}
v = _mm_add_epi32(_mm_slli_si128(v, 8), v);
// {v0, v1, v0+v2, v1+v3} + {0, v0, v1, v0+v2} = {v0, v0+v1, v0+v1+v2, v0+v1+v2+v3}
v = _mm_add_epi32(_mm_slli_si128(v, 4), v);
// {v0, v0+v1, v0+v1+v2, v0+v1+v2+v3} + {acc3, acc3, acc3, acc3}
acc = _mm_add_epi32(v, _mm_shuffle_epi32(acc, 0xff));

同样,不失一般性,这种错位相加的方法完全可以推广到 8 维整数向量的 delta 计算,可以推广到 256 位和 512 位的情形得到更高的加速比。

本节内容主要参考了论文《SIMD Compression and the Intersection of Sorted Integers》。

基于 SIMD 的查找比较

对 PFOR-DELTA 解压完成之后对有序数组,如果想找到某个整数的位置,我们还需要逐个进行比较。这种比较,也可以用 SIMD 指令完成:

// 初始化一次
__m128i key = _mm_set1_epi32(v_to_find);
// 向量比较
v = _mm_cmplt_epi32(key, acc);
// 比较结果处理
int res = _mm_movemask_epi8(v);
if (res != 0) {
index += __builtin_ctz(res) >> 2;
} else {
index += 4;
}

总结

综上,本文描述了完全使用 SIMD 指令进行 PFOR-DELTA 解压和查找的详细算法,给出了在 SSE 指令集下的具体代码,并且可以推广到更高的数据宽度下。至于优化的收益,将根据基线实现的不同存在差异,感兴趣的读者可以自行实现比较一下。

此外,上文的实现主要着眼通用性,针对特定的小宽度整数,其实可以使用更小的计算粒度以增大并行度。对性能有苛求的读者可自行研究。

再谈龙芯

这次博客更新间隔比较长,因为最近确实很忙,这篇也是应景之作。

下午一朋友忽然给我来一条消息“龙芯 sb 了,到头来还是用的美国技术”,我还以为他看到了 07 年意法和龙芯的协议那条新闻。结果他告诉我去看看各大门户科技板块的头条,果然发现了一条很热的新闻:龙芯购美公司专利授权:CPU核自主产权战略失败,其来源在这里

于是想起来我两年前写的那篇关于龙芯的文章,现在证明事情确如我当时所想“说龙芯拥有全部自主知识产权,我不信”。但是对当前新闻中所持的那些观点,我认为,太过了。龙芯的是,是新闻炒出来的,龙芯的非,也难脱炒作的嫌疑。

虽然没有参与过龙芯的任何工作,但是我原来在简约纳是做(类 MIPS)芯片工具链的开发,现在也是在清华-Intel 的联合实验室实习,接触过一些和龙芯相关的人——尤其是龙芯编译组。因此我大概可以算是一个稍微明白点儿真相的群众——但也仅仅是群众,请谨慎围观。

首先,新闻标题是“龙芯购美公司专利授权:CPU核自主产权战略失败”——其实从龙芯决定使用 MIPS 的 ISA 起,自主产权战略已经失败了。我想没有人会相信一些受过那么多年良好教育,中国最顶尖的人才们不懂得什么叫做知识产权吧?但是在中国的科研环境下,科研人员的业绩都体现在科研成果上。先不谈有没有相应方面的人才,仅仅说从头开始设计指令集,得花多少钱?得用多长时间?IC 设计本身就是一个烧钱的工作,恐怕没有人敢顶着上峰的压力往一个看不到成果的项目里烧钱吧?

其次,就算报道“自主产权战略失败”,也应该早就报道了。以前龙芯大概不想让大家认识到知识产权问题,转着弯地跟意法半导体合作,07 年新闻里只注重报道意法对龙芯海外市场的买断,但是谁不知道龙芯向意法买 MIPS 架构授权啥意思呢?如果要报道“自主产权战略失败”,也应当那时就报道了。

再次,龙芯直接从 MIPS 手里买授权,我认为这对它是件好事。就像新闻中所说,龙芯以前肯定和 MIPS 谈过,谈不拢才和意法谈。现在它能从 MIPS 直接买,当然要比从意法买要好得多。而且,作为 MIPS-Compatible 的 CPU,利用已有代码,可以减少很多软件移植的工作量。

最后,仅仅因为一个 MIPS 架构否决龙芯团队的所有努力,是错误的,媒体的报道,不理也罢。我虽然不清楚龙芯团队做了些什么工作,但是把一个嵌入式的 CPU 扩展成一个通用 CPU,我认为不是一件简单的工作。(这一句其实有误,因为龙芯的芯片是自己设计的,但我的意思主要是指从媒体批评的 ISA 的角度来说。朋友告诉我说,即使这样说也不对,因为指令集和 CPU 应用类型并没有必然联系。)成也媒体,败也媒体,为什么记者不真真切切地去了解去报道一个科技成果,总是要加上许多夸张的成分呢?

PS:这篇博贴出来,我收到了很多批评,这是我在自己不熟悉领域发表看法的代价,我非常感谢那些愿意指教我的人。不过对于那些只有指责没有指点的评论,我只能说谢谢您,但这里不欢迎您。

龙芯

这星期算是比较闲的一个星期,很多工作要等别人的进度,而且 ^_^,我自己也有想偷懒的因素。中间我居然花了一整天时间浏览了一下 《See Mips Run》,因为是 MIPS 的老员工出的书,写得还算不错,好像去年 10 月份还出了第二版:《See Mips Run Linux》,只是电子书现在还不太好找。

《See Mips Run》可以作为一本了解 MIPS 体系架构和编程的教材,甚至可以作为加深理解计算机体系架构的参考书。因为它几乎涵盖了体系结构设计和程序底层实现的所有方面,而且能将 RISC 结合某种特定的 CPU 进行探讨,又没有一般手册的枯燥,在这本书中可以了解到各种理论模型的实际实现方式。也有个缺陷就是它不够新,它所关注的是 R10000 以前的 MIPS,特别注重的是 R4xxx,但这些也是 MIPS 的经典之作。

在当前很多的嵌入式芯片中的设计中都可以看到 MIPS 的影子,包括计算所的龙芯(Loongson),更是受到 MIPS 的深刻影响。从一则新闻就可以看出来:2007年3月28日下午,中科院计算所与意法半导体在人民大会堂举行龙芯处理器技术合作签约仪式。龙芯通过该合作获得了 MIPS64 架构全部许可使用权,而意法半导体通过合作取得龙芯处理器芯片在全球制造和销售的授权。

随便扯点龙芯的话题,最近慢慢地了解了一些龙芯神话,也算揭开了一点龙芯的面纱。不是说龙芯不好,计算所的研究人员的确很优秀,龙芯做得也很好,很厉害。但是作为中国的科研机构,总会有一些很有意思的现象,就比如龙芯总是拿自己跟奔腾比。有这样的话:"最高主频达到1.0GHz,实测性能超过1.5GHz奔腾IV处理器的水平。"我以前看到这种说法总是觉得,哦,好厉害。只是现在慢慢懂了,龙芯和奔腾的 x86 根本不是同一个架构,这种比较可以说毫无意义。拿寄存器来说,龙芯 2E 总共有 64 个定点寄存器,64 个浮点寄存器,而奔腾 IV 是 16 个定点寄存器和 16 个浮点寄存器,可以想象四条腿的马和两条腿的人赛跑的景象。

当然,不是寄存器多就肯定快,也不是寄存器多就好,只是采用 MIPS 体系架构的 CPU, 使用了那么多快速存储设备,主频相同情况下某些方面性能高点当然是很正常的事情。作为 RISC 的龙芯和 CISC (相对) 的奔腾,它们两个的流水线技术、指令集、内存访问基本不同,可以说没有什么可比性。但是为什么连用户手册里龙芯都是拿自己和奔腾系列比较呢,而且只比主频?显然是看起来好看,就像很多公司发布 CPU 都会专门找测试集合一样,这样能忽悠人。

我不相信龙芯的开发人员会真拿与奔腾系列的参数比较当事儿。但很显然的是,中科院、科技部和国家领导们希望它与奔腾比,因为砸下了那么多钱却只看到和一个自己都没有听说过的芯片比性能,谁爽啊?也没办法跟上头交代啊!中国老百姓也希望它与奔腾比,因为是人都知道奔3奔4,你说别的咱老百姓也不懂啊。就拿算是精英阶层的大学生们来说,不知道 PSP,iPod 是什么东西的大概没几个,但知道 PSP 用的是两块 MIPS CPU, 知道 iPod 用的是 PortalPlayer(NVIDIA now),nano 用的是 ARM9, shuffle 用的是 SigmaTel STMP 的有几个,听说过这几个名词的又有几个?龙芯相关公司也希望它跟奔腾比,因为总是要糊弄老百姓的,让科研人员和媒体忽悠一下比自己忽悠效果好多了。

还有一句中国的科研机构经常挂在嘴边的话:"拥有全部自主知识产权。"这句话说起来很有气势,但做起来有那么容易吗?像龙芯,采用了多少 MIPS 架构的东西,连指令集都那么的相似,我不懂这些方面知识产权保护的方式和内容,但说龙芯拥有全部自主知识产权,我不信。就像中国的麒麟操作系统一样,你是改的别人的内核,在宣传中说"该系统已通过自由标准组织Linux标准基认证"的同时还说拥有自主知识产权,真当老百姓是傻子啊。

可以这样说,如果不使用 Linux,基本上没有亲切了解龙芯电脑的机会,因为龙芯上现在只能跑 Linux,而且我想,龙芯在可以预见的时间内,基本不可能赢得 Windows 的支持,就算是 Linux,它也只是支持一小部分。所以,龙芯的存在,和老百姓的 PC 没有太大关系,龙芯的市场也只能是在嵌入式和服务器领域,很难进入桌面市场。但是,龙芯的存在,给了中国一个机会,在处理器市场上竞争的机会,谁知道未来是什么样呢,别管手中的东西是好是坏,总比从敌人那里拿来的用着顺手。只是很难那,计算所的老师都说,想达到 Intel 的水平,那得至少 20 个计算所去做,还得国家鼎力支持。龙芯能做的现在的样子,很不容易了。只要别重蹈汉芯的覆辙,为了赢得国家和民众的支持,吹牛也吹点吧,只是别吹破了就行。我的同事有好多计算所出来的,有的还担过教职,他们对龙芯评价也很高,但都说,龙芯项目现在没多少真正厉害的人了,有能力的都跑国外了,看来龙芯的发展也并没有那么乐观。

呵呵,之所以聊龙芯的话题是因为好像公司下星期会来几个参与过龙芯项目组的计算所毕业生,忽然想起来了就扯点。毕竟算是中国芯片产业的希望啊,关注的人越多,才有可能变得越好。