Repository navigation
feat: 1018: zero-allocation flyweight writers for whole message trees - #1023
jasperpotts wants to merge 6 commits into
Conversation
Generated Xxx.Writer classes encode fields straight into a reusable ProtoWriteBuffer, byte-identical to toBytes, with always-on order, oneof and map key checks, auto-close of nested messages, item byte ranges, writeFrom, the transitive pbj.writers option, tests and a JMH benchmark. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
… in both configurations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
…ed on EPYC Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Integration Test Report 657 files + 11 657 suites +11 59m 6s ⏱️ + 12m 52s Results for commit b42070f. ± Comparison against base commit c791de5. ♻️ This comment has been updated with latest results. |
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
With #1024 the model's writers agree on wrapper values: `UInt32Value` of 2^31 or more as protoc writes it, and `FloatValue`/`DoubleValue` of -0.0 as the default (an empty wrapper, #1020). Writers now treat -0.0 the same way, so they stay byte-identical to `toBytes`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
|
Synthetic HAPI workload (#1028), EPYC, ns per transaction, every option on:
Three of the four validation ratios are within ±20%. The writers' ratio isn't, because of the reference encoder rather than the data; details are in #1028. Also in this PR: with #1024 (#1026), wrapper writers now write 🤖 Generated with Claude Code |
|
#1028 updated: the reference codec now writes back to front and reads in order, so it's a harder target. EPYC, synthetic HAPI block, ns per transaction, every option on:
Writers now validate against the external ratio; the views ratio is now 30% over it, as HAPI has more small nested messages per byte. #1033 (writers, #1029): one-call setters for small messages (ids, timestamps; 439 in HAPI) and a leaner writer state. 🤖 Generated with Claude Code |
|
EPYC, synthetic HAPI block, ns per transaction, every option on: #1034 (views, #1030): small oneofs in the in-order fast path, a shared slot per oneof, smaller resets, child views wrapped from the parent's checked cursor, unboxed wrapper accessors, and bytes fields read in place ( #1035 (array parser, #1031): small oneofs in order, short lists sized once, and a varint read the JIT inlines. The final numbers for the whole stack (0.16.4 vs defaults vs 🤖 Generated with Claude Code |
Final numbers for the whole stackThese figures are for the synthetic HAPI block (#1028), in ns per transaction. Allocation per transaction is in brackets, and the ratio compares each figure with the hand-written reference codec in the same benchmark jar. Setup: AMD EPYC 9275F, JDK 25.0.4.1, one CCD ( Configurations:
A second, identical machine agreed within about 3%. On it, writers were 1.54× and views 3.19× the reference. In short:
🤖 Generated with Claude Code |
On a busy CI runner a round can run before the JIT has compiled everything, and allocate a little in code that isn't compiled yet (144 B over a whole block). Take the best of up to 50 rounds, so a real allocation still fails every round. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
39b6231 to
b42070f
Compare
Closes #1018. Stacked on #1016 (
1014-unboxed-oneofs-part1).Summary
Adds an opt-in, generated, zero-allocation write API. With
pbj.writers, each message gets a nestedXxx.Writer: a reusable flyweight that encodes fields straight into a reusableProtoWriteBufferas the caller givesthem, with no model objects. Output is byte-identical to
Xxx.PROTOBUF.toBytes(object)for the same values. It is thewrite-side counterpart of the views, and one option on a root message (for example
Block) covers the whole tree.The whole issue is in this PR: the runtime, the option with its transitive closure, writers for every field kind (maps,
wrapper types, raw and object embeds, and cross-module fields included),
writeFrom, tests, fuzzing, the benchmark andthe docs.
Results on EPYC, in short: writing block items from field values allocates 0 B/item (building objects then
encoding them allocates 1,604 B/item) and is 15% faster than build-then-
toBytes, but 10% slower thanbuild-then-
writeReverse.writeFromon cached objects is 33% faster thanwriteReverse, but 2.3× slower thanwrite(T, byte[]). Two of the issue's targets are not met; details and options for closing the gap are below.After the rest of the stack (#1029, the final run on the synthetic HAPI block): the writers encode a transaction in 1,122 ns with 0 B, 1.5× the hand-written reference codec, and 21% faster than building objects and writing them back to front (1,426 ns, 8,066 B) or 40% faster than building them and calling
toBytes(1,884 ns, 9,347 B). Full table: #1023 (comment)The option
pbj { })// <<<pbj.writers = true>>>generateWritersperformance = "max": writers are additive API with a real code-size cost, as the issue suggested.PerformanceOptionsgains awriterscomponent andresolveExplicit, so aperformancesetting at one level doesn't hidewritersset at aless specific level.
LookupHelper.writers()): a worklist over a visited set, starting from every message thatresolves to
true, through singular, repeated, oneof and map-value message fields, across files. Cycles are reachedonce, with no provisional values. A reachable message with an explicit
false(in its own or its file's comments)fails the build, naming the path, for example
example.Root.middle -> example.Middle.leaf -> example.Leaf.x(Obj), writtenwith
Obj.PROTOBUF, andxRaw(Bytes).Writertype, a field whose type is namedWriter, a repeatedgoogle.protobufwrapper field (no HAPI message has one), more than 64 oneofs, or two fields generating the samemethod signature.
start,end,offset,length,isOpen,writeFrom, theObjectmethods, the protected checks) get the suffixField, for examplelengthField(long).Runtime (
com.hedera.pbj.runtime.io.buffer)ProtoWriteBufferowns its array and doubles it when full. It offersreset(),length(),array(),bytes(),bytes(offset, length),toBytes(),copyTo()andshiftCount(), with aLengthReservationofADAPTIVE(default),
PER_TYPEorONE_BYTE. The API for generated writers is one call per field (tagVarint,tagFixed32,tagFixed64,tagString,tagBytes,tagMessage,tagRawandpackedXxx(tag, values, offset, count)). Each hasits common case (a one-byte tag and value, room available) as a few inline stores and everything else out of line,
so it fits C2's inlining budget (see Performance work).
MessageWriteris the base of every generated writer and holds the state machine:guard, which is the last field number written, orInteger.MAX_VALUEwhile a child is open or the writer isn't. The slow path ends the child (auto-close) or throws.It also covers a repeated singular field, repeated elements that aren't consecutive, a second oneof alternative
(one bit per oneof), use after
end(), use beforestart(), andwriteFromafter any field.length is patched in place (one or two stores when the guess was right), otherwise the body moves by the
difference. Output is always canonical. A closing child restores its parent's fast path directly, so an explicit
end()costs one call.ending a parent ends its children.
offset()/length()afterend(). Direct children of a top-level message never move, sohashing block items as they end is exact. Deeper messages can move when an ancestor's length prefix needs a
different size; this is documented.
PackedIntWriter,PackedLongWriter,PackedFloatWriter,PackedDoubleWriterandPackedBooleanWriterstreampacked elements; an empty field is dropped entirely, as the model writes it.
MapEntryWriterholds the entry of amessage-valued map.
Generated writer
WriterGenerator, called fromModelGeneratornext to the view. Per field kind:x(value); enums alsox(Enum)andxProtoOrdinal(int); defaults skipped outside oneofsx(value);-0.0counts as the default and NaN doesn't, exactly as the modelx(String),x(byte[] utf8, off, len), validated as well-formed UTF-8 (throws before writing anything)x(Bytes),x(byte[], off, len)Child.Writer x(),x(Child obj)(static codecwriteTowith cached size),xRaw(Bytes)addX(),addX(obj),addXRaw(Bytes)x(long[]/int[]/…, off, count)packed in one call, orPackedXxxWriter x()addX(...)putX(key, value);Value.Writer putX(key)for message values; ascending keys checkedx(value)writes the wrapper even for the default value; leaving the field out means nullwriteFrom(object)writes every field, and the object's unknown fields, through the writer's own methods and childwriters. It sizes each nested child exactly from the object's cached
protobufSize(), so it never moves bytes, and itallocates nothing. In messages with more than
MAX_INLINE_FIELDSfields, each message alternative's code goes in asmall helper method (
TransactionBody.Writer.writeFromwas 7,181 bytes before, whichGeneratedMethodSizeTestcaught).Generated code for one field kind of each sort (from the issue's
Orderexample)Tests
All green in both integration configurations, which now run 327,265 tests each (default and
-PpbjPerformance=max);maxalso turnsgenerateWriterson for every message.pbj-corequalityGatepasses.TestGenerator): every test object of every message with writers,random objects included, goes through
writeFrominto a 1-byte buffer (so it grows mid-message), once perreservation strategy and again after a
reset(). The bytes must equaltoBytes, and parse back equal. Inmaxthat is all 608 messages, the full HAPI corpus included: about 162k cases, 0 failures.
WriterTest(hand-written, both configurations) covers every row of the field-kind table:-0.0; five-byte tags;writerShared.proto, used from thecustomsource set);custom, since the generated test-argument code can't build recursive objects);writeFromrules) and auto-close;fuzzCallSequences, seeded): 2,000 random valid call sequences from a walk ofWriterRoot's schema.Each randomly picks child writers, objects or raw bytes; arrays or streamed packed fields; explicit or automatic
ends. All must equal the model's bytes. Then 2,000 shuffled sequences, with duplicates, must throw at the first
invalid call without writing a byte, and only sorted ones may succeed.
ProtoWriteBufferTest(runtime): every varint and tag size; length boundaries 0 to 2.5 MB under every strategyand per-type guess; adaptive learning; 200-deep nesting; growth mid-message; closed children's ranges; packed
elision; auto-close; misuse; reset abandoning open writers.
WriterGenerationTest(compiler): closure across files; self, 2- and 3-cycles; explicitfalseas an error withthe path (message and file level); the project option, and
maxnot enabling writers; cross-module fields gettingonly embed methods; the
Writername guard; reserved-name suffixes; repeated wrappers rejected; the 1-or-2-bytereservation.
PerformanceOptionsTestcovers parsing andresolveExplicit.AllocationTest.writersDoNotAllocatecovers a message tree from values and throughwriteFrom(0 B).
BlockWriterTest(src/testMax) writes the sample block's items from extracted values, from objects, and asa whole block with SHA-384 hashing of each item as it ends: 0 B for all three. It warms up over every item first,
since uncompiled branches deoptimize and that allocates. It also checks the item hashes equal hashes of
toBytes(item)and the whole block equalsBlock.PROTOBUF.toBytes, under each strategy.Benchmark:
BlockItemWriterBenchon EPYCAMD EPYC 9275F (Zen 5), JDK 25.0.4,
-Xms16g -Xmn12g, pinned to one CCD of an otherwise idle machine, 3 forks × 5iterations of 2 s,
-prof gc, per block item of the sample block…030221."From values":
BlockItemValues(insrc/testMax, also used by the tests) extracts, once, the field values of the 4item kinds that make up almost all of the block: signed transactions, transaction results, event headers, and state
changes that are map updates (accounts, token relations, NFTs, topics, storage slots) or map deletes. That is 66,166
of 67,869 items. It is hand-written. Both paths read the same flat arrays in the same order; every extracted item is
checked to rebuild and rewrite byte for byte.
objectsThenToBytes: build objects with builders,toBytesobjectsThenWriteByteArray: build,write(T, byte[])objectsThenWriteReverse: build,writeReversewriterFromValues(ADAPTIVE)writerFromValues(PER_TYPE / ONE_BYTE)objectsWriteByteArray: cached objects,write(T, byte[])objectsWriteReverse: cached objects,writeReversewriterFromObjects(ADAPTIVE): cached objects,writeFromwriterFromObjects(PER_TYPE / ONE_BYTE)objectsWholeBlock: each itemwrite+ SHA-384, thenwrite(Block)writerWholeBlock(ADAPTIVE):Block.Writer, SHA-384 of each item's rangeAgainst the acceptance targets
writerFromValues0 B/opwriterFromValuesclearly faster thanobjectsThenToBytestoBytesand 12% faster thanwrite(byte[]), but 10% slower than build +writeReversewriterFromObjectsno slower thanwrite(T, byte[])with cached sizeswriterFromObjectswithin 20% ofwriteReverseWhere the gap is. Per item kind (local per-kind loops), from values the writer is much faster than building objects
for transaction results and signed transactions (about 98 against 370 ns, and 16 against 61). State changes carry it
all: about 45 nested messages per item (StateChange → MapUpdateChange → key/value → Account → AccountID, Key, TokenID,
NftID …). With cached sizes a codec writes a nested message's tag and length and calls its static
writeTo. A writeropens a child, checks each field, and patches the length on close.
Profiling (JFR,
-XX:+LogCompilation) shows that bookkeeping is what's left. Inlining and length moves are not:writeFrom(zero moves) improved it by about 5%.Options to close it, for discussion; none is in this PR:
writeFromembeds nested in-build objects with their static codec, asx(Child obj)already does. That givescodec speed from objects, but then the generated per-message tests would only check top-level methods; child
writers would remain covered by
WriterTest, the fuzz test andBlockWriterTest's values path.always-on checks.
it ends, instead of a patch (and possibly a move) per nested message.
Length reservation
Moves per item from values (over all 66,166 items; state changes alone in brackets):
writerFromValuesns/itemMispredictions come mostly from writers whose message alternates around 128 bytes;
MapChangeValue, for example, holdsaccounts of about 150 bytes and token relations of about 50. ADAPTIVE and ONE_BYTE are within noise, PER_TYPE is 3%
slower. ADAPTIVE stays the default because it doesn't move every message of 128 bytes or more, every time, as ONE_BYTE
does.
writeFromsizes children from the objects, so it never moves bytes.Varint stores
The issue asked to measure the single 8-byte store, which the buffer's slack makes safe without a guard, against the
exact-size stores. With the store,
writerFromValuestook 355.7 ± 14.8 ns andwriterFromObjects395.8 ± 24.4 ns,against 317.1 and 372.4 ns. Exact-size stores stay, and the variant is removed.
Code size
Full HAPI build, writers on for every message (the
maxintegration configuration): +6.36 MB of class files, +9.9%of HAPI's generated main classes (70.82 MB against 64.46 MB), in 1,008
Writerclasses. With writers on only for aroot such as
Block, it is the reachable subset.Performance work in this PR
method for a 40-field message such as
Accountused about 150 bytes of inlined bytecode per field. C2 stops at8,000 (
DesiredMethodLimit), so half ofAccount's fields became real calls. The fix was one buffer call perfield, with a tiny inline common case.
closeinlined into it, instead of four.Found along the way (not changed here)
toBytescorrupts three wrapper values. ForFloatValue(-0.0)andDoubleValue(-0.0), the sizesays 0 but 5 or 9 bytes are written. For
UInt32Valueof 2³¹ or more, the size uses the signed varint length (10bytes) but writes 5. Writers write these as protoc does;
WriterTest.wrapperValuesTheModelMeasuresWrongAreWrittenAsProtocDoeschecks against protoc. The generated test data never hits these values.
reports. That's why
WriterMapshas writers of its own, rather than being a field ofWriterRoot.Docs
docs/usage-guide.md: when to use writers against objects, a state-changes example, order, auto-close, aliasing,embeds, item byte ranges with a hashing example, allocation, reservation.
docs/codec-protobuf.md: the option, its transitive closure, the per-kind API, output rules.docs/fast-codegen-plan.md: step 6, PR 12, and the lessons (per-nested-message cost, the single-store varint again).🤖 Generated with Claude Code