专栏首页一心无二用,本人只专注于基础图像算法的实现与优化。【算法随记七】巧用SIMD指令实现急速的字节流按位反转算法。

【算法随记七】巧用SIMD指令实现急速的字节流按位反转算法。

  字节按位反转算法,在有些算法加密或者一些特殊的场合有着较为重要的应用,其速度也是一个非常关键的应用,比如一个byte变量a = 3,其二进制表示为00000011,进行按位反转后的结果即为11000000,即十进制的192。还有一种常用的应用是int型变量按位反转,其基本的原理和字节反转类似,本文仅以字节反转为例来比较这个算法的实现。

  一种最为传统和直接的算法实现如下:

unsigned char Reverse8U(unsigned char x)
{
    x = (x & 0xaa) >> 1 | (x & 0x55) << 1;
    x = (x & 0xcc) >> 2 | (x & 0x33) << 2;
    x = (x & 0xf0) >> 4 | (x & 0x0f) << 4;
    return x;
}

  我们对大数据进行测试,测试的代码如下:

void Byte_Reverse_01(unsigned char *Src, unsigned char *Dest, int Length)
{
    for (int Y = 0; Y < Length; Y++)
    {
        Dest[Y] = Reverse8U(Src[Y]);
    }
}

  当Length=100000000(一亿)时,上面的代码大概用时470毫秒,我们稍微更改下函数的样式,更改如下:

unsigned char Reverse8U(unsigned int x)
{
    x = (x & 0xaa) >> 1 | (x & 0x55) << 1;
    x = (x & 0xcc) >> 2 | (x & 0x33) << 2;
    x = (x & 0xf0) >> 4 | (x & 0x0f) << 4;
    return x;
}

  还是使用Byte_Reverse_01的代码,神奇的结果显示速度一下子就跳到220ms,快了一倍多。其实这个看下反汇编的代码就可以看到问题所在了,主要是前面的代码使用了寄存器的低位,在32位的环境下不是很有效。

  注意C语言中默认是传值,所以在函数体内改变了x变量的值,并不会产生其他的什么问题,直接返回这个x不会影响Src中的数据。

  第二步改动,我们试着4路并行看看,即如下代码:

void Byte_Reverse_02(unsigned char *Src, unsigned char *Dest, int Length)
{
    int BlockSize = 4, Block = Length / BlockSize;
    for (int Y = 0; Y < Block * BlockSize; Y += BlockSize)
    {
        Dest[Y + 0] = Reverse8U(Src[Y + 0]);
        Dest[Y + 1] = Reverse8U(Src[Y + 1]);
        Dest[Y + 2] = Reverse8U(Src[Y + 2]);
        Dest[Y + 3] = Reverse8U(Src[Y + 3]);
    }
    for (int Y = Block * BlockSize; Y < Length; Y++)
    {
        Dest[Y] = Reverse8U(Src[Y]);
    }
}

  四路并行,一个是可以让编译器编译出能更充分利用指令级并行的指令(即在同一个指令周期内完成2个或多个指令),二是在一定程度上减少了循环变量计数的耗时,虽然这个对大循环不明显,但是在本例这种轻计算量的代码里还是有一定作用的。

  算法的速度变为大概195ms,提速不是很明显。

  下一步改进,我们知道,现代编译器对字节变量的处理其实速度可能还不如处理int类型,因此,我们考虑把这个四个字节的反转用一个int类型的变量也一次性实现,这可以用下面的代码实现:

unsigned int Reverse32I(unsigned int x)
{
    x = (((x & 0xaaaaaaaa) >> 1) | ((x & 0x55555555) << 1));
    x = (((x & 0xcccccccc) >> 2) | ((x & 0x33333333) << 2));
    x = (((x & 0xf0f0f0f0) >> 4) | ((x & 0x0f0f0f0f) << 4));
    return x;
}

  注意这里起名叫Reverse32I其实不是很适合,毕竟他不是反转32位数,但你知道就可以了。

  测试代码如下:

void Byte_Reverse_03(unsigned char *Src, unsigned char *Dest, int Length)
{
    int BlockSize = 4, Block = Length / BlockSize;
    for (int Y = 0; Y < Block * BlockSize; Y += BlockSize)
    {
        *((unsigned int *)(Dest + Y)) = Reverse32I(*((unsigned int *)(Src + Y)));
    }
    for (int Y = Block * BlockSize; Y < Length; Y++)
    {
        Dest[Y] = Reverse8U(Src[Y]);
    }
}

  测试结果显示执行耗时为65ms,靠,速度提高了三四倍。

  接下来,我们考虑另外的实现方法,因为byte只有256个不同的数,因此,我们也可以直接用查表的方式来实现,这个表可以实时计算(耗时可以忽视),也可以静态给出,前人已经给给出了,这里我直接贴出来:

static const unsigned char BitReverseTable256[] =
{
    0x00, 0x80, 0x40, 0xC0, 0x20, 0xA0, 0x60, 0xE0, 0x10, 0x90, 0x50, 0xD0, 0x30, 0xB0, 0x70, 0xF0,
    0x08, 0x88, 0x48, 0xC8, 0x28, 0xA8, 0x68, 0xE8, 0x18, 0x98, 0x58, 0xD8, 0x38, 0xB8, 0x78, 0xF8,
    0x04, 0x84, 0x44, 0xC4, 0x24, 0xA4, 0x64, 0xE4, 0x14, 0x94, 0x54, 0xD4, 0x34, 0xB4, 0x74, 0xF4,
    0x0C, 0x8C, 0x4C, 0xCC, 0x2C, 0xAC, 0x6C, 0xEC, 0x1C, 0x9C, 0x5C, 0xDC, 0x3C, 0xBC, 0x7C, 0xFC,
    0x02, 0x82, 0x42, 0xC2, 0x22, 0xA2, 0x62, 0xE2, 0x12, 0x92, 0x52, 0xD2, 0x32, 0xB2, 0x72, 0xF2,
    0x0A, 0x8A, 0x4A, 0xCA, 0x2A, 0xAA, 0x6A, 0xEA, 0x1A, 0x9A, 0x5A, 0xDA, 0x3A, 0xBA, 0x7A, 0xFA,
    0x06, 0x86, 0x46, 0xC6, 0x26, 0xA6, 0x66, 0xE6, 0x16, 0x96, 0x56, 0xD6, 0x36, 0xB6, 0x76, 0xF6,
    0x0E, 0x8E, 0x4E, 0xCE, 0x2E, 0xAE, 0x6E, 0xEE, 0x1E, 0x9E, 0x5E, 0xDE, 0x3E, 0xBE, 0x7E, 0xFE,
    0x01, 0x81, 0x41, 0xC1, 0x21, 0xA1, 0x61, 0xE1, 0x11, 0x91, 0x51, 0xD1, 0x31, 0xB1, 0x71, 0xF1,
    0x09, 0x89, 0x49, 0xC9, 0x29, 0xA9, 0x69, 0xE9, 0x19, 0x99, 0x59, 0xD9, 0x39, 0xB9, 0x79, 0xF9,
    0x05, 0x85, 0x45, 0xC5, 0x25, 0xA5, 0x65, 0xE5, 0x15, 0x95, 0x55, 0xD5, 0x35, 0xB5, 0x75, 0xF5,
    0x0D, 0x8D, 0x4D, 0xCD, 0x2D, 0xAD, 0x6D, 0xED, 0x1D, 0x9D, 0x5D, 0xDD, 0x3D, 0xBD, 0x7D, 0xFD,
    0x03, 0x83, 0x43, 0xC3, 0x23, 0xA3, 0x63, 0xE3, 0x13, 0x93, 0x53, 0xD3, 0x33, 0xB3, 0x73, 0xF3,
    0x0B, 0x8B, 0x4B, 0xCB, 0x2B, 0xAB, 0x6B, 0xEB, 0x1B, 0x9B, 0x5B, 0xDB, 0x3B, 0xBB, 0x7B, 0xFB,
    0x07, 0x87, 0x47, 0xC7, 0x27, 0xA7, 0x67, 0xE7, 0x17, 0x97, 0x57, 0xD7, 0x37, 0xB7, 0x77, 0xF7,
    0x0F, 0x8F, 0x4F, 0xCF, 0x2F, 0xAF, 0x6F, 0xEF, 0x1F, 0x9F, 0x5F, 0xDF, 0x3F, 0xBF, 0x7F, 0xFF
};

  最直接的查找代码如下:

void Byte_Reverse_04(unsigned char *Src, unsigned char *Dest, int Length)
{
    for (int Y = 0; Y < Length; Y++)
    {
        Dest[Y] = BitReverseTable256[Src[Y]];
    }
}

  测试耗时:70ms,速度也是不错的。

  如果分四路并行测试,代码如下:

void Byte_Reverse_05(unsigned char *Src, unsigned char *Dest, int Length)
{
    int BlockSize = 4, Block = Length / BlockSize;
    for (int Y = 0; Y < Block * BlockSize; Y += BlockSize)
    {
        Dest[Y + 0] = BitReverseTable256[Src[Y + 0]];
        Dest[Y + 1] = BitReverseTable256[Src[Y + 1]];
        Dest[Y + 2] = BitReverseTable256[Src[Y + 2]];
        Dest[Y + 3] = BitReverseTable256[Src[Y + 3]];
    }
    for (int Y = Block * BlockSize; Y < Length; Y++)
    {
        Dest[Y] = BitReverseTable256[Src[Y]];
    }
}

  测试耗时:40ms,速度又有了大幅度的提高了。同时和前面的Byte_Reverse_02相比,明天提速比例完全不在一个档次,这是因此这里代码非常简单,就是一个查找表,他很容易实现指令级的并行。

  还有一种方式,其实也类似于四路并行,如下所示:

void Byte_Reverse_06(unsigned char *Src, unsigned char *Dest, int Length)
{
    int BlockSize = 4, Block = Length / BlockSize;
    for (int Y = 0; Y < Block * BlockSize; Y += BlockSize)
    {
        unsigned int Value = *((unsigned int *)(Src + Y));
        *((unsigned int *)(Dest + Y)) = (BitReverseTable256[Value & 0xff]) |
            (BitReverseTable256[(Value >> 8) & 0xff] << 8) |
            (BitReverseTable256[(Value >> 16) & 0xff] << 16) |
            (BitReverseTable256[(Value >> 24) & 0xff] << 24);
    }
    for (int Y = Block * BlockSize; Y < Length; Y++)
    {
        Dest[Y] = BitReverseTable256[Src[Y]];
    }
}

  本来想利用int比byte处理起来快的特性,但是这样处理有计算量增大了,结果耗时50ms,比四路并行反而慢了一点。

  在 c语言实现bit反转的最佳算法-从msb-lsb到lsb-msb一文的回复一栏中,我无意看到ytfhwfnh的回复如下:

   我觉得查表法不错,但是表太大了,建议改为半字节为单元的查表。这样,只需要16个uchar的表就够了。查表,再翻转高低半字节,再翻转一个int32的4个字节。就能搞定了。

  他这个话的后续的再翻转一个int32的4个字节在本例中正好不要,他提供的示例代码如下:

LOCAL u_long ucharBitsListR2Ulong(u_char* ucBits)
{
    const static u_char BitReverseTable16[] = 
    {
        0x0, 0x8, 0x4, 0xC, 0x2, 0xA, 0x6, 0xE,
        0x1, 0x9, 0x5, 0xD, 0x3, 0xB, 0x7, 0xF
    };
    u_long ret = 0;
    int i = 0;
 
    for (; i < 4; i++)
    {
        /* 获取当前字节,高4位 */
        u_char ucTmp = (ucBits[i] >> 4) & 0x0F;
        /* 查表得反转的半字节,并转为u_long */
        u_long ulTmp = BitReverseTable16[ucTmp];
        /* 存入ret对应位置的低4位 */
        ret [表情]= ulTmp << (i * 8);
        
        /* 获取当前字节,低4位 */
        ucTmp = ucBits[i] & 0x0F;
        /* 查表得反转的半字节,并转为u_long */
        ulTmp = BitReverseTable16[ucTmp];
        /* 存入ret对应位置的高4位 */
        ret [表情]= ulTmp << (i * 8 + 4);
    }
 
    return ret;
}

  这个[表情]是 CSDN的特色,实际上他应该是| 运算符。

  我们把他这个函数直接展开嵌入到循环中,形成了如下的利用16位进行查表的算法:

void Byte_Reverse_08(unsigned char *Src, unsigned char *Dest, int Length)
{
    int BlockSize = 4, Block = Length / BlockSize;
    for (int Y = 0; Y < Block * BlockSize; Y += BlockSize)
    {
        unsigned int Value = *((unsigned int *)(Src + Y));
        *((unsigned int *)(Dest + Y)) =
            (BitReverseTable16[(Src[Y + 0] >> 4) & 0x0F]) | (BitReverseTable16[Src[Y + 0] & 0x0F] << 4) |
            (BitReverseTable16[(Src[Y + 1] >> 4) & 0x0F] << 8) | (BitReverseTable16[Src[Y + 1] & 0x0F] << 12) |
            (BitReverseTable16[(Src[Y + 2] >> 4) & 0x0F] << 16) | (BitReverseTable16[Src[Y + 2] & 0x0F] << 20) |
            (BitReverseTable16[(Src[Y + 3] >> 4) & 0x0F] << 24) | (BitReverseTable16[Src[Y + 3] & 0x0F] << 28);
    }
    for (int Y = Block * BlockSize; Y < Length; Y++)
    {
        Dest[Y] = Reverse8U(Src[Y]);
    }
}

  很可惜,我没有得到我想要的效果,这段代码结果耗时110ms,比256个元素的查找表慢。

  那同样,我们用四路并行实现他们试下,即如下代码:

void Byte_Reverse_08(unsigned char *Src, unsigned char *Dest, int Length)
{
    int BlockSize = 4, Block = Length / BlockSize;
    for (int Y = 0; Y < Block * BlockSize; Y += BlockSize)
    {
        Dest[Y + 0] = (BitReverseTable16[(Src[Y + 0] >> 4) & 0x0F]) | (BitReverseTable16[Src[Y + 0] & 0x0F] << 4);
        Dest[Y + 1] = (BitReverseTable16[(Src[Y + 1] >> 4) & 0x0F]) | (BitReverseTable16[Src[Y + 1] & 0x0F] << 4);
        Dest[Y + 2] = (BitReverseTable16[(Src[Y + 2] >> 4) & 0x0F]) | (BitReverseTable16[Src[Y + 2] & 0x0F] << 4);
        Dest[Y + 3] = (BitReverseTable16[(Src[Y + 3] >> 4) & 0x0F]) | (BitReverseTable16[Src[Y + 3] & 0x0F] << 4);
    }
    for (int Y = Block * BlockSize; Y < Length; Y++)
    {
        Dest[Y] = Reverse8U(Src[Y]);
    }
}

  同样道理,这样又要快一点了,能做到75ms,但比256个查找表的多路并行还是要慢的。

  这是可以理解的,一般来说,查找表越少,同样的查找次数耗时则越小,这主要得益于小的查找表有着较小的cache miss,但是我们注意到,上述16个元素的查找表的查找次数多了一倍,而且也多了很多移位和或运算,因此,总的耗时并没有减少。

  但是,到这里,就出现了一个令我非常感兴趣的话题了,我一直在思考如何利用SIMD指令实现快速的查表问题,后来得到的结论是,这个基本上不可行,对应SSE,除非几个特殊的表,一个情况就是,这个查找表只有16个元素,而且表的类型是byte类型,这个时候,我们就可以利用_mm_shuffle_epi8指令进行快速的shuffle,此时的效果就比直接查表要快了很多了。

  那么仔细的观察上面的代码,除了查表之外,其他的计算太容易用SSE相应的指令实现了,或计算,并计算,注意移位计算SSE指令的_mm_srli_si128 、_mm_slli_si128并不是按位移位的,他是按照字节进行的移位,这个时候我们可借用_mm_srli_epi16或者_mm_srli_epi32来实现相同的功能。

  此时,可编制如下的SSE代码实现相同的功能:

void Byte_Reverse_09(unsigned char *Src, unsigned char *Dest, int Length)
{
    int BlockSize = 16, Block = Length / BlockSize;
    __m128i Table = _mm_loadu_si128((__m128i *)BitReverseTable16);
    __m128i Mask = _mm_set1_epi8(0x0F);
    for (int Y = 0; Y < Block * BlockSize; Y += BlockSize)
    {
        __m128i SrcV = _mm_loadu_si128((__m128i *)(Src + Y));
        __m128i High = _mm_and_si128(_mm_srli_epi16(SrcV, 4), Mask);        //    高四位
        __m128i Low = _mm_and_si128(SrcV, Mask);                            //    低四位
        High = _mm_shuffle_epi8(Table, High);                                //    查找表
        Low = _mm_shuffle_epi8(Table, Low);
        _mm_storeu_si128((__m128i *)(Dest + Y), _mm_or_si128(High, _mm_slli_epi16(Low, 4)));    //    合并保存
    }
    for (int Y = Block * BlockSize; Y < Length; Y++)
    {
        Dest[Y] = Reverse8U(Src[Y]);
    }
}

  此时函数的执行速度提高到了25ms左右,并且我们看到,这里其实是实质上就没有任何的查表工作了,也不存在所谓的cache miss的。

  在此基础上,我们可以将这个函数扩展到使用AVX优化,AVX支持一次性处理32个字节的数据,比SSE还要扩展一倍,并且现在大部分CPU已经支持AVX2了,尝试一下:

void Byte_Reverse_10(unsigned char *Src, unsigned char *Dest, int Length)
{
    int BlockSize = 32, Block = Length / BlockSize;
    __m256i Table = _mm256_loadu_si256((__m256i *)BitReverseTable32);
    __m256i Mask = _mm256_set1_epi8(0x0F);
    for (int Y = 0; Y < Block * BlockSize; Y += BlockSize)
    {
        __m256i SrcV = _mm256_loadu_si256((__m256i *)(Src + Y));
        __m256i High = _mm256_and_si256(_mm256_srli_epi16(SrcV, 4), Mask);        //    高四位
        __m256i Low = _mm256_and_si256(SrcV, Mask);                            //    低四位
        High = _mm256_shuffle_epi8(Table, High);                                //    查找表
        Low = _mm256_shuffle_epi8(Table, Low);
        _mm256_storeu_si256((__m256i *)(Dest + Y), _mm256_or_si256(High, _mm256_slli_epi16(Low, 4)));    //    合并保存
    }
    for (int Y = Block * BlockSize; Y < Length; Y++)
    {
        Dest[Y] = Reverse8U(Src[Y]);
    }
}

  速度也基本在25ms左右波动,区别和SSE不是很多大。

  最后,我们在返回到最开始的unsigned char Reverse8U(unsigned char x)函数,我们发现,这个函数内部的算法天然的就支持SSE并行化处理,我们可以稍微修改下语法就可以得到对应的SSE版本函数,如下所示:

void Byte_Reverse_11(unsigned char *Src, unsigned char *Dest, int Length)
{
    int BlockSize = 16, Block = Length / BlockSize;
    for (int Y = 0; Y < Block * BlockSize; Y += BlockSize)
    {
        __m128i V = _mm_loadu_si128((__m128i *)(Src + Y));
        V = _mm_or_si128(_mm_srli_epi16(_mm_and_si128(V, _mm_set1_epi8(0xaa)), 1), _mm_slli_epi16(_mm_and_si128(V, _mm_set1_epi8(0x55)), 1));
        V = _mm_or_si128(_mm_srli_epi16(_mm_and_si128(V, _mm_set1_epi8(0xcc)), 2), _mm_slli_epi16(_mm_and_si128(V, _mm_set1_epi8(0x33)), 2));
        V = _mm_or_si128(_mm_srli_epi16(_mm_and_si128(V, _mm_set1_epi8(0xf0)), 4), _mm_slli_epi16(_mm_and_si128(V, _mm_set1_epi8(0x0f)), 4));
        _mm_storeu_si128((__m128i *)(Dest + Y), V);
    }
    for (int Y = Block * BlockSize; Y < Length; Y++)
    {
        Dest[Y] = Reverse8U(Src[Y]);
    }
}

  这个版本也是相当的快的,大约用时28ms左右,而且不占用任何其他内存。

  当然,以上的时间比较只基于本人的一台电脑,在不同的CPU系列当中,各算法之间的耗时比例是不太相同的。有些甚至出现了相反的现象,总的来说,用多路并行256个元素的查找表方式是最为稳妥和夸平台的,如果在PC段,则可以考虑时候用SIMD优化的版本。

  各版本的总和速度比较如下:

  附本文完整测试代码供有兴趣的朋友研究:https://files.cnblogs.com/files/Imageshop/Byte_Reverse.rar

  既然是针对字节的数据处理,自然而然我们想到可以直接把他应用在图像上,比如对lena图像,应用这个算法得到如下效果:

  后面一幅图你还能看出他是lena吗,但是确实可以对后面的图再次利用本算法,恢复出完整的lena图,这也可以算是最简答的图像加密算法之一吧。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • SSE图像算法优化系列五:超高速指数模糊算法的实现和优化(10000*10000在100ms左右实现)。

          今天我们来花点时间再次谈谈一个模糊算法,一个超级简单但是又超级牛逼的算法,无论在效果上还是速度上都可以和Boxblur, stackblur或者是G...

    用户1138785
  • SSE图像算法优化

      图像金字塔技术在很多层面上都有着广泛的应用,很多开源的工具也都有对他们的建立写了专门的函数,比如IPP,比如OpenCV等等,这方面的理论文章特别多,我不需...

    用户1138785
  • 基于局部均方差相关信息的图像去噪及其在实时磨皮美容算法中的应用。      在1979年Lee发表的论文《Lee Filter Digital Image Enhancement and Noise

         在1979年Lee发表的论文《Lee Filter Digital Image Enhancement and Noise Filtering by ...

    用户1138785
  • 力扣213——打家劫舍 II

    你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有...

    健程之道
  • iOS中UIImageView用法总结 原

    - (instancetype)initWithImage:(UIImage *)image;

    珲少
  • 【C语言笔记】关于数组的一个陷阱!

    所以,&a[2] - &a[0]的结果是8?但是,事实不是这样的!!让我们把其结果打印出来:

    正念君
  • 51Nod-2000-四边形分割平面

    ACM模版 描述 ? 题解 一开始脑子卡死了,想着两个四边形最多只能分成 44 个,后来大佬提示我同心旋转 4545 度,然后秒懂……╮(╯▽╰)╭哎,脑子不够...

    f_zyj
  • leetcode: 108. Convert Sorted Array to Binary Search Tree

    JNingWei
  • C++构造无向图&求最短路径源码

    用vector<edge> es[MAX]表示点,每个点队列里放着点的相邻边和到边的距离。 以下源码经过测试可运行 #include <iostream> #i...

    kalifa_lau
  • iOS开发UI之日期控件的使用(UIDatePicker)

    @property (nonatomic) UIDatePickerMode datePickerMode; 

    珲少

扫码关注云+社区

领取腾讯云代金券