SSE圖像算法優化系列三十一:Base64編碼和解碼算法的指令集優化(C#自帶函數的3到4倍速度)。

    一、基礎原理

         Base64是一種用64個Ascii字符來表示任意二進制數據的方法。主要用於將不可打印的字符轉換成可打印字符,或者簡單的說是將二進制數據編碼成Ascii字符。Base64也是網絡上最常用的傳輸8bit字節數據的編碼方式之一。

        標準的Base64編碼方式過程可簡單描述如下:

        第一步,將每三個字節作爲一組,一共是24個二進制位。

        第二步,將這24個二進制位分爲四組,每個組有6個二進制位。

        第三步,在每組前面加兩個00,擴展成32個二進制位,即四個字節。

        第四步,根據下表,得到擴展後的每個字節的對應符號,這就是Base64的編碼值。

       

        複製一段別人的文件對這個算法進行了後續的描述了,我們以英語單詞Man如何轉成Base64編碼。

Text content M a n
ASCII 77 97 110
Bit pattern 0 1 0 0 1 1 0 1 0 1 1 0 0 0 0 1 0 1 1 0 1 1 1 0
Index 19 22 5 46
Base64-Encoded T W F u

       第一步,"M"、"a"、"n"的ASCII值分別是77、97、110,對應的二進制值是01001101、01100001、01101110,將它們連成一個24位的二進制字符串010011010110000101101110。

       第二步,將這個24位的二進制字符串分成4組,每組6個二進制位:010011、010110、000101、101110。

       第三步,在每組前面加兩個00,擴展成32個二進制位,即四個字節:00010011、00010110、00000101、00101110。它們的十進制值分別是19、22、5、46。

       第四步,根據上表,得到每個值對應Base64編碼,即T、W、F、u。

       如果字節數不足三,則這樣處理:

       a)二個字節的情況:將這二個字節的一共16個二進制位,按照上面的規則,轉成三組,最後一組除了前面加兩個0以外,後面也要加兩個0。這樣得到一個三位的Base64編碼,再在末尾補上一個"="號。

     比如,"Ma"這個字符串是兩個字節,可以轉化成三組000100110001011000010000以後,對應Base64值分別爲TWE,再補上一個"="號,因此"Ma"Base64編碼就是TWE=

     b)一個字節的情況:將這一個字節的8個二進制位,按照上面的規則轉成二組,最後一組除了前面加二個0以外,後面再加40。這樣得到一個二位的Base64編碼,再在末尾補上兩個"="號。

     比如,"M"這個字母是一個字節,可以轉化爲二組0001001100010000,對應的Base64值分別爲TQ,再補上二個"="號,因此"M"Base64編碼就是TQ==

   基本就是這個簡單的過程。

   由以上過程可以看到,Base64編碼不是一個壓縮過程(反而是個膨脹的過程,處理後體積是增加了1/3的),也不是一個加密過程(沒任何密鑰)。

二、C語言實現

  由上述描述可見這是一個比較簡單的過程,通過移位和一些查找表可以快速的寫出一個簡單的版本。

int IM_ToBase64CharArray_C(unsigned char *Input, int Length, unsigned char* Output)
{
    static const char* LookUpTable = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    int CurrentIndex = 0, ValidLen = (Length / 3) * 3;
    for (int Y = 0; Y < ValidLen; Y += 3, CurrentIndex += 4)
    {
        int Temp = ((Input[Y]) << 24) + (Input[Y + 1] << 16) + (Input[Y + 2] << 8);    //    注意C++是Little-Endian佈局的
        int V0 = (Temp >> 26) & 0x3f;
        int V1 = (Temp >> 20) & 0x3f;
        int V2 = (Temp >> 14) & 0x3f;
        int V3 = (Temp >> 8) & 0x3f;
        Output[CurrentIndex + 0] = LookUpTable[V0];
        Output[CurrentIndex + 1] = LookUpTable[V1];
        Output[CurrentIndex + 2] = LookUpTable[V2];
        Output[CurrentIndex + 3] = LookUpTable[V3];
    }
    //    如果字節數不足三
    int Remainder = Length - ValidLen;
    if (Remainder == 2)
    {        
    }
    else if (Remainder == 1)
    { 
    }
    return IM_STATUS_OK;
}

 

  一個簡單的版本如上所示,注意由於C++的數據在內存中Little-Endian佈局的,因此,低字節在高位,可以通過向上面的移位方式組合成一個int型的Temp變量。然後在提取出各自的6位數據,最後通過查找表來獲得最後的結果。

      當輸入的長度不是3字節的整數倍數時,需要獨立的寫相關代碼,如上面的Remainder == 2和Remainder == 1所示,這部分可以自行添加代碼。

      上面的代碼,我們用 10000 * 10000  * 3 = 3億長度的數據量進行測試, 純算法部分的耗時約爲 440ms。我們用C#的Convert.ToBase64CharArray方法做同樣的事情,發現C#居然需要640ms。這個有點詫異。

       在PC上,我們可以對上述代碼進行適當的改動,使得效率更加優秀。

       在PC上,有_byteswap_ulong一個指令,這個指令可以直接對int數據進行大小端的轉換,而且我們反編譯後看到這個內建函數其實就對應了一條彙編指令bswap,改指令的解釋如下:

              BSWAP是彙編指令指令作用是:32位寄存器內的字節次序變反。比如:(EAX)=9668 8368H,執行指令:BSWAP EAX ,則(EAX)=6883 6896H。

     新的代碼如下所示:

int IM_ToBase64CharArray_C(unsigned char *Input, int Length, unsigned char* Output)
{
    static const char* LookUpTable = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    int CurrentIndex = 0, ValidLen = (Length / 3) * 3;
    for (int Y = 0; Y < ValidLen; Y += 3, CurrentIndex += 4)
    {
        int Temp = _byteswap_ulong(*(int *)(Input + Y));        //    這個的效率還是高很多的,注意C++是Little-Endian佈局的
        int V0 = (Temp >> 26) & 0x3f;
        int V1 = (Temp >> 20) & 0x3f;
        int V2 = (Temp >> 14) & 0x3f;
        int V3 = (Temp >> 8) & 0x3f;
        Output[CurrentIndex + 0] = LookUpTable[V0];
        Output[CurrentIndex + 1] = LookUpTable[V1];
        Output[CurrentIndex + 2] = LookUpTable[V2];
        Output[CurrentIndex + 3] = LookUpTable[V3];
    }
    //    如果字節數不足三
    int Remainder = Length - ValidLen;
    if (Remainder == 2)
    {
    }
    else if (Remainder == 1)
    {
    }
    return IM_STATUS_OK;
}

      反編譯部分代碼如下所示:  

             

       可以看到明顯bswap指令。

       同樣的3億數據量,上述代碼編譯後執行的耗時約爲350ms

       但是上述代碼是有個小小的問題的,我們知道 

              int Temp = _byteswap_ulong(*(int *)(Input + Y)); 

  這句代碼實際上是從內存 Input + Y 處加載4個字節,如果在數據的末尾,恰好還剩3個字節時,此時的加載指令實際就會訪問野內存,造成內存錯誤。所以實際編碼時這個位置還是要做適當的修改的。

 三、SSE優化實現

      上述C的代碼也是非常簡單的,但是由於有一個查表的過程,要把他翻譯成SIMD指令,還是要做一番特備的處理的。 這裏我們找到一個非常優異的國外朋友的博客,基本上把這個算法分析的特別透徹。詳見:http://0x80.pl/notesen/2016-01-12-sse-base64-encoding.html

       該文的作者對Base64的解碼和編碼做了特備全面的解讀,包括普通的scalar優化、SSE、AVX256、AVX512、Neon等代碼都有實現,我這裏只分析下SSE的實現,基本也就是翻譯的過程。

       1、數據加載

       我們知道,在Base64的過程中,原始數據的3個字節處理完成後變爲4個字節,因此,爲了適應SSE的需求,我們應該只加載連續的12個字節數據,然後把他們擴展到16個字節。

       加載12字節數據,有多重方法,一個是直接用_mm_loadu_si128指令,然後把最後四個捨棄掉,這樣的話同樣要注意類似_byteswap_ulong的問題,不要訪問越界的內存。另外還可以自定一個這樣的函數:

//    從指針p處加載12個字節數據到XMM寄存器中,寄存器最高32位清0
inline __m128i _mm_loadu_epi96(const __m128i * p)
{
    return _mm_unpacklo_epi64(_mm_loadl_epi64(p), _mm_cvtsi32_si128(((int *)p)[2]));
}

  還有一個方式就是使用 _mm_maskload_epi32指令,把最後一個高位的mask設置爲0。

    當加載完數據到SSE寄存器後,我們可以按照上述C的代碼進行算法的移位和位運算,得到一個重新組合的數據,但是也可以根據觀察採用下面的一種方式

    //  Base64以3個字節爲一組,對於任意一個三元組合,其在內存二進制位佈局如下
    //      [????????|ccdddddd|bbbbcccc|aaaaaabb]
    //        byte 3   byte 2   byte 1   byte 0    -- byte 3  是冗餘的
    __m128i In = _mm_loadu_epi96((__m128i*)(Input + Y));
    //      [bbbbcccc|ccdddddd|aaaaaabb|bbbbcccc]
    //           ^^^^ ^^^^^^^^ ^^^^^^^^ ^^^^                ^表示有效位,這樣的好處是中心對稱了
    In = _mm_shuffle_epi8(In, _mm_set_epi8(10, 11, 9, 10, 7, 8, 6, 7, 4, 5, 3, 4, 1, 2, 0 ,1));

  通過shuffle混洗後,我們的需要的4個6個位的數據在分佈上都相鄰了,這個時候移位操作就方便了很多。那麼最直接的實現方式如下所示:

    // Index_a = packed_dword([00000000|00000000|00000000|00aaaaaa] x 4)
    __m128i Index_a = _mm_and_si128(_mm_srli_epi32(In, 10), _mm_set1_epi32(0x0000003f));

    // Index_a = packed_dword([00000000|00000000|00BBbbbb|00000000] x 4)
    __m128i Index_b = _mm_and_si128(_mm_slli_epi32(In, 4), _mm_set1_epi32(0x00003f00));

    // Index_a = packed_dword([00000000|00ccccCC|00000000|00000000] x 4)
    __m128i Index_c = _mm_and_si128(_mm_srli_epi32(In, 6), _mm_set1_epi32(0x003f0000));

    // Index_a = packed_dword([00dddddd|00000000|00000000|00000000] x 4)
    __m128i Index_d = _mm_and_si128(_mm_slli_epi32(In, 8), _mm_set1_epi32(0x3f000000));

     //     [00dddddd|00cccccc|00bbbbbb|00aaaaaa]
    //          byte 3   byte 2   byte 1   byte 0  
    __m128i Indices = _mm_or_si128(_mm_or_si128(_mm_or_si128(Index_a, Index_b), Index_c), Index_d);

  一共有4次移位,4次and運算,以及3次or運算。

       直接的這樣實現其實效率也相當的高,因爲都是一些位運算,但是還有一種更爲精妙的實現方式,雖然效率上實際沒有提高多少,但是實現方式看起來確實讓人覺得不錯,一般人還真是想不到。核心代碼如下所示:

// T0   = [0000cccc|cc000000|aaaaaa00|00000000]
    __m128i T0 = _mm_and_si128(In, _mm_set1_epi32(0x0fc0fc00));
    // T0    = [00000000|00cccccc|00000000|00aaaaaa]   (c * (1 << 10), a * (1 << 6)) >> 16 (注意是無符號的乘法, 借用16位的乘法實現不同位置的移位,這個技巧很好)
    T0 = _mm_mulhi_epu16(T0, _mm_set1_epi32(0x04000040));
        
    // T1    = [00000000|00dddddd|000000bb|bbbb0000]
    __m128i T1 = _mm_and_si128(In, _mm_set1_epi32(0x003f03f0));
    // T1    = [00dddddd|00000000|00bbbbbb|00000000]     (d * (1 << 8), b * (1 << 4))
    T1 = _mm_mullo_epi16(T1, _mm_set1_epi32(0x01000010));

    // res   = [00dddddd|00cccccc|00bbbbbb|00aaaaaa] 
    __m128i Indices = _mm_or_si128(T0, T1);

       這裏的核心技巧是借用16的乘法來實現一個32位內兩個16位部分的不同移位,而且在一個指令內。感覺無法解釋,還是自己看指令吧。

       二、數據查表

       其實查表,如果是16字節的查表,而且是表的範圍也是0到15,那麼是可以直接使用_mm_shuffle_epi8指令的,這個其實我在前面有個文章的優化裏是用到的,但是Base64是64字節的查表,這個如果查表的數據沒啥特殊性,那SSE指令還真的沒有用於之地的。

       但是,Base64的表就是有特殊性,我們看到表的輸入是連續的0到63的值,表的輸出可以分成四類:

       第一類: ABCDEFGHIJKLMNOPQRSTUVWXYZ        ASCII值連續

       第二類: abcdefghijklmnopqrstuvwxyz                        ASCII值連續

       第三類: 0123456789                ASCII值連續,且只有10個數據

       第四類: +      

       第五類: /  

       那麼對於某個輸入索引 X,我們首先有一些比較指令把輸入數據區分爲某一類,然後每一類可以有對應的結果偏移量,這裏只有5個類,完全在SSE的16個字節的範圍內。同時我們注意觀察,如果把第三類認爲他是10個類,同時這1個類都對應一個相同的偏移量,那麼總共的內別數也還只有14類,沒有超過16的,這樣是更有利於編程的。

      那麼怎麼說呢,我感覺這個過程無論用什麼語言表達,可能都還沒有代碼本身意義大。一個可選的優化方式如下所示:

    //           0..51 -> 0
    //        52..61 -> 1 .. 10
    //            62 -> 11
    //            63 -> 12
    __m128i Shift = _mm_subs_epu8(Indices, _mm_set1_epi8(51));

    // 接着在區分 0..25 和 26..51兩組數據:
    //         0 .. 25 -> 仍然保持0 
    //        26 .. 51 -> 變爲 13
    const __m128i Less = _mm_cmpgt_epi8(_mm_set1_epi8(26), Indices);
    //          0..26 -> 0
    //         26..51 -> 13
    //       52..61 -> 1 .. 10
    //           62 -> 11
    //           63 -> 12
    Shift = _mm_or_si128(Shift, _mm_and_si128(Less, _mm_set1_epi8(13)));

    const __m128i shift_LUT = _mm_setr_epi8('a' - 26, '0' - 52, '0' - 52, '0' - 52, '0' - 52, '0' - 52,'0' - 52, '0' - 52, '0' - 52, '0' - 52, '0' - 52, '+' - 62,'/' - 63, 'A', 0, 0);

    //    按照Shift的數據這讀取偏移量
    Shift = _mm_shuffle_epi8(shift_LUT, Shift);

  很簡單的代碼,但是也是很優美的文字。卻能迸發出驚人的效率。我們同樣的測試發現,對於相同的3億數據量,SSE優化編碼後的速度大概是210ms,比優化後的C++代碼塊約70%,比原生的C#函數快了近4倍。

       在同樣的作者的較新的一篇文章《Base64 encoding and decoding at almost the speed of a memory copy》中,使用最新的AVX512指令集,獲得了速度比肩memcpy的Base64編解碼實現,這是因爲使用AVX512,可以只用2條指令實現相關的過程,而AVX512一次性可以讀取64個字節的特性,讓這個BASE64的64字節查找表可以直接實現也是這個極速的關鍵所在。

       上面這個表沒有SSE的數據,SSE速度大概是AVX2的0.8倍左右。

四、關於解碼

      Base64的解碼是編碼的相反過程,就是先進行查找表,然後在進行移位合併。但是不同的地方是,解碼的時候一般是需要進行一些合理性判斷的,如果輸入的數據不在前述的64位範圍內,說明這個是數據是無效的。作爲SSE實現來說,其核心還是在於查表和移位合併,當然這裏查表的方式也有很多優化技巧,這裏可以參考http://0x80.pl/notesen/2016-01-17-sse-base64-decoding.html 一文,那個作者寫的是真的很好。直接閱讀英文版的,可能會受益更多,這裏不進行過多的講解。

      但是那個代碼真的值得學習,尤其是其中的數據組合部分。

      關於解碼的速度,如果不考慮錯誤判斷和處理,其實基本上和解碼是一個檔次的。測試表面,解碼同樣的比C#自帶的函數也要快很多。

      如果想時刻關注本人的最新文章,也可關注公衆號:

                             

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章