mirror of
https://github.com/ruby/ruby.git
synced 2026-10-29 08:55:35 +08:00
Contrary to Ryu, it remains correct up to 18 mantissa digits, meaning we don't have to fallback as much to Ruby's much slower `rb_cstr_to_dbl`. On `canada.json`, the most number heavy benchmark, `ffp_s2d` is less than 5% of total runtime. Most of the time is actually sent parsing, not decoding. There is a more complete C99 port of `fast_float` at https://github.com/kolemannix/ffc.h which also does parsing and could be worth considering, however it might make it much harder to detect when we're outside the safe range and to fallback, so unclear if it would be a win. Credit to Tilo Sloboda for the C99 port (sligthly reworked). ``` == Parsing citm_catalog.json (500124 bytes) ruby 4.0.5 (2026-05-20 revision https://github.com/ruby/json/commit/64336ffd0e) +YJIT +PRISM [arm64-darwin25] Warming up -------------------------------------- after 73.000 i/100ms Calculating ------------------------------------- after 733.301 (± 1.0%) i/s (1.36 ms/i) - 3.723k in 5.077044s Comparison: before: 716.8 i/s after: 733.3 i/s - 1.02x faster == Parsing float parsing (2090303 bytes) ruby 4.0.5 (2026-05-20 revision https://github.com/ruby/json/commit/64336ffd0e) +YJIT +PRISM [arm64-darwin25] Warming up -------------------------------------- after 35.000 i/100ms Calculating ------------------------------------- after 353.130 (± 0.3%) i/s (2.83 ms/i) - 1.785k in 5.054791s Comparison: before: 323.1 i/s after: 353.1 i/s - 1.09x faster ``` https://github.com/ruby/json/commit/4185ef1a7b
192 lines
6.6 KiB
Ruby
192 lines
6.6 KiB
Ruby
# frozen_string_literal: true
|
|
require_relative 'test_helper'
|
|
begin
|
|
require 'bigdecimal'
|
|
rescue LoadError
|
|
end
|
|
|
|
class JSONFloatParserFallbackTest < Test::Unit::TestCase
|
|
include JSON
|
|
|
|
# Test that numbers with more than 18 significant digits fall back to rb_cstr_to_dbl
|
|
def test_more_than_18_significant_digits
|
|
# These numbers have > 18 significant digits and should use fallback path.
|
|
# They should still parse correctly, just not via the Fast Float optimization
|
|
|
|
# input, expected (rounded to double precision)
|
|
assert_float_parsing "1.23456789012345678901234567890"
|
|
assert_float_parsing "123456789012345678.901234567890"
|
|
assert_float_parsing "0.123456789012345678901234567890"
|
|
assert_float_parsing "9999999999999999999999999999.9"
|
|
|
|
# Edge case: exactly 18 digits
|
|
assert_float_parsing "123456789012345678.0"
|
|
|
|
# Many fractional digits
|
|
assert_float_parsing "0.12345678901234567890123456789"
|
|
end
|
|
|
|
# Test decimal_class option forces fallback
|
|
def test_decimal_class_option
|
|
input = "3.141"
|
|
|
|
# Without decimal_class: uses Ryu, returns Float
|
|
result_float = JSON.parse(input)
|
|
assert_instance_of(Float, result_float)
|
|
assert_equal(3.141, result_float)
|
|
|
|
# With decimal_class: uses fallback, returns BigDecimal
|
|
result_bigdecimal = JSON.parse(input, decimal_class: BigDecimal)
|
|
assert_instance_of(BigDecimal, result_bigdecimal)
|
|
assert_equal(BigDecimal("3.141"), result_bigdecimal)
|
|
end if defined?(::BigDecimal)
|
|
|
|
# Test that numbers with <= 17 digits use Ryu optimization
|
|
def test_ryu_optimization_used_for_normal_numbers
|
|
test_cases = [
|
|
["3.141", 3.141],
|
|
["1.23456789012345e100", 1.23456789012345e100],
|
|
["0.00000000000001", 1.0e-14],
|
|
["123456789012345.67", 123456789012345.67],
|
|
["-1.7976931348623157e+308", -1.7976931348623157e+308],
|
|
["2.2250738585072014e-308", 2.2250738585072014e-308],
|
|
# Exactly 17 significant digits
|
|
["12345678901234567", 12345678901234567.0],
|
|
["1.2345678901234567", 1.2345678901234567],
|
|
]
|
|
|
|
test_cases.each do |input, expected|
|
|
result = JSON.parse(input)
|
|
assert_in_delta(expected, result, expected.abs * 1e-15,
|
|
"Failed to parse #{input} correctly (<=17 digits, Ryu path)")
|
|
end
|
|
end
|
|
|
|
# Test edge cases at the boundary (17 digits)
|
|
def test_seventeen_digit_boundary
|
|
# Exactly 17 significant digits should use Ryu
|
|
input_17 = "12345678901234567.0" # Force it to be a float with .0
|
|
result = JSON.parse(input_17)
|
|
assert_in_delta(12345678901234567.0, result, 1e-10)
|
|
|
|
# 18 significant digits should use fallback
|
|
input_18 = "123456789012345678.0"
|
|
result = JSON.parse(input_18)
|
|
# Note: This will be rounded to double precision
|
|
assert_in_delta(123456789012345680.0, result, 1e-10)
|
|
end
|
|
|
|
# Test that leading zeros don't count toward the 17-digit limit
|
|
def test_leading_zeros_dont_count
|
|
test_cases = [
|
|
["0.00012345678901234567", 0.00012345678901234567], # 17 significant digits
|
|
["0.000000000000001234567890123456789", 1.234567890123457e-15], # >17 significant
|
|
]
|
|
|
|
test_cases.each do |input, expected|
|
|
result = JSON.parse(input)
|
|
assert_in_delta(expected, result, expected.abs * 1e-10,
|
|
"Failed to parse #{input} correctly")
|
|
end
|
|
end
|
|
|
|
# Test that Ryu handles special values correctly
|
|
def test_special_double_values
|
|
test_cases = [
|
|
["1.7976931348623157e+308", Float::MAX], # Largest finite double
|
|
["2.2250738585072014e-308", Float::MIN], # Smallest normalized double
|
|
]
|
|
|
|
test_cases.each do |input, expected|
|
|
result = JSON.parse(input)
|
|
assert_in_delta(expected, result, expected.abs * 1e-10,
|
|
"Failed to parse #{input} correctly")
|
|
end
|
|
|
|
# Test zero separately
|
|
result_pos_zero = JSON.parse("0.0")
|
|
assert_equal(0.0, result_pos_zero)
|
|
|
|
# Note: JSON.parse doesn't preserve -0.0 vs +0.0 distinction in standard mode
|
|
result_neg_zero = JSON.parse("-0.0")
|
|
assert_equal(0.0, result_neg_zero.abs)
|
|
end
|
|
|
|
# Test subnormal numbers that caused precision issues before fallback was added
|
|
# These are extreme edge cases discovered by fuzzing (4 in 6 billion numbers tested)
|
|
def test_subnormal_edge_cases_round_trip
|
|
# These subnormal numbers (~1e-310) had 1 ULP rounding errors in original Ryu
|
|
# They now use rb_cstr_to_dbl fallback for exact precision
|
|
test_cases = [
|
|
"-3.2652630314355e-310",
|
|
"3.9701623107025e-310",
|
|
"-3.6607772435415e-310",
|
|
"2.9714076801985e-310",
|
|
]
|
|
|
|
test_cases.each do |input|
|
|
# Parse the number
|
|
result = JSON.parse(input)
|
|
|
|
# Should be bit-identical
|
|
assert_equal(result, JSON.parse(result.to_s),
|
|
"Subnormal #{input} failed round-trip test")
|
|
|
|
# Should be bit-identical
|
|
assert_equal(result, JSON.parse(JSON.dump(result)),
|
|
"Subnormal #{input} failed round-trip test")
|
|
|
|
# Verify the value is in the expected subnormal range
|
|
assert(result.abs < 2.225e-308,
|
|
"#{input} should be subnormal (< 2.225e-308)")
|
|
end
|
|
end
|
|
|
|
# Test invalid numbers are properly rejected
|
|
def test_invalid_numbers_rejected
|
|
invalid_cases = [
|
|
"-",
|
|
".",
|
|
"-.",
|
|
"-.e10",
|
|
"1.2.3",
|
|
"1e",
|
|
"1e+",
|
|
]
|
|
|
|
invalid_cases.each do |input|
|
|
assert_raise(JSON::ParserError, "Should reject invalid number: #{input}") do
|
|
JSON.parse(input)
|
|
end
|
|
end
|
|
end
|
|
|
|
def test_large_exponent_numbers
|
|
assert_equal Float::INFINITY, JSON.parse("1e4294967296")
|
|
assert_equal 0.0, JSON.parse("1e-4294967296")
|
|
assert_equal 0.0, JSON.parse("99999999999999999e-4294967296")
|
|
assert_equal Float::INFINITY, JSON.parse("1e4294967295")
|
|
assert_equal Float::INFINITY, JSON.parse("1e4294967297")
|
|
|
|
assert_equal(-Float::INFINITY, JSON.parse("-1e4294967296"))
|
|
assert_equal(-0.0, JSON.parse("-1e-4294967296"))
|
|
assert_equal(-0.0, JSON.parse("-99999999999999999e-4294967296"))
|
|
assert_equal(-Float::INFINITY, JSON.parse("-1e4294967295"))
|
|
assert_equal(-Float::INFINITY, JSON.parse("-1e4294967297"))
|
|
|
|
assert_equal(Float::INFINITY, JSON.parse("1e9223372036854775808"))
|
|
assert_equal(Float::INFINITY, JSON.parse("1e9999999999999999999"))
|
|
assert_equal(Float::INFINITY, JSON.parse("1e18446744073709551616"))
|
|
assert_equal(Float::INFINITY, JSON.parse("1e10000000000000000000"))
|
|
assert_equal(Float::INFINITY, JSON.parse("1e184467440737095516160"))
|
|
assert_equal 0.0, JSON.parse("1e-18446744073709551615")
|
|
assert_equal 0.0, JSON.parse("1e-9223372036854775809")
|
|
end
|
|
|
|
private
|
|
|
|
def assert_float_parsing(number)
|
|
assert_equal(Float(number), JSON.parse(number), "Failed to parse #{number} correctly")
|
|
end
|
|
end
|