Skip to content

Fix #1680: drop the per-character escape-table load in the ASCII copy loop - #1681

Open
franz1981 wants to merge 2 commits into
FasterXML:3.xfrom
franz1981:3.1
Open

franz1981 wants to merge 2 commits into
FasterXML:3.xfrom
franz1981:3.1

Conversation

@franz1981

Copy link
Copy Markdown

_writeStringSegment() reads _outputEscapes[ch] per character. For the standard table the same test is constant-expressible, so the loop now branches on a loop-invariant flag that C2 unswitches and constant-folds; the standard clone has no table load. Custom quote char, escaped slashes and CharacterEscapes are unaffected -- different array, flag false, original lookup.

Shorter body lets C2 unroll 4 characters per iteration instead of 2: 15.5 ->
13.8 instructions and 2 -> 1 stack operands per character on JDK 25/x86_64. Writing one String property per object: 356.5 -> 347.5 ns/op (3.1.5).

Measure after databind#6182: with that present the loop is not unrolled at all and this change appears to be worth ~23%, which is mostly that issue and not the removed load.

Output verified byte-identical over chars 0x00-0x100, quotes, backslashes, control chars and strings crossing the segment boundary.

@franz1981

Copy link
Copy Markdown
Author

Thanks @pjfanning - I tried an alternative that avoids hardcoding anything: derive a 128-bit mask
(two longs) from whatever _outputEscapes is in effect, and test it with a shift. It would have
covered custom quote chars and CharacterEscapes too. Measured on the reproducer with real
jackson-core 3.1.5, SingleBench.serialize, JDK 25, 3 forks:

clean profile String.charAt profile polluted
stock 370.6 ± 42.7 444.9 ± 5.3
this PR 344.5 ± 11.5 345.2 ± 4.7
mask 381.6 ± 6.8 552.4 ± 20.8

So the mask is worse in both cases - its two extra live values add register pressure. Reading the
compiled loop explains why this PR helps: when the profile is polluted C2 cannot unroll, and then
the stock loop spills 13 stack operands per character and the mask 18, while this PR spills zero.
Dropping the table reference is what keeps the live set in registers. That is also why it is flat
across profiles while stock degrades 20%.

On your correctness point, I agree the constants must not silently drift from the table. Rather than
supporting or removing _outputEscapes, I can make the duplication self-verifying: compute a
static final boolean once by checking the predicate against get7BitOutputEscapes() over 0..127,
and require it in addition to the identity check, so if the default table ever changes the fast path
simply switches itself off instead of changing output. Plus a unit test asserting equivalence for
the standard table, escaped slashes, a custom quote char and CharacterEscapes.

Would that address the concern?

@pjfanning

Copy link
Copy Markdown
Member

@franz1981 have you seen #1682? There are probably a few different ways to do it.

@cowtowncoder cowtowncoder added the performance Issue related to performance problems or enhancements label Oct 9, 2026
@cowtowncoder

Copy link
Copy Markdown
Member

This needs to be changed to against 3.x, if we want to proceed.

@pjfanning

Copy link
Copy Markdown
Member

@cowtowncoder #1682 covers this, at least partially.

@cowtowncoder

Copy link
Copy Markdown
Member

@pjfanning Can/should this be closed then?

…SCII copy loop

_writeStringSegment() reads _outputEscapes[ch] per character. For the standard
table the same test is constant-expressible, so the loop now branches on a
loop-invariant flag that C2 unswitches and constant-folds; the standard clone
has no table load. Custom quote char, escaped slashes and CharacterEscapes are
unaffected -- different array, flag false, original lookup.

Shorter body lets C2 unroll 4 characters per iteration instead of 2: 15.5 ->
13.8 instructions and 2 -> 1 stack operands per character on JDK 25/x86_64.
Writing one String property per object: 356.5 -> 347.5 ns/op (3.1.5).

Measure after databind#6182: with that present the loop is not unrolled at all
and this change appears to be worth ~23%, which is mostly that issue and not
the removed load.

Output verified byte-identical over chars 0x00-0x100, quotes, backslashes,
control chars and strings crossing the segment boundary.
@pjfanning

Copy link
Copy Markdown
Member

@cowtowncoder I rebased this to 3.x - it simpler than my 1682 changes so I'm happy to get it merged.
I'm going to change 1682 to it is a separate fix to this, targeting the writer based generator.

@pjfanning

Copy link
Copy Markdown
Member

@franz1981 has blocked maintainer changes to his branch.
We are looking to get releases done over the next few days. This PR will miss the release if it isn't rebased to 3.x.

pjfanning added a commit to pjfanning/jackson-core that referenced this pull request Oct 15, 2026
…ring loops

Same as FasterXML#1681 for WriterBasedJsonGenerator: with the standard escape
table the fast scan in _writeString2, _writeSegment and
_writeString(char[]) tests constants instead of loading escCodes[c].
Custom quote char, escaped slashes and CharacterEscapes keep the lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@franz1981

franz1981 commented Oct 15, 2026 •

Copy link
Copy Markdown
Author

@pjfanning I see that @cowtowncoder rebased it over 3.1 once so it should work fine.
In few mins I will be back home and I will do it over 3.x if you cannot 🙏

@pjfanning
pjfanning changed the base branch from 3.1 to 3.x October 15, 2026 11:16
@pjfanning

Copy link
Copy Markdown
Member

Rebased on 3.x. I'm still investigating some issues that I see with Java 17 with perf regressions in some cases. Java 25: this change is positive or neutral in all scenarios.

@franz1981

Copy link
Copy Markdown
Author

@pjfanning

I'm still investigating some issues that I see with Java 17 with perf regressions in some cases

if you have perfasm output I can help, just need the JDK 25 and JDK 17 perfasm output or asm of the tight loops.
Or if you have the relevant tests I can take a look myself and help you ❤️

@pjfanning

Copy link
Copy Markdown
Member

Ran JMH perfasm/perfnorm on this change rebased onto 3.x (vs 3.x @ 4bd708f), JDK 17, x86 (i7-8550U, WSL2). Benchmark: writeString(String) x 200, ASCII-only, length 1000, UTF8JsonGenerator.

Generated code (C2): both versions are 2x unrolled, byte-at-a-time, not vectorized.

  • 3.x per char: ch > 0x7F branch, escCodes bounds check, escCodes[ch] load + test/branch.
  • This PR per char: (ch - 0x20) >= 0x60 (unsigned), == '"', == '\\'. C2 does unswitch on stdEsc as intended: no table load in the loop.
  • Branches per char are the same (3); the PR swaps one load + 2 branches for 2 compare/branch pairs plus an extra add/mov.

perfnorm (per op):

counter 3.x PR change
instructions 3.94M 4.14M +5%
cycles 904K 989K +9%
branches 807K 807K same
branch-misses 278 291 negligible
L1-dcache-loads 509K 208K -59%
IPC 4.36 4.19

The loop already runs at ~4.3 IPC, near this core's 4-wide issue limit. The 512-byte escape table always hits L1, so its load was effectively free; removing it doesn't help, while the extra compare uops cost a bit. That matches the JDK 17 writeString slowdown seen on a Mac. Plain JMH timing on this machine was within noise (-9%..+7% across lengths 12/100/1000).

A real gain probably needs fewer instructions per char (e.g. SWAR over 8 chars, or a vectorized Latin-1 String path) rather than a different per-char check.

@pjfanning

pjfanning commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

@franz1981 thanks! Here is the scenario.

JMH benchmark (standalone project; jackson-core jar swapped on the classpath: 3.x @ 4bd708f vs this PR rebased on 3.x):

StringWriteBench.java
package bench;

import java.io.OutputStream;
import java.io.Writer;
import java.util.Random;
import java.util.concurrent.TimeUnit;

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;

import tools.jackson.core.JsonGenerator;
import tools.jackson.core.ObjectWriteContext;
import tools.jackson.core.json.JsonFactory;

@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
public class StringWriteBench
{
    static final int COUNT = 200;

    /** ascii = no escapes; escaped = one '"' near the end of each value */
    @Param({"ascii", "escaped"})
    public String content;

    @Param({"12", "100", "1000"})
    public int length;

    final JsonFactory f = new JsonFactory();
    String[] values;
    char[][] charValues;

    static final OutputStream NULL_OUT = new OutputStream() {
        @Override public void write(int b) { }
        @Override public void write(byte[] b, int off, int len) { }
    };
    static final Writer NULL_W = new Writer() {
        @Override public void write(char[] c, int off, int len) { }
        @Override public void write(String s, int off, int len) { }
        @Override public void flush() { }
        @Override public void close() { }
    };

    @Setup
    public void setup() {
        Random r = new Random(123);
        String alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .,:;-_/";
        values = new String[COUNT];
        charValues = new char[COUNT][];
        for (int i = 0; i < COUNT; ++i) {
            char[] c = new char[length];
            for (int j = 0; j < length; ++j) {
                c[j] = alphabet.charAt(r.nextInt(alphabet.length()));
            }
            if ("escaped".equals(content)) {
                c[length - 2] = '"';
            }
            values[i] = new String(c);
            charValues[i] = c;
        }
    }

    @Benchmark
    public void utf8String(Blackhole bh) {
        try (JsonGenerator g = f.createGenerator(ObjectWriteContext.empty(), NULL_OUT)) {
            g.writeStartArray();
            for (String s : values) g.writeString(s);
            g.writeEndArray();
        }
    }

    @Benchmark
    public void utf8Chars(Blackhole bh) {
        try (JsonGenerator g = f.createGenerator(ObjectWriteContext.empty(), NULL_OUT)) {
            g.writeStartArray();
            for (char[] c : charValues) g.writeString(c, 0, c.length);
            g.writeEndArray();
        }
    }

    @Benchmark
    public void writerString(Blackhole bh) {
        try (JsonGenerator g = f.createGenerator(ObjectWriteContext.empty(), NULL_W)) {
            g.writeStartArray();
            for (String s : values) g.writeString(s);
            g.writeEndArray();
        }
    }

    @Benchmark
    public void writerChars(Blackhole bh) {
        try (JsonGenerator g = f.createGenerator(ObjectWriteContext.empty(), NULL_W)) {
            g.writeStartArray();
            for (char[] c : charValues) g.writeString(c, 0, c.length);
            g.writeEndArray();
        }
    }
}

Run, e.g.:

java -cp benchmarks.jar:jackson-core-<variant>.jar org.openjdk.jmh.Main 'StringWriteBench.utf8String' \
  -p content=ascii -p length=1000 -f 1 -wi 5 -i 5 \
  -prof 'perfasm:events=cycles:u;intelSyntax=true'

(jackson-core is a provided-style dependency so the same benchmarks jar runs against either build.)

JDK 17 (17.0.20 Temurin), x86 i7-8550U — hot loop of UTF8JsonGenerator._writeStringSegment(String,int,int) inlined into utf8String (~91-94% of cycles in both). The hsdis build I had didn't decode on 17, so these were decoded from perfasm's raw bytes with objdump -Mintel; percentages are perfasm cycles:u.

3.x
  2.47%     7522c8d09430:  8b cf                  mov    ecx,edi
            7522c8d09432:  41 03 cd               add    ecx,r13d
            7522c8d09435:  48 63 f7               movsxd rsi,edi
            7522c8d09438:  45 0f be 74 33 10      movsx  r14d,BYTE PTR [r11+rsi*1+0x10]
            7522c8d0943e:  41 8b d6               mov    edx,r14d
            7522c8d09441:  0f b6 d2               movzx  edx,dl
            7522c8d09444:  83 fa 7f               cmp    edx,0x7f
            7522c8d09447:  0f 8f b8 02 00 00      jg     0x7522c8d09705
            7522c8d0944d:  3b d3                  cmp    edx,ebx
            7522c8d0944f:  0f 83 bd 00 00 00      jae    0x7522c8d09512
            7522c8d09455:  45 8b 54 91 10         mov    r10d,DWORD PTR [r9+rdx*4+0x10]
            7522c8d0945a:  66 0f 1f 44 00 00      nop    WORD PTR [rax+rax*1+0x0]
            7522c8d09460:  45 85 d2               test   r10d,r10d
            7522c8d09463:  0f 85 eb 02 00 00      jne    0x7522c8d09754
            7522c8d09469:  3b 0c 24               cmp    ecx,DWORD PTR [rsp]
            7522c8d0946c:  0f 83 f2 00 00 00      jae    0x7522c8d09564
            7522c8d09472:  45 88 74 08 10         mov    BYTE PTR [r8+rcx*1+0x10],r14b
            7522c8d09477:  45 0f be 74 33 11      movsx  r14d,BYTE PTR [r11+rsi*1+0x11]
            7522c8d0947d:  8b c7                  mov    eax,edi
            7522c8d0947f:  ff c0                  inc    eax
            7522c8d09481:  8b cf                  mov    ecx,edi
            7522c8d09483:  41 03 cd               add    ecx,r13d
            7522c8d09486:  41 8b d6               mov    edx,r14d
            7522c8d09489:  0f b6 d2               movzx  edx,dl
  2.28%     7522c8d0948c:  ff c1                  inc    ecx
            7522c8d0948e:  83 fa 7f               cmp    edx,0x7f
            7522c8d09491:  0f 8f 70 02 00 00      jg     0x7522c8d09707
            7522c8d09497:  3b d3                  cmp    edx,ebx
            7522c8d09499:  0f 83 75 00 00 00      jae    0x7522c8d09514
            7522c8d0949f:  45 8b 54 91 10         mov    r10d,DWORD PTR [r9+rdx*4+0x10]
            7522c8d094a4:  45 85 d2               test   r10d,r10d
            7522c8d094a7:  0f 85 a9 02 00 00      jne    0x7522c8d09756
            7522c8d094ad:  c4 41 f9 7e da         vmovq  r10,xmm11
            7522c8d094b2:  4c 03 d6               add    r10,rsi
            7522c8d094b5:  47 88 74 10 11         mov    BYTE PTR [r8+r10*1+0x11],r14b
            7522c8d094ba:  83 c7 02               add    edi,0x2
            7522c8d094bd:  66 66 90               data16 xchg ax,ax
            7522c8d094c0:  3b fd                  cmp    edi,ebp
            7522c8d094c2:  0f 8c 68 ff ff ff      jl     0x7522c8d09430
This PR
  2.62%     75ad50d0bb10:  8b cb                  mov    ecx,ebx
            75ad50d0bb12:  03 c8                  add    ecx,eax
            75ad50d0bb14:  48 63 eb               movsxd rbp,ebx
            75ad50d0bb17:  45 0f be 54 2d 10      movsx  r10d,BYTE PTR [r13+rbp*1+0x10]
            75ad50d0bb1d:  41 8b fa               mov    edi,r10d
  1.59%     75ad50d0bb20:  40 0f b6 ff            movzx  edi,dil
            75ad50d0bb24:  44 8b c7               mov    r8d,edi
            75ad50d0bb27:  41 83 c0 e0            add    r8d,0xffffffe0
            75ad50d0bb2b:  41 83 f8 60            cmp    r8d,0x60
            75ad50d0bb2f:  0f 83 e8 01 00 00      jae    0x75ad50d0bd1d
            75ad50d0bb35:  83 ff 22               cmp    edi,0x22
            75ad50d0bb38:  0f 84 36 02 00 00      je     0x75ad50d0bd74
            75ad50d0bb3e:  66 90                  xchg   ax,ax
            75ad50d0bb40:  83 ff 5c               cmp    edi,0x5c
            75ad50d0bb43:  0f 84 7f 02 00 00      je     0x75ad50d0bdc8
            75ad50d0bb49:  41 3b ce               cmp    ecx,r14d
            75ad50d0bb4c:  0f 83 9b 00 00 00      jae    0x75ad50d0bbed
            75ad50d0bb52:  45 88 54 0b 10         mov    BYTE PTR [r11+rcx*1+0x10],r10b
            75ad50d0bb57:  45 0f be 54 2d 11      movsx  r10d,BYTE PTR [r13+rbp*1+0x11]
            75ad50d0bb5d:  8b cb                  mov    ecx,ebx
            75ad50d0bb5f:  03 c8                  add    ecx,eax
            75ad50d0bb61:  41 8b fa               mov    edi,r10d
  2.20%     75ad50d0bb64:  40 0f b6 ff            movzx  edi,dil
  2.31%     75ad50d0bb68:  ff c1                  inc    ecx
            75ad50d0bb6a:  44 8b c7               mov    r8d,edi
            75ad50d0bb6d:  41 83 c0 e0            add    r8d,0xffffffe0
            75ad50d0bb71:  8b d3                  mov    edx,ebx
            75ad50d0bb73:  ff c2                  inc    edx
            75ad50d0bb75:  41 83 f8 60            cmp    r8d,0x60
            75ad50d0bb79:  0f 83 a0 01 00 00      jae    0x75ad50d0bd1f
            75ad50d0bb7f:  90                     nop
            75ad50d0bb80:  83 ff 22               cmp    edi,0x22
            75ad50d0bb83:  0f 84 ed 01 00 00      je     0x75ad50d0bd76
            75ad50d0bb89:  83 ff 5c               cmp    edi,0x5c
            75ad50d0bb8c:  0f 84 38 02 00 00      je     0x75ad50d0bdca
            75ad50d0bb92:  4d 8b c1               mov    r8,r9
            75ad50d0bb95:  4c 03 c5               add    r8,rbp
            75ad50d0bb98:  47 88 54 03 11         mov    BYTE PTR [r11+r8*1+0x11],r10b
            75ad50d0bb9d:  83 c3 02               add    ebx,0x2
            75ad50d0bba0:  3b de                  cmp    ebx,esi
            75ad50d0bba2:  0f 8c 68 ff ff ff      jl     0x75ad50d0bb10

Both are 2x unrolled, byte-at-a-time, no vectorization. With the PR, the stdEsc unswitch works (no table load), but per char it becomes add -0x20; cmp 0x60; jae + cmp 0x22; je + cmp 0x5c; je versus cmp 0x7f; jg + table bounds check + escCodes[ch] load/test. JDK 25 perfasm output will follow in a separate comment.

@franz1981

franz1981 commented Oct 15, 2026 •

Copy link
Copy Markdown
Author

@pjfanning and what JDK 25 does differently? you have the perfasm and perfnorm there?
Since you have an i7 maybe the problem are the cost of number of takenbranches to resolve which "should" be a fronted stall at the end?

@pjfanning

pjfanning commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

JDK 25 (25.0.4 Temurin), same machine/benchmark (utf8String, ascii, length 1000). hsdis didn't decode here either, so again decoded from perfasm's raw bytes with objdump -Mintel. The hot region is ~78% (3.x) / ~80% (PR) of cycles:u; timings under perfasm were noisy, so treat only the code shape as meaningful.

3.x: 2x unrolled, same shape as JDK 17
            792007efd0f0:  c5 f9 7e d9            vmovd  ecx,xmm3
  2.81%     792007efd0f4:  03 cb                  add    ecx,ebx
            792007efd0f6:  4c 63 d3               movsxd r10,ebx
            792007efd0f9:  44 8b c9               mov    r9d,ecx
  1.97%     792007efd0fc:  41 ff c1               inc    r9d
            792007efd0ff:  47 0f be 6c 13 10      movsx  r13d,BYTE PTR [r11+r10*1+0x10]
            792007efd105:  41 0f b6 c5            movzx  eax,r13b
            792007efd109:  83 f8 7f               cmp    eax,0x7f
            792007efd10c:  0f 8f c4 03 00 00      jg     0x792007efd4d6
            792007efd112:  3b c6                  cmp    eax,esi
            792007efd114:  0f 83 0b 03 00 00      jae    0x792007efd425
            792007efd11a:  8b 44 87 10            mov    eax,DWORD PTR [rdi+rax*4+0x10]
            792007efd11e:  66 90                  xchg   ax,ax
            792007efd120:  85 c0                  test   eax,eax
            792007efd122:  0f 85 f8 03 00 00      jne    0x792007efd520
  4.41%     792007efd128:  83 c1 02               add    ecx,0x2
            792007efd12b:  4d 8d 0c 2a            lea    r9,[r10+rbp*1]
            792007efd12f:  47 88 6c 08 11         mov    BYTE PTR [r8+r9*1+0x11],r13b
            792007efd134:  47 0f be 54 13 11      movsx  r10d,BYTE PTR [r11+r10*1+0x11]
            792007efd13a:  41 0f b6 c2            movzx  eax,r10b
            792007efd13e:  66 90                  xchg   ax,ax
            792007efd140:  83 f8 7f               cmp    eax,0x7f
            792007efd143:  0f 8f 92 03 00 00      jg     0x792007efd4db
            792007efd149:  3b c6                  cmp    eax,esi
            792007efd14b:  0f 83 d9 02 00 00      jae    0x792007efd42a
            792007efd151:  8b 44 87 10            mov    eax,DWORD PTR [rdi+rax*4+0x10]
            792007efd155:  85 c0                  test   eax,eax
            792007efd157:  0f 85 c8 03 00 00      jne    0x792007efd525
            792007efd15d:  47 88 54 08 12         mov    BYTE PTR [r8+r9*1+0x12],r10b
            792007efd162:  83 c3 02               add    ebx,0x2
            792007efd165:  3b da                  cmp    ebx,edx
            792007efd167:  7c 87                  jl     0x792007efd0f0
This PR: 4x unrolled
            7ee68fefd110:  8b 7c 24 2c            mov    edi,DWORD PTR [rsp+0x2c]
  2.20%     7ee68fefd114:  03 f9                  add    edi,ecx
            7ee68fefd116:  48 63 c1               movsxd rax,ecx
            7ee68fefd119:  8b ef                  mov    ebp,edi
            7ee68fefd11b:  ff c5                  inc    ebp
            7ee68fefd11d:  89 6c 24 20            mov    DWORD PTR [rsp+0x20],ebp
            7ee68fefd121:  44 0f be 44 06 10      movsx  r8d,BYTE PTR [rsi+rax*1+0x10]
            7ee68fefd127:  41 0f b6 d8            movzx  ebx,r8b
            7ee68fefd12b:  44 8d 53 e0            lea    r10d,[rbx-0x20]
            7ee68fefd12f:  41 83 fa 60            cmp    r10d,0x60
            7ee68fefd133:  0f 83 89 02 00 00      jae    0x7ee68fefd3c2
            7ee68fefd139:  0f 1f 80 00 00 00 00   nop    DWORD PTR [rax+0x0]
            7ee68fefd140:  83 fb 22               cmp    ebx,0x22
            7ee68fefd143:  0f 84 c1 02 00 00      je     0x7ee68fefd40a
            7ee68fefd149:  83 fb 5c               cmp    ebx,0x5c
            7ee68fefd14c:  0f 84 00 03 00 00      je     0x7ee68fefd452
            7ee68fefd152:  8d 6f 02               lea    ebp,[rdi+0x2]
            7ee68fefd155:  89 6c 24 20            mov    DWORD PTR [rsp+0x20],ebp
            7ee68fefd159:  4a 8d 2c 18            lea    rbp,[rax+r11*1]
            7ee68fefd15d:  8b 5c 24 24            mov    ebx,DWORD PTR [rsp+0x24]
            7ee68fefd161:  44 88 44 2b 11         mov    BYTE PTR [rbx+rbp*1+0x11],r8b
            7ee68fefd166:  44 0f be 54 06 11      movsx  r10d,BYTE PTR [rsi+rax*1+0x11]
  0.21%     7ee68fefd16c:  41 0f b6 da            movzx  ebx,r10b
            7ee68fefd170:  44 8d 43 e0            lea    r8d,[rbx-0x20]
            7ee68fefd174:  41 83 f8 60            cmp    r8d,0x60
            7ee68fefd178:  0f 83 3d 02 00 00      jae    0x7ee68fefd3bb
            7ee68fefd17e:  66 90                  xchg   ax,ax
            7ee68fefd180:  83 fb 22               cmp    ebx,0x22
            7ee68fefd183:  0f 84 7a 02 00 00      je     0x7ee68fefd403
            7ee68fefd189:  83 fb 5c               cmp    ebx,0x5c
            7ee68fefd18c:  0f 84 b9 02 00 00      je     0x7ee68fefd44b
            7ee68fefd192:  8b 5c 24 24            mov    ebx,DWORD PTR [rsp+0x24]
            7ee68fefd196:  44 88 54 2b 12         mov    BYTE PTR [rbx+rbp*1+0x12],r10b
            7ee68fefd19b:  8d 6f 03               lea    ebp,[rdi+0x3]
            7ee68fefd19e:  89 6c 24 20            mov    DWORD PTR [rsp+0x20],ebp
            7ee68fefd1a2:  44 0f be 54 06 12      movsx  r10d,BYTE PTR [rsi+rax*1+0x12]
  1.08%     7ee68fefd1a8:  41 0f b6 da            movzx  ebx,r10b
            7ee68fefd1ac:  44 8d 43 e0            lea    r8d,[rbx-0x20]
            7ee68fefd1b0:  41 83 f8 60            cmp    r8d,0x60
            7ee68fefd1b4:  0f 83 05 02 00 00      jae    0x7ee68fefd3bf
            7ee68fefd1ba:  66 0f 1f 44 00 00      nop    WORD PTR [rax+rax*1+0x0]
            7ee68fefd1c0:  83 fb 22               cmp    ebx,0x22
            7ee68fefd1c3:  0f 84 3e 02 00 00      je     0x7ee68fefd407
            7ee68fefd1c9:  83 fb 5c               cmp    ebx,0x5c
            7ee68fefd1cc:  0f 84 7d 02 00 00      je     0x7ee68fefd44f
            7ee68fefd1d2:  8d 6f 04               lea    ebp,[rdi+0x4]
            7ee68fefd1d5:  89 6c 24 20            mov    DWORD PTR [rsp+0x20],ebp
            7ee68fefd1d9:  4c 63 c7               movsxd r8,edi
            7ee68fefd1dc:  8b 6c 24 24            mov    ebp,DWORD PTR [rsp+0x24]
            7ee68fefd1e0:  46 88 54 05 13         mov    BYTE PTR [rbp+r8*1+0x13],r10b
            7ee68fefd1e5:  0f be 7c 06 13         movsx  edi,BYTE PTR [rsi+rax*1+0x13]
            7ee68fefd1ea:  40 0f b6 df            movzx  ebx,dil
            7ee68fefd1ee:  44 8d 53 e0            lea    r10d,[rbx-0x20]
            7ee68fefd1f2:  41 83 fa 60            cmp    r10d,0x60
            7ee68fefd1f6:  0f 83 bc 01 00 00      jae    0x7ee68fefd3b8
            7ee68fefd1fc:  0f 1f 40 00            nop    DWORD PTR [rax+0x0]
            7ee68fefd200:  83 fb 22               cmp    ebx,0x22
            7ee68fefd203:  0f 84 f7 01 00 00      je     0x7ee68fefd400
            7ee68fefd209:  83 fb 5c               cmp    ebx,0x5c
            7ee68fefd20c:  0f 84 36 02 00 00      je     0x7ee68fefd448
            7ee68fefd212:  42 88 7c 05 14         mov    BYTE PTR [rbp+r8*1+0x14],dil
            7ee68fefd217:  83 c1 04               add    ecx,0x4
            7ee68fefd21a:  66 0f 1f 44 00 00      nop    WORD PTR [rax+rax*1+0x0]
            7ee68fefd220:  3b ca                  cmp    ecx,edx
            7ee68fefd222:  0f 8c e8 fe ff ff      jl     0x7ee68fefd110

Differences from JDK 17:

  • With the PR, C2 on 25 unrolls 4x instead of 2x (stride limit 0xfa0 vs 0x7d0), but runs short of registers: the output pointer is stored to the stack after each char (mov [rsp+0x20],ebp), and the output array base is reloaded from the stack before each store (mov ebx,[rsp+0x24]).
  • 3.x on 25 keeps the JDK 17 shape: 2x unrolled, with the escCodes bounds check (cmp eax,esi; jae) and table load per char.

The JDK 17 loops in my previous comment are both 2x unrolled with no spills, so the regression there looks like the plain extra compare work rather than a spill problem.

@franz1981

franz1981 commented Oct 15, 2026 •

Copy link
Copy Markdown
Author

yep @pjfanning the 4X is what I have observed, but didn't observed register spilling if the caller is small: did you have a @CompilerSomething(DONTINLINE) in the caller of the root method of the benchmark?
OR in the writeString caller too is fine as it is what would happen with reflection - u can use compile command there instead.

@pjfanning

Copy link
Copy Markdown
Member

@franz1981 answers + more JDK 17 data (JDK 17 is where this PR regresses vs 3.x; JDK 25 is already fine).

Inlining: no, my benchmark had no DONTINLINE, so writeString was inlined into the benchmark loop. Re-ran with -XX:CompileCommand=dontinline,tools.jackson.core.json.UTF8JsonGenerator::writeString. On JDK 25 the PR loop (now in writeString) is still 4x unrolled and still spills, but less: the loop counter goes through [rsp] once per 4 chars, and the output array base is reloaded from [rsp+0x4] before each store. It is still faster than 3.x there.

JDK 17 perfnorm (dontinline, ascii, length 1000, per op, i7-8550U):

counter 3.x PR change
time (us/op) 397.6 ± 29.9 420.5 ± 22.5 +5.7%
cycles 930K 1000K +7.5%
instructions 4.09M 4.12M +0.7%
uops_issued.any 3.42M 3.46M +1.2%
branches / br_inst_retired.near_taken 808K / 102K 806K / 102K same
idq.dsb_uops / idq.mite_uops / lsd.uops 3.40M / 29K / 0 3.43M / 29K / 0 same
idq_uops_not_delivered.core 68K 259K 3.8x

So it is front-end, as you suspected, but not from more taken branches: the work, branch counts and uop source (DSB) are all the same. The extra ~191K undelivered slots (about 48K cycles) account for most of the +70K cycles.

JCC erratum check (-XX:-IntelJccErratumMitigation, JDK 17):

3.x PR
time (us/op) 593 712
idq.dsb_uops 6K 906K
idq.mite_uops 3.15M 2.33M
idq_uops_not_delivered.core 1.79M 2.57M

This CPU has the JCC microcode fix. Without C2's padding, the loop falls out of the DSB onto the legacy decoders (MITE), and both versions get 50-70% slower. With padding (the default), my guess is that the PR's 3 macro-fused cmp/jcc pairs per char, plus padding, sit worse in 32-byte DSB windows, so delivery per cycle drops. That would also explain why results differ between machines. I couldn't test 32-byte loop alignment: OptoLoopAlignment is capped at 16 on a release JDK 17.

JDK 25 avoids this by unrolling 4x, which halves taken branches (52K vs 101K per op).

Next I'll try two variants on JDK 17: (1) fewer branches per char, and (2) a manual 4x unroll.

@pjfanning

Copy link
Copy Markdown
Member

Tried two variants aimed at the JDK 17 front-end issue, in a standalone microbenchmark (one String/char[] of ASCII per op, no escapes; i7-8550U):

  • bitmask: standard escapes as a 128-bit mask in two long constants, a single branch per char: ((ch >>> 7) | ((ch < 64 ? LO : HI) >>> ch & 1)) != 0, and a version that ORs 4 chars together and branches once per 4 chars. Verified against CharTypes.get7BitOutputEscapes() for all 65536 char values.
  • PR check, manual 4x unroll: this PR's comparisons with non-short-circuit |, one branch per 4 chars, plus a char-by-char tail.

ns per string (lower is better):

variant JDK 17, 12 chars JDK 17, 100 JDK 17, 1000 JDK 25, 12 JDK 25, 100 JDK 25, 1000
3.x (table) 26.8 152 1408 21.0 114 1062
this PR 29.3 165 1576 21.5 116 1090
PR check, manual 4x unroll 29.8 156 1459 25.4 124 1135
bitmask, 1 char per branch 40.5 299 2783 31.9 191 1831
bitmask, 4 chars per branch 46.2 293 2779 34.7 181 1760
char[] 3.x 28.6 144 1412 18.7 93 915
char[] bitmask, 4 per branch 38.2 243 2499 29.4 159 1538
  • Bitmask: a clear loss on both JDKs (60-100% slower). The per-char constant select plus variable shift costs more than the table load, which always hits L1.
  • Manual unroll: on JDK 17 it recovers about two thirds of this PR's regression at 1000 chars, but is still ~4% slower than 3.x, and it is slower than this PR on JDK 25.
  • In this microbenchmark the plain table loop in 3.x is still the fastest or tied on both JDKs.

Next I'll re-check a different approach: scan first, then bulk-copy with String.getBytes(int, int, byte[], int) (an arraycopy for Latin-1 strings), which looked promising on JDK 17 in an earlier noisy run.

@franz1981

Copy link
Copy Markdown
Author

@pjfanning reproduced this on different hardware — Ryzen 9 7950X pinned at 4.3 GHz (performance governor), JDK 17.0.17 and 25.0.3 Temurin — using your StringWriteBench verbatim, jackson-core 3.x @ 4bd708fa0 vs this PR's head 4c376a483 as loose jars on the classpath (checked that the two jars differ in exactly one class).

All numbers below use -XX:CompileCommand=dontinline,tools.jackson.core.json.UTF8JsonGenerator::writeString, clean runs with no profiler attached, 3 repetitions of -f 2 -wi 5 -i 5, utf8String/ascii:

length 1000    base          pr            delta
jdk17          167.96 us/op  161.11 us/op   -4.1%   PR faster
jdk25          156.25 us/op  119.02 us/op  -23.8%   PR faster

length 100
jdk17           15.80 us/op   17.98 us/op  +13.8%   PR slower
jdk25           16.91 us/op   13.80 us/op  -18.4%   PR faster

Repeatability was tight — the jdk17/len=100 PR series was 17.986 / 17.985 / 17.975, and five of the six series held to ±0.1 us/op across reps.

So on this machine JDK 17 regresses only at length 100, and is faster at length 1000 — the opposite sign to your +5.7% at length 1000. Might be worth checking more than one length before tuning against the regression.

Codegen under dontinline. hsdis decoded for me on both JDKs, so this is real disassembly rather than reconstructed bytes. The copy loop now lives in writeString, and:

  • JDK 25 unrolls it 4x, JDK 17 only 2x. That lines up with your idq_uops_not_delivered.core finding: 4x halves the taken branches.
  • On JDK 25 the output buffer base is held in %xmm4 and pulled out with vmovq %xmm4,%r8 before the stores, instead of going through [rsp]. So the pressure you described is present here too, just parked in a vector register on this core rather than on the stack.

That makes your planned variant (2) — the manual 4x unroll on JDK 17 — look like the right lever: 4x vs 2x is exactly what separates the two JDKs here.

Also worth noting: this is Zen 4, which has no JCC erratum and no IntelJccErratumMitigation padding at all, and a regression still appears at length 100. So that mechanism can't be the whole story.

Finally, three traps I fell into while reading this assembly — I reported wrong numbers from all three before catching them, so in case they save anyone else time:

  • hsdis attributes several instructions to a single bytecode, so counting lines containing ;*bastore overcounts the unroll factor. Count stores by instruction form (mov %rNb,mem) instead.
  • cmp (%rsp),%reg is an array-bounds check, not a spill. Only a store/reload round-trip of the same slot counts.
  • With writeString out of line there are several loops containing the escape test in one method — the 4x main loop, a 1x drain loop, and the segment-chunking loop. Picking "the loop containing cmp $0x5c" can land on an enclosing region and produce nonsense per-character figures.

@pjfanning

Copy link
Copy Markdown
Member

Clean re-run (3 forks, idle machine) of a different approach for the String path: scan for the first char needing escaping (using the existing escCodes table, no store), then bulk-copy the clean prefix with String.getBytes(int, int, byte[], int). For Latin-1 strings that is a single System.arraycopy. It is safe because the scan has already checked every copied char is <= 0x7F, so truncating to the low byte loses nothing.

String, ASCII only, ns per string (lower is better):

variant JDK 17, 12 chars JDK 17, 100 JDK 17, 1000 JDK 25, 12 JDK 25, 100 JDK 25, 1000
3.x 27.8 155 1434 21.2 114 1093
this PR 28.8 162 1587 21.9 116 1083
table scan + getBytes 29.6 (+7%) 111 (-29%) 995 (-31%) 29.0 (+37%) 100 (-12%) 929 (-15%)
constant-check scan + getBytes 30.7 127 1327 30.6 105 919
  • Clear win on both JDKs for strings of 100+ chars. The fixed cost of the getBytes call loses on short strings, so it needs a length cutoff.
  • The table scan beats this PR's constant check here too.
  • For char[] the split scan-then-copy is slower than 3.x (no intrinsic char[] -> byte[] copy), so char[] should keep the current loop.

I'll try this in UTF8JsonGenerator._writeStringSegment(String, ...) with a length cutoff, and post end-to-end StringWriteBench numbers for JDK 17 and 25 (vs 3.x and this PR) once I have them.

@franz1981

Copy link
Copy Markdown
Author

@pjfanning on JDK 27 IIRC there should be a new String method to know the decodedLength but IDK if can help here

@franz1981

Copy link
Copy Markdown
Author

Tried your scan + getBytes idea end-to-end in StringWriteBench (rather than a standalone micro), same box as my last comment: Ryzen 9 7950X pinned at 4.3 GHz, JDK 17.0.17 / 25.0.3 Temurin, -XX:CompileCommand=dontinline,…UTF8JsonGenerator::writeString, clean runs with no profiler.

I applied it to _writeStringSegment(String,int,int) on top of 3.x @ 4bd708fa0, leaving the char[] overload alone as you suggested:

// Scan only: find the first char needing escaping, no per-char store.
int i = offset;
while (i < len) {
    int ch = text.charAt(i);
    if (ch > 0x7F || escCodes[ch] != 0) {
        break;
    }
    ++i;
}
if (i > offset) {
    text.getBytes(offset, i, outputBuffer, outputPtr);
    outputPtr += (i - offset);
    offset = i;
}

Checked first that it is byte-identical to 3.x over empty strings, quotes, backslash, control chars, DEL, Latin-1, euro, an emoji surrogate pair and lengths spanning the 2000-char segment boundary. It is.

utf8String, ascii, us/op (lower is better):

                  base        pr      scan    scan/base  scan/pr
jdk17 len=12        3.675     3.467     2.841    -22.7%   -18.1%
jdk17 len=100      15.818    17.956     9.984    -36.9%   -44.4%
jdk17 len=1000    167.806   161.057    81.907    -51.2%   -49.1%

jdk25 len=12        2.940     2.710     2.809     -4.5%    +3.7%
jdk25 len=100      16.862    13.808    11.198    -33.6%   -18.9%
jdk25 len=1000    156.283   119.411    81.552    -47.8%   -31.7%

Two differences from your table worth noting:

  1. About 2x faster at length 1000 on both JDKs, which is a bigger win than your -31%/-15%.
  2. No short-string penalty here. At 12 chars scan is 22.7% faster than 3.x on JDK 17 and only 3.7% behind this PR on JDK 25 — nothing like your +7%/+37%. On this box a length cutoff does not look necessary. Your numbers are ns-per-string from a standalone micro and mine are end-to-end through the generator, so that may be where the difference lives.

Assembly (hsdis decoded, loop in writeString, read rather than grepped):

                     insns/char   unroll   notes
jdk17 base              19.5        2x     table + store
jdk17 pr                19.5        2x     constants + store
jdk17 scan              10.5        4x     table, no store, no stack traffic
jdk25 base              15.0        2x     table + store, counter round-trips 0x4(%rsp)
jdk25 pr                13.0        4x     constants + store, output base held in %xmm4
jdk25 scan               9.25       4x     table, no store, no stack traffic

Removing the store does two things at once: it roughly halves the per-character instruction count, and on JDK 17 it is what finally lets C2 unroll 4x — neither 3.x nor this PR gets past 2x there. That fits your idq_uops_not_delivered.core finding, since 4x halves the taken branches. The actual copying then costs one System.arraycopy.

One small thing: the scan loop still carries the escCodes bounds check (cmp %ecx,%r10d ; jae) on both JDKs, even though ch <= 0x7F has just been proven and the table has 128 entries. Cheap and perfectly predicted, but it is the one piece a constant check would remove — I am building that fourth variant (constant-check scan + getBytes) next and will post it.

@pjfanning

Copy link
Copy Markdown
Member

#1775 is what Claude AI found to work best on one of my machines, an old HP laptop with WSL2 so I do most dev on Ubuntu.

@franz1981

Copy link
Copy Markdown
Author

Promised numbers for the fourth variant — constant-check scan + getBytes (this PR's stdEsc test, with the per-char store replaced by the bulk copy). Same box, same harness, dontinline on writeString, output verified byte-identical to 3.x.

utf8String, ascii, us/op:

                  base        pr      scan  scanconst
jdk17 len=12      3.679     3.459     2.870     2.907
jdk17 len=100    15.776    17.977    10.023    10.384
jdk17 len=1000  168.186   161.058    81.747    99.586
jdk25 len=12      2.935     2.703       -       2.790
jdk25 len=100    16.872    13.785    11.176     9.830
jdk25 len=1000  156.283*  119.258    81.644    81.864

(* that base cell is unstable on this box, +-51 in one run and +-0.2 in another; 156.3 is the stable value. The missing scan cell is one I killed by accident.)

Your observation holds on JDK 17 and does not on JDK 25, and the assembly says why. Main 4x-unrolled scan loop in writeString:

                        insns/char   escCodes loads   immediate cmps
scan      jdk17           10.50            4                0
scanconst jdk17           13.00            0               12
scan      jdk25            9.25            4                0
scanconst jdk25            9.25            0               12

On JDK 17 the constant check costs 2.5 more instructions per character (13.00 vs 10.50), which matches the measured +22% at length 1000. On JDK 25 the two loops are the same length — 37 instructions for 4 characters either way — and the measurements are tied (81.86 vs 81.64). So on 25, trading the table load for three immediate compares is free in the scan loop.

That matters beyond x86: on aarch64 a table lookup needs the table's base address materialised into a register (mov+movk+movk for a decoded oop), and C2 does not always hoist it — there is a long-standing issue where it rebuilds the same base repeatedly inside a loop. The constant check needs no base at all. So if the scan loop is free of the table on modern JDKs, that is worth having on ARM even where it is merely neutral on x86.

One thing I tried and should report as a negative: both variants above still have the method's original prologue, which hoists _outputTail and _outputBuffer into locals before the scan loop even though the loop never touches them. I confirmed in the disassembly that C2 does not sink them — the buffer base is decompressed into a register before the loop (lea (%r12,%r8,8),%r14 on 17, lea (%r12,%r10,8),%rcx on 25) and held across it unused. I rebuilt both variants reading those fields after the scan instead. It makes no difference for the table version (within noise at every length) and makes the constant version ~12% worse on JDK 25. So the pointless hoisting is genuinely free; not worth changing.

@pjfanning

Copy link
Copy Markdown
Member

@franz1981 so are you proposing to change this PR or are you sticking with the existing code?

pjfanning added a commit to pjfanning/jackson-core that referenced this pull request Oct 15, 2026
Segments under 32 chars use the constant comparison (standard escape table
only) from FasterXML#1681; longer ones keep the scan + bulk-copy path.

Co-authored-by: Francesco Nigro <nigro.fra@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📈 Overall Code Coverage

Metric Coverage Change
Instructions coverage 86.50% 📉 -0.030%
Branches branches 80.62% 📈 +0.000%

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

This branch has not been deployed

No deployments
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.

3 participants