perf(encode): audit the write path and apply the wins that measured - #3
Open
Fyzu wants to merge 2 commits into
Open
perf(encode): audit the write path and apply the wins that measured#3Fyzu wants to merge 2 commits into
Fyzu wants to merge 2 commits into
Conversation
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
force-pushed
the
claude/encoding-performance-improvements-x9kv71
branch
from
July 30, 2026 11:03
950699f to
2b68831
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdis 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.shnumbers 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: twobenchmarks.jarbuilds 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)
SimpleMessage.compiledUtf16SimpleMessage.compiledUtf8SimpleMessage.runtimeUtf16SimpleMessage.runtimeUtf8ComplexMessage.buffJsonCompiledComplexMessage.buffJsonRuntimeWkt.structCompiledWkt.structRuntimeWkt.timestampCompiledRepeatedAndMap.*Allocation (
gc.alloc.rate.norm, B/op)SimpleMessage.compiledUtf16SimpleMessage.compiledUtf8ComplexMessage.buffJsonCompiledWkt.struct*RepeatedAndMap.mapRuntimeThe flat −88 B/op everywhere is the per-call
JSONWriter.Context— 30% ofSimpleMessage's total. After the change the returnedString/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 twoUnsafe.putLongs — what fastjson2's own ASM/@JSONCompiledwriters use, and the largest structural gap to the POJO ceilingCeilingBenchmarkmeasures. The oldwriteNameRaw(byte[])/writeNameRaw(char[])path cost a static array load, a length read, anarraycopy, and anisUTF8()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. NewFieldNamesderives it and documents the table.JSONWriterUTF16consumes the same constants (it widens the packed ASCII tochars on the way out), so the encoding branch and the second array disappear rather than moving.Typed
Struct/Value/ListValue.Descriptor.getOneofs()isCollections.unmodifiableList(Arrays.asList(oneofs))— two allocations on every call — andWellKnownTypes.writeValuecalled it once perValuewritten, then scanned for the set oneof field and switched on itsStringname. Compiled messages now usegetFieldsMap()/getKindCase();DynamicMessagekeeps the reflective path with the map-entry descriptors hoisted out of the per-entry loop and thekindoneof 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 emitsjsonWriter.writeInt32(wrap.getValue())instead of routing throughWellKnownTypes.write()'s full-nameStringswitch, descriptor cache probe andgetField()boxing.One
JSONWriter.Contextper encoder instead of one perencode()call.Reflection path:
hasFieldbeforegetField. The old order paid a boxed reflective read for every absentoptionalfield and discarded it.Map key/value descriptors resolved at schema-build time.
FieldWriter.writeMapwas callingfindFieldByName("key")andfindFieldByName("value")on every map field write.Repeated primitives / strings. Numeric and bool lists narrow to
Internal.IntList/LongList/DoubleList/FloatList/BooleanListand read primitives; repeated strings go throughwriteString(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
switchon the name length — an indirect branch in the hottest code, keyed on a per-field instance value. OnComplexMessage(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 movedSimpleMessage.runtimeUtf16from −0.4% to +7.7%. The reasoning is recorded inFieldName's javadoc so it does not get "fixed" again.One measured as exactly nothing.
IntArrayList.get(i)returnsInteger.valueOf(...), which looks like a per-element allocation — but the change measured 0 B/op and 0% on a field holding 50–200rng.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:buildNameWithColonUtf8did(byte) name.charAt(i), truncating non-ASCII into corrupt UTF-8 and emitting a raw"/\straight into the JSON string;EncoderGeneratorinterpolated the name into a Java string literal unescaped, so ajson_namecontaining a quote or newline generated source that does not compile.FieldNames.isRawWritablenow gates the raw arrays; anything else goes throughJSONWriter.writeName(String)+writeColon()so fastjson2 escapes and transcodes it, and all emitted literals go throughFieldNames.javaStringLiteral.Testing
JsonFormatbyte-for-byte.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.shpasses with budgets tightened to the new baselines (SimpleMessage380→290,ComplexMessage2300→2000, etc.).Not included
docs/encoding-performance.mdPart 2 ranks what's left. Top three: devirtualizingJSONWritervia per-encoding generatedwriteFieldsbodies (the remaining big structural item, but 2× generated bytecode — must be measured, not assumed); pre-sizing the output buffer through the public-but-unusedensureCapacity(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