RapidJSON 代碼剖析(二):使用 SSE4.2 優化字符串掃描

如今的 CPU 都提供了單指令流多數據流(single instruction multiple data, SIMD)指令集。最多見的是用於大量的浮點數計算,但其實也能夠用在文字處理方面。git

其中,SSE4.2 包含了一些專爲字符串而設的指令。咱們經過使用這些指令,能夠大幅提高某些 JSON 解析的性能。github

(配圖爲 2008 年發售的 Intel Core i7 芯片,它採用的 Nehalem 是第一個支持 SSE4.2 的微架構。)json

跳過空白字符

咱們知道,有一些 JSON 含有縮進(indentation),這些 JSON 有大量的空白字符(whitespace)。在解析 JSON 的時候,須要跳過這些空白字符。這個操做在 RapidJSON 下是這樣的(reader.h,爲配合版面稍改排版):api

template<typename InputStream>
void SkipWhitespace(InputStream& is) {
    internal::StreamLocalCopy<InputStream> copy(is);
    InputStream& s(copy.s);

    while (s.Peek() == ' '  ||
           s.Peek() == '\n' ||
           s.Peek() == '\r' ||
           s.Peek() == '\t')
    {
        s.Take();
    }
}

咱們先不關注 StreamLocalCopy 等東西。這段代碼很簡單,就是凡在輸入流中遇到4種空白字符,都提取出來跳過,直至流裏的字符爲非空白字符。架構

但這種代碼會帶來不少分支(branching),並且咱們每次只能處理一個字符。less

SSE4.2

在 Intel 的 SSE4.2 指令集中,有一個 pcmpistrm 指令,它能夠一次對一組16個字符與另外一組字符做比較,也就是說一個指令能夠做最多16×16=256次比較。ide

對於上面跳過空白字符的需求,咱們只須要對16個輸入流裏的字符與4個空白字符比較,即16×4=64次比較。雖然這樣未用盡全部計算能力,但一個指令能代替64個比較以及「或」運算,仍是很划算的。svn

咱們可使用 VC/gcc/clang 都支持的 instrinsic 函數去使用這個指令。這個指令的函數命名爲 _mm_cmpistrm(),在nmmintrin.h中定義。函數

SkipWhitespace 的 SSE4.2 版本只能跳過字符串的輸入流,其部分代碼以下:性能

inline const char *SkipWhitespace_SIMD(const char* p) {
    // ... 非對齊處理

    static const char whitespace[16] = " \n\r\t";
    const __m128i w = _mm_load_si128((const __m128i *)&whitespace[0]);

    for (;; p += 16) {
        const __m128i s = _mm_load_si128((const __m128i *)p);
        const unsigned r = _mm_cvtsi128_si32(_mm_cmpistrm(w, s, 
            _SIDD_UBYTE_OPS | _SIDD_CMP_EQUAL_ANY |
            _SIDD_BIT_MASK | _SIDD_NEGATIVE_POLARITY));

        if (r != 0) {   // some of characters is non-whitespace
#ifdef _MSC_VER         // Find the index of first non-whitespace
            unsigned long offset;
            _BitScanForward(&offset, r);
            return p + offset;
#else
            return p + __builtin_ffs(r) - 1;
#endif
}

解析一下這裏 _mm_cmpistrm() 用上了的選項:

  • _SIDD_UBYTE_OPS: 操做單位是無號字節,即16個 unsigned char
  • _SIDD_CMP_EQUAL_ANY: 每次比較 s 裏的字符,是否和 w 中的任意字符相等。
  • _SIDD_BIT_MASK: 以比特方式返回結果。
  • _SIDD_NEGATIVE_POLARITY: 把結果反轉。這裏指返回值的1表明非空白字符。

而後,咱們用_mm_cvtsi128_si32()指令,把返回的最低位32字節儲存成普通的32位整數。若是含有非空白字符,就使用_BitScanForward()__builtin_ffs()計算出最先出現的非空白字符,並把指針跳到那裏返回。

對齊問題

經過 SSE 讀寫內存,每次能夠讀寫128位(16字節)數據。理想地是使用 128位對齊的地址來讀寫,這樣會最大化讀寫速度。

最初我使用了 _mm_loadu_si128() 從非對齊的來源字符串讀取16個字符。當時我以爲最多就是損失一些時間吧,問題彷佛不大。但實際上仍是出現了問題

If rapidjson::SkipWhitespace_SIMD(char const*) is called at close to the end of string buffer which has less than 16 bytes of allocated space, the function will read beyond the memory it owns.

In our use case, we parse around 50 million JSON files/buffers per day and
we got hit by the bug around 100 times per day on average before the
workaround.

後來,我估計是由於用非對齊讀取,有可能在邊界會讀到未分配的內存分頁,作成很低機率的崩潰。所以,修正方法是先用普通代碼處理未對齊的地址,而後才使用 SIMD 進行讀取。

inline const char *SkipWhitespace_SIMD(const char* p) {
    // ...

    // 16-byte align to the next boundary
    const char* nextAligned = reinterpret_cast<const char*>(
        (reinterpret_cast<size_t>(p) + 15) & ~15);

    while (p != nextAligned)
        if (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t')
            ++p;
        else
            return p;

    // The rest of string using SIMD
    // ...
}

快速返回

優化其實還要看實際狀況。咱們發現,有比較多的狀況是,第一個字符已經是非空白字符。尤爲是已去除空白字符的JSON,上面代碼的初始時間仍是比較大。所以,咱們把第一個字符的檢測獨立出來。

inline const char *SkipWhitespace_SIMD(const char* p) {
    // Fast return for single non-whitespace
    if (*p == ' ' || *p == '\n' || *p == '\r' || *p == '\t')
        ++p;
    else
        return p;

    // ...
}

性能測試

測試環境

  • iMac 2.7 GHz Intel Core i5
  • Apple LLVM version 6.1.0 (clang-602.0.49) (based on LLVM 3.6.0svn)

測試用例 1

跳過1M個空白字符1000次。

  • 基本實現: 675 ms
  • SSE4.2: 86 ms
  • strspn: 897 ms

測試用例 2

使用 SAX API 去原位解析(in situ parse)一個含縮進的 671KB sample.json,不處理事件(null handler)。

  • 基本實現: 934 ms
  • SSE4.2: 650 ms

結語

RapidJSON 中使用 SSE4.2 指令集跳過空白字符,能夠在一個迭代中進行 64 次字符比較,並且每次讀取 128 位數據應該對內存頻寬友好。爲了兼容更舊的 x86 系 CPU,RapidJSON 也提供了一個 SSE2 的版本,但每一個迭代須要執行更多指令,讀取可參考源代碼

此優化只對含縮進的 JSON 有利,但咱們經過「快速返回」使非縮進 JSON 也不會減慢,算是一種權衡之策。在後續的 v1.1 版本中,我但願嘗試利用 SIMD 指令去快速掃瞄需處理轉義(escaping)的字符,不需轉義的部分能使用到 128 位複製至目標緩衝。因爲轉義符在 JSON 的出現率較低,此舉應該能進一步提高總體性能。

最後,關於 x86/x64 系的 SIMD 指令,我推薦 Intel Instrinsic Guide 及 Agner Fog 的5本優化手冊

這兩期都是比較低階的東西,下期將會談一些比較高層一點的,敬請關注。

相關文章
相關標籤/搜索