[快速阅读六] 统计内存数据中二进制1的个数(SSE指令集优化版).

sse · 浏览次数 : 0

小编点评

| 算法 | 耗时 (ms) | |---|---| | SSE2 优化 | 5.2 | | SSE3 优化 | 5.5 | | SSE4 shuffle | 5.8 | | SSE2 手动嵌入 | 11 | | SSE3 手动嵌入 | 12 |

正文

  关于这个问题,网络上讨论的很多,可以找到大量的资料,我觉得就就是下面这一篇讲的最好,也非常的全面:

          统计无符号整数二进制中 1 的个数(Hamming Weight)

  在指令集不参与的情况下,分治法速度最快,MIT HAKMEM 169 算法因为最后有一个mod取余操作,速度要稍微慢一点,256元素的查表算法速度要次之,当然,其实要建议那个256元素的表不要使用int类型,而是使用unsigned char类型的,尽量减少表的内存占用量,也意味着cache miss小一些。 16位的查表算法速度反而慢了不少,主要是因为他用while,即使我们把他展开,也需要8次数据组合,还是比16位的慢。其他的就不要说了,都比较慢。

  在SSE4指令集能得到CPU的支持时,可以有一个直接的指令_mm_popcnt_u32可以使用,这个就可以加速很多了,一个常用的过程如下:

    Amount = 0;
    for (int Y = 0; Y < Length; Y++)
    {
        Amount += _mm_popcnt_u32(Array[Y]);
    }

  一千万的随机数据,用这个指令大概只要3.2毫秒多就可以处理完成,如果稍微改下代码,让他能并行化一点,如下所示:

    Amount = 0;
    for (int Y = 0; Y < Length; Y+=4)
    {
        int T0 = _mm_popcnt_u32(Array[Y + 0]);
        int T1 = _mm_popcnt_u32(Array[Y + 1]);
        int T2 = _mm_popcnt_u32(Array[Y + 2]);
        int T3 = _mm_popcnt_u32(Array[Y + 3]);
        Amount += T0 + T1 + T2 + T3;
    }

  还可以提高到大约2.7ms就可以处理完。

  其实,现在在运行的新的CPU基本上没有那个不支持SSE4的了,但是也不排除还有一些老爷机。因为SSE4最早是2008年发布的,如果CPU不支持SSE4,但是支持SSE3(2004年发布的),那是否有合适的指令集能加速这个过程呢,实际上也是有的。 

  我们这里喵上了统计无符号整数二进制中 1 的个数(Hamming Weight)一文中的16元素查表算法,原文中的代码为:

  Amount = 0;
    for (int Y = 0; Y < Length; Y++)
    {
        unsigned int Value = Array[Y];
        while (Value)
        {
            Amount += Table16[Value & 0xf];
            Value >>= 4;
        }
    }

  这个明显是不合适指令处理的,前面说了,这个可以展开,展开后形式如下:

  Amount = 0;
    for (int Y = 0; Y < Length; Y++)
    {
        unsigned int Value = Array[Y];
        Amount += Table16[Value & 0xf] + Table16[(Value >> 4) & 0xf] + Table16[(Value >> 8) & 0xf] + Table16[(Value >> 12) & 0xf] +
                Table16[(Value >> 16) & 0xf] + Table16[(Value >> 20) & 0xf] + Table16[(Value >> 24) & 0xf] + Table16[(Value >> 28) & 0xf];
    }

  仔细观察他的意思就是提取内存的4位,然后根据4位的值来查16个元素的表,我在之前的多个文章里都有提高,16个元素的表(表内的值不能超过255)是可以借用一个_mm_shuffle_epi8指令进行优化的,一次性得到16个值。

  _mm_shuffle_epi8是在SSE3就开始支持的,因此,这一改动可以将硬件的适应性提前4年。

  具体的来首,就是我们加载16个字节数据,然后和0xF进行and操作,得到每个字节的低4位,然后进行shuffle,得到每个字节低4位的二进制中1的个数,然后在把原始字节数右移4位,再和0xF进行and操作,得到每个字节的高4位,然后进行shuffle,两次shuffle的结果相加,就得到了这16个字节数据的二进制中1的个数。 具体代码如下所示:

    __m128i Table = _mm_loadu_si128((__m128i*)Table16);
    __m128i Mask = _mm_set1_epi8(0xf);
    __m128i UsedV = _mm_setzero_si128();
    for (int Y = 0; Y < Length; Y += 4)
    {
        __m128i Src = _mm_loadu_si128((__m128i*)(Array + Y));
        __m128i SrcL = _mm_and_si128(Src, Mask);
        __m128i SrcH = _mm_and_si128(_mm_srli_epi16(Src, 4), Mask);
        __m128i ValidL = _mm_shuffle_epi8(Table, SrcL);
        __m128i ValidH = _mm_shuffle_epi8(Table, SrcH);
        UsedV = _mm_add_epi32(UsedV, _mm_add_epi32(_mm_sad_epu8(ValidL, _mm_setzero_si128()), _mm_sad_epu8(ValidH, _mm_setzero_si128())));
    }
    //    提取出前面的使用SSE指令计算出的总的有效点数
    Amount = _mm_cvtsi128_si32(_mm_add_epi32(UsedV, _mm_unpackhi_epi64(UsedV, UsedV)));

   注意到这里的函数除了_mm_shuffle_epi8,其他的都是SSE2就已经能支持的了,其中_mm_sad_epu8可以快速的把16字节结果相加。

   使用这个代码,测试上述1千万数据,大概只需要2.1ms就能处理完,比优化后的_mm_popcnt_u32还要快。

   实际上,我还遇到一种情况,一个AMD的早期CPU,用CPUID看他支持的指令集,他是支持SSE4.2的,也支持SSE3,但是执行_mm_shuffle_epi8确提示不识别的指令,也是很奇怪,或者说如果遇到一个机器不支持SSE3,只支持SSE2,那是否还能用指令集优化这个算法呢(SSE2是2001年发布的)。

  其实也是可以的,我们观察上面的不使用指令集的版本的,特别是分冶法的代码:

  Amount = 0;
    for (int Y = 0; Y < Length; Y++)
    {
        unsigned int Value = Array[Y];
        Value = (Value & 0x55555555) + ((Value >> 1) & 0x55555555);
        Value = (Value & 0x33333333) + ((Value >> 2) & 0x33333333);
        Value = (Value & 0x0f0f0f0f) + ((Value >> 4) & 0x0f0f0f0f);
        Value = (Value & 0x00ff00ff) + ((Value >> 8) & 0x00ff00ff);
        Value = (Value & 0x0000ffff) + ((Value >> 16) & 0x0000ffff);
        Amount += Value;
    }

  这里就是简单的一些位运算和移位,他们对应的指令集在SSE2里都能得到支持的,而且这个改为指令集也是水到渠成的事情:

  UsedV = _mm_setzero_si128();
    for (int Y = 0; Y < Length; Y += 4)
    {
        __m128i Value = _mm_loadu_si128((__m128i*)(Array + Y));
        Value = _mm_add_epi32(_mm_and_si128(Value, _mm_set1_epi32(0x55555555)), _mm_and_si128(_mm_srli_epi32(Value, 1), _mm_set1_epi32(0x55555555)));
        Value = _mm_add_epi32(_mm_and_si128(Value, _mm_set1_epi32(0x33333333)), _mm_and_si128(_mm_srli_epi32(Value, 2), _mm_set1_epi32(0x33333333)));
        Value = _mm_add_epi32(_mm_and_si128(Value, _mm_set1_epi32(0x0f0f0f0f)), _mm_and_si128(_mm_srli_epi32(Value, 4), _mm_set1_epi32(0x0f0f0f0f)));
        Value = _mm_add_epi32(_mm_and_si128(Value, _mm_set1_epi32(0x00ff00ff)), _mm_and_si128(_mm_srli_epi32(Value, 8), _mm_set1_epi32(0x00ff00ff)));
        Value = _mm_add_epi32(_mm_and_si128(Value, _mm_set1_epi32(0x0000ffff)), _mm_and_si128(_mm_srli_epi32(Value, 16), _mm_set1_epi32(0x0000ffff)));
        UsedV = _mm_add_epi32(UsedV, Value);
    }
    //    提取出前面的使用SSE指令计算出的总的有效点数
    //Amount = _mm_cvtsi128_si32(_mm_add_epi32(UsedV, _mm_unpackhi_epi64(UsedV, UsedV)));
    Amount = UsedV.m128i_u32[0] + UsedV.m128i_u32[1] + UsedV.m128i_u32[2] + UsedV.m128i_u32[3];

  这里唯一要注意的就是最后从UsedV 变量得到Amount的过程不能用之前shuffle那一套代码了,因为这里的UsedV的最高位里含有了符号位,所以要换成下面哪一种搞法。

  我们注意到,编译运行这个代码后,我们得到的耗时大概是5.2ms,但是同样的数据,前面的分冶法对应的C代码也差不多是5.5ms左右,速度感觉毫无提高,这是怎么回事呢,我们尝试反汇编C的代码,结果发现如下片段:

        

  注意到pand   psrld  paddd等指令没有,那些就对应了_mm_and_si128   _mm_srli_epi32  _mm_add_epi32等等,原来是编译器已经帮我们向量化了,而且即使在设置里设置  无增强指令 (/arch:IA32) 选项,编译器也会进行向量化(VS2019)。

  

  所以我暂时还得不到这个和纯C比的真正的加速比。

  但是,在编译器没有这个向量化能力时,直接手工嵌入SSE2的指令,还是能有明显的加速作用的,不过也可以看到,SSE2的优化速度还是比SSE3的shuffle版本慢一倍的,而sse3的shuffle确可以比SSE4的popcount快30%。

  以前我一直在想,这个算法有什么实际的应用呢,有什么地方我会用到统计二进制中1的个数呢,最近确实遇到过了一次。

  具体的应用是,我有一堆数据,我要统计出数据中符合某个条件(有可能是多个条件)的目标有多少个,这个时候我们多次应用了_mm_cmpxx_ps等函数组合,最后得到一个Mask,这个时候我们使用_mm_movemask_ps来得到一个标记,我们看看_mm_movemask_ps 这个函数的具体意思:

      

   他返回的是一个0到15之间的整形数据,很明显我们可以把他保存到一个unsigned char类型的变量里,这样,在计算完一堆数据后,我们就得到了一个mask数组,这个时候我们统计下数组里有多少个二进制1就可以得到符合条件的目标数量了。 当然,这里和前面的还不太一样,这个usnigned char变量的高4位一直为0,还可以不用处理的,还能进一步加速。 

  当然,如果系统支持AVX2,那还可以进一步做速度优化。这个就不多言了。

  最后,列一下各个算法的耗时比较数据吧:

      

  相关测试代码地址: 数据流二进制中1的个数统计 

       如果想时刻关注本人的最新文章,也可关注公众号或者添加本人微信:  laviewpbt

                             

 

与[快速阅读六] 统计内存数据中二进制1的个数(SSE指令集优化版).相似的内容:

[快速阅读六] 统计内存数据中二进制1的个数(SSE指令集优化版).

介绍了在不同指令集环境下(SSE4\SSE3\SSE2)统计二进制中1的个数的优化,其优化速度较最原始版本的有近80倍提高。

《代码整洁之道》精华速览,助你提升代码质量

最近重读了一遍《代码整洁之道》,这本书既是整洁代码的定义,也是写出整洁代码的指南。我认为既适合新手阅读,快速提升代码质量;也适合老鸟阅读,持续精进。本篇将汇总《代码整洁之道》的必读要点,把书读薄,方便各位快速阅读。

Spring Boot虚拟线程与Webflux在JWT验证和MySQL查询上的性能比较

早上看到一篇关于Spring Boot虚拟线程和Webflux性能对比的文章,觉得还不错。内容较长,我就不翻译了,抓重点给大家介绍一下这篇文章的核心内容,方便大家快速阅读。 测试场景 作者采用了一个尽可能贴近现实操作的场景: 从授权头信息中提取JWT 验证JWT并从中提取用户的Email 使用用户的

opengrok源代码在线阅读平台搭建及字体修改

服务搭建 我所编写的docker-compose.yml如下,成功运行后将源码目录移动至 /data/opengrok/src ,重启容器使得opengrok快速更新索引 services: opengrok: container_name: opengrok # 1.6版本在使用中还算稳定 ima

CodeArts Snap:辅助你编程的神器

摘要:通过将自然语言转化为规范可阅读、无开源漏洞的安全编程语言,提升开发者编程效率,助力企业快速响应市场需求。 本文分享自华为云社区《华为云发布智能编程助手 CodeArts Snap!》,作者:DevAI 。 基于大模型的程序自动生成取得重大突破 数字时代竞争激烈,应用研发效率提升在企业竞争力构建

#PowerBi 1分钟学会,以“万”为单位显示数据

PowerBi是一款强大的数据分析和可视化工具,它可以帮助我们快速地制作出各种图表和报表,展示数据的价值和洞察。 但是,有时候我们的数据量太大,导致图表上的数字难以阅读和比较。例如,如果我们想要查看某个月的销售额,而数据是以元为单位的,那么可能会看到一串很长的数字,如123456789元。 这样的数

[kernel] 带着问题看源码 —— 进程 ID 是如何分配的

Linux 可用 pid 上限是多少?如何提升上限?为何提升上限可以实时生效?Linux 底层如何实现 pid 快速分配与归还?这种实现为何只需要极少的内存开销?本文通过阅读 Linux 内核源码,一一为你揭秘

[转帖]Java命令学习系列(二)——Jstack

2015-04-18 分类:Java 阅读(42792) 评论(3) GitHub 24k Star 的Java工程师成神之路,不来了解一下吗! jstack是java虚拟机自带的一种堆栈跟踪工具。 功能 jstack用于生成java虚拟机当前时刻的线程快照。线程快照是当前java虚拟机内每一条线程

.NET性能系列文章一:.NET7的性能改进

这些方法在.NET7中变得更快 照片来自 CHUTTERSNAP 的 Unsplash 欢迎阅读.NET性能系列的第一章。这一系列的特点是对.NET世界中许多不同的主题进行研究、比较性能。正如标题所说的那样,本章节在于.NET7中的性能改进。你将看到哪种方法是实现特定功能最快的方法,以及大量的技巧和

上周热点回顾(6.10-6.16)

热点随笔: · 「指间灵动,快码加编」:阿里云通义灵码,再次降临博客园 (博客园团队)· 老生常谈!程序员为什么要阅读源代码? (Yxh_blogs)· 千万级流量冲击下,如何保证极致性能 (Hello-Brand)· 面试官:你讲下接口防重放如何处理? (程序员博博)· C#开发的目录图标更改器