【演算法隨記七】巧用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圖,這也可以算是最簡答的影像加密演算法之一吧。