Conversation
…) variants `StringOutputUtil.categorize(int)` evaluated a chain of range checks for every character of every String value and key written. ASCII categories are now pre-computed into a 128-entry table (populated from the same logic, which remains in use for non-ASCII); the String and char[] scanning loops also take the ASCII fast path before the surrogate check. `TomlGenerator.writeRaw(String, int, int)` copied via `substring()`; now `getChars()` directly into the output buffer (falling back only when longer than the buffer). `writeRaw(SerializableString)` used `toString()`; now `getValue()`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
yawkat
left a comment
There was a problem hiding this comment.
Is this actually faster in practice?
|
Before/after numbers (JMH 1.37, JDK 17.0.19, 3 forks × 5 warmup + 5 measurement iterations). "before" = 1.
Roughly 2 ns/char → 1 ns/char for ASCII. Non-ASCII input also improves (the ASCII chars in it take the table path; the others pay one extra compare before the unchanged full logic). The old 2. End-to-end generator (
End-to-end, categorization is a minority of generator time (map iteration, key-path Benchmark source// package tools.jackson.dataformat.toml
@BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark) @Fork(3) @Warmup(iterations = 5, time = 1) @Measurement(iterations = 5, time = 1)
public class CategorizeBench {
String asciiKey = "property_name_17";
String asciiValue = "plain ascii value number 17 for table 3";
String asciiEscapes = "value with \"quotes\" and a\ttab";
String nonAscii = "café naïve 日本語 value 17";
char[] asciiValueChars = asciiValue.toCharArray();
@Benchmark public int asciiKey() { return StringOutputUtil.categorize(asciiKey); }
@Benchmark public int asciiValue() { return StringOutputUtil.categorize(asciiValue); }
@Benchmark public int asciiEscapes() { return StringOutputUtil.categorize(asciiEscapes); }
@Benchmark public int nonAscii() { return StringOutputUtil.categorize(nonAscii); }
@Benchmark public int asciiValueCharArray() { return StringOutputUtil.categorize(asciiValueChars, 0, asciiValueChars.length); }
}
@BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark) @Fork(3) @Warmup(iterations = 5, time = 1) @Measurement(iterations = 5, time = 1)
public class TomlWriteBench {
static final int TABLES = 20, KEYS_PER_TABLE = 25;
TomlMapper mapper; Map<String, Object> asciiDoc, mixedDoc;
@Setup public void setup() { mapper = TomlMapper.builder().build(); asciiDoc = doc(false); mixedDoc = doc(true); }
static Map<String, Object> doc(boolean mixed) {
Map<String, Object> root = new LinkedHashMap<>();
for (int t = 0; t < TABLES; t++) {
Map<String, Object> table = new LinkedHashMap<>();
for (int k = 0; k < KEYS_PER_TABLE; k++) {
String value;
switch ((t * KEYS_PER_TABLE + k) % 4) {
case 0: value = "plain ascii value number " + k + " for table " + t; break;
case 1: value = "value with 'apostrophes' inside, item " + k; break;
case 2: value = mixed ? "café naïve 日本語 value " + k : "value with \"double quotes\" inside " + k; break;
default: value = mixed ? "tab\there and newline\nthere " + k : "just-a-plain-slug-" + t + "-" + k; break;
}
table.put("property_" + t + "_" + k, value);
}
root.put("table_" + t, table);
}
return root;
}
@Benchmark public int writeAscii() throws Exception { return mapper.writeValueAsString(asciiDoc).length(); }
@Benchmark public int writeMixed() throws Exception { return mapper.writeValueAsString(mixedDoc).length(); }
@Benchmark public int writeRawRanges() throws Exception {
StringWriter w = new StringWriter(); String line = "xxkey = 'value'yy";
try (JsonGenerator g = mapper.createGenerator(w)) {
for (int i = 0; i < 500; i++) { g.writeRaw(line, 2, line.length() - 4); g.writeRaw('\n'); }
}
return w.getBuffer().length();
}
} |
Two small hot-path improvements in the TOML generator.
StringOutputUtil.categorize()— the per-character category (unquoted-key / literal / basic / basic-no-escape / ASCII-only) was computed via a chain of range checks for every character of every String value and key. ASCII categories are now pre-computed into a 128-entryint[]at class init, from the same logic (which stays in place, as_categorize(), for non-ASCII, surrogates and BOM). TheStringandchar[]scanning loops take the ASCII branch before the surrogate check, so the common case is a load-and-AND per char.TomlGenerator.writeRaw()—writeRaw(String, int, int)allocated viasubstring(); it now doesgetChars()straight into the output buffer, only falling back to theStringpath when the range is longer than the buffer.writeRaw(SerializableString)went throughtoString(); nowgetValue(). (3.x'sSerializableStringhas no unquotedchar[]accessor —asQuotedChars()is JSON-escaped — so theStringoverload is the right one.)Tests:
StringOutputUtilTestgains a consistency check thatint,Stringandchar[]categorization agree for every BMP char plus surrogate-pair / lone-surrogate cases, and a per-String AND-of-chars check; the existing exhaustive all-code-points write/read test now runs through the table for ASCII.TomlGeneratorTestgainswriteRawoverload coverage including a >2000-char range. Full TOML suite passes.While here I noticed a pre-existing bug in
_writeStringImpl(int, char[], int, int)(text[offset + len]instead oftext[offset + i]in the escaped-basic-string branch). Not touched in this PR — separate fix incoming against 2.18.🤖 Generated with Claude Code