The dominant cost in the reflection serializer was the strict-aliasing
reload pattern: every char* write through `string_builder::buffer.get()`
forced the compiler to assume `string_builder::position` and `::capacity`
may have been clobbered, so it reloaded both members from memory after
each byte. perf annotate showed `ldr [position]` and `ldr [capacity]`
taking 15-20% of CITM serialization time.
This change refactors the reflection atom path to use an internal
`writer` struct (buffer pointer + position + capacity + back-ref to
string_builder). The writer lives as a stack-local in the top-level
append() entry point, threaded through the inlined atom() chain via
`writer&`. After SROA, the three fields are register-resident across
the entire serialization. The `pos` is never round-tripped through
memory between writes — it's a local size_t.
This mirrors the pattern Glaze uses (passing `B&& b, auto&& ix` through
every helper). The simdjson-specific bit is keeping the public
string_builder API intact: only the internal atom() family is
refactored to take `writer&`. The top-level append(string_builder&, T)
constructs a writer on the stack, threads it through atom(), then
syncs the local position back at the end.
For string fields specifically, the escape path is also inlined
through the writer — the existing `write_string_escaped(input, out)`
helper already takes a destination pointer and returns bytes-written,
so it composes cleanly with `w.ptr + w.pos`. Without this, sync/
reload around each string write was a real cost on Twitter (-7%).
Performance
===========
TRUE A/B in single docker invocation, 7 alternating rounds, byte-
identical output, all static_reflection_comprehensive_tests pass.
Build: clang-p2996 21.0.0git, -O3 -DNDEBUG, aarch64 Linux.
CITM Catalog (496 682 bytes):
baseline (master): 4356 MB/s
patched: 6776 MB/s +55.5%
Glaze: 4860 MB/s simdjson now 39.4% AHEAD of Glaze
Twitter (81 927 bytes):
baseline (master): 6437 MB/s
patched: 6452 MB/s +0.2% (break-even)
Twitter is string-heavy; the writer's gain over the existing
member-based path is small here, but it no longer regresses.
Default initial capacity is unchanged at 1024.
* Add tests for dumping out NaN/Infinity to the json builder
* If SIMDJSON_ENABLE_NAN_INF is set, write out NaN and Infinity
* Add tests for dumping out NaN/Infinity when serializing DOM
* If SIMDJSON_ENABLE_NAN_INF, use 'NaN' and 'Infinity' for nan/inf
* Fix bug where 'Inf' as a root atom + spaces of padding parses incorrectly
* Update FracturedJson printers so tables containing NaN/Infinity are aligned
* add compile option 'SIMDJSON_ENABLE_NAN_INF' but disable by default
* extend parser to support NaN/Infinity when SIMDJSON_ENABLE_NAN_INF=1
* update tests to check parsing of NaN/Infinity, when enabled
* update minefield tests: mark nan/inf tests as passing when nan/inf is ON
* update CI/CD to run tests with extensions for NaN/Infinity enabled
* builder: force-inline atom templates and replace integer writer
Two complementary changes that together speed up reflection-driven JSON
serialization by ~28% on integer-heavy structs (CITM) and ~12% on
string-heavy structs (Twitter).
(1) Add simdjson_really_inline (always_inline) to the hot atom<T>
template overloads (arithmetic, struct, container, optional,
string-like). The constexpr-only declaration was only a hint; the
compiler routinely chose to leave atom<unsigned long> as a real
out-of-line function.
(2) Replace string_builder::append<UInt> body with a forward
cascade-on-magnitude integer writer. The old code computed
digit_count(v) upfront and wrote backward in a loop; the new code is
a straight-line if/else cascade that writes digits forward, no loop,
no helper call.
Either change alone gives only modest gains. Together they unlock the
compiler's cross-call optimization: with all atoms inlined and the
integer writer reduced to straight-line code, the compiler can hoist
b.position into a register across the whole struct serialization, fold
redundant capacity_check calls, and eliminate the strict-aliasing
penalty that otherwise forces b.position/b.capacity reloads after every
char* write.
Output is byte-identical to master on CITM (496682 bytes) and Twitter
(81927 bytes). All static_reflection_comprehensive_tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* builder: de-recurse write_uint_jeaiii so always_inline applies on g++/MSVC
g++ ('inlining failed in call to always_inline ...: function not
considered for inlining') and MSVC ('warning C4714: __forceinline not
inlined' under warnings-as-errors) both refuse to inline recursive
functions marked simdjson_really_inline. The original write_uint_jeaiii
called itself in the >=10^4 branches.
Refactor into a non-recursive DAG of helpers: write_lt100, write_lt10000,
write_4_digits, write_lt1e8, write_uint_jeaiii. Each calls strictly
smaller-domain helpers, no cycles. Same straight-line cascade behavior,
same byte output, but every node is now a candidate for always_inline on
all compilers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* builder: port signed-int append to jeaiii writer and drop digit_count helpers
The signed-integer branch in string_builder::append was structurally
identical to the OLD unsigned branch — same digit_count() upfront +
backward 4-digit batched loop. Port it to use the same forward
write_uint_jeaiii() helper as the unsigned branch (write '-'
unconditionally and advance position only if negative — branchless).
This makes int_log2 / fast_digit_count_32 / fast_digit_count_64 /
digit_count fully unused (verified via grep across include/ and src/);
remove them, ~80 lines of dead code.
The signed write path now benefits from the same compiler-level
optimization (full inlining, capacity-check fusion, no opaque-loop
boundary) as the unsigned path. Signed-int microbench (200 × 100k
values, mixed magnitude and sign): 2272 → 2685 MB/s (+18.2%).
CITM and Twitter benchmarks unchanged in shape and remain byte-identical
to baseline output.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix tests
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Daniel Lemire <daniel@lemire.me>
Each non-first field in a reflection-driven struct serialization was
emitting three separate string_builder::append calls (',', "\"key\"", ':'),
each going through capacity_check + a small write. Combine them into a
single compile-time string per field so each field does one capacity_check
and one memcpy. Same pattern fixed in atom(), append(), and extract_from().
Measured under clang-p2996 -O3 -DNDEBUG -freflection -std=c++26
(median of 5 contemporaneous runs, output byte-identical to baseline):
CITM serialization (496682 bytes):
simdjson_reuse_buffer: 3192 -> 3673 MB/s (+15.1%)
simdjson_static_reflection: 3014 -> 3443 MB/s (+14.2%)
simdjson_to: 2717 -> 3234 MB/s (+19.0%)
simdjson_to_reuse: 2836 -> 3226 MB/s (+13.7%)
Twitter serialization (81927 bytes):
simdjson_reuse_buffer: 6637 -> 7162 MB/s (+7.9%)
simdjson_static_reflection: 5527 -> 5915 MB/s (+7.0%)
simdjson_to: 5070 -> 5350 MB/s (+5.5%)
simdjson_to_reuse: 4976 -> 5303 MB/s (+6.6%)
extract_from (out-of-tree micro-bench, 100 records per call):
User 4-of-9 fields: 1833 -> 1888 MB/s (+3.0%)
Status 3-of-6 fields: 4286 -> 4334 MB/s (+1.1%)
Parsing benchmarks unchanged. tests/builder/static_reflection_comprehensive_tests
still passes (round-trip through extract_from / extract_into is verified).
* add std::ranges support for On-Demand API (#2382)
Add zero-cost range wrappers (array_range, object_range) that satisfy
std::ranges::input_range, enabling std::views::transform and other
C++20 range adaptors with the On-Demand parser.
Uses direct forwarding via simdjson_inline with no value buffering,
avoiding the per-element overhead (~20%) of the previous approach.
Guarded by SIMDJSON_SUPPORTS_RANGES.
* fix: replace non-ASCII em dash in test comment
The just_ascii CI check flags any non-ASCII characters in source files.
* let us see what we get with this...
* minor tweak
* minor update
* update doc
---------
Co-authored-by: Justin Li <justin53@bu.edu>
* adding a guard in document::allocate.
Co-authored-by: jmestwa-coder jmestwa@gmail.com
* adding a max depth
Co-authored-by: jmestwa-coder jmestwa@gmail.com
* add get_int32() and get_uint32() to the On Demand API
Add convenience methods that call get_int64()/get_uint64() and
range-check the result, returning NUMBER_OUT_OF_RANGE on overflow.
Added to value, document, document_reference, and their
simdjson_result wrappers.
Closes#1890
* fix document_reference streaming for get_int32/get_uint32
The document_reference getters delegated to document::get_int32/get_uint32
which call get_root_int64/get_root_uint64 with allow_trailing_content=true,
rejecting the next document in a stream as trailing content.
Call get_root_value_iterator().get_root_int64/get_root_uint64(false) directly
to allow trailing content, matching get_int64/get_uint64.
Add streaming tests for both getters.
* add get_int32/get_uint32 to basics.md
* add get_int32/get_uint32 to wrong-type error tests
* add get<uint32_t>/get<int32_t> template specializations
Wire the 32-bit getters into the generic get<T>() and get(T&)
dispatch so that doc.get<uint32_t>() compiles on C++11/14/17
without requiring the C++20 concepts path in std_deserialize.h.
#2617 introduced an RVV check that only checks if the compiler supports RVV intrinsics without checking if RVV is enabled at compile time.
This has caused compilation failure of node.js on riscv64. Fix it by appending a check for __riscv_vector.
* perf(serialization): SIMD-accelerated string escape position finding
Profiling revealed that string serialization was performing redundant
scanning: first calling fast_needs_escaping() (SIMD scan to check IF
escape needed), then find_next_json_quotable_character() (scalar
byte-by-byte scan to find WHERE).
Profile data from Twitter benchmark showed:
- 54.76% time in atom<std::string> (string serialization)
- 24.85% time in find_next_json_quotable_character (scalar position finding)
This optimization unifies both operations into a single SIMD pass that
directly locates the first quotable character position:
- NEON (ARM64): Uses vceqq_u8/vcltq_u8 for character detection, then
extracts position via __builtin_ctzll on 64-bit vector lanes
- SSE2 (x86-64): Uses _mm_cmpeq_epi8/_mm_subs_epu8 for detection, then
_mm_movemask_epi8 + __builtin_ctz for position extraction
The write_string_escaped function now uses the position finder directly,
eliminating the separate fast_needs_escaping check.
Benchmark results (ARM64, Apple Silicon via Docker with p2996 clang):
- Twitter (string-heavy): 4330 -> 5723 MB/s (+32%)
- CITM (numeric-heavy): ~neutral (expected, few strings)
* perf(serialization): batch integer formatting with 4-digit processing
Profiling with perf annotate revealed that the integer-to-string
conversion loop was a significant hotspot in numeric-heavy workloads.
The original implementation processed 2 digits per iteration:
while (pv >= 100) {
memcpy(write_pointer - 1, &decimal_table[(pv % 100) * 2], 2);
write_pointer -= 2;
pv /= 100;
}
Profile data from CITM benchmark showed:
- 31.20% time in atom<unsigned long> (integer formatting)
- 39.07% of integer formatting time in the 2-byte store instruction
(sturh on ARM64)
- CITM integers average 8.8 digits, meaning 4+ store operations per number
This optimization processes 4 digits per iteration, reducing both store
operations and division count by approximately half for large numbers:
while (pv >= 10000) {
q = pv / 10000;
r = pv % 10000;
r_hi = r / 100; // High 2 digits
r_lo = r % 100; // Low 2 digits
memcpy(write_pointer - 1, &decimal_table[r_lo * 2], 2);
memcpy(write_pointer - 3, &decimal_table[r_hi * 2], 2);
write_pointer -= 4;
pv = q;
}
The division by 10000 compiles to an efficient multiply-high instruction
(umulh on ARM64). Applied to both unsigned and signed integer paths.
Benchmark results (ARM64, Apple Silicon via Docker with p2996 clang):
- Twitter: ~neutral (few integers)
- CITM (numeric-heavy): 2912 -> 3086 MB/s (+6%)
* fix(build): add MSVC compatibility for bit manipulation intrinsics
MSVC does not have __builtin_ctz/__builtin_ctzll. Use _BitScanForward
and _BitScanForward64 from <intrin.h> on MSVC instead.
This fixes the build on all Windows configurations (x64, ARM64, Win32).
* refactor: clean up comments to be implementation-focused
Remove references to specific benchmarks and previous implementations
from code comments. Comments now describe what the code does rather
than historical context.