Fix #1680: overridable escape-scan fast path for JSON generators - #1682
Fix #1680: overridable escape-scan fast path for JSON generators#1682pjfanning wants to merge 1 commit into
Conversation
|
@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. |
|
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 |
|
Ran #1682 against #1681 on the reproducer at https://github.com/franz1981/c2-writestring-spill-jmh
With a single implementation loaded the virtual call is devirtualized and inlined, so #1682 compiles One thing I did not measure: the bimorphic case. Once a subclass override is loaded the call site is |
|
@franz1981 I checked and the settable character escapes will need to be retained. On JsonFactoryBuilder:
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. |
|
@pjfanning that spread may be profile state rather than JDK version. The size of the win depends on whether On the reproducer (jackson-core 3.1.5, JDK 25,
A benchmark that only ever writes ASCII keeps the profile clean and so measures the small number; a FasterXML/jackson-databind#6182 is one common way it gets polluted: |
|
@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 (
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
Without #6182 the loop is not unrolled and is badly register-starved: 15 stack accesses to emit one character, with the Removing the escape-table lookup is a separate win on top: it takes the per-character 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. |
|
FYI @pjfanning #1680 (comment) is further explaining how this PR interact with the already merged change which pollute all String::charAt calls and where the performance diff is more visible. |
|
@franz1981 feel free to update your PR or create a new one and include the changes from here if they help. |
|
The overall benefits between this or the other approach I have suggested is still minimal, and very CPU dependent at this point. so, feel free to go for it - if not happy I will push the alternative one, if you spot any regression with this one. Just remember to try both with and without my other change in databind as it will impact the chance that any unrolling would happen 🙏 I can share a specific benchmark with custom serializers if you want which is removing reflection out of the equation and create the inlining condition where this PR deliver a very big benefit |
…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>
0dfe403 to
41b264e
Compare
|
JMH results comparing this PR's Benchmark:
JDK 17.0.19, JMH 1.36, throughput (ops/ms, higher is better), 2 forks × 10 iterations, error is 99.9% CI. Old Intel i7-4770HQ MacBook, so absolute numbers are low; look at the relative delta.
Takeaways:
Output was verified byte-identical between the two implementations for random mixed content (ASCII, control chars, quotes, backslashes, 2/3/4-byte UTF-8) via Benchmark source: https://github.com/pjfanning/double-reader-writer ( |
|
@pjfanning it is with FasterXML/jackson-databind#6183 in? Or without? |
my testing and microbenchmarks are with jackson-core only |
|
Thanks — then those are all clean-profile numbers. With jackson-core only, Could you add a @Param({"false", "true"})
public boolean pollutedCharAtProfile;
@Setup(Level.Trial)
public void pollute() {
if (pollutedCharAtProfile) {
// a single call is enough: String.charAt has one process-wide
// MethodData, and C2 prunes the non-LATIN1 branch only at a
// zero count - that's what makes profile pollution so scary
if ("\u2030".charAt(0) != '\u2030') {
throw new AssertionError();
}
}
}
|
|
@franz1981 I see the effect that the 'pollution' has on String.charAt - maybe we need to try to remove any code that uses String.charAt. jackson-databind#6183 only removes one way that the 'pollution' happens. |
|
It's true @pjfanning and I had the same impression as well; that said if you need to iterate an unknown relatively long String object to know what could escape, I cannot see how to do it without a double iteration (copy into the recycler chars and iterate over them) which seems an overkill for larger strings which can escape :( The problem is on the JIT and I am working with the IBM openJDK to provide a fix for that ie. by making the JIT able to trust the coder field (which state the asciness of the string) and hoist it out of the loop allowing the loop to be duplicated instead of containing the utf16 (maybe untaken!) read char call which prevent loop unrolling to happen. |
Alternative/extension to #1681 (by @franz1981), targeting
3.xsince it adds newprotectedAPI.Extracts the ASCII fast-scan loops into overridable
protectedmethods, 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_writeStringSegmentfast loopsWriterBasedJsonGenerator._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 testsch < 0x20 || ch > 0x7F || ch == '"' || ch == '\\'directly (no table load; C2 unswitches on the invariant). CustomCharacterEscapes,ESCAPE_FORWARD_SLASHESand 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 in3.1first.Fixes #1680
🤖 Generated with Claude Code