漫谈SIMD、SSE指令集与ClickHouse向量化执行

SIMD即"single instruction, multiple data"(单指令流多数据流),是Flynn分类法对计算机的四大分类之一。它本质上是采用一个控制器来控制多个处理器,同时对一组数据中的每一条分别执行相同的操作,从而实现空间上的并行性的技术。

可见,“单指令流”指的是同时只能执行一种操作,“多数据流”则指的是在一组同构的数据(通常称为vector,即向量)上进行操作,如下图所示,PU=processing unit。

在这里插入图片描述

二、SIMD在现代计算机的应用甚广泛,最典型的则是在GPU的像素处理流水线中。举个例子,如果要更改一整幅图像的亮度,只需要取出各像素的RGB值存入向量单元(向量单元很宽,可以存储多个像素的数据),再同时将它们做相同的加减操作即可,效率很高。SIMD和MIMD流水线是GPU微架构的基础,就不再展开聊了。

话说回来,CPU是如何实现SIMD的呢?答案是扩展指令集。Intel的第一版SIMD扩展指令集称为MMX,于1997年发布。后来至今的改进版本有SSE(Streaming SIMD Extensions)、AVX(Advanced Vector Extensions),以及AMD的3DNow!等。ClickHouse的向量化执行机制主要依赖于SSE指令集,下面简要介绍之。

三、ClickHouse提供的检查CPU是否支持SSE4.2的命令如下。

grep -q sse4_2 /proc/cpuinfo && echo “SSE 4.2 supported” || echo “SSE 4.2 not supported”

SSE指令集以8个128位寄存器为基础,命名为XMM0XMM7。在AMD64(即64位扩展)指令集中,又新增了XMM8XMM15。一个XMM寄存器原本只能存储一种数据类型:

4个32位单精度浮点数
SSE2又扩展到能够存储以下类型:

2个64位双精度浮点数
2个64位/4个32位/8个16位整数
16个字节或字符

四、那么如何利用SSE指令集呢?主要有以下3种方法:

1.直接编写(内嵌)汇编语句;
2.利用厂商提供的扩展库函数。Intel将这类指令和函数统称为intrinsics,官方提供的速查手册见这里;
3.开启编译器的优化(-msse、-msse2等等),编译器会自动将符合条件的情景(如数组相加、矩阵相乘等)编译为intrinsic指令。

五、ClickHouse向量化执行示例

计算Filter中1的数量
在ClickHouse的底层,过滤器(Filter)是一个预分配空间的、无符号8位整形数的数组,用于表示WHERE和HAVING子句的条件及真值,每一位的取值为0或1,即表示条件为假或真。Filter和列(IColumn)是共生的,在ColumnsCommon.cpp中,提供了通用的计算Filter中1的数量的方法,代码如下。

 size_t countBytesInFilter(const IColumn::Filter & filt)
{
	size_t count = 0;

/** NOTE: In theory, `filt` should only contain zeros and ones.
  * But, just in case, here the condition > 0 (to signed bytes) is used.
  * It would be better to use != 0, then this does not allow SSE2.
  */

const Int8 * pos = reinterpret_cast<const Int8 *>(filt.data());
const Int8 * end = pos + filt.size();

#if defined(__SSE2__) && defined(__POPCNT__)
const __m128i zero16 = _mm_setzero_si128();
const Int8 * end64 = pos + filt.size() / 64 * 64;

for (; pos < end64; pos += 64)
    count += __builtin_popcountll(
        static_cast<UInt64>(_mm_movemask_epi8(_mm_cmpgt_epi8(
            _mm_loadu_si128(reinterpret_cast<const __m128i *>(pos)),
            zero16)))
        | (static_cast<UInt64>(_mm_movemask_epi8(_mm_cmpgt_epi8(
            _mm_loadu_si128(reinterpret_cast<const __m128i *>(pos + 16)),
            zero16))) << 16)
        | (static_cast<UInt64>(_mm_movemask_epi8(_mm_cmpgt_epi8(
            _mm_loadu_si128(reinterpret_cast<const __m128i *>(pos + 32)),
            zero16))) << 32)
        | (static_cast<UInt64>(_mm_movemask_epi8(_mm_cmpgt_epi8(
            _mm_loadu_si128(reinterpret_cast<const __m128i *>(pos + 48)),
            zero16))) << 48));

      /// TODO Add duff device for tail?
     #endif
 
    for (; pos < end; ++pos)
        count += *pos > 0;
 
    return count;
}

defined(SSE2)说明当前环境支持SSE2指令集,而defined(POPCNT)说明支持硬件级位计数的POPCNT指令。下面根据手册简要介绍一下代码中涉及到的intrinsic函数:

_mm_setzero_si128():初始化128位(16字节)的全0位图,即一个XMM寄存器。
_mm_loadu_si128(mem_addr):从内存地址mem_addr处加载128位的整形数据。
_mm_cmpgt_epi8(a, b):按8位比较a和b两个128位整形数,若a的对应8位比b的对应8位大,则填充对应位为全1,否则填充全0。
_mm_movemask_epi8(a):根据128位整形数a的每个8位组的最高位创建掩码,一共16位长,返回int结果(高16位用0填充)。
最后,__builtin_popcountll()函数相当于直接调用POPCNT指令算出64位数的汉明权重。

由上可见,这个函数的每次循环都将连续64个Filter的真值数据(即Int8类型)压缩到一个UInt64中一起做位计数。其中每次调用上述指令都会处理16个Int8,正好是128位,SIMD的思想就是这样体现出来的。由于SSE指令集中没有真正的位运算指令,所以压缩的过程略显繁琐,但是仍然比笨方法(逐个遍历判断)效率高很多。

六、经过多次测试,不使用SSE的版本的耗时总是使用SSE的版本的3倍多。鉴于ClickHouse在很多地方都渗透了SIMD和SSE,积少成多,效率提升自然就非常可观了。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值