Repository navigation
Conversation
Under a locale whose decimal point is longer than one byte (e.g. U+066B in fa_IR.UTF-8 or ar_EG.UTF-8), every float that reached the strtod fallback was truncated at the decimal point: "3.14159265358979323846" became 3.0, and "1.5e400" became 1.0 instead of throwing. With libc++ and in C++11/14, that fallback was taken for most floats. The lexer now converts floats in this order: 1. std::from_chars, now also for float and double with libc++ 20 or later, which does not define __cpp_lib_to_chars (on Apple platforms only if the deployment target provides it); 2. Clinger's fast path (double only); 3. strtof_l/strtod_l/strtold_l with a "C" locale created once, on glibc, Apple platforms, and MSVC; 4. strtof/strtod/strtold with the decimal point of the current locale, which now puts a multi-byte decimal point into a copy of the token. If std::from_chars reports a value out of range, the result is derived from the token (+-infinity or +-0) instead of calling strtod, because implementations disagree on the stored value (P4168). Values that may be subnormal are left to the next step, because libstdc++ before GCC 13 reports some of them as out of range. The conversion helpers moved from the lexer to number_parse.hpp, so the last-resort path can be tested directly. Fixes #5660. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
libc++'s std::from_chars for float and double is 1.3x to 2.8x slower per number than Apple's strtod_l, and it was tried before Clinger's fast path. With Apple clang in C++17 mode, parsing random doubles took 1.75x as long as on develop, short numbers such as 123.45 1.3x, and mesh.json 1.2x. Use libc++'s std::from_chars only where the C library has no strtod_l. On Apple platforms, floats are now converted by Clinger's fast path and strtod_l, which is 0.90x to 1.01x the time of develop. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
|
I measured how this PR affects parsing speed and pushed one change as a result (42e3489). What I found: step 1 of the proposal, using
Timed directly, libc++ 22's The change: libc++'s Result: compared with This comment was written by Claude Code on behalf of @nlohmann. |
|
Closing in favor of #5738 (in the json_view stack #5739, directly after #5617). It fixes #5660 more completely, and with less platform-specific code:
Parts of this PR that are deliberately dropped:
#5660 will be closed when #5738 lands. This comment was written by Claude Code on behalf of @nlohmann. |
| } | ||
| const std::string decimal_point = std::localeconv()->decimal_point; | ||
| if (decimal_point.size() < 2) | ||
| if (std::setlocale(LC_NUMERIC, name) != nullptr && std::strlen(std::localeconv()->decimal_point) > 1) |
Summary
Under a locale whose decimal point is longer than one byte (U+066B in
fa_IR.UTF-8,ar_EG.UTF-8, ...), every float that reached thestrtodfallback was silently truncated at the decimal point, and out-of-range values such as1.5e400became1.0instead of throwing. With libc++ (all standards) and in C++11/14, that fallback handled most floats, because libc++ does not define__cpp_lib_to_chars. This PR implements the four steps proposed in #5660 (comment), so that the locale-dependentstrtodis only the last resort, and fixes that last resort for multi-byte decimal points.Changes
The lexer now converts a float token in this order (
lexer::convert_number()):std::from_chars: as before if__cpp_lib_to_charsis defined (float, double, long double), and now also forfloat/doublewith libc++ 20 or later in C++17 or later (_LIBCPP_VERSION >= 200000), but only where the C library has nostrtod_l(see step 3). libc++'s implementation is 1.3x to 2.8x slower per number than Apple'sstrtod_l, and it would be tried before Clinger's fast path (see "Performance" below). On Apple platforms, floating-pointfrom_charsalso lives in the system dylib and is marked available from macOS/iOS 26, so the check also requires_LIBCPP_AVAILABILITY_HAS_FROM_CHARS_FLOATING_POINT.long doubleis not supported by libc++ and skips this step.strtod: iffrom_charsreportsresult_out_of_range, the value is derived from the token (parse_float_out_of_range()): the sign from a leading-, the direction from the decimal exponent of the first nonzero digit. ±infinity then throwsout_of_range.406in the parser as before, and an underflow gives ±0. The reason is in a comment: implementations disagree on the stored value (P4168). One deviation from the proposal: a value that may be subnormal is left to the next step, because libstdc++'sfrom_charsbefore GCC 13 reports some subnormal results as out of range (it usesstrtodand itsERANGE: for all types in GCC 11, forlong doublein GCC 12; checked in the libstdc++ sources). ±0 is only returned when the token is below half the smallest subnormal number regardless of its digits (a conservative bound computed fromnumeric_limits).strtof_l/strtod_l/strtold_lwith a "C" locale (parse_float_c_locale()), revived from Switch number parsing tostd::from_chars/ extended localestrto*_lif available #5237. The locale is created once in a function-local static (newlocale(LC_NUMERIC_MASK, "C", nullptr), or_create_locale(LC_NUMERIC, "C")with_strto*_lon MSVC) and never freed. It is used only on an allowlist: MSVC/UCRT (_MSC_VER >= 1900, not MinGW), Apple (<xlocale.h>, included after<cstdlib>because it only declares thestrto*_lfunctions if<stdlib.h>came first), and glibc (__GLIBC__and__USE_GNU, not uClibc). g++ and clang++ define_GNU_SOURCEfor C++ on glibc, so users do not need to define anything. If_GNU_SOURCEis not in effect, the code falls back cleanly.parse_float_locale_aware(), previouslylexer::convert_float_locale_aware()):strtodwith the decimal point of the current locale, still looked up at conversion time with the retry from Look up the locale decimal point at conversion time, not lexer construction #5597. A decimal point longer than one byte is now put into a copy of the token, as in the patch from the issue.Other changes:
strtofoverloads, decimal point lookup, last-resort conversion) moved fromlexer.hpptonumber_parse.hpp, next to the other conversion helpers, so the last resort can be tested directly on platforms that no longer use it.JSON_HAS_FLOAT_FROM_CHARS,JSON_HAS_LONG_DOUBLE_FROM_CHARS, andJSON_HAS_C_LOCALE_STRTOD, which are undefined at the end ofjson.hppunlessJSON_TEST_KEEP_MACROSis defined.features/types/number_handling.mddescribes how floats are converted and that underflows become ±0;api/basic_json/parse.mdhas a 3.13.0 version-history entry.Which platform uses which path after this change:
float/doublelong doublefrom_charsfrom_charsstrtod_l(e.g. Android, FreeBSD)from_charsstrtod_lstrtold_lstrtod_lstrtold_l_strtod_l_strtold_lfrom_chars)Overlap with draft #5617 (Eisel-Lemire for
doublewhenfrom_charsis unavailable): both PRs touchnumber_parse.hpp. The conflicts should be textual only, and this PR does not depend on it. After this PR, #5617 would only speed up thedoublepath and avoidstrtod_l/strtodfor it on platforms withoutfrom_chars. It is no longer needed for correctness under exotic locales.Tests
unit-locale-cpp.cpp, "locale with a multi-byte decimal point": now checks values under the first usable locale:3.141592653589793238462643383279→ 3.141592653589793,1.7976931348623157e308→DBL_MAX,-2.5e-320,1.5e400→out_of_range.406(exact message; the exception has no JSON context, so diagnostic positions do not change it),1.5e-400→ 0.0, and1.5withfloatandlong doubleasnumber_float_t. It is still skipped with a message if no such locale is installed.unit-locale-cpp.cpp, new "conversion with the decimal point of the current locale": callsparse_float_locale_aware()directly underC,de_DE, and the multi-byte locale (all three float types, token without a dot, an invalid token that stops early, token restored to.).unit-class_lexer.cpp:parse_float_out_of_range()directly (overflow, underflow, saturated exponents, possibly subnormal → declines, float/long double), and locale-free parsing of out-of-range values fordouble/float/long doublenumber types, positive and negative, incl. signed zeros and values next to the smallest subnormal.develop(633de8e) underfa_IR.UTF-8on macOS: 7 failed assertions with Apple clang (C++11/17/20) and GCC 16 C++11, 2 with GCC 16 C++17 (1.5e400,1.5e-400). The locale-free out-of-range tests already pass ondevelop; they guard the new token-derived results.unit-locale-cpp,unit-class_lexer): Apple clang 21 with C++11/17/20 and ASan/UBSan; GCC 16 (Homebrew, macOS SDK) with C++11/17. Alsounit-testsuites,unit-class_parser,unit-regression1, andunit-deserializationwith Apple clang C++11/17. Compile checks:-mmacosx-version-min=10.15and11.0with C++17/20. After 42e3489,unit-locale-cppandunit-class_lexerpass again with Apple clang C++11/17/20 (ASan/UBSan) and GCC 16 C++17; the Apple clang C++17 binaries no longer referencefrom_chars. No warnings with-Weverything(CI's clang flags, Apple clang and clang 22 on Linux) and with CI'sgcc_flags.cmake(GCC 16). In Docker/glibc: GCC 16 C++11/17, GCC 4.9 C++11, and clang 4 C++11/14 run both tests. clang 22 with-stdlib=libc++compiles; that image's libc++ is 14, so it correctly does not usefrom_chars.LC_NUMERIC=fa_IR.UTF-8, macOS 27:3.1415926535897932384626433832791.7976931348623157e308-2.5e-3201.5e4001.5e-4001.5(float)1.5(long double)1.5(double, Clinger)Performance
Parse time of this PR relative to develop (1.00 = unchanged, lower is faster; median of 21 parses, best of 2–3 rounds,
-O2, arm64; glibc natively in Docker). Parsed values are bit-identical to develop in every configuration (checksum over all floats).%.17g%.17g123.45, Clinger)floattype)long doubletype)strtod_lneeds neitherlocaleconv()nor a copy of the token per number (strtod_litself is as fast asstrtod: 203 vs 206 ns per random double on glibc).from_charsbefore and after).from_charsthere and was slower (random doubles 1.75x, short numbers 1.3x, mesh.json 1.2x, canada.json 1.03–1.08x). Measured directly, libc++ 22'sfrom_charstakes 104 ns per random double, 41 ns per 17-digit value in [0,1), and 19 ns per short number, versus 37, 27, and 14 ns for Apple'sstrtod_l. 42e3489 therefore uses libc++'sfrom_charsonly where there is nostrtod_l; the Apple clang C++17 column shows the result.Public API
No breaking changes. User-visible differences:
out_of_range.406or become ±0 there too.std::from_charsreports out of range, the result now comes from the token (±infinity →out_of_range.406, ±0) instead ofstrtod. The values are the same asstrtodproduced in the "C" locale.strtod_lnow getfrom_charsforfloat/double: correctly rounded and locale-independent.<xlocale.h>on Apple platforms;number_parse.hppnow includes<clocale>,<cstdlib>,<string>, and<utility>(lexer.hppno longer includes<clocale>/<cstdlib>directly; they are still included throughnumber_parse.hpp).from_charsallocates a "C" locale object that is intentionally never freed (still reachable, not a leak for LSan/Valgrind's default settings).json.hpp.Fixes #5660
This PR was written by Claude Code on behalf of @nlohmann.
🤖 Generated with Claude Code