SIMD Programming for JSON Parssing: How simdjson Achieves 2-3 GB/s
The Hook
Your JSON parser does 200 MB/s. simdjson does 2.5 GB/s. It’s not faster algorithms — it’s SIMD: processing 64 bytes per CPU instruction. The secret: parallel bit streams that find structural characters (,, :, {, }, ") in parallel, then branchless parsing. Here’s how it works.
The Thesis
JSON parsing is memory-bandwidth bound, not CPU-bound. Standard parsers process byte-by-byte with branches. SIMD processes 64 bytes/instruction, finds all structural characters in parallel, then builds the parse tree with near-zero branches. The result: 10-15x speedup on modern CPUs.
The Science
The SIMD Advantage
| Parser | Throughput | Approach |
|---|---|---|
| RapidJSON | ~200 MB/s | Byte-by-byte, branchy |
| simdjson (AVX2) | ~2.5 GB/s | 32-byte vectors, parallel bit streams |
| simdjson (AVX-512) | ~3.5 GB/s | 64-byte vectors |
| simdjson (ARM NEON) | ~1.8 GB/s | 16-byte vectors |
The Core Insight: Parallel Bit Streams
Problem: Find all structural characters ({ } [ ] : , " \) in a JSON document.
Scalar: Check each byte, branch on match.
SIMD: Load 32/64 bytes. Compare against all 7 structural chars in parallel using vector instructions. Result: 7 bitmasks (one per char class).
// AVX2: 32 bytes at a time
__m256i chunk = _mm256_loadu_si256((__m256i*)ptr);
// Compare against each structural character
__m256i is_brace_open = _mm256_cmpeq_epi8(chunk, _mm256_set1_epi8('{'));
__m256i is_brace_close = _mm256_cmpeq_epi8(chunk, _mm256_set1_epi8('}'));
__m256i is_bracket_open = _mm256_cmpeq_epi8(chunk, _mm256_set1_epi8('['));
__m256i is_bracket_close = _mm256_cmpeq_epi8(chunk, _mm256_set1_epi8(']'));
__m256i is_colon = _mm256_cmpeq_epi8(chunk, _mm256_set1_epi8(':'));
__m256i is_comma = _mm256_cmpeq_epi8(chunk, _mm256_set1_epi8(','));
__m256i is_quote = _mm256_cmpeq_epi8(chunk, _mm256_set1_epi8('"'));
// Extract bitmasks
uint32_t brace_open_mask = _mm256_movemask_epi8(is_brace_open);
uint32_t brace_close_mask = _mm256_movemask_epi8(is_brace_close);
// ... etc
Result: 7 uint32 bitmasks per 32 bytes. Each bit = position of that structural character.
Branchless UTF-8 Validation
Problem: Validate UTF-8 without branches.
SIMD Solution: Use lookup tables + vector compares.
// Check for valid UTF-8 continuation bytes (0x80-0xBF)
// and valid leading bytes
__m256i is_continuation = _mm256_and_si256(
_mm256_cmpgt_epi8(chunk, _mm256_set1_epi8(0x7F)),
_mm256_cmplt_epi8(chunk, _mm256_set1_epi8(0xC0))
);
// Check for overlong sequences, surrogates, etc.
// All done with vector compares + bitmasks
Structural Index Building
Instead of recursive descent: Build structural index from bitmasks.
struct StructuralIndex {
uint32_t* brace_open_positions;
uint32_t* brace_close_positions;
uint32_t* bracket_open_positions;
uint32_t* bracket_close_positions;
uint32_t* colon_positions;
uint32_t* comma_positions;
uint32_t* string_positions;
size_t count;
};
// Build from bitmasks using _tzcnt (trailing zero count)
void build_index(uint32_t mask, uint32_t* positions, size_t& count) {
while (mask) {
uint32_t tz = __builtin_ctz(mask); // or _tzcnt_u32
positions[count++] = base_offset + tz;
mask &= mask - 1; // clear lowest set bit
}
}
Parsing from Index
Instead of recursive descent: Navigate via index arrays.
// Find matching brace: binary search in brace_close_positions
// Find object members: iterate between { and } using comma positions
// Find key-value pairs: colon positions between braces
String Parsing (The Hard Part)
Challenge: Unescape strings (\n, \t, \uXXXX, \") at SIMD speed.
Solution: Two-phase
- Find all quotes → string boundaries (already have bitmask)
- Vectorized unescape: Process 32 bytes at a time
// Find escape sequences
__m256i is_backslash = _mm256_cmpeq_epi8(chunk, _mm256_set1_epi8('\\'));
uint32_t escape_mask = _mm256_movemask_epi8(is_backslash);
// Process escapes in batches
// Use shuffle instructions for \n \t \r \" \\
// Unicode \uXXXX: scalar fallback (rare)
The Protocol: Build a SIMD JSON Parser
Phase 1: Structural Index (Week 1)
// 1. Load 64 bytes (AVX-512) or 32 bytes (AVX2)
// 2. Generate 7 structural bitmasks
// 3. Extract positions via tzcnt
// 4. Build structural index arrays
Phase 2: UTF-8 Validation (Week 1)
// Vectorized validation
// Check: overlong, surrogates, invalid codepoints
// All branchless
Phase 3: String Unescaping (Week 2)
// Find all strings via quote positions
// Vectorized unescape for common escapes
// Scalar fallback for \uXXXX
Phase 4: Number Parsing (Week 2)
// Parse integers/floats from structural index
// Use fast float parsing (lemire/fast_float)
The Counterpoint
- Complexity: 10x more code than RapidJSON
- Maintenance: SIMD intrinsics are hardware-specific
- Edge cases: UTF-8 edge cases are brutal
- Diminishing returns: Network/disk often bottleneck
Practical Takeaway
Don’t write from scratch. Use simdjson (C++), pysimdjson (Python), go-simdjson (Go), simd-json (Rust).
If you must write: Start with structural index from bitmasks. That’s 80% of the speedup. UTF-8 validation and string unescaping are the remaining 20%.
Benchmark: simdjson_benchmark --json your_file.json vs your current parser.
Sources: Lemire & Kostya (2019) Parsing Gigabytes of JSON per Second; simdjson GitHub; Lemire & Kaser (2021) Validating UTF-8 in Less Than One Instruction Per Byte; fast_float (lemire); RapidJSON benchmarks; Intel Intrinsics Guide; ARM NEON Programmer’s Guide.