7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C++ JSONパーサー自作シリーズ

Part1 再帰下降 Part2 エラー処理 Part3 SIMD高速化
👈 Now

はじめに

前回までで、ちゃんと動くJSONパーサーができた。

でも、速度はどうだろう?

今回はSIMD(Single Instruction Multiple Data)を使って、パフォーマンスを限界まで引き上げよう。

ベースラインの測定

まず、今のパーサーの速度を測る。

#include <chrono>

void benchmark(const std::string& json, const std::string& name) {
    const int iterations = 1000;
    
    auto start = std::chrono::high_resolution_clock::now();
    
    JsonParser parser;
    for (int i = 0; i < iterations; ++i) {
        auto result = parser.parse(json);
    }
    
    auto end = std::chrono::high_resolution_clock::now();
    auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
    
    double throughput = (json.size() * iterations) / (duration.count() / 1e6) / 1e9;
    
    std::cout << name << ": " << throughput << " GB/s\n";
}
シンプル実装: 0.12 GB/s
nlohmann/json: 0.18 GB/s
rapidjson: 0.89 GB/s
simdjson: 2.5 GB/s  ← 目標

SIMDとは

1回の命令で複数のデータを処理する技術。

┌──────────────────────────────────────────────────────────────┐
│ 通常の処理                                                   │
│                                                              │
│   a[0] + b[0] → c[0]                                        │
│   a[1] + b[1] → c[1]    4回のループが必要                    │
│   a[2] + b[2] → c[2]                                        │
│   a[3] + b[3] → c[3]                                        │
│                                                              │
│ SIMD処理                                                     │
│                                                              │
│   [a[0], a[1], a[2], a[3]]                                  │
│ + [b[0], b[1], b[2], b[3]]   1回の命令で4要素同時処理       │
│ = [c[0], c[1], c[2], c[3]]                                  │
└──────────────────────────────────────────────────────────────┘

Intel SIMDの種類

命令セット レジスタサイズ 同時処理バイト数
SSE 128bit 16バイト
AVX 256bit 32バイト
AVX-512 512bit 64バイト

今回はSSE/AVX2を使う(ほとんどのPCで使える)。

高速化ポイント1: 空白スキップ

JSONでは空白が大量に出現する。これをSIMDで高速化。

#include <immintrin.h>

// 空白文字を探す(スペース、タブ、改行、キャリッジリターン)
size_t skip_whitespace_simd(const char* data, size_t pos, size_t len) {
    // 空白文字のベクトル
    const __m256i space = _mm256_set1_epi8(' ');
    const __m256i tab = _mm256_set1_epi8('\t');
    const __m256i newline = _mm256_set1_epi8('\n');
    const __m256i carriage = _mm256_set1_epi8('\r');
    
    while (pos + 32 <= len) {
        // 32バイト読み込み
        __m256i chunk = _mm256_loadu_si256(
            reinterpret_cast<const __m256i*>(data + pos)
        );
        
        // 各空白文字と比較
        __m256i is_space = _mm256_cmpeq_epi8(chunk, space);
        __m256i is_tab = _mm256_cmpeq_epi8(chunk, tab);
        __m256i is_newline = _mm256_cmpeq_epi8(chunk, newline);
        __m256i is_carriage = _mm256_cmpeq_epi8(chunk, carriage);
        
        // ORで結合
        __m256i is_whitespace = _mm256_or_si256(
            _mm256_or_si256(is_space, is_tab),
            _mm256_or_si256(is_newline, is_carriage)
        );
        
        // 空白でない位置を探す
        int mask = _mm256_movemask_epi8(is_whitespace);
        
        if (mask != (int)0xFFFFFFFF) {
            // 非空白文字を発見
            // 最初の0ビットの位置を探す
            int first_non_whitespace = __builtin_ctz(~mask);
            return pos + first_non_whitespace;
        }
        
        pos += 32;
    }
    
    // 残りは通常処理
    while (pos < len) {
        char c = data[pos];
        if (c != ' ' && c != '\t' && c != '\n' && c != '\r') {
            break;
        }
        pos++;
    }
    
    return pos;
}

高速化ポイント2: 文字列パース

文字列内のエスケープ文字を探すのも並列化できる。

// 文字列の終端(")またはエスケープ(\)を探す
size_t find_string_end_simd(const char* data, size_t pos, size_t len) {
    const __m256i quote = _mm256_set1_epi8('"');
    const __m256i backslash = _mm256_set1_epi8('\\');
    
    while (pos + 32 <= len) {
        __m256i chunk = _mm256_loadu_si256(
            reinterpret_cast<const __m256i*>(data + pos)
        );
        
        __m256i is_quote = _mm256_cmpeq_epi8(chunk, quote);
        __m256i is_backslash = _mm256_cmpeq_epi8(chunk, backslash);
        
        __m256i is_special = _mm256_or_si256(is_quote, is_backslash);
        int mask = _mm256_movemask_epi8(is_special);
        
        if (mask != 0) {
            return pos + __builtin_ctz(mask);
        }
        
        pos += 32;
    }
    
    // 残りは通常処理
    while (pos < len) {
        char c = data[pos];
        if (c == '"' || c == '\\') {
            return pos;
        }
        pos++;
    }
    
    return pos;
}

// 高速文字列パース
JsonValue parse_string_fast() {
    size_t start = pos_ + 1;  // 開始クォートをスキップ
    pos_++;
    
    // エスケープがない文字列を高速にコピー
    std::string result;
    
    while (true) {
        size_t end = find_string_end_simd(input_.data(), pos_, input_.size());
        
        // エスケープなしの部分をコピー
        result.append(input_.data() + pos_, end - pos_);
        pos_ = end;
        
        if (pos_ >= input_.size()) {
            error("Unterminated string");
        }
        
        if (input_[pos_] == '"') {
            pos_++;  // 終了クォートをスキップ
            break;
        }
        
        // エスケープシーケンスを処理
        pos_++;  // バックスラッシュをスキップ
        result += parse_escape_sequence();
    }
    
    return JsonValue(std::move(result));
}

高速化ポイント3: 数値変換

文字列から数値への変換も高速化できる。

// 8桁の数字を一度に変換
uint64_t parse_8digits_simd(const char* data) {
    // '0'を引いて数値化
    __m128i chunk = _mm_loadl_epi64(reinterpret_cast<const __m128i*>(data));
    __m128i zeros = _mm_set1_epi8('0');
    chunk = _mm_sub_epi8(chunk, zeros);
    
    // 各桁の位置に対応する重みを掛ける
    const __m128i mult1 = _mm_set_epi8(0, 0, 0, 0, 0, 0, 0, 0,
                                        1, 10, 1, 10, 1, 10, 1, 10);
    chunk = _mm_maddubs_epi16(chunk, mult1);
    
    const __m128i mult2 = _mm_set_epi16(0, 0, 0, 0, 1, 100, 1, 100);
    chunk = _mm_madd_epi16(chunk, mult2);
    
    // 最終的な合計
    chunk = _mm_packus_epi32(chunk, chunk);
    
    const __m128i mult3 = _mm_set_epi16(0, 0, 0, 0, 0, 0, 1, 10000);
    chunk = _mm_madd_epi16(chunk, mult3);
    
    return _mm_cvtsi128_si32(chunk);
}

// 数値パース(整数部分)
double parse_number_fast() {
    size_t start = pos_;
    
    bool negative = false;
    if (input_[pos_] == '-') {
        negative = true;
        pos_++;
    }
    
    uint64_t integer_part = 0;
    size_t digit_count = 0;
    
    // 8桁ずつ処理
    while (pos_ + 8 <= input_.size()) {
        // 8文字が全て数字かチェック
        __m128i chunk = _mm_loadl_epi64(
            reinterpret_cast<const __m128i*>(input_.data() + pos_)
        );
        __m128i zero = _mm_set1_epi8('0');
        __m128i nine = _mm_set1_epi8('9');
        
        __m128i ge_zero = _mm_cmpgt_epi8(chunk, _mm_sub_epi8(zero, _mm_set1_epi8(1)));
        __m128i le_nine = _mm_cmplt_epi8(chunk, _mm_add_epi8(nine, _mm_set1_epi8(1)));
        __m128i is_digit = _mm_and_si128(ge_zero, le_nine);
        
        int mask = _mm_movemask_epi8(is_digit);
        
        if ((mask & 0xFF) != 0xFF) {
            break;  // 数字でない文字がある
        }
        
        integer_part = integer_part * 100000000 + parse_8digits_simd(input_.data() + pos_);
        pos_ += 8;
        digit_count += 8;
    }
    
    // 残りの桁を処理
    while (pos_ < input_.size() && isdigit(input_[pos_])) {
        integer_part = integer_part * 10 + (input_[pos_] - '0');
        pos_++;
        digit_count++;
    }
    
    double result = static_cast<double>(integer_part);
    
    // 小数部と指数部は通常処理
    // ...
    
    return negative ? -result : result;
}

高速化ポイント4: 構造文字の検索

{, }, [, ], :, , を並列に探す。

// 構造文字のビットマップを作成
uint64_t find_structural_chars(const char* data, size_t len) {
    const __m256i open_brace = _mm256_set1_epi8('{');
    const __m256i close_brace = _mm256_set1_epi8('}');
    const __m256i open_bracket = _mm256_set1_epi8('[');
    const __m256i close_bracket = _mm256_set1_epi8(']');
    const __m256i colon = _mm256_set1_epi8(':');
    const __m256i comma = _mm256_set1_epi8(',');
    
    __m256i chunk = _mm256_loadu_si256(reinterpret_cast<const __m256i*>(data));
    
    __m256i structural = _mm256_or_si256(
        _mm256_or_si256(
            _mm256_cmpeq_epi8(chunk, open_brace),
            _mm256_cmpeq_epi8(chunk, close_brace)
        ),
        _mm256_or_si256(
            _mm256_or_si256(
                _mm256_cmpeq_epi8(chunk, open_bracket),
                _mm256_cmpeq_epi8(chunk, close_bracket)
            ),
            _mm256_or_si256(
                _mm256_cmpeq_epi8(chunk, colon),
                _mm256_cmpeq_epi8(chunk, comma)
            )
        )
    );
    
    return _mm256_movemask_epi8(structural);
}

Stage 1 / Stage 2 アーキテクチャ

simdjsonのようなアプローチ。

┌──────────────────────────────────────────────────────────────┐
│ 2段階パース                                                  │
│                                                              │
│ Stage 1: 構造解析(SIMD)                                    │
│   入力: "{"name":"value"}"                                   │
│   出力: 構造文字のインデックス [0, 1, 6, 7, 13, 14]          │
│                                                              │
│ Stage 2: 値の変換(スカラー)                                │
│   構造インデックスを使ってDOMを構築                         │
└──────────────────────────────────────────────────────────────┘
class SimdJsonParser {
public:
    JsonValue parse(const std::string& json) {
        // Stage 1: 構造解析
        std::vector<size_t> structural_indices = stage1(json);
        
        // Stage 2: DOM構築
        return stage2(json, structural_indices);
    }

private:
    std::vector<size_t> stage1(const std::string& json) {
        std::vector<size_t> indices;
        indices.reserve(json.size() / 4);  // 推定
        
        bool in_string = false;
        size_t pos = 0;
        
        while (pos + 32 <= json.size()) {
            // 32バイト処理
            uint32_t quotes = find_quotes(json.data() + pos);
            uint32_t backslashes = find_backslashes(json.data() + pos);
            uint32_t structural = find_structural_chars(json.data() + pos, 32);
            
            // エスケープされたクォートを除外
            quotes = remove_escaped_quotes(quotes, backslashes);
            
            // 文字列内の構造文字を除外
            structural = mask_string_content(structural, quotes, in_string);
            
            // インデックスを追加
            while (structural) {
                int bit = __builtin_ctz(structural);
                indices.push_back(pos + bit);
                structural &= structural - 1;  // 最下位ビットをクリア
            }
            
            pos += 32;
        }
        
        // 残りは通常処理
        // ...
        
        return indices;
    }
    
    JsonValue stage2(const std::string& json, 
                     const std::vector<size_t>& indices) {
        // 構造インデックスを使ってパース
        // ...
    }
};

メモリアロケーション最適化

小さいオブジェクトの頻繁なアロケーションを避ける。

class ArenaAllocator {
public:
    ArenaAllocator(size_t block_size = 1024 * 1024)
        : block_size_(block_size) {
        allocate_block();
    }
    
    void* allocate(size_t size, size_t alignment = 8) {
        // アライメント調整
        size_t padding = (alignment - (offset_ % alignment)) % alignment;
        
        if (offset_ + padding + size > block_size_) {
            allocate_block();
            padding = 0;
        }
        
        void* ptr = current_block_ + offset_ + padding;
        offset_ += padding + size;
        return ptr;
    }
    
    template<typename T, typename... Args>
    T* create(Args&&... args) {
        void* ptr = allocate(sizeof(T), alignof(T));
        return new (ptr) T(std::forward<Args>(args)...);
    }
    
    void reset() {
        // 全ブロックを再利用可能に
        offset_ = 0;
    }

private:
    void allocate_block() {
        auto block = std::make_unique<char[]>(block_size_);
        current_block_ = block.get();
        blocks_.push_back(std::move(block));
        offset_ = 0;
    }
    
    size_t block_size_;
    char* current_block_ = nullptr;
    size_t offset_ = 0;
    std::vector<std::unique_ptr<char[]>> blocks_;
};

文字列の参照を保持

パース結果の文字列を入力JSONへの参照として保持(コピーを避ける)。

struct StringView {
    const char* data;
    size_t length;
    
    operator std::string() const {
        return std::string(data, length);
    }
    
    bool operator==(const StringView& other) const {
        return length == other.length && 
               memcmp(data, other.data, length) == 0;
    }
};

// JsonValueでStringViewを使う
using JsonString = std::variant<std::string, StringView>;

ベンチマーク結果

テストデータ: twitter.json (630KB)

実装                   スループット    改善率
-------------------------------------------
シンプル実装           0.12 GB/s       基準
+ SIMD空白スキップ     0.25 GB/s       2.1x
+ SIMD文字列パース     0.45 GB/s       3.8x
+ SIMD数値変換         0.62 GB/s       5.2x
+ Stage1/2分離         0.95 GB/s       7.9x
+ アリーナアロケータ   1.35 GB/s       11.3x
+ 文字列参照           1.82 GB/s       15.2x
-------------------------------------------
simdjson (参考)        2.5 GB/s        20.8x

15倍以上の高速化を達成!

コンパイラの最適化フラグ

# GCC/Clang
g++ -O3 -march=native -flto json_parser.cpp

# MSVC
cl /O2 /arch:AVX2 /GL json_parser.cpp
フラグ 効果
-O3 積極的な最適化
-march=native 実行CPUに最適化
-flto リンク時最適化

まとめ

最適化 効果
SIMD空白スキップ 2x
SIMD文字列パース 2x
SIMD数値変換 1.4x
Stage1/2分離 1.5x
アリーナアロケータ 1.4x
文字列参照 1.3x
合計 15x

実際のプロダクションではsimdjsonを使うのがおすすめ。

でも、自分で実装することで:

  • SIMDプログラミングの理解
  • パーサーの内部構造の理解
  • パフォーマンス最適化の勘所

が身につく。ぜひ試してみてね!

このシリーズが役に立ったら、いいね・ストックしてもらえると嬉しいです!

7
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?