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:
- 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.
- 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.
What happens
In
fory-json, once a string contains its first escape, the rest of that string is decoded one characterper 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-readStringStopcopies the prefix intostringDecodeBufferand hands the remainder toreadStringLatin1Tail, which loops per character(quote/escape/control/ascii discrimination, a capacity check, one store, an input-limit check) and never
returns to the
stringStopMaskword 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(): astringDecodeBufferlarger thanRETAINED_STRING_DECODE_BUFFER_SIZE(8192) is replaced by a fresh 8 KiB array after every parse. The readeritself 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
admstring holding VAST XML - pure ASCII, about 100 escapedquotes, the first of them at character 14 of 20006.
Reading the six documents into the same model,
byte[]in both cases:The cause is isolated by changing only the escapes inside that one string, keeping its length at 20006
characters:
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:
(
stringStopMask/asciiStringStopMask/utf16StringStopMask) to find the next one and copy the plainrun in a single pass -
System.arraycopyfor the byte-backed readers, a packing loop for the utf16 readerwhose characters are two bytes wide. Roughly 20 lines per reader.
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:
That is 6.2x against unpatched Fory, and it turns a 2.8x loss against Jackson into a 2.2x win. The
fromStringpaths improve as well: 4-6x for the latin1 reader and 1.4-3.5x for the utf16 one, measured on thesame 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 endingimmediately 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.