Skip to content

Fast JSON Double and Float codec (read + write) - #906

Open
najder-k wants to merge 6 commits into
masterfrom
fast-number
Open

Fast JSON Double and Float codec (read + write)#906
najder-k wants to merge 6 commits into
masterfrom
fast-number

Conversation

@najder-k

@najder-k najder-k commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Opt-in faster codec for JSON number serialization/deserialization - JsonOptions(numberCodec = JsonNumberCodec.Fast).

  • Write Double/Float: xjb port (XjbDouble/XjbFloat). Always round-trips; char-identical to toString except JDK<19 edge cases (JDK-4511638).
  • Read Double/Float: Eisel-Lemire (FastDoubleParser port). Bit-identical to parseDouble/parseFloat.
  • BigInt/BigDecimal untouched 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), GC
profiler 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.norm in bytes/op (lower is better). Number payloads: a 128-entry
Map[String, Double], a parse-dominated 512-element List[Double], a mixed 132-key object
(Int/Long/Double/Float), and a List[Long].

Double/Float number codec — the paths this PR changes

JDK 17

Benchmark Current thrpt Master thrpt Δ thrpt Current alloc Master alloc Δ alloc
Double read — Map[String,Double] 35,527 ± 493 27,796 ± 246 +27.8% 40,040 60,352 −33.7%
Double read — List[Double] 23,515 ± 587 12,165 ± 161 +93.3% 71,480 159,217 −55.1%
Double write — Map[String,Double] 80,791 ± 387 48,386 ± 163 +67.0% 13,016 21,480 −39.4%
mixed read (Int/Long/Double/Float) 31,286 ± 665 28,506 ± 898 +9.8% 47,040 61,080 −23.0%
mixed write 110,003 ± 1,888 82,354 ± 1,032 +33.6% 15,816 20,288 −22.0%
List[Long] read 31,171 ± 517 31,268 ± 401 −0.3% 69,840 69,840 0.0%

JDK 25

Benchmark Current thrpt Master thrpt Δ thrpt Current alloc Master alloc Δ alloc
Double read — Map[String,Double] 37,875 ± 589 25,727 ± 518 +47.2% 40,016 58,608 −31.7%
Double read — List[Double] 23,104 ± 137 9,658 ± 111 +139.2% 71,464 151,225 −52.7%
Double write — Map[String,Double] 82,828 ± 808 65,025 ± 362 +27.4% 13,064 25,856 −49.5%
mixed read (Int/Long/Double/Float) 30,596 ± 2,221 25,982 ± 1,014 +17.8% 46,944 58,104 −19.2%
mixed write 118,660 ± 4,134 103,054 ± 1,335 +15.1% 16,968 25,016 −32.2%
List[Long] read 30,627 ± 526 30,695 ± 286 −0.2% 69,840 69,840 0.0%

The write throughput gain is larger on JDK 17 and smaller on JDK 25 because JDK 19+'s Schubfach
already made Double.toString shortest-and-faster; Xjb's own allocation is JDK-independent, so the
write allocation saving is instead larger on JDK 25. Int/Long reads are unchanged from master
and 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-numberCodec apply/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 via BigInteger, not transcribed. No GPL code.

AI disclosure

Developed with Claude Code, Claude Fable 5.

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>
@ddworak

ddworak commented Jul 24, 2026

Copy link
Copy Markdown
Member

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).

@Pituuss Pituuss self-assigned this Jul 24, 2026
@najder-k

Copy link
Copy Markdown
Contributor Author

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

najder-k added 3 commits July 24, 2026 19:55
…performance regression when parsing Integers, remove the substring allocation optimization (did more harm than good)
@najder-k najder-k changed the title Fast JSON number codec (read + write) behind JsonNumberCodec.Fast Fast JSON Double and Float codec (read + write) Jul 24, 2026
najder-k and others added 2 commits July 24, 2026 21:27
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>
@Pituuss
Pituuss self-requested a review July 28, 2026 12:30
@Pituuss

Pituuss commented Jul 28, 2026

Copy link
Copy Markdown

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)")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)

Comment thread THIRD-PARTY-NOTICES.md
@@ -0,0 +1,56 @@
# Third-party notices

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

not included in the jar

Comment thread THIRD-PARTY-NOTICES.md

* 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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

apache license requires you to include it https://www.apache.org/licenses/LICENSE-2.0.html#redistribution

Comment thread THIRD-PARTY-NOTICES.md

## FastDoubleParser

* Project: <https://github.com/wrandelshofer/FastDoubleParser>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FDP ships with thirdparty-LICENSE so make sure we don't need it

@Pituuss Pituuss assigned najder-k and unassigned Pituuss Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants