Skip to content

perf: 1030: views pay less per nested message, allocate nothing, and read bytes in place - #1034

Open
jasperpotts wants to merge 18 commits into
1029-writer-open-closefrom
1030-view-child-cost
Open

jasperpotts wants to merge 18 commits into
1029-writer-open-closefrom
1030-view-child-cost

Conversation

@jasperpotts

@jasperpotts jasperpotts commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

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 of performance = "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".

  • More of index() is the in-order fast path:
    • Oneofs of up to four alternatives are part of it (73e58742); it used to stop at the first oneof, so every AccountID fell through to the tag loop.
    • A oneof's message and bytes alternatives share one offset and length, recognised with one mask test (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.
  • Child views are wrapped from the parent's checked cursor (53e77a48): ArrayCursor.reset(outer, offset, length) does one range check instead of re-checking the bounds and the source Bytes for every child. Wrapper fields are read with a cursor of their own, so the view's cursor keeps spanning the message.
  • Multi-byte varints near a message's end are fast (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.
  • Views allocate nothing when reading every field (509dc3eb): wrapper-type fields get xxxOrElse(primitive default), which doesn't box.
  • Bytes fields are read in place (84ec56a9):
    • xxxByteAt(int), and valueByteAt(int) on repeated bytes cursors;
    • xxxAs(view) wraps any view over a bytes field holding a message, such as SignedTransaction.bodyBytes, without copying it. Every view implements the new runtime interface MessageView for 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.

#1033 head This PR, before the cursor change This PR (head) Hand-written reference
scanViews: block, read every field 2,415 ± 10 (22 B) 2,258 ± 14 (0 B), −6.5% 2,140 ± 20 (0 B), −11% 662-682
txScanViews: transactions 244 ± 6 (0 B) 217 ± 1, −11% 215 ± 5, −12% 56-58

The 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, and valueByteAt(int) on repeated bytes cursors;
  • wrap(ArrayCursor outer, int offset, int length, …), and implements com.hedera.pbj.runtime.MessageView.

In the runtime, MessageView is new, and ArrayCursor gains atField(int mask) and reset(ArrayCursor outer, int offset, int length).

Tests

  • ViewInPlaceAccessorsTest: bytes fields read byte by byte and through a view, over Bytes and 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.
  • The whole integration suite with every option on reads every test object through its view and compares it with the model.

🤖 Generated with Claude Code

jasperpotts and others added 17 commits October 30, 2026 23:20
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>
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

JUnit Test Report

   559 files  ±0     559 suites  ±0   1m 16s ⏱️ -2s
 2 114 tests +2   2 110 ✅ +2   4 💤 ±0  0 ❌ ±0 
11 646 runs  +2  11 618 ✅ +2  28 💤 ±0  0 ❌ ±0 

Results for commit dadf12c. ± Comparison against base commit de84353.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Integration Test Report

    661 files  +1      661 suites  +1   46m 53s ⏱️ + 2m 12s
164 383 tests +6  164 383 ✅ +6  0 💤 ±0  0 ❌ ±0 
652 346 runs  +6  652 346 ✅ +6  0 💤 ±0  0 ❌ ±0 

Results for commit dadf12c. ± Comparison against base commit de84353.

♻️ This comment has been updated with latest results.

Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
@jasperpotts
jasperpotts force-pushed the 1029-writer-open-close branch from e0fb5c6 to de84353 Compare November 2, 2026 20:33
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.

1 participant