diff --git a/include/nlohmann/detail/input/lexer.hpp b/include/nlohmann/detail/input/lexer.hpp index 4c3665593..0edd64288 100644 --- a/include/nlohmann/detail/input/lexer.hpp +++ b/include/nlohmann/detail/input/lexer.hpp @@ -1075,6 +1075,12 @@ class lexer : public lexer_base // changed if minus sign, decimal point, or exponent is read token_type number_type = token_type::value_unsigned; + // offset just past the last mantissa byte in token_buffer (i.e. the + // index of 'e'/'E', or the whole token when there is no exponent). + // convert_number() uses it to count significant digits; npos means + // "not seen an exponent yet" and is resolved at scan_number_done + std::size_t mantissa_end = std::string::npos; + // state (init): we just found out we need to scan a number switch (current) { @@ -1260,6 +1266,9 @@ scan_number_decimal2: scan_number_exponent: // we just parsed an exponent number_type = token_type::value_float; + // this label is reached only right after the 'e'/'E' was appended (from + // the zero, any1, and decimal2 states), so the mantissa ends before it + mantissa_end = token_buffer.size() - 1; switch (get()) { case '+': @@ -1346,7 +1355,13 @@ scan_number_done: // we are done scanning a number) unget(); - return convert_number(number_type); + // no exponent was scanned: the mantissa spans the whole token + if (mantissa_end == std::string::npos) + { + mantissa_end = token_buffer.size(); + } + + return convert_number(number_type, mantissa_end); } /*! @@ -1380,6 +1395,59 @@ scan_number_done: return token_type::uninitialized; } + /*! + @brief check whether Clinger's fast path can still succeed for this token + + parse_float_fast() needs a significand below 2^53. A mantissa with 17 or + more significant digits is at least 10^16 and therefore always exceeds it, + so calling the fast path would walk the token one extra time only to + decline before strtod has to run anyway. + + Significant digits are the mantissa's digits from the first nonzero one on; + the sign, the decimal point, leading zeros, and the exponent do not count. + The answer is derived from indices - the digits are not scanned again - so + this stays off the hot path of the number scanners. + + @param[in] mantissa_end offset just past the last mantissa byte in + token_buffer + @return false if parse_float_fast() is guaranteed to decline + */ + bool mantissa_fits_clinger(std::size_t mantissa_end) const + { + // 10^16 already exceeds 2^53, so 17 digits can never fit + constexpr std::size_t limit = 17; + + const std::size_t neg = (!token_buffer.empty() && token_buffer[0] == '-') ? 1u : 0u; + const std::size_t has_dot = (decimal_point_position != std::string::npos) ? 1u : 0u; + // the JSON grammar restricts the integer part to "0" or [1-9][0-9]*, so + // a leading zero can only be a lone "0", which is not significant + const std::size_t lead_zero = (token_buffer[neg] == '0') ? 1u : 0u; + JSON_ASSERT(mantissa_end >= neg + has_dot + lead_zero); + std::size_t digits = mantissa_end - neg - has_dot - lead_zero; + + if (JSON_HEDLEY_LIKELY(digits < limit)) + { + return true; + } + + // Only a number below 1 can carry further insignificant zeros, and only + // while the count stays at the limit does removing them change the + // answer - so this loop is skipped for all but a few tokens. Note + // token_buffer holds the locale's decimal point, so the fraction is + // located through decimal_point_position rather than by searching '.'. + if (lead_zero != 0) + { + JSON_ASSERT(has_dot != 0); // an integer "0" cannot reach the limit + for (std::size_t i = decimal_point_position + 1; + digits >= limit && i < mantissa_end && token_buffer[i] == '0'; ++i) + { + --digits; + } + } + + return digits < limit; + } + /*! @brief convert the number text in token_buffer to its value and token type @@ -1388,8 +1456,14 @@ scan_number_done: locale decimal point in place of '.'. Integers are parsed first and fall back to floating point on overflow. This is shared so both scanners produce identical results. + + @param[in] mantissa_end offset just past the last mantissa byte in + token_buffer (the index of 'e'/'E', or + token_buffer.size() when there is no exponent); + used to skip Clinger's fast path when it cannot + possibly succeed - see mantissa_fits_clinger() */ - token_type convert_number(token_type number_type) + token_type convert_number(token_type number_type, std::size_t mantissa_end) { const char* const num_begin = token_buffer.data(); const char* const num_end = num_begin + token_buffer.size(); @@ -1412,7 +1486,11 @@ scan_number_done: { return token_type::value_float; } - if (parse_float_fast(num_begin, num_end, decimal_point_char, value_float)) + // Skipping a fast path that cannot succeed is lossless and saves a full + // extra pass over the token's bytes, which otherwise shows up on + // high-precision inputs such as canada.json + if (mantissa_fits_clinger(mantissa_end) + && parse_float_fast(num_begin, num_end, decimal_point_char, value_float)) { return token_type::value_float; } @@ -1498,6 +1576,8 @@ scan_number_done: ++i; } } + // the mantissa ends here, whether or not an exponent part follows + const std::size_t mantissa_end = i; if (i < avail && (data[i] == 'e' || data[i] == 'E')) { number_type = token_type::value_float; @@ -1560,7 +1640,7 @@ scan_number_done: position.chars_read_total += (len - 1); position.chars_read_current_line += (len - 1); - return convert_number(number_type); + return convert_number(number_type, mantissa_end); } /// contiguous input: try the number fast path, else the byte-path scanner diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index a06338a64..9dedfe204 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -9450,6 +9450,12 @@ class lexer : public lexer_base // changed if minus sign, decimal point, or exponent is read token_type number_type = token_type::value_unsigned; + // offset just past the last mantissa byte in token_buffer (i.e. the + // index of 'e'/'E', or the whole token when there is no exponent). + // convert_number() uses it to count significant digits; npos means + // "not seen an exponent yet" and is resolved at scan_number_done + std::size_t mantissa_end = std::string::npos; + // state (init): we just found out we need to scan a number switch (current) { @@ -9635,6 +9641,9 @@ scan_number_decimal2: scan_number_exponent: // we just parsed an exponent number_type = token_type::value_float; + // this label is reached only right after the 'e'/'E' was appended (from + // the zero, any1, and decimal2 states), so the mantissa ends before it + mantissa_end = token_buffer.size() - 1; switch (get()) { case '+': @@ -9721,7 +9730,13 @@ scan_number_done: // we are done scanning a number) unget(); - return convert_number(number_type); + // no exponent was scanned: the mantissa spans the whole token + if (mantissa_end == std::string::npos) + { + mantissa_end = token_buffer.size(); + } + + return convert_number(number_type, mantissa_end); } /*! @@ -9755,6 +9770,59 @@ scan_number_done: return token_type::uninitialized; } + /*! + @brief check whether Clinger's fast path can still succeed for this token + + parse_float_fast() needs a significand below 2^53. A mantissa with 17 or + more significant digits is at least 10^16 and therefore always exceeds it, + so calling the fast path would walk the token one extra time only to + decline before strtod has to run anyway. + + Significant digits are the mantissa's digits from the first nonzero one on; + the sign, the decimal point, leading zeros, and the exponent do not count. + The answer is derived from indices - the digits are not scanned again - so + this stays off the hot path of the number scanners. + + @param[in] mantissa_end offset just past the last mantissa byte in + token_buffer + @return false if parse_float_fast() is guaranteed to decline + */ + bool mantissa_fits_clinger(std::size_t mantissa_end) const + { + // 10^16 already exceeds 2^53, so 17 digits can never fit + constexpr std::size_t limit = 17; + + const std::size_t neg = (!token_buffer.empty() && token_buffer[0] == '-') ? 1u : 0u; + const std::size_t has_dot = (decimal_point_position != std::string::npos) ? 1u : 0u; + // the JSON grammar restricts the integer part to "0" or [1-9][0-9]*, so + // a leading zero can only be a lone "0", which is not significant + const std::size_t lead_zero = (token_buffer[neg] == '0') ? 1u : 0u; + JSON_ASSERT(mantissa_end >= neg + has_dot + lead_zero); + std::size_t digits = mantissa_end - neg - has_dot - lead_zero; + + if (JSON_HEDLEY_LIKELY(digits < limit)) + { + return true; + } + + // Only a number below 1 can carry further insignificant zeros, and only + // while the count stays at the limit does removing them change the + // answer - so this loop is skipped for all but a few tokens. Note + // token_buffer holds the locale's decimal point, so the fraction is + // located through decimal_point_position rather than by searching '.'. + if (lead_zero != 0) + { + JSON_ASSERT(has_dot != 0); // an integer "0" cannot reach the limit + for (std::size_t i = decimal_point_position + 1; + digits >= limit && i < mantissa_end && token_buffer[i] == '0'; ++i) + { + --digits; + } + } + + return digits < limit; + } + /*! @brief convert the number text in token_buffer to its value and token type @@ -9763,8 +9831,14 @@ scan_number_done: locale decimal point in place of '.'. Integers are parsed first and fall back to floating point on overflow. This is shared so both scanners produce identical results. + + @param[in] mantissa_end offset just past the last mantissa byte in + token_buffer (the index of 'e'/'E', or + token_buffer.size() when there is no exponent); + used to skip Clinger's fast path when it cannot + possibly succeed - see mantissa_fits_clinger() */ - token_type convert_number(token_type number_type) + token_type convert_number(token_type number_type, std::size_t mantissa_end) { const char* const num_begin = token_buffer.data(); const char* const num_end = num_begin + token_buffer.size(); @@ -9787,7 +9861,11 @@ scan_number_done: { return token_type::value_float; } - if (parse_float_fast(num_begin, num_end, decimal_point_char, value_float)) + // Skipping a fast path that cannot succeed is lossless and saves a full + // extra pass over the token's bytes, which otherwise shows up on + // high-precision inputs such as canada.json + if (mantissa_fits_clinger(mantissa_end) + && parse_float_fast(num_begin, num_end, decimal_point_char, value_float)) { return token_type::value_float; } @@ -9873,6 +9951,8 @@ scan_number_done: ++i; } } + // the mantissa ends here, whether or not an exponent part follows + const std::size_t mantissa_end = i; if (i < avail && (data[i] == 'e' || data[i] == 'E')) { number_type = token_type::value_float; @@ -9935,7 +10015,7 @@ scan_number_done: position.chars_read_total += (len - 1); position.chars_read_current_line += (len - 1); - return convert_number(number_type); + return convert_number(number_type, mantissa_end); } /// contiguous input: try the number fast path, else the byte-path scanner diff --git a/tests/src/unit-class_lexer.cpp b/tests/src/unit-class_lexer.cpp index f138d33fa..e89497738 100644 --- a/tests/src/unit-class_lexer.cpp +++ b/tests/src/unit-class_lexer.cpp @@ -12,6 +12,7 @@ #include using nlohmann::json; +#include // strtod #include // stringstream #include // string #include // vector @@ -273,6 +274,56 @@ TEST_CASE("lexer number fast path") } } + SECTION("significant-digit gate for the Clinger fast path") + { + // Clinger's fast path needs a significand below 2^53, so it cannot + // succeed once the mantissa has 17 or more significant digits (the + // significand would be at least 10^16). The lexer skips the attempt + // there. That is only allowed to save work: every value must still come + // out bit-exactly, and both scanners must agree. In particular the gate + // must not fire for tokens whose leading zeros merely look like extra + // digits - "0.1234567890123456" has 16 significant digits, not 17. + const std::vector numbers = + { + "1234567890123456", // 16 significant digits + "12345678901234567", // 17 -> attempt skipped + "123456789012345678", // 18 -> attempt skipped + "0.1234567890123456", // 16: the leading "0" is not significant + "0.12345678901234567", // 17 + "0.00000000000000001", // 1, in a long token + "0.000000000000000012345678901234", // 14, in a long token + "-0.0000000000000000000001", // 1, negative + "1.0000000000000000", // 17: trailing zeros are significant here + "10000000000000000", // 17 + "9007199254740992", // 2^53 + "9007199254740993", // 2^53 + 1 + "-65.613616999999977", // canada.json shape + "1.2345678901234567e-250", // 17 with an exponent + "1.234567890123456e-250", // 16 with an exponent + "1e10", "0.0", "-0.0", "0e0", "0.000123" + }; + + for (const auto& n : numbers) + { + CAPTURE(n); + const std::string doc = "[" + n + "]"; + + const json a = json::parse(doc); // contiguous fast path + std::stringstream ss(doc); + const json b = json::parse(ss); // streaming byte path + + CHECK(a[0].type() == b[0].type()); + CHECK(a == b); + + if (a[0].is_number_float()) + { + const double expected = std::strtod(n.c_str(), nullptr); + CHECK(a[0].get() == expected); + CHECK(b[0].get() == expected); + } + } + } + SECTION("token type classification") { CHECK((scan_string("0") == json::lexer::token_type::value_unsigned));