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

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

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

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

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

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

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

unsigned char Reverse8U(unsigned int x)
{
x = (x & 0xaa) >> | (x & 0x55) << ;
x = (x & 0xcc) >> | (x & 0x33) << ;
x = (x & 0xf0) >> | (x & 0x0f) << ;
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 = , Block = Length / BlockSize;
for (int Y = ; Y < Block * BlockSize; Y += BlockSize)
{
Dest[Y + ] = Reverse8U(Src[Y + ]);
Dest[Y + ] = Reverse8U(Src[Y + ]);
Dest[Y + ] = Reverse8U(Src[Y + ]);
Dest[Y + ] = Reverse8U(Src[Y + ]);
}
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) >> ) | ((x & 0x55555555) << ));
x = (((x & 0xcccccccc) >> ) | ((x & 0x33333333) << ));
x = (((x & 0xf0f0f0f0) >> ) | ((x & 0x0f0f0f0f) << ));
return x;
}

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

  测试代码如下:

void Byte_Reverse_03(unsigned char *Src, unsigned char *Dest, int Length)
{
int BlockSize = , Block = Length / BlockSize;
for (int Y = ; 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 = ; Y < Length; Y++)
{
Dest[Y] = BitReverseTable256[Src[Y]];
}
}

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

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

void Byte_Reverse_05(unsigned char *Src, unsigned char *Dest, int Length)
{
int BlockSize = , Block = Length / BlockSize;
for (int Y = ; Y < Block * BlockSize; Y += BlockSize)
{
Dest[Y + ] = BitReverseTable256[Src[Y + ]];
Dest[Y + ] = BitReverseTable256[Src[Y + ]];
Dest[Y + ] = BitReverseTable256[Src[Y + ]];
Dest[Y + ] = BitReverseTable256[Src[Y + ]];
}
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 = , Block = Length / BlockSize;
for (int Y = ; Y < Block * BlockSize; Y += BlockSize)
{
unsigned int Value = *((unsigned int *)(Src + Y));
*((unsigned int *)(Dest + Y)) = (BitReverseTable256[Value & 0xff]) |
(BitReverseTable256[(Value >> ) & 0xff] << ) |
(BitReverseTable256[(Value >> ) & 0xff] << ) |
(BitReverseTable256[(Value >> ) & 0xff] << );
}
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 = ;
int i = ; for (; i < ; i++)
{
/* 获取当前字节,高4位 */
u_char ucTmp = (ucBits[i] >> ) & 0x0F;
/* 查表得反转的半字节,并转为u_long */
u_long ulTmp = BitReverseTable16[ucTmp];
/* 存入ret对应位置的低4位 */
ret [表情]= ulTmp << (i * ); /* 获取当前字节,低4位 */
ucTmp = ucBits[i] & 0x0F;
/* 查表得反转的半字节,并转为u_long */
ulTmp = BitReverseTable16[ucTmp];
/* 存入ret对应位置的高4位 */
ret [表情]= ulTmp << (i * + );
} return ret;
}

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

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

void Byte_Reverse_08(unsigned char *Src, unsigned char *Dest, int Length)
{
int BlockSize = , Block = Length / BlockSize;
for (int Y = ; Y < Block * BlockSize; Y += BlockSize)
{
unsigned int Value = *((unsigned int *)(Src + Y));
*((unsigned int *)(Dest + Y)) =
(BitReverseTable16[(Src[Y + ] >> ) & 0x0F]) | (BitReverseTable16[Src[Y + ] & 0x0F] << ) |
(BitReverseTable16[(Src[Y + ] >> ) & 0x0F] << ) | (BitReverseTable16[Src[Y + ] & 0x0F] << ) |
(BitReverseTable16[(Src[Y + ] >> ) & 0x0F] << ) | (BitReverseTable16[Src[Y + ] & 0x0F] << ) |
(BitReverseTable16[(Src[Y + ] >> ) & 0x0F] << ) | (BitReverseTable16[Src[Y + ] & 0x0F] << );
}
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 = , Block = Length / BlockSize;
for (int Y = ; Y < Block * BlockSize; Y += BlockSize)
{
Dest[Y + ] = (BitReverseTable16[(Src[Y + ] >> ) & 0x0F]) | (BitReverseTable16[Src[Y + ] & 0x0F] << );
Dest[Y + ] = (BitReverseTable16[(Src[Y + ] >> ) & 0x0F]) | (BitReverseTable16[Src[Y + ] & 0x0F] << );
Dest[Y + ] = (BitReverseTable16[(Src[Y + ] >> ) & 0x0F]) | (BitReverseTable16[Src[Y + ] & 0x0F] << );
Dest[Y + ] = (BitReverseTable16[(Src[Y + ] >> ) & 0x0F]) | (BitReverseTable16[Src[Y + ] & 0x0F] << );
}
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 = , Block = Length / BlockSize;
__m128i Table = _mm_loadu_si128((__m128i *)BitReverseTable16);
__m128i Mask = _mm_set1_epi8(0x0F);
for (int Y = ; Y < Block * BlockSize; Y += BlockSize)
{
__m128i SrcV = _mm_loadu_si128((__m128i *)(Src + Y));
__m128i High = _mm_and_si128(_mm_srli_epi16(SrcV, ), 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, ))); // 合并保存
}
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 = , Block = Length / BlockSize;
__m256i Table = _mm256_loadu_si256((__m256i *)BitReverseTable32);
__m256i Mask = _mm256_set1_epi8(0x0F);
for (int Y = ; Y < Block * BlockSize; Y += BlockSize)
{
__m256i SrcV = _mm256_loadu_si256((__m256i *)(Src + Y));
__m256i High = _mm256_and_si256(_mm256_srli_epi16(SrcV, ), 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, ))); // 合并保存
}
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 = , Block = Length / BlockSize;
for (int Y = ; 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)), ), _mm_slli_epi16(_mm_and_si128(V, _mm_set1_epi8(0x55)), ));
V = _mm_or_si128(_mm_srli_epi16(_mm_and_si128(V, _mm_set1_epi8(0xcc)), ), _mm_slli_epi16(_mm_and_si128(V, _mm_set1_epi8(0x33)), ));
V = _mm_or_si128(_mm_srli_epi16(_mm_and_si128(V, _mm_set1_epi8(0xf0)), ), _mm_slli_epi16(_mm_and_si128(V, _mm_set1_epi8(0x0f)), ));
_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优化的版本。

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

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

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

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

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

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

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

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

上一篇:Javascript引擎单线程机制及setTimeout执行原理说明


下一篇:FineUI第十一天---布局概述