Fast JSON Double and Float codec (read + write) - #906
Conversation
Opt-in JsonOptions.numberCodec (default Standard, no behavior change): - Double/Float write via xjb port: shortest digits straight into the builder; always round-trips; differs from toString only on JDK<19 edge cases (JDK-4511638) - Double/Float read via Eisel-Lemire (FastDoubleParser port) from the reader buffer, no substring: bit-identical to platform, ambiguous cases fall back - Byte/Short/Int/Long read: digit loop on the buffer, results identical incl. "1.0"/"2e1"/overflow (fallback) - BigInt/BigDecimal untouched MiMa-clean via pre-numberCodec apply/copy/ctor overloads; Scala.js supported and tested. Attribution: file headers + THIRD-PARTY-NOTICES.md (xjb Apache-2.0, FastDoubleParser MIT); tables generated, not transcribed. Tests: 2M-value bit-exact sweeps, Paxson testbase stress vectors, exhaustive power-of-two sweep, JVM+JS cross test. JMH: reads 1.5-1.8x with ~half the alloc; writes 1.6x (JDK17) / 1.25x (JDK25) double-heavy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Does it make sense to keep this separate from the default? Looks like it's fully compatible with the previous format (unless the issues were omitted or not detected). |
I'm open to making it non-configurable, then we should test it more internally before release |
…performance regression when parsing Integers, remove the substring allocation optimization (did more harm than good)
The final design touches only Double/Float; integers were left on the released path. Remove the tests/benchmarks and docs that still described the abandoned buffer-based integer parsing. - Delete JsonReadIntegerBufferTest: it tested the abandoned buffer-based integer path. Integers are unchanged from master, and the one changed behavior (non-integer literals -> EiselLemire fallback, overflow rejection) is covered by the cross test. - Rename JsonNumberCodecCrossTest -> JsonFastNumberCrossTest (NumberCodec was the config option that never shipped) and JsonReadDoubleBufferTest -> JsonReadDoubleTest (there is no buffer-based read path). - Correct stale "parse straight from the reader buffer" docstrings in JsonIntReadBenchmark, JsonNumberBenchmark, BigNumbers, PaxsonConversionTest, and a stale test name in JsonStringFastFloatTest. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
nit: It is no longer opt in; PR description needs updating |
| val actual = EiselLemireDouble.parse(s) | ||
| assert(bits(actual) == bits(expected), s"'$s' -> $actual (expected $expected)") | ||
| } | ||
|
|
There was a problem hiding this comment.
class EiselLemireExponentClampTest extends AnyFunSuite {
private def dbits(d: Double): Long = java.lang.Double.doubleToLongBits(d)
private def fbits(f: Float): Int = java.lang.Float.floatToIntBits(f)
// "1" followed by (n-1) zeros, i.e. 10^(n-1), times 10^-19999 -> underflows to 0.0
private def hugeIntPart(n: Int): String = "1" + ("0" * (n - 1)) + "e-19999"
test("double: long integer part with a 5-digit negative exponent must match Double.parseDouble") {
List(1700, 1800, 2300).foreach { digits =>
val s = hugeIntPart(digits)
val expected = java.lang.Double.parseDouble(s)
val actual = EiselLemireDouble.parse(s)
assert(dbits(actual) == dbits(expected), s"$digits-digit significand: got $actual, expected $expected")
}
}
test("float: long integer part with a 5-digit negative exponent must match Float.parseFloat") {
List(1997, 2000, 2020).foreach { digits =>
val s = "9" + ("1" * (digits - 1)) + "e-19999"
val expected = java.lang.Float.parseFloat(s)
val actual = EiselLemireFloat.parse(s)
assert(fbits(actual) == fbits(expected), s"$digits-digit significand: got $actual, expected $expected")
}
}
test("the same input read through JsonStringInput must match the platform parser") {
val s = hugeIntPart(2300)
val expected = java.lang.Double.parseDouble(s)
val actual = JsonStringInput.read[Double](s)
assert(dbits(actual) == dbits(expected), s"readDouble: got $actual, expected $expected")
}
/** Mirror of the same defect in the overflow direction: leading zeros after the dot inflate
* `truncatedDigitCount`, so a clamped *positive* exponent also lands back in table range. The fast path then
* returns a finite value where the true value is Infinity — which silently defeats any downstream range check.
*/
test("double: leading zeros with a clamped positive exponent must not turn Infinity into a finite value") {
List(925, 1000, 1540).foreach { zeros =>
val s = "0." + ("0" * zeros) + "1234567890123456789012e12345"
val expected = java.lang.Double.parseDouble(s)
val actual = EiselLemireDouble.parse(s)
assert(dbits(actual) == dbits(expected), s"$zeros leading zeros: got $actual, expected $expected")
}
}
}
actually fails, so Read Double/Float: Eisel-Lemire (FastDoubleParser port). Bit-identical to parseDouble/parseFloat. is not correct
sbt:commons> commons-core/testOnly com.avsystem.commons.serialization.json.EiselLemireExponentClampTest
[info] EiselLemireExponentClampTest:
[info] - double: long integer part with a 5-digit negative exponent must match Double.parseDouble *** FAILED ***
[info] 118622047889322841 did not equal 0 1700-digit significand: got 1.0E-300, expected 0.0 (EiselLemireExponentClampTest.scala:29)
[info] - float: long integer part with a 5-digit negative exponent must match Float.parseFloat *** FAILED ***
[info] 1008027333 did not equal 0 1997-digit significand: got 0.009111111, expected 0.0 (EiselLemireExponentClampTest.scala:38)
[info] - the same input read through JsonStringInput must match the platform parser *** FAILED ***
[info] 9094988921128908188 did not equal 0 readDouble: got 1.0E300, expected 0.0 (EiselLemireExponentClampTest.scala:46)
[info] - double: leading zeros with a clamped positive exponent must not turn Infinity into a finite value *** FAILED ***
[info] 9216046942731835485 did not equal 9218868437227405312 925 leading zeros: got 1.2345678901234567E308, expected Infinity (EiselLemireExponentClampTest.scala:58)
| @@ -0,0 +1,56 @@ | |||
| # Third-party notices | |||
|
|
||
| * Project: <https://github.com/xjb714/xjb> | ||
| * Copyright 2026 xjb714 and contributors | ||
| * License: Apache License, Version 2.0 — <http://www.apache.org/licenses/LICENSE-2.0> |
There was a problem hiding this comment.
apache license requires you to include it https://www.apache.org/licenses/LICENSE-2.0.html#redistribution
|
|
||
| ## FastDoubleParser | ||
|
|
||
| * Project: <https://github.com/wrandelshofer/FastDoubleParser> |
There was a problem hiding this comment.
FDP ships with thirdparty-LICENSE so make sure we don't need it
Opt-in faster codec for JSON number serialization/deserialization -
JsonOptions(numberCodec = JsonNumberCodec.Fast).Double/Float: xjb port (XjbDouble/XjbFloat). Always round-trips; char-identical totoStringexcept JDK<19 edge cases (JDK-4511638).Double/Float: Eisel-Lemire (FastDoubleParser port). Bit-identical toparseDouble/parseFloat.BigInt/BigDecimaluntouched as of yet (might be in the future).Benchmarks (to be updated after recent changes)
Measured with JMH, 3 forks × 30 measurement iterations (
-f 3 -wi 8 -w 1 -i 10 -r 1), GCprofiler on (
-prof gc), on JDK 17.0.18 and JDK 25.0.2, current vs. the released code.thrpt= throughput in ops/s (higher is better, ± is the 99.9% CI half-width);alloc=gc.alloc.rate.normin bytes/op (lower is better). Number payloads: a 128-entryMap[String, Double], a parse-dominated 512-elementList[Double], a mixed 132-key object(
Int/Long/Double/Float), and aList[Long].Double/Floatnumber codec — the paths this PR changesJDK 17
Map[String,Double]List[Double]Map[String,Double]List[Long]readJDK 25
Map[String,Double]List[Double]Map[String,Double]List[Long]readThe write throughput gain is larger on JDK 17 and smaller on JDK 25 because JDK 19+'s Schubfach
already made
Double.toStringshortest-and-faster; Xjb's own allocation is JDK-independent, so thewrite allocation saving is instead larger on JDK 25.
Int/Longreads are unchanged from masterand stay at parity with byte-identical allocation.
Tests
2M-value bit-exact sweeps per type; Paxson testbase from A program for Testing IEEE Decimal-Binary Conversion; exhaustive power-of-two sweep; JVM+JS cross test.
Compat
MiMa clean vs 2.21+ (explicit pre-
numberCodecapply/copy/ctor overloads). Scala.js supported, tested on Node. Heavy suites JVM-only.Licensing
xjb (Apache-2.0), FastDoubleParser (MIT) - full notices in headers +
THIRD-PARTY-NOTICES.md. Tables generated viaBigInteger, not transcribed. No GPL code.AI disclosure
Developed with Claude Code, Claude Fable 5.