Repository navigation
Description
What happens
This is the writing side of #4055. In fory-json, once a string being written contains its first character that
needs escaping, the rest of that string is written one character at a time. The scan that found the character is
never resumed, so the cost of writing a string is proportional to the distance from its first such character to
its end, not to the number of characters that need escaping.
On objects dominated by one large string with a few quotes or newlines in it, this makes writing an order of
magnitude slower than writing the same string without them.
Where
main at f85877f2, both writers:
org/apache/fory/json/writer/Utf8JsonWriter.java- A compact latin1
Stringlonger than the fixed-size fast paths that contains any byte to escape is rejected
bywriteLongLatin1StringNoEnsureand goes towriteStringChars, which scans it withcharAt, four
characters at a time, up to the first stop. - From there
writeStringSlowwrites every remaining character throughwriteEscapedChar: a switch, a
capacity check and one store per character, and it never returns to a bulk copy. TheCharSequence
overload and the UTF16Stringpath end in the same loop.
- A compact latin1
org/apache/fory/json/writer/StringJsonWriter.java- the same shape in all four tails:
writeLatin1StringSlowandwriteLatin1StringUtf16Slowfor compact latin1 input,writeStringSlowand
writeStringUtf16Slowfor character input.
Measurements
Standalone harness, JDK 25.0.2, ForyJson.builder().build(), best of 7 rounds of 5000 writes after 30000 warm-up
writes per variant. The object is a bid with a 19998-character adm string holding VAST XML, pure ASCII, 612
quotes and 345 newlines, the first quote at character 15. The cause is isolated by changing only the
characters that need escaping inside adm, keeping its length:
| variant | toJsonBytes |
toJson |
|---|---|---|
| nothing to escape | 3.9 us | 3.6 us |
| one quote at the very end | 19.6 us | 3.6 us |
| one quote at character 1 | 49.7 us | 38.0 us |
| the real string | 46.9 us | 43.6 us |
With nothing to escape the 20 KB string is written in 3.9 us. One quote near the front costs 13 times that, the
same as all 957 of them, because everything after it is written character by character. For toJsonBytes even a
quote at the very end costs 5 times, because the whole string is first rescanned character by character up to it.
For reference, Jackson 3.1 writeValueAsString on the same object takes 12.5 us without escapes and 25.0 us on
the real string.
Suggested fix
The same shape as #4058, local to the slow paths and leaving the fixed-size fast paths in writeString untouched:
- In
Utf8JsonWriter, send a compact latin1 string that the fast paths reject to a loop over its bytes that
copies each plain run a word at a time with the existingisJsonAsciiWordpredicate, writes the one byte that
stopped it throughwriteEscapedChar, and resumes. - In every character tail of both writers, copy the run of plain characters up to the next stop with one
capacity check per run, then handle the stop character exactly as now. InStringJsonWriterthe run follows
the current output coder, since a character above0xFFupgrades the output to UTF16 in the middle of a string.
Measured with that applied to main, same harness, output byte-identical in every variant:
| variant | toJsonBytes |
toJson |
|---|---|---|
| nothing to escape | 3.9 us | 3.5 us |
| one quote at the very end | 6.0 us | 3.4 us |
| one quote at character 1 | 4.5 us | 3.8 us |
| the real string | 10.3 us | 11.8 us |
I have the patch and will open a pull request against main.
Activity
perf(java): copy plain runs after the first escape when writing JSON …