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プログラミングの理解
- パーサーの内部構造の理解
- パフォーマンス最適化の勘所
が身につく。ぜひ試してみてね!
このシリーズが役に立ったら、いいね・ストックしてもらえると嬉しいです!