Repository navigation
perf: 1030: views pay less per nested message, allocate nothing, and read bytes in place - #1034
Open
jasperpotts wants to merge 18 commits into
Open
jasperpotts wants to merge 18 commits into
jasperpotts wants to merge 18 commits into
Conversation
Oneofs of at most four alternatives are read in order too, so ids, whose shard and realm are usually absent, no longer fall to the tag switch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
ArrayCursor's XOR-chain varint needed ten bytes before the limit, so varints in small nested messages (ids, timestamps) were read a byte at a time. It now needs ten bytes before the array's end, and throws an underflow if the varint ends past the limit, as the byte loop does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Views of numeric and bool wrapper fields get xxxOrElse(default), which returns the primitive; xxx() boxes it as before. The synthetic block's views scan no longer allocates (22 B per transaction before). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
A oneof alternative's offset and value are read only while the oneof's kind says it is set, so wrapping no longer resets them: for a transaction body, about 120 stores per wrap. A wrap generation replaces the per-child 'current' flags, one increment instead of a store per message field. 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>
Views get xxxByteAt(index) for bytes fields and valueByteAt(index) for repeated bytes elements, and xxxAs(view) wraps the view of the message a bytes field holds over it, without copying: every generated view now implements MessageView. The synthetic workload's views scan reads the transaction body and every bytes field in place, instead of copying them. 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>
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
A oneof's message and bytes alternatives with one-byte tags share one offset and length, as at most one is set, and are indexed by one method: one compare in the in-order fast path (ArrayCursor.atField, a mask of field numbers) and one switch case for all of them. A state change's operation is read in order, and index() is smaller. Errors still name the alternative. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
A view wrapping a nested message reset its cursor with the source Bytes and array, checking the bounds and that the Bytes holds them again for every child. The parent checked both when it was wrapped, so children are now wrapped with ArrayCursor.reset(outer, offset, length): one check that the bytes lie inside the outer cursor's, with its array and Bytes copied. MessageView and every generated view gain the matching wrap(ArrayCursor, ...) overload. Wrapper fields were read by resetting the view's own cursor, which now has to keep spanning the message for later child wraps, so they are read with a cursor of their own, created on first use. Repeated-field cursors narrow the view's cursor too, so the elements' child views keep the view's Bytes. 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>
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>
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
This was referenced Nov 2, 2026
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
jasperpotts
force-pushed
the
1029-writer-open-close
branch
from
November 2, 2026 20:33
e0fb5c6 to
de84353
Compare
jasperpotts
force-pushed
the
1030-view-child-cost
branch
from
November 2, 2026 20:33
99cc427 to
dadf12c
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.
Closes #1030. Stacked on #1033 (
1029-writer-open-close).Summary
Reading every field through views cost 3.5× a hand-written in-place reader on the synthetic HAPI block (#1028), and was slower than parsing into objects. Almost all of it is per nested message (HAPI nests a message for every id and timestamp): wrapping the child view, its
index()pass and the accessors' checks. This PR cuts that cost, makes views allocate nothing, and lets a view read a bytes field holding a message without copying it. Everything here is in views (pbj.views, part ofperformance = "max", tier B); models and codecs are unchanged.Performance (AMD EPYC 9275F): on the synthetic HAPI block, reading every field through views is 11% faster (2,415 → 2,140 ns per transaction), transactions 12% faster (244 → 215 ns), and views now allocate 0 B (from 22 B). Tier B, on with
performance = "max".index()is the in-order fast path:73e58742); it used to stop at the first oneof, so everyAccountIDfell through to the tag loop.ArrayCursor.atField) and indexed by one small method (1e7d5338).index()resets only what wrapping must (05a0689c): a oneof needs no per-alternative reset, and child views are found current by a wrap generation counter rather than per-child flags.53e77a48):ArrayCursor.reset(outer, offset, length)does one range check instead of re-checking the bounds and the sourceBytesfor every child. Wrapper fields are read with a cursor of their own, so the view's cursor keeps spanning the message.cf558efa): the fast path is guarded by the array's length rather than the message's limit, which for a view is often a few bytes away.509dc3eb): wrapper-type fields getxxxOrElse(primitive default), which doesn't box.84ec56a9):xxxByteAt(int), andvalueByteAt(int)on repeated bytes cursors;xxxAs(view)wraps any view over a bytes field holding a message, such asSignedTransaction.bodyBytes, without copying it. Every view implements the new runtime interfaceMessageViewfor this.EPYC results
AMD EPYC 9275F, JDK 25.0.4, one CCD (
taskset -c 0-2,24-26) of an idle machine, 3 forks × 5 iterations of 2 s,-prof gc. ns per transaction of the synthetic HAPI block, every option on.scanViews: block, read every fieldtxScanViews: transactionsThe two right-hand PBJ columns come from different runs on the same machine; the cursor change was measured against the column before it in one run (2,244 → 2,140, −4.7%; transactions −1.3%, within noise). Views are now about 3.2× the reference for the block (from 3.5×) and 3.8× for transactions (from 4.4×), and allocate nothing.
What's left is per-message bookkeeping, not anything one change can remove. An EPYC profile puts it at about half of the view scan (field offset stores, wrap and reset, cursor steps, accessor checks) against almost none in the hand-written reader, which only decodes varints. Getting much closer needs a different API, such as a streaming reader, which is a separate decision.
API
New public surface on generated views, all additive:
xxxOrElse(primitive default)for wrapper-type fields;xxxByteAt(int),xxxAs(view)for bytes fields, andvalueByteAt(int)on repeated bytes cursors;wrap(ArrayCursor outer, int offset, int length, …), andimplements com.hedera.pbj.runtime.MessageView.In the runtime,
MessageViewis new, andArrayCursorgainsatField(int mask)andreset(ArrayCursor outer, int offset, int length).Tests
ViewInPlaceAccessorsTest: bytes fields read byte by byte and through a view, overBytesand over a plain array with the message in its middle; empty and malformed bytes; repeated bytes elements; wrapper fields without boxing; child views read before and after wrapper fields.ArrayCursorTest: varints read the same at every distance from the limit and from the array's end; the mask match; a cursor narrowed from another reads inside it only.AllocationTest: a view wrap that reads every field allocates nothing.🤖 Generated with Claude Code