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

Imageshop發表於2021-09-02

    一、基礎原理

         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#自帶的函式也要快很多。

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

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

相關文章