Repository navigation
Speed up the lexer: own float parser, string scan, and \u table - #5738
Merged
Merged
Conversation
nlohmann
added this pull request to stack #5739
September 30, 2026 13:21
This was referenced Sep 30, 2026
nlohmann
force-pushed
the
json-view/02b-float-parser
branch
from
September 30, 2026 18:06
d92b762 to
39092df
Compare
nlohmann
force-pushed
the
json-view/02b-float-parser
branch
from
September 30, 2026 18:06
39092df to
192b99b
Compare
nlohmann
marked this pull request as ready for review
September 30, 2026 18:15
nlohmann
force-pushed
the
json-view/02b-float-parser
branch
from
September 30, 2026 18:19
192b99b to
9c71689
Compare
gregmarr
reviewed
Oct 1, 2026
nlohmann
removed this pull request from stack #5739
October 6, 2026 09:26
This was referenced Oct 6, 2026
nlohmann
force-pushed
the
json-view/02b-float-parser
branch
from
October 6, 2026 09:28
15b0cc0 to
953d74d
Compare
nlohmann
added this pull request to stack #5768
October 6, 2026 09:29
nlohmann
force-pushed
the
json-view/02b-float-parser
branch
from
October 7, 2026 14:44
953d74d to
68d61b6
Compare
This was referenced Oct 10, 2026
nlohmann
force-pushed
the
json-view/02b-float-parser
branch
2 times, most recently
from
October 11, 2026 09:33
fb02078 to
247b03b
Compare
nlohmann
force-pushed
the
json-view/02b-float-parser
branch
from
October 13, 2026 15:32
247b03b to
711093e
Compare
gregmarr
approved these changes
Oct 13, 2026
gregmarr
left a comment
Contributor
There was a problem hiding this comment.
A couple minor questions about alternate methods/functions, and a minor comment question, nothing required.
I did not do a deep review of the float conversion algorithms, will leave that to the tests.
Give the library its own correctly rounded float converter for binary32 and binary64 (IEEE 754), and speed up the lexer's string and escape scanning. The converter splits a number token into sign, significand, and decimal exponent, then tries Clinger's fast path, then a templated Eisel-Lemire step, and falls back to an exact big-integer digit comparison for tokens with more than 19 significant digits whose two candidate values round differently. This replaces std::from_chars and strtod/strtof for both formats, so parsed values no longer depend on the C/C++ library or the current locale. The strtold fallback kept for other long double formats (x87, binary128) now also copies a multi-byte decimal point correctly, fixing #5660. eisel_lemire() and decimal_to_float() are always inlined so callers keep the whole conversion in their hot loop. The string-scanning kernels in string_scan.hpp find a stop byte with the trailing-zero count of the SWAR mask instead of a byte loop, and scalar_string_bulk_run() validates a run of multi-byte UTF-8 sequences one after another instead of re-searching after each one. get_codepoint() decodes a contiguous \uXXXX escape with one table lookup per byte instead of four range-checked get() calls; the streaming path and all error positions are unchanged. Adds 508 generated hard float-parsing cases with expected binary32 and binary64 bits, and kernel-comparison tests for the string scans and the escape table against byte-by-byte references. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
- pow5_table.hpp: pow5_128_largest_power was unused in this branch's own code (GCC -Werror=unused-const-variable); tie it to the table size with a static_assert instead of removing it, since a later branch in the stack (json-view/23-zmij) uses it. - number_parse.hpp: rename the local variable `copy` to `buffer` to satisfy cpplint's build/include_what_you_use check. - unit-class_lexer.cpp: extend the NOLINT list on the seeded mt19937 with bugprone-random-generator-seed, and parenthesize `8 * sizeof(Bits) - 1` for clang-tidy. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
…docs Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
nlohmann
force-pushed
the
json-view/02b-float-parser
branch
from
October 13, 2026 20:22
ac6dda3 to
af71114
Compare
gregmarr
approved these changes
Oct 14, 2026
nlohmann
added a commit
that referenced
this pull request
Oct 14, 2026
Resolve the macro_unscope.hpp conflict by keeping both sets of #undefs and regenerate the amalgamation. The \u fast path from #5738 (get_codepoint_bulk) assumed that bulk scanning implies lazy position tracking and advanced the adapter with a bare bulk_skip(4). Stream adapters now scan in bulk but track position and token eagerly, so a lone-surrogate error was reported 4 columns too early. Advance through bulk_consume() instead. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
nlohmann
added a commit
that referenced
this pull request
Oct 15, 2026
parse_float_eisel_lemire() was removed by #5738, so the test no longer compiled. The lexer converts floats from its token buffer, so the JSON section never places a float token at the page boundary; the section now calls parse_float_native() for float and double on every complete number token directly and compares the result bit for bit with converting the same token from a std::string. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
nlohmann
added a commit
that referenced
this pull request
Oct 16, 2026
parse_float_eisel_lemire() was removed by #5738, so the test no longer compiled. The lexer converts floats from its token buffer, so the JSON section never places a float token at the page boundary; the section now calls parse_float_native() for float and double on every complete number token directly and compares the result bit for bit with converting the same token from a std::string. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
nlohmann
added a commit
that referenced
this pull request
Oct 17, 2026
parse_float_eisel_lemire() was removed by #5738, so the test no longer compiled. The lexer converts floats from its token buffer, so the JSON section never places a float token at the page boundary; the section now calls parse_float_native() for float and double on every complete number token directly and compares the result bit for bit with converting the same token from a std::string. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
nlohmann
added a commit
that referenced
this pull request
Oct 20, 2026
* Test that the contiguous fast paths never read outside the input The bulk string scanner, the SWAR searches, the UTF-8 prefix validator, the 8-digit number loop, the 8-byte indentation skip, and the BSON/BON8 bulk string readers load several bytes at a time from contiguous input. ASan misses over-reads into the slack of the same heap allocation, and fuzzers rarely end an input exactly at a page boundary. The new test copies each input directly in front of, and directly behind, an inaccessible page (mmap/mprotect on POSIX, VirtualAlloc/VirtualProtect on Windows), so reading even one byte outside the input crashes. It covers the string-scan functions directly and json::parse, accept, sax_parse (with and without comments) and all binary readers through the public API, with inputs ending at every byte of strings, escapes, UTF-8 sequences, numbers, literals, indentation runs, and binary documents. Each result must match parsing the same input from a std::string. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Rename the reader parameter to avoid a flawfinder false positive Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Make the guard page test work without exceptions and under UBSan Under JSON_NOEXCEPTION, the error paths abort instead of throwing, so the outcomes are now computed with allow_exceptions=false there. Copying the input with std::copy from char to unsigned char tripped Clang's implicit conversion sanitizer; std::memcpy avoids the conversion. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Test parse_float_native() at the guard page parse_float_eisel_lemire() was removed by #5738, so the test no longer compiled. The lexer converts floats from its token buffer, so the JSON section never places a float token at the page boundary; the section now calls parse_float_native() for float and double on every complete number token directly and compares the result bit for bit with converting the same token from a std::string. Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Fix clang-tidy findings in the guard-page tests Signed-off-by: Niels Lohmann <mail@nlohmann.me> * Convert explicitly between bytes and characters in the guard-page tests The clang sanitizer job (-fsanitize=integer, x86-64 where char is signed) reported the implicit char -> unsigned char conversions of std::copy() into the guarded buffer and of the std::string built from the binary inputs. Signed-off-by: Niels Lohmann <mail@nlohmann.me> --------- Signed-off-by: Niels Lohmann <mail@nlohmann.me>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of the stack for the zero-copy view (#5295). This change speeds up
json::parseon its own: it adds a float converter and faster string kernels, which the view uses later in the stack.Summary
float,double, andlong doublewhere it is IEEE 754 binary64 (MSVC, Apple arm64) are now converted by the library itself: correctly rounded (to nearest, ties to even), independent of the locale, and without calling the C or C++ library.w(at most 19 digits), and decimal exponentq. The scanners already record the positions of the decimal point and the exponent, so no character is classified again; digits are read eight at a time.wand10^|q|are exact (skipped whereFLT_EVAL_METHOD != 0).wandw + 1round differently: an exact big-integer comparison with the midpoint between the two candidates (fast_float's digit comparison, simplified because Eisel-Lemire already yields both candidates).std::from_chars/strtod) was correctly rounded. Out-of-range values still throwout_of_range.406, and underflow still gives a zero with the sign of the token.eisel_lemire()anddecimal_to_float()are always inlined, so callers' hot loops keep the whole conversion inline.strtoldfallback left forlong doubleformats other than binary64 (x87, binary128) now copies a decimal point longer than one byte into a copy of the token instead of substituting its first byte. This fixes Floats are truncated at the decimal point under locales with a multi-byte decimal point (fa_IR.UTF-8) #5660 completely for a locale such asfa_IR.UTF-8.string_scan.hppfind the first stop byte with the trailing-zero count of the comparison mask instead of a byte loop once a word contains a hit (the lowest flagged byte is always a real hit, since borrows can only flag bytes above one).scalar_string_bulk_run()validates a run of multi-byte UTF-8 sequences one after another instead of re-searching for the next special byte after each sequence, which helps text in non-Latin scripts. These kernels serve the lexer's contiguous fast path, the serializer, and the binary formats.memcpyload on little-endian targets, assembled from bytes otherwise.bit_ops.hppgainscount_trailing_zeros(), and MSVC gets_BitScanForward64/_BitScanReverse64for it and forcount_leading_zeros().\uescape decoding.get_codepoint()decoded four hex digits with four calls toget(), each going through a chain of range comparisons. For contiguous input it now uses one table lookup per byte (after yyjson'sread_hex_u16); an invalid digit shows in the OR of the four values, and the lexer then falls back to the per-character loop, so error messages and positions are unchanged. The streaming path is untouched.number_handling.md,template_parameters.md, andnumber_float_t.mdno longer say that parsing usesstrtod/strtof/strtold.strtoldand a NUL-terminateddata()only matter for along doublethat is not binary64.number_handling.mdalso documents that tokens too small fordoublebecome zero with the token's sign.README.md/license.mdnow also names the digit comparison.Performance
Measured on Apple M1 Max (Apple clang,
-O2) and an x86-64 KVM Haswell VPS pinned to one core (GCC 13 / Clang 18,-O2; the VPS is noisy, about ±5-10%). µs, best of 3 interleaved rounds of 15 runs; files from nativejson-benchmark.json::parseandjson::dump, develop → this PR:The dump speedup comes from the string-scan kernels, which the serializer also uses. Parsing citm_catalog is unchanged within the noise.
Tests
json::parsewith both scanners.doubleandfloat(ties to even, subnormal/overflow boundaries), a 200,000-value round trip, and a 100,000-valuefloatround trip.\utable is checked for valid escapes, surrogate pairs, truncation at every distance from the end, an invalid digit at each of the four positions, contiguous against streaming input, and seeded random escapes.unit-locale-cpp.cppchecks a multi-byte decimal point fordoubleexactly and forlong doubleagainst the "C" locale.Generator of
float_hard_cases.hppThe 508 embedded cases are produced by
compact_hard_cases.py 5(importinghard_cases.py, the fuller generator used for the 240,187-case offline checks), then formatted as one C++ initializer per output line. For every format boundary and a set of random values, it computes the exact midpoint to the next representable value and emits that midpoint, one unit above/below it, the midpoint with digits appended just past the rounding boundary, and the midpoint truncated at several digit counts around 17-30, in all three JSON number notations, with 30% negative. Both scripts round with exact rational arithmetic and cross-check against Python'sfloat().Public API
No breaking changes. Everything added or removed is in
nlohmann::detail. Parsed values are unchanged wherever the previous conversion was correctly rounded; they change only where it was not (a locale with a multi-byte decimal point, or a C library whosestrtodis not correctly rounded). No change todump()or any other output.Fixes #5660.
Written by Claude Code.
🤖 Generated with Claude Code