Skip to content

[Java] Escaped strings are decoded one character at a time from the first escape to the end of the string (java, fory-json) #4055

Description

@pavel-ptashyts

What happens

In fory-json, once a string contains its first escape, the rest of that string is decoded one character
per loop iteration. The word scanner that found the escape is never resumed, so the cost of a string is
proportional to the distance from its first escape to its end, not to the number of escapes.

On documents dominated by one large escaped string this makes reading several times slower than Jackson,
on the same document and into the same model.

Where

All three readers, 1.7.3:

  • org/apache/fory/json/reader/Utf8JsonReader.java - readStringStop copies the prefix into
    stringDecodeBuffer and hands the remainder to readStringLatin1Tail, which loops per character
    (quote/escape/control/ascii discrimination, a capacity check, one store, an input-limit check) and never
    returns to the stringStopMask word scan.
  • org/apache/fory/json/reader/Latin1JsonReader.java - same shape.
  • org/apache/fory/json/reader/Utf16JsonReader.java - same shape.

A second, independent effect is in clear(): a stringDecodeBuffer larger than
RETAINED_STRING_DECODE_BUFFER_SIZE (8192) is replaced by a fresh 8 KiB array after every parse. The reader
itself is pooled, so a document whose string is larger than 8 KiB re-grows the buffer by doubling on every
single parse, and the growth arrays plus the replacement are garbage each time.

Measurements

Application harness (JDK 25, JMH-free: min of 7 rounds of 300 iterations, both libraries warmed over all
documents before anything is timed, rounds alternating between the libraries). Six real OpenRTB bid
responses, 12-110 KB, each roughly 95% one adm string holding VAST XML - pure ASCII, about 100 escaped
quotes, the first of them at character 14 of 20006.

Reading the six documents into the same model, byte[] in both cases:

fory 1.7.3 Jackson 3
ns/document, total 567382 201662
allocated bytes/document, total 934976 511184

The cause is isolated by changing only the escapes inside that one string, keeping its length at 20006
characters:

variant fory ns Jackson ns
no escapes at all 7036 12385
one escape, at the very end 10104 10169
one escape, at character 1 41833 13562
the real document, 106 escapes 42533 14610

With no escape Fory is 1.76x faster than Jackson. One escape at the end costs almost nothing. One escape
near the front costs the same as a hundred of them - because everything after the first one is decoded
character by character.

The declared field order and the generated reader's fast/slow path are not involved: crafting variants of
the same documents so the fast path never breaks, and so it breaks on the very first key, changes the timing
by less than the harness noise.

Suggested fix

Two independent changes, both local to the readers:

  1. In the escaped-string tail, after handling one stop character, reuse the existing word scanner
    (stringStopMask / asciiStringStopMask / utf16StringStopMask) to find the next one and copy the plain
    run in a single pass - System.arraycopy for the byte-backed readers, a packing loop for the utf16 reader
    whose characters are two bytes wide. Roughly 20 lines per reader.
  2. In clear(), keep a buffer the recent documents actually needed instead of shrinking unconditionally:
    track the longest string decoded since the buffer was last sized and only shrink when the buffer is more
    than twice that. A steady stream of large documents then stops re-growing the buffer, while a one-off
    large document is still released.

Measured with both changes applied to 1.7.3, same harness and documents:

fory 1.7.3 fory 1.7.3 + fix Jackson 3
ns/document, total 567382 91074 202520
allocated bytes/document, total 934976 295664 511064

That is 6.2x against unpatched Fory, and it turns a 2.8x loss against Jackson into a 2.2x win. The
fromString paths improve as well: 4-6x for the latin1 reader and 1.4-3.5x for the utf16 one, measured on the
same documents.

Correctness was checked by reading 264 payloads through all three readers and comparing every decoded value
against Jackson's: escapes at every position of a string spanning several scanner words, for \", \\, \/,
\n, \t, é, ; surrogate pairs; raw multi-byte UTF-8; escapes back to back; a string ending
immediately after an escape; and 30000-character payloads with escapes scattered at random. The suite passes
on unpatched 1.7.3 first, and still passes with the fix.

I have the patch and will open a pull request against main.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions