Repository navigation
perf: reverse writer into a reusable buffer - #1003
jasperpotts wants to merge 22 commits into
Conversation
Adds step 3 of docs/fast-codegen-plan.md (sections 5.4 and 6.4), opt-in with pbj.fast_write: - ReverseBuffer: a reusable, growable buffer written back to front; marks count bytes written, so they survive growth; results via bytes() (a view), toBytes() or copyTo(); - Codec.writeReverse(T, ReverseBuffer), additive public API; codecs without fast_write measure and write forward into the buffer; - generated writeReverseTo for fast_write messages: unknown fields, then fields in descending order and list elements in reverse, each value before its length and tag, so nothing is measured. Messages with maps or wrapper types write themselves forward into the buffer. Every generated test checks writeReverse against toBytes; AllocationTest checks 0 bytes per reverse write into a warm buffer; BlockItemBench gains reverse-write cases. Fixes #1001 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Integration Test Report 632 files 632 suites 46m 34s ⏱️ Results for commit 7a209a4. ♻️ This comment has been updated with latest results. |
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com> # Conflicts: # pbj-integration-tests/src/jmh/java/com/hedera/pbj/integration/jmh/BlockItemBench.java
|
Merged in the review follow-ups from below in the stack: exact-size bulk varint writes (#994), unique writer local names (#995), dead-code removal (#996), map entries without try/finally plus tests (#997), and strict string decoding without per-call allocations (#1010). On #999 the array parser's |
…everse ReverseBuffer.reserve can grow the buffer and replace its array, so the generated code must call it before out.array(). Adds a test that writes an object carrying unknown fields into a 16-byte buffer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
…e per run of scalars - Maps are written as their entries in reverse of the sorted key order, value before key, and wrapper types as the wrapped value, its length and tag, so messages with them no longer fall back to measuring and writing forward. Only maps of enums still do; HAPI has none. - A run of adjacent scalar fields calls ReverseBuffer.ensure once with its largest encoded size, then writes with the new unchecked primitives. - BlockItemKindBench times both writers on one kind of block item at a time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
… CPU caches Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
|
Review follow-ups (second review, sections 1.1 and 3), all on this branch:
Results (AMD EPYC, max options)The follow-ups changed nothing measurable, so the fallbacks were not the cause. Per kind, with sizes cached (µs for all items of the kind):
The same jar on Apple Silicon shows reverse about equal to forward. JFR on EPYC put
Corrected cause: the reverse writer visits every object graph last field first, the opposite of the order the objects were allocated in. Out of cache, on EPYC, that costs 2-4×. In cache, it saves the measuring pass: 9-20% faster than step 1 on freshly built objects, 24-29% slower when sizes are already cached. Recommendation: park this PR. It meets the plan's 10% bar only for objects encoded right after they are built and still in cache, and it is 2-4× slower whenever they aren't, which a whole-block write can't rule out. I have not reverted it out of the stack (section 0.3), since that touches #1005, #1007, #1015 and #1016; say if you want it out. |
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
…riteReverse Codec.writeReverse, and the generated fallback for maps of enums, reserve the measured size inside a ReverseBuffer that already holds output and write forward into it. A size mismatch would overwrite earlier output or leave stale bytes, where the exactly-sized array of toBytes() would throw. Check the bytes written against the measured size and throw IllegalStateException if they differ. This also fixes the default byte array writeImpl for hand-written codecs, which returned the end offset instead of the number of bytes written when startOffset was not zero. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
ReverseBuffer writes downward into a buffer it owns, so a varint of up to 8 bytes can be written with one little-endian 8-byte store ending at its last byte: the store also covers bytes below the varint, which are free space, so it needs no end guard. This replaces sizeOfUnsignedVarInt64 plus the unrolled writer, about a third of reverse encode time in profiles. Varints of 9 or 10 bytes, and varints with fewer than 8 bytes of room below them, keep the existing path. ReverseBufferVarintTest checks every varint length against the reference writer, with and without room below and checked and unchecked, and that the output already written is untouched. InlineBudgetTest covers the per-field ReverseBuffer methods. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
The 8-byte single-store varint did not win on AMD EPYC: block item reverse encode was 1-4% slower (5 forks; 659 vs 621 ns cached, 686 vs 662 ns fresh with the other changes, 646 vs 621 and 661 vs 662 ns without the varint read change). Restores the unrolled writer and keeps ReverseBufferVarintTest, which checks every varint length against the reference writer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
What and why
This PR is step 3 of
docs/fast-codegen-plan.md(sections 5.4 and 6.4), opt-in withpbj.fast_write: a back-to-front writer into a reusable buffer, which needs no measuring pass and allocates nothing.Performance (AMD EPYC 9275F): the reverse writer is 9-20% faster for objects encoded while still in cache, with 0 B/op, but 2.7-4.3× slower on all of a block's items out of cache, and slower whenever sizes are already cached. So it is kept as an off-by-default option (tier B).
ReverseBuffer(io.buffer): a reusable, growable buffer written from the end toward the start.bytes()(a view),toBytes()orcopyTo().Codec.writeReverse(T, ReverseBuffer): codecs withoutfast_writemeasure, then write forward into the buffer.writeReverseToforfast_writemessages: it writes the unknown fields first, then the fields in descending order and list elements in reverse. Each value goes before its length and tag, so nested lengths are known without measuring. Maps are written as entries in reverse sorted-key order and wrapper types as value, then length and tag; only messages with maps of enums write themselves forward into the buffer.toBytesand the otherwritemethods never use the reverse writer; onlywriteReversedoes.writeReversegives exactlytoBytes' bytes, into a new and into a reset buffer;AllocationTestchecks 0 B/op into a warm buffer;BlockItemBenchgainsitemWriteReverseFresh,itemWriteReverseCachedandtransactionWriteReverseFresh.Generated code for
TransactionID(excerpt):Tests
pbj-core:spotlessApply qualityGate buildpasses.pbj-integration-tests:./gradlew buildand./gradlew build -PpbjPerformance=maxboth pass, 326,406 tests each. The reverse writer runs on all 579 HAPI and test messages, and its bytes equaltoBytesfor every test object.Result on AMD EPYC: kept as an off-by-default option
Decision: keep
pbj.fast_writeas a tier B option, off by default, and document when it pays. Investigating the out-of-cache slowdown on HAPI is follow-up work and does not block merging.BlockItemKindBench, everything on (-PpbjPerformance=max), 3 forks,-prof gc, µs per batch:STATE_CHANGESitemsTRANSACTION_RESULTitemsOneOf.kind()at the top). It also loses whenever sizes are already cached.ReverseBuffer.ensureplus unchecked writes).UnknownFieldsTest.testUnknownFieldsWriteReverseIntoSmallBuffercovers it.docs/codec-protobuf.md("Reverse Writer") anddocs/usage-guide.mddescribe the API, give these numbers and tell users to measure on their own data before turning it on.Allocation: 0 B/op for reverse writes into a warm buffer.
Generated code size (full HAPI build,
-PpbjPerformance=max)Stacked on #1002.
Fixes #1001
🤖 Generated with Claude Code