Skip to content

Speed up the lexer: own float parser, string scan, and \u table - #5738

Merged
nlohmann merged 10 commits into
developfrom
json-view/02b-float-parser
Oct 14, 2026
Merged

nlohmann merged 10 commits into
developfrom
json-view/02b-float-parser

Conversation

@nlohmann

@nlohmann nlohmann commented Sep 30, 2026 •

Copy link
Copy Markdown
Owner

Part of the stack for the zero-copy view (#5295). This change speeds up json::parse on its own: it adds a float converter and faster string kernels, which the view uses later in the stack.

Summary

  • Own float converter for binary32/binary64. float, double, and long double where 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.
    1. Split the token into sign, significand w (at most 19 digits), and decimal exponent q. 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.
    2. Clinger's fast path where w and 10^|q| are exact (skipped where FLT_EVAL_METHOD != 0).
    3. Eisel-Lemire otherwise, templated for binary32 and binary64.
    4. For tokens with more than 19 significant digits whose w and w + 1 round 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).
    • Parsed values are bit-identical wherever the previous chain (Eisel-Lemire in front of std::from_chars/strtod) was correctly rounded. Out-of-range values still throw out_of_range.406, and underflow still gives a zero with the sign of the token.
    • eisel_lemire() and decimal_to_float() are always inlined, so callers' hot loops keep the whole conversion inline.
    • The strtold fallback left for long double formats 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 as fa_IR.UTF-8.
  • Faster string scanning. The SWAR kernels in string_scan.hpp find 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.
  • Word loads and bit operations. Words are read in little-endian order on every platform: with one memcpy load on little-endian targets, assembled from bytes otherwise. bit_ops.hpp gains count_trailing_zeros(), and MSVC gets _BitScanForward64/_BitScanReverse64 for it and for count_leading_zeros().
  • Faster \u escape decoding. get_codepoint() decoded four hex digits with four calls to get(), each going through a chain of range comparisons. For contiguous input it now uses one table lookup per byte (after yyjson's read_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.
  • Docs. number_handling.md, template_parameters.md, and number_float_t.md no longer say that parsing uses strtod/strtof/strtold. strtold and a NUL-terminated data() only matter for a long double that is not binary64. number_handling.md also documents that tokens too small for double become zero with the token's sign.
  • Credit. The fast_float entry in README.md/license.md now 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::parse and json::dump, develop → this PR:

Apple M1 x86-64 GCC x86-64 Clang
parse canada 8958 → 7529 (−16%) 16494 → 15553 (−6%) 15709 → 14950 (−5%)
parse twitter 1528 → 1402 (−8%) 4445 → 4034 (−9%) 4386 → 3990 (−9%)
parse citm_catalog 2994 → 2974 (−1%) 7898 → 7843 (−1%) 6511 → 6546 (+1%)
dump twitter 490 → 411 (−16%) 1058 → 1007 (−5%) 1075 → 875 (−19%)

The dump speedup comes from the string-scan kernels, which the serializer also uses. Parsing citm_catalog is unchanged within the noise.

Tests

  • 508 generated hard float-parsing cases with expected binary32 and binary64 bits (exact midpoints and their neighbours, boundary values, huge exponents, long tokens), checked through the converter and through json::parse with both scanners.
  • Exact-bit tests for double and float (ties to even, subnormal/overflow boundaries), a 200,000-value round trip, and a 100,000-value float round trip.
  • The string-scan kernels are compared against byte-by-byte reference scans on generated buffers at three alignments; the \u table 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.cpp checks a multi-byte decimal point for double exactly and for long double against the "C" locale.
Generator of float_hard_cases.hpp

The 508 embedded cases are produced by compact_hard_cases.py 5 (importing hard_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's float().

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 whose strtod is not correctly rounded). No change to dump() or any other output.

Fixes #5660.


Written by Claude Code.

🤖 Generated with Claude Code

@nlohmann
nlohmann added this pull request to stack #5739 September 30, 2026 13:21
@nlohmann
nlohmann force-pushed the json-view/02b-float-parser branch from d92b762 to 39092df Compare September 30, 2026 18:06
Base automatically changed from json-view/02-eisel-lemire to develop September 30, 2026 18:06
@nlohmann
nlohmann force-pushed the json-view/02b-float-parser branch from 39092df to 192b99b Compare September 30, 2026 18:06
@nlohmann
nlohmann marked this pull request as ready for review September 30, 2026 18:15
@nlohmann
nlohmann force-pushed the json-view/02b-float-parser branch from 192b99b to 9c71689 Compare September 30, 2026 18:19
@nlohmann nlohmann added the review needed It would be great if someone could review the proposed changes. label Sep 30, 2026
Comment thread docs/mkdocs/docs/features/types/number_handling.md Outdated
Comment thread tests/src/unit-class_lexer.cpp Dismissed
@nlohmann
nlohmann removed this pull request from stack #5739 October 6, 2026 09:26
@nlohmann
nlohmann force-pushed the json-view/02b-float-parser branch from 15b0cc0 to 953d74d Compare October 6, 2026 09:28
@nlohmann nlohmann changed the title Convert float and double with the library's own correctly rounded parser Speed up the lexer: own float parser, string scan, and \u table Oct 6, 2026
@nlohmann
nlohmann added this pull request to stack #5768 October 6, 2026 09:29
@nlohmann
nlohmann force-pushed the json-view/02b-float-parser branch from 953d74d to 68d61b6 Compare October 7, 2026 14:44
@nlohmann
nlohmann force-pushed the json-view/02b-float-parser branch 2 times, most recently from fb02078 to 247b03b Compare October 11, 2026 09:33
@nlohmann
nlohmann force-pushed the json-view/02b-float-parser branch from 247b03b to 711093e Compare October 13, 2026 15:32

@gregmarr gregmarr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread include/nlohmann/detail/input/lexer.hpp Outdated
Comment thread include/nlohmann/detail/input/number_parse.hpp
Comment thread include/nlohmann/detail/input/number_parse.hpp
Comment thread include/nlohmann/detail/input/number_parse.hpp Outdated
@nlohmann nlohmann removed the review needed It would be great if someone could review the proposed changes. label Oct 13, 2026
@nlohmann nlohmann added this to the Release 3.13.0 milestone Oct 13, 2026
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
nlohmann force-pushed the json-view/02b-float-parser branch from ac6dda3 to af71114 Compare October 13, 2026 20:22
@nlohmann
nlohmann merged commit 5b4c8f9 into develop Oct 14, 2026
170 checks passed
@nlohmann
nlohmann deleted the json-view/02b-float-parser branch October 14, 2026 15:38
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Floats are truncated at the decimal point under locales with a multi-byte decimal point (fa_IR.UTF-8)

3 participants