一、基礎原理
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"這個字串是兩個位元組,可以轉化成三組00010011、00010110、00010000以後,對應Base64值分別為T、W、E,再補上一個"="號,因此"Ma"的Base64編碼就是TWE=。
b)一個位元組的情況:將這一個位元組的8個二進位制位,按照上面的規則轉成二組,最後一組除了前面加二個0以外,後面再加4個0。這樣得到一個二位的Base64編碼,再在末尾補上兩個"="號。
比如,"M"這個字母是一個位元組,可以轉化為二組00010011、00010000,對應的Base64值分別為T、Q,再補上二個"="號,因此"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#自帶的函式也要快很多。
如果想時刻關注本人的最新文章,也可關注公眾號: