Skip to content

Fix #1680: overridable escape-scan fast path for JSON generators - #1682

Draft
pjfanning wants to merge 1 commit into
FasterXML:3.xfrom
pjfanning:core-1680-escape-scan-hook
Draft

Fix #1680: overridable escape-scan fast path for JSON generators#1682
pjfanning wants to merge 1 commit into
FasterXML:3.xfrom
pjfanning:core-1680-escape-scan-hook

Conversation

@pjfanning

Copy link
Copy Markdown
Member

Alternative/extension to #1681 (by @franz1981), targeting 3.x since it adds new protected API.

Extracts the ASCII fast-scan loops into overridable protected methods, one virtual call per segment (per-character hooks would defeat the optimization once any override is loaded):

  • UTF8JsonGenerator._writeUnescapedAscii(char[]/String, ...) — scan+copy, used by both _writeStringSegment fast loops
  • WriterBasedJsonGenerator._findFirstToEscape(char[], int, int) — scan only, used by _writeString2, _writeSegment, _writeString(char[],...)

Default implementations include #1681's loop-invariant specialization: when _outputEscapes == CharTypes.get7BitOutputEscapes() the loop tests ch < 0x20 || ch > 0x7F || ch == '"' || ch == '\\' directly (no table load; C2 unswitches on the invariant). Custom CharacterEscapes, ESCAPE_FORWARD_SLASHES and alternate quote chars keep the original table lookup, so output is unchanged everywhere. Sub-classes with statically known escaping rules can override with a simple || check of their own.

Relative to #1681: also covers WriterBasedJsonGenerator, and exposes the extension point; happy to rebase on top of #1681 if that lands in 3.1 first.

Fixes #1680

🤖 Generated with Claude Code

…rite fast path

Extract the ASCII fast-scan loops of UTF8JsonGenerator and
WriterBasedJsonGenerator into protected methods (_writeUnescapedAscii /
_findFirstToEscape); default implementations specialize the standard
escape table into a direct comparison check (loop-invariant, C2-unswitched),
and sub-classes with statically known escaping may override with their own.

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

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📈 Overall Code Coverage

Metric Coverage Change
Instructions coverage 83.91% 📈 +0.000%
Branches branches 76.94% 📈 +0.030%

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

@pjfanning

Copy link
Copy Markdown
Member Author

@franz1981 Claude AI suggested this as it keeps the configurability of the escapes but if you keep the standard escapes, it uses the if statement that you suggested. I'll see if I can get jmh numbers.

@pjfanning

Copy link
Copy Markdown
Member Author

I got Claude AI to do a quick benchmark for the utf8 generator and it says that for Java 17, there is little gain. There is a measurable gain in Java 21 (8-15% when the hardcoded if kicks in).

@franz1981

franz1981 commented Sep 1, 2026

Copy link
Copy Markdown

Ran #1682 against #1681 on the reproducer at https://github.com/franz1981/c2-writestring-spill-jmh
(SingleBench.serialize, JDK 25, jackson-core 3.1.5, 3 forks, 5x5 iterations, pinned). The two are
indistinguishable:

clean String.charAt profile polluted
stock 352.6 ± 3.7 443.8 ± 4.7
#1681 341.1 ± 2.3 350.6 ± 11.8
#1682 342.3 ± 5.2 354.4 ± 6.9

With a single implementation loaded the virtual call is devirtualized and inlined, so #1682 compiles
to the same loop - it costs nothing and covers WriterBasedJsonGenerator too, so it looks like the
better vehicle to me.

One thing I did not measure: the bimorphic case. Once a subclass override is loaded the call site is
no longer monomorphic and the call becomes real. It is per segment rather than per character so it
should be cheap, but it is the case the extension point exists for and it is currently unmeasured.

@pjfanning

Copy link
Copy Markdown
Member Author

@franz1981 I checked and the settable character escapes will need to be retained.

On JsonFactoryBuilder:

  1. characterEscapes(CharacterEscapes) — fully custom: the user's getEscapeCodesForAscii() supplies the whole 128-entry table, arbitrary contents.
  2. quoteChar(char) — e.g. apostrophe instead of " changes which table get7BitOutputEscapes(quoteChar, ...) returns.
  3. JsonWriteFeature.ESCAPE_FORWARD_SLASHES — switches to the sOutputEscapes128WithSlash variant.

And despite factories being immutable in 3.x, setCharacterEscapes() is still a mutable setter on the generator itself — it can be called mid-stream (the JsonGeneratorBase._characterEscapes field comment says it can't be final because of JSONP-style use cases). So the table can even change during a generator's lifetime.

@franz1981

Copy link
Copy Markdown

@pjfanning that spread may be profile state rather than JDK version.

The size of the win depends on whether String.charAt has ever seen a UTF-16 String in that JVM.
Its branch profile is a single process-wide MethodData, and C2 prunes the isLatin1 branch only at a
zero UTF-16 count - so one UTF-16 charAt anywhere leaves a cold StringUTF16.charAt call in every
ASCII charAt loop compiled afterwards, which disables unrolling and range check elimination.

On the reproducer (jackson-core 3.1.5, JDK 25, SingleBench.serialize, 3 forks, pinned):

stock with this change
no UTF-16 charAt anywhere in the JVM 352.6 ± 3.7 341.1 ± 2.3 (~3%)
after a single UTF-16 charAt in setup 443.8 ± 4.7 350.6 ± 11.8 (~21%)

A benchmark that only ever writes ASCII keeps the profile clean and so measures the small number; a
real application usually does not. So it may be worth checking whether your 17 and 21 runs differ in
that rather than in the JIT - this is a guess, but it is what we measure here.

FasterXML/jackson-databind#6182 is one common way it gets polluted: StdDateFormat's static
initializer builds a SimpleDateFormat, which calls String.charAt on the locale per-mille sign, so
merely constructing an ObjectMapper is enough.

@franz1981

franz1981 commented Sep 2, 2026

Copy link
Copy Markdown

@pjfanning if you are converging to an hybrid solution which can still do the best of the 2 worlds I am happy to close my PR ❤️


Some numbers on how the two changes interact, in case it's useful for the discussion.

Setup: a real Quarkus app doing reflection-free Jackson serialization (GET returning a list of beans), Quarkus main + Jackson 3.1.4, Temurin 25.0.2, 40s @ 100 connections, app pinned to 2 cores. Each configuration run 3+ times, interleaved. I measured the escape-table removal using #1681; this PR takes the same approach.

configuration req/s vs previous
neither fix 140,073 (sd 485, n=3)
databind #6182 only 147,246 (sd 2,494, n=6) +5.1%
#6182 + escape-table removal 151,154 (sd 2,177, n=3) +2.7%

Total +7.9%.

The C2 assembly explains why. Numbers below are for the ASCII copy loop as it appears in the method that actually runs -- the generated serializer, which inlines writeString (in several places, so there are multiple copies of the loop; the figures are the same in each):

configuration chars per iteration stack accesses per char escCodes[] loads per char instructions per char
neither 1 15 1 33
#6182 only 2 0 1 16
both 4 0 0 13.8

Without #6182 the loop is not unrolled and is badly register-starved: 15 stack accesses to emit one character, with the String object and the index reloaded from the stack on every iteration. The cause is that String.charAt has a single process-wide MethodData, and one UTF-16 call from StdDateFormat's <clinit> leaves a non-zero count on its non-LATIN1 branch. The LATIN1 case is still inlined to a plain byte load -- the hot path is fine -- but C2 can no longer prune the other branch, so it keeps an out-of-line copy of it. That path is never executed for ASCII data and costs no profiler samples; the damage is that C2 will not unroll a loop containing a call, and the allocator must keep values live across it. Fixing the <clinit> makes the spilling disappear entirely and the loop unrolls to 2 chars/iteration.

Removing the escape-table lookup is a separate win on top: it takes the per-character escCodes[] load and its bounds check out of the loop, and the loop unrolls to 4 chars/iteration.

So the escape-table change doesn't look redundant once the profile pollution is fixed -- the two address different costs, and the +2.7% is measured with #6182 already applied.

Standalone JMH reproducer with the same three configurations, on both Jackson 2.22.0 and 3.1.5: https://github.com/franz1981/c2-writestring-spill-jmh

The compiled copy loop there is identical in the two Jackson versions: 1 char/iteration and 13 stack accesses per character with neither fix, 2 chars/iteration and 2 with the databind fix, 4 chars/iteration and no table load with both. On the inlined benchmark #6183 is worth ~20% and #1681 a further 3-5%, and the not-inlined control never regresses.

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.

Avoid per-character escape-table load in _writeStringSegment ASCII loop

2 participants