Skip to content

perf(encode): audit the write path and apply the wins that measured - #3

Open
Fyzu wants to merge 2 commits into
mainfrom
claude/encoding-performance-improvements-x9kv71
Open

perf(encode): audit the write path and apply the wins that measured#3
Fyzu wants to merge 2 commits into
mainfrom
claude/encoding-performance-improvements-x9kv71

Conversation

@Fyzu

@Fyzu Fyzu commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Audit of the three encode paths against what fastjson2 and protobuf-java actually make available, plus the changes that measured as wins. docs/encoding-performance.md is the standing record: what was applied, what measured as nothing and why, and a ranked backlog with the reasoning for each item.

Measurement method

run-benchmarks.sh numbers are not comparable across sessions on a shared host — the first baseline run here showed score errors up to 40% of the score at -f 1 -i 3. Everything below is an interleaved A/B: two benchmarks.jar builds from the same reactor, run back to back on the same host at -f 3 -wi 3 -i 5 -r 1 -w 1. Score error was ±2–5% on both sides except where noted.

Throughput (ops/s)

benchmark before after delta
SimpleMessage.compiledUtf16 9,874,885 11,071,259 +12.1%
SimpleMessage.compiledUtf8 11,474,893 13,021,993 +13.5%
SimpleMessage.runtimeUtf16 7,485,001 8,064,932 +7.7%
SimpleMessage.runtimeUtf8 8,870,673 9,099,641 +2.6%
ComplexMessage.buffJsonCompiled 749,489 773,347 +3.2%
ComplexMessage.buffJsonRuntime 646,093 662,785 +2.6%
Wkt.structCompiled 296,651 467,034 +57.4%
Wkt.structRuntime 292,599 481,153 +64.4%
Wkt.timestampCompiled 4,736,791 4,891,055 +3.3%
RepeatedAndMap.* flat (within noise)

Allocation (gc.alloc.rate.norm, B/op)

benchmark before after delta
SimpleMessage.compiledUtf16 296 208 −88
SimpleMessage.compiledUtf8 272 184 −88
ComplexMessage.buffJsonCompiled 1,532 1,444 −88
Wkt.struct* 1,305 648 −657
RepeatedAndMap.mapRuntime 8,021 6,727 −1,294

The flat −88 B/op everywhere is the per-call JSONWriter.Context — 30% of SimpleMessage's total. After the change the returned String/byte[] is essentially the only thing allocated per encode.

Changes

Word-packed field names (codegen). fastjson2 exposes a writeName2Raw(long)writeName16Raw(long, long) family that takes the name already packed into machine words and stores it with one or two Unsafe.putLongs — what fastjson2's own ASM/@JSONCompiled writers use, and the largest structural gap to the POJO ceiling CeilingBenchmark measures. The old writeNameRaw(byte[])/writeNameRaw(char[]) path cost a static array load, a length read, an arraycopy, and an isUTF8() branch per field.

The layout is an undocumented fastjson2 detail and irregular — native-order word reads out of the literal "name":, at offset 0 for every length except 8 and 16, where fastjson2 emits the opening quote itself. New FieldNames derives it and documents the table. JSONWriterUTF16 consumes the same constants (it widens the packed ASCII to chars on the way out), so the encoding branch and the second array disappear rather than moving.

Typed Struct/Value/ListValue. Descriptor.getOneofs() is Collections.unmodifiableList(Arrays.asList(oneofs)) — two allocations on every call — and WellKnownTypes.writeValue called it once per Value written, then scanned for the set oneof field and switched on its String name. Compiled messages now use getFieldsMap()/getKindCase(); DynamicMessage keeps the reflective path with the map-entry descriptors hoisted out of the per-entry loop and the kind oneof cached per Descriptor. Biggest single win in the audit, and it was hiding inside a WKT helper rather than on the main field loop.

Typed WKT wrapper writes in codegen. An Int32Value/StringValue/… field emits jsonWriter.writeInt32(wrap.getValue()) instead of routing through WellKnownTypes.write()'s full-name String switch, descriptor cache probe and getField() boxing.

One JSONWriter.Context per encoder instead of one per encode() call.

Reflection path: hasField before getField. The old order paid a boxed reflective read for every absent optional field and discarded it.

Map key/value descriptors resolved at schema-build time. FieldWriter.writeMap was calling findFieldByName("key") and findFieldByName("value") on every map field write.

Repeated primitives / strings. Numeric and bool lists narrow to Internal.IntList/LongList/DoubleList/FloatList/BooleanList and read primitives; repeated strings go through writeString(List<String>) in one call.

Two results worth calling out

One idea measured worse and was reverted. Routing the runtime paths through the packed names needs a switch on the name length — an indirect branch in the hottest code, keyed on a per-field instance value. On ComplexMessage (15 fields, 9 distinct name lengths) that cost −10.8% on the typed path. Reverting to the pre-encoded arrays turned that into +2.6%, and moved SimpleMessage.runtimeUtf16 from −0.4% to +7.7%. The reasoning is recorded in FieldName's javadoc so it does not get "fixed" again.

One measured as exactly nothing. IntArrayList.get(i) returns Integer.valueOf(...), which looks like a per-element allocation — but the change measured 0 B/op and 0% on a field holding 50–200 rng.nextInt() values (essentially no cache hits). HotSpot already scalar-replaces the boxes once the loop inlines; if boxing were really allocating there it alone would have been ~2,000 of the 5,385 B/op measured. Kept only as insurance for the C1 and interpreted tiers, and documented as not-a-win so nobody re-measures it hoping.

Correctness fix found on the way

[json_name = "…"] accepts any string, and two things were broken independently of performance:

  • buildNameWithColonUtf8 did (byte) name.charAt(i), truncating non-ASCII into corrupt UTF-8 and emitting a raw "/\ straight into the JSON string;
  • EncoderGenerator interpolated the name into a Java string literal unescaped, so a json_name containing a quote or newline generated source that does not compile.

FieldNames.isRawWritable now gates the raw arrays; anything else goes through JSONWriter.writeName(String) + writeColon() so fastjson2 escapes and transcodes it, and all emitted literals go through FieldNames.javaStringLiteral.

Testing

  • 545 tests pass, including the existing cross-path fuzz test that asserts codegen == typed == reflection == JsonFormat byte-for-byte.
  • New FieldNamesTest (54 cases) pins the fastjson2 packing layout for every length 2–16 on both encodings, plus the escaping fallback. A fastjson2 upgrade that moves the layout now fails there instead of silently corrupting every field name in every message.
  • allocation-check.sh passes with budgets tightened to the new baselines (SimpleMessage 380→290, ComplexMessage 2300→2000, etc.).
  • The official protobuf conformance runner binary is not available in this environment, so that suite was not run; it is report-only in CI.

Not included

docs/encoding-performance.md Part 2 ranks what's left. Top three: devirtualizing JSONWriter via per-encoding generated writeFields bodies (the remaining big structural item, but 2× generated bytecode — must be measured, not assumed); pre-sizing the output buffer through the public-but-unused ensureCapacity(int); and fusing constant name+value pairs for bools and enums. It also records the ideas that look promising and aren't — e.g. writeName(int) writes an unquoted number with no colon, so it cannot serve proto3 map keys.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Rjh5q2cHevWc6DPzyoACQp


Generated by Claude Code

claude added 2 commits July 30, 2026 00:05
Adds docs/encoding-performance.md — a standing audit of the three encode paths
with what was applied, what measured as nothing and why, and a ranked backlog.

Measured on an interleaved A/B of two benchmarks.jar builds, same host, -f 3
-wi 3 -i 5. Throughput: SimpleMessage codegen +12.1% (UTF-16) / +13.5% (UTF-8),
typed +7.7% / +2.6%; ComplexMessage +3.2% / +2.6%; Struct +57% / +64%.
Allocation: -88 B/op everywhere (30% of SimpleMessage's total), -657 B/op on
Struct, -1294 B/op on map-heavy typed encodes.

Applied:

- Word-packed field names in codegen. fastjson2's writeName2Raw(long) ...
  writeName16Raw(long, long) family takes the name already packed into machine
  words and stores it with one or two Unsafe.putLongs — what fastjson2's own
  ASM/@JSONCompiled writers use. New FieldNames derives the constants as
  native-order word reads out of the literal `"name":` (offset 0, except
  lengths 8 and 16 where fastjson2 emits the opening quote itself). The same
  constants serve UTF-8 and UTF-16 writers, so the isUTF8() branch and the
  second array disappear rather than moving. Codegen only, by measurement: a
  runtime holder has to switch on the length, and that indirect branch cost
  10% on ComplexMessage's typed path — recorded in FieldName's javadoc.
- Typed Struct/Value/ListValue. getOneofs() allocates an Arrays.asList +
  unmodifiableList pair on every call, so the old path allocated twice per
  Value written, then scanned for the set oneof field and switched on its
  String name. Compiled messages now use getFieldsMap()/getKindCase();
  DynamicMessage keeps the reflective path with the map-entry descriptors
  hoisted out of the per-entry loop and the kind oneof cached.
- Typed WKT wrapper writes in codegen — an Int32Value/StringValue/... field
  emits writeInt32(wrap.getValue()) instead of routing through
  WellKnownTypes.write()'s full-name String switch and getField() boxing.
- One JSONWriter.Context per encoder instead of one per encode call.
- Reflection path checks hasField before getField, so an absent optional field
  no longer pays a boxed reflective read that is thrown away.
- Map key/value FieldDescriptors resolved at schema-build time;
  FieldWriter.writeMap no longer calls findFieldByName per map write.
- Repeated numeric/bool lists narrow to Internal.IntList/LongList/... and read
  primitives; repeated strings go through writeString(List<String>). The
  boxing removal measured as exactly nothing — HotSpot already scalar-replaces
  it once the loop inlines — and is kept only as insurance for the C1 and
  interpreted tiers. Documented as such so nobody re-measures it hoping.

Also fixes a latent correctness bug found on the way: an explicit
[json_name = "..."] carrying non-ASCII was truncated by a (byte) charAt(i)
cast, and one carrying a quote or newline made the generator emit Java source
that does not compile. Such names now go through writeName(String) and all
emitted literals are escaped.

Allocation budgets tightened to the new baselines. New FieldNamesTest pins the
undocumented fastjson2 packing layout for every length on both encodings, so a
fastjson2 upgrade that moves it fails there rather than corrupting every field
name; 545 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rjh5q2cHevWc6DPzyoACQp
…acked-name path

Code review of the previous commit surfaced two bugs in the word-packed field
names, both reproduced, plus several smaller defects.

**UseSingleQuotes emitted invalid JSON.** The quote characters are split between
us and fastjson2, and the split differs per name length: both quotes are baked
into the packed word for lengths 2-6 and 9-14, only the opening quote for 7 and
15, neither for 8 and 16. fastjson2 emits its share from `this.quote`, so a
single-quote writer produced `{"optionalFixed32':7}` — unparseable. The
`writeNameRaw` arrays had always *ignored* the feature and emitted valid
double-quoted names, so this was a regression from "ignores a feature" to
"emits garbage".

**Native-order packing is not endian-agnostic.** `JSONWriterUTF8` stores the
word with `Unsafe.putLong` (native order), but `JSONWriterUTF16.putLong` widens
it by extracting bytes at hard-coded little-endian positions with no
`BIG_ENDIAN` branch (confirmed by disassembly). One constant cannot satisfy both
on a big-endian host; the javadoc claimed otherwise.

Both are now gated in one place. `FieldNames.PACKED_NAMES_SUPPORTED` self-checks
the layout at class init — it packs a probe name of every length 2-16, writes it
through both encodings and compares the text — so a big-endian host or a future
fastjson2 that moves the layout degrades to the arrays instead of silently
corrupting every field name. `ProtobufMessageWriter.canUsePackedNames`
additionally vetoes the codegen tier for a single-quote writer: one field load
per message, falling through to the typed tier, which the cross-path fuzz test
already proves byte-identical. Measured cost: none (final interleaved A/B still
+15.1%/+15.2% on SimpleMessage codegen, +116%/+108% on Struct).

Also from the review:

- Deprecated fields: the name-constant loop skipped `[deprecated = true]` while
  the writeFields loop did not, so such a field referenced an undeclared
  constant and the generated source did not compile. They now serialize like any
  other field (matching JsonFormat and both runtime paths), with
  `@SuppressWarnings("deprecation")` emitted on the class since consumers build
  with -Werror.
- Java literal escaping: `\uXXXX` for a line terminator does not work — javac
  translates unicode escapes before tokenizing (JLS 3.3), so a unicode-escaped
  LF becomes a real newline and leaves the literal unclosed. Both copies now emit
  `\n`/`\r`/`\t`/`\b`/`\f` properly, and the test compiles the emitted literal
  with javax.tools.JavaCompiler instead of asserting the weaker "printable
  ASCII" property a broken escape satisfies.
- `Value.getKindCase()` switch gained a `default -> writeNull()` arm; a kind
  added by a future protobuf-java would otherwise write nothing after the name
  and colon, i.e. `{"k":}`, and javac has no exhaustiveness lint for switch
  statements.
- Dropped the `VALUE_KIND_CACHE` added last commit: a strong-keyed Descriptor
  map pins the descriptor pool of every schema ever loaded, and it only served a
  path `DynamicMessage` reaches, to save two allocations.
- `packedWord1`/`packedTailInt` now reject lengths that have no such word,
  instead of returning 0 from the zero-padded tail and writing NUL bytes.
- Removed the dead `writeMap` overload and the `nameWithColon()` accessors,
  which had become callerless and returned null for a non-raw-writable name.
- Corrected stale docs: the reflection-path field-name dispatch in the
  architecture diagram, the accessor list, the DoubleHeavy allocation baselines,
  and the `Struct`/`Value` fast path missing from Key Design Decisions.

The audit doc now also records that the largest generated `writeFields` in the
repo is 3,240 bytecodes against HotSpot's 8,000 `DontCompileHugeMethods` limit
(a reviewer's 14,644 figure did not reproduce), and that absolute benchmark
numbers on this host moved ~25% between runs while the ratios held — so only
ever compare two jars run back to back.

560 tests pass; allocation budgets green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rjh5q2cHevWc6DPzyoACQp
@Fyzu
Fyzu force-pushed the claude/encoding-performance-improvements-x9kv71 branch from 950699f to 2b68831 Compare July 30, 2026 11:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants