【算法随记七】巧用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图,这也可以算是最简答的图像加密算法之一吧。

        写博不易,欢迎土豪打赏赞助。

 

 

 

posted @ 2019-12-29 21:54  Imageshop  阅读(2908)  评论(1编辑  收藏  举报