Skip to content

Add NumberOutput.outputDouble/outputFloat(char[]), use in WriterBasedJsonGenerator - #1704

Merged
cowtowncoder merged 12 commits into
FasterXML:3.xfrom
pjfanning:fast-double-char-output
Sep 12, 2026
Merged

cowtowncoder merged 12 commits into
FasterXML:3.xfrom
pjfanning:fast-double-char-output

Conversation

@pjfanning

@pjfanning pjfanning commented Sep 11, 2026

Copy link
Copy Markdown
Member

I'd still prefer to use XJB algorithm (#1656) but this change improves the Schubfach so that that algorithm can be used without allocating temp Strings which is the biggest performance win in the XJB PR anyway.

Adds char[] counterparts to the existing NumberOutput.outputDouble(double, byte[], int) / outputFloat(float, byte[], int) (3.2.2), backed by new DoubleToDecimal.writeDouble(double, char[], int) / FloatToDecimal.writeFloat(float, char[], int).

WriterBasedJsonGenerator.writeNumber(double|float) now uses them when StreamWriteFeature.USE_FAST_DOUBLE_WRITER is enabled, writing straight into its char output buffer instead of going through String + writeRaw() — the same shape UTF8JsonGenerator already has for the byte path.

The quoted paths (WRITE_NUMBERS_AS_STRINGS, and WRITE_NAN_AS_STRINGS for non-finite values) get the same treatment in both generators: quote + digits + quote written directly into the output buffer, instead of writeString(NumberOutput.toString(v, useFast)).

What this does and does not avoid

To be precise about the allocation win, since that is the point of the change:

  • Avoided: the intermediate String per value (and the char[] copy inside it), plus the subsequent writeRaw() copy into the output buffer.
  • Not avoided: DoubleToDecimal.writeDouble(...) / FloatToDecimal.writeFloat(...) still do new DoubleToDecimal() / new FloatToDecimal() per call, and each instance holds a byte[MAX_CHARS] scratch array. Digits are rendered into that array first and then widened byte-to-char in a loop (the byte[] variant gets a System.arraycopy instead).

This matches the design of the existing 3.2.2 byte[] methods rather than diverging from it, and escape analysis typically eliminates the scratch instance. Making it genuinely allocation-free would mean char-emitting append() / appendDigit() variants in the Schubfach classes — deliberately left out of scope here.

Behavior

Rendered number text is unchanged: the fast path emits exactly what NumberOutput.toString(v, true) emitted, including NaN / Infinity / -Infinity / -0.0.

One deliberate change beyond that: the non-fast quoted branch now goes through _writeQuotedRaw() as well, matching what writeNumber(BigInteger) / writeNumber(BigDecimal) / writeNumber(String) have always done. Previously it went through writeString(), so a custom CharacterEscapes escaping a character that appears in number text would escape quoted double/float output — but not quoted BigDecimal output, and not the new fast path. Quoted number output is now consistent across all of those, and independent of USE_FAST_DOUBLE_WRITER.

Tests

  • SchubfachWriteCharBufferTest — char output matches toString(v, true) and the byte variant, including 10k random bit patterns and tight-buffer max-length cases.
  • FastDoubleCharBufferBoundaryTest — Writer-based generator flushing across output-buffer boundaries.
  • FastDoubleQuotedWriteTest — quoted double/float for both generators: WRITE_NUMBERS_AS_STRINGS, WRITE_NAN_AS_STRINGS (on and off), custom quote char, root values, object values, buffer-boundary runs, and a CharacterEscapes regression test pinning the consistency described above.

🤖 Generated with Claude Code

pjfanning and others added 6 commits September 11, 2026 23:11
…sedJsonGenerator

Char-buffer counterparts of the existing byte[] direct-write methods, so
the Writer-based generator can also skip String allocation when
USE_FAST_DOUBLE_WRITER is enabled (as UTF8JsonGenerator already does).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WRITE_NUMBERS_AS_STRINGS / WRITE_NAN_AS_STRINGS paths went through
writeString(NumberOutput.toString(...)) in both generators; with
USE_FAST_DOUBLE_WRITER write quote + digits + quote straight into the
output buffer instead. Number text is ASCII so no escaping is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Float-only boundary runs (double's larger reserve was masking the float
flush branch), NaN with WRITE_NAN_AS_STRINGS disabled, custom quote char,
root-value separator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pjfanning

Copy link
Copy Markdown
Member Author

Rough benchmark numbers — JDK 17.0.19, hand-rolled loop (no JMH in this repo), best-of-3 runs, min ns per number, writing a 1000-element array of mixed-magnitude values to a discarding Writer/OutputStream. Base = merge-base on 3.x, PR = this branch.

Scenario (Writer-based unless noted) Base PR Δ
default (JDK toString), double — control 752 745 ~0
fast, double 152 144 −5%
fast, float 125 100 −20%
fast + NUMBERS_AS_STRINGS, double 236 153 −35%
fast + NUMBERS_AS_STRINGS, float 174 119 −32%
[UTF8] fast + NUMBERS_AS_STRINGS, double 219 137 −37%
[UTF8] fast, double — unchanged path, control 140 142 ~0
  • Quoted numbers are the main win (~35% on both generators): the old path was String alloc → writeString() → escape scan → copy; now it's quote + digits + quote straight into the buffer.
  • Unquoted Writer-based gains are smaller (5–20%): Schubfach already builds digits in its internal byte[], so this only removes the String alloc + getChars copy. Floats gain more since the fixed overhead is a bigger share of a shorter number.
  • Controls held: the two untouched paths moved <2%, within run-to-run noise.
  • For context, the fast writer is ~5× faster than JDK 17's Double.toString regardless of this PR; on JDK 19+ that gap shrinks a lot.

Default behavior (USE_FAST_DOUBLE_WRITER off) is unchanged.

@pjfanning

Copy link
Copy Markdown
Member Author

Follow-up: same harness on JDK 21, plus the XJB branch (#1656, head e14df592c) for comparison. Interleaved runs of all three jars, best-of-3 min ns/number. base = 3.x merge-base, PR = this branch, xjb = #1656.

JDK 17.0.19 (control row agrees across jars, so this table is trustworthy)

Scenario (Writer-based unless noted) base PR xjb
default (JDK toString), double — control 718 717 720
fast, double 150 139 101
fast, float 115 96 54
fast + NUMBERS_AS_STRINGS, double 193 152 95
fast + NUMBERS_AS_STRINGS, float 136 116 47
[UTF8] fast + NUMBERS_AS_STRINGS, double 192 127 70
[UTF8] fast, double — unchanged path 126 134 74

JDK 21.0.11 (control row scattered 120–169 on identical code, so ±20% noise)

Scenario base PR xjb
default (JDK toString), double — control 120 139 169
fast, double 139 137 97
fast, float 99 83 51
fast + NUMBERS_AS_STRINGS, double 161 149 92
fast + NUMBERS_AS_STRINGS, float 118 104 45
[UTF8] fast + NUMBERS_AS_STRINGS, double 217 117 68
[UTF8] fast, double — unchanged path 109 127 68

Takeaways:

  • On JDK 21 the JDK's own Double.toString (Schubfach since 19) ties our Schubfach fast writer for plain doubles, as the USE_FAST_DOUBLE_WRITER Javadoc warns. This PR's quoted-path gains persist on 21 (~10–45%) since they come from skipping String + writeString() escape scanning, not from the digit algorithm.
  • XJB (Use XJB algorithm for writing Floats/Doubles #1656) is ~1.4–2× faster than Schubfach on every fast path (digit-pair tables + VarHandle multi-byte stores vs. one digit at a time), and is the only variant that beats Double.toString on JDK 21. This PR can't close that gap — it only removes the String overhead around Schubfach.
  • The quoted-path direct writes here are independent of the algorithm choice: they land the same way whether Schubfach stays or XJB replaces it.

@github-actions

Copy link
Copy Markdown
Contributor

📈 Overall Code Coverage

Metric Coverage Change
Instructions coverage 84.26% 📈 +0.190%
Branches branches 77.46% 📈 +0.180%

Overall project coverage from JaCoCo test results. Change values compare against the latest base branch build.

@cowtowncoder
cowtowncoder merged commit 4ffb4dc into FasterXML:3.x Sep 12, 2026
6 checks passed
@cowtowncoder cowtowncoder added this to the 3.3.0 milestone Sep 12, 2026
@cowtowncoder cowtowncoder added the performance Issue related to performance problems or enhancements label Sep 12, 2026
@pjfanning
pjfanning deleted the fast-double-char-output branch September 12, 2026 08:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance Issue related to performance problems or enhancements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants