Skip to content

feat: zero-copy views, part 2: oneofs, repeated fields and wrapper types - #1007

Open
jasperpotts wants to merge 20 commits into
1004-views-part1from
1006-views-part2
Open

jasperpotts wants to merge 20 commits into
1004-views-part1from
1006-views-part2

Conversation

@jasperpotts

@jasperpotts jasperpotts commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

What and why

This PR is part 2 of zero-copy views (docs/fast-codegen-plan.md section 6.5, pbj.views). It adds what block items need: oneofs, repeated fields and google.protobuf wrapper types. With -PpbjPerformance=max, every HAPI message without maps now has a view, BlockItem included.

Performance (AMD EPYC 9275F): routing a block item through a view is 7.4× faster than parsing it with the array parser (300.9 → 40.8 ns), with 0 B instead of 1,565 B. Tier B, opt-in.

  • Oneofs. The view records which alternative it saw last, so the last one wins, as in parsing. xKind() returns the model's existing kind enum (for example BlockItem.ItemOneOfType). Each alternative's accessors return its default unless it is the current one.
  • Repeated fields. Wrapping validates and counts the elements and records where the first one is. xCount() gives the count, and x() returns a reusable cursor, as in the plan's sketch:
    for (var it = changes.stateChanges(); it.next(); ) {
        sum += it.value().stateId();
    }
    The cursor reads unpacked elements and packed runs alike, and skips other fields. Message elements are child views re-wrapped by next(); strings and bytes elements have offset, length and equals accessors that don't allocate.
  • google.protobuf wrapper types: hasX(), plus x() returning the boxed value, null when absent and the default when the wrapper is empty, as in the model. The value is decoded with the view's own cursor.
  • BlockItemViewBench (new src/jmhMax source set, built only with -PpbjPerformance=max, where HAPI messages have views) routes each block item: its kind, the consensus time of state changes, the id and operation of every state change, and the length of signed transactions. It does this through a view and through parsing.

Not in this PR: maps and repeated wrapper types. Messages containing them, directly or through a nested message, have no view.

Tests

  • Generated view tests now check every oneof's kind and current alternative, and every repeated field's elements and count, against the model. They run from canonical bytes and from reversed-order bytes with an unknown field. With -PpbjPerformance=max that is 488,619 integration tests, all passing, with views on 574 messages.
  • pbj-core: spotlessApply qualityGate build passes.
  • pbj-integration-tests: ./gradlew build (options off) passes, 326,416 tests.

Performance: AMD EPYC 9275F, 3 forks, -prof gc

Benchmark ns/op ± B/op
BlockItemViewBench.routeWithParse 300.9 10.8 1,565
BlockItemViewBench.routeWithView 40.8 0.2 0
ViewBench.readEveryFieldWithParse 26.4 0.7 152
ViewBench.readEveryFieldWithView 16.3 0.0 0

Generated code size (full HAPI build, -PpbjPerformance=max)

main classes: 53,275,718 bytes, against 45,502,478 on main (+17%), with every option on. Views are opt-in (tier C), and with options off generated main code is unchanged.

Stacked on #1005.

Fixes #1006

🤖 Generated with Claude Code

Extends pbj.views (docs/fast-codegen-plan.md section 6.5) so block items and the messages they contain
get views:
- oneofs: the view records the alternative seen last; xKind() returns the model's kind enum, and the
  other alternatives' accessors return defaults;
- repeated fields: wrapping validates and counts the elements and records the first one; xCount()
  and a reusable cursor (for (var it = view.x(); it.next(); ) ...) read unpacked elements and packed
  runs alike, with child views for messages;
- google.protobuf wrapper types: hasX() and a boxed x(), null when absent, as in the model.

Messages with maps or repeated wrapper types still have no view. Generated tests check every oneof
alternative and repeated element against the model. BlockItemViewBench, in a jmhMax source set built
only with -PpbjPerformance=max, routes block items through a view and through parsing.

Fixes #1006

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
@jasperpotts jasperpotts self-assigned this Oct 30, 2026
@jasperpotts
jasperpotts requested review from a team as code owners October 30, 2026 01:48
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

JUnit Test Report

   557 files  +1     557 suites  +1   1m 8s ⏱️ ±0s
 2 084 tests +4   2 080 ✅ +4   4 💤 ±0  0 ❌ ±0 
11 616 runs  +4  11 588 ✅ +4  28 💤 ±0  0 ❌ ±0 

Results for commit 10acfd3. ± Comparison against base commit 0f0c516.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Integration Test Report

    639 files      639 suites   58m 21s ⏱️
164 113 tests 164 113 ✅ 0 💤 0 ❌
489 072 runs  489 072 ✅ 0 💤 0 ❌

Results for commit 10acfd3.

♻️ This comment has been updated with latest results.

@jasperpotts

Copy link
Copy Markdown
Member Author

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 ArrayCursor.string now uses the shared Utf8Tools.decodeUtf8Strict. The plan was updated too (#991). Re-verified: pbj-core spotless, quality gate and build, and the integration build with options off and with -PpbjPerformance=max, all passing. The benchmark tables in this PR's description predate these changes; writes and string parsing are now faster than shown (see #994 and #1010).

jasperpotts and others added 3 commits October 29, 2026 23:19
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>

# Conflicts:
#	pbj-core/pbj-compiler/src/main/java/com/hedera/pbj/compiler/impl/generators/ViewGenerator.java
…se child views

- wrap(bytes, strictMode, maxDepth, maxSize) and wrap(in, offset, length,
  strictMode, maxDepth, maxSize) validate as parse does with the same
  arguments, and pass them to child views and repeated field cursors.
  Strict mode rejects unknown fields and repeated singular messages.
- Repeated string elements are checked to be valid UTF-8 while wrapping.
- A nested accessor returns its child view without indexing it again if
  it is still wrapped over the field since the parent was wrapped.
- LookupHelper.hasView is a greatest fixed point, so a cycle of messages
  through one without a view gives none of them a view, whichever is
  looked at first. The build says why a view is denied to a message that
  asks for one in its comments.
- A message with views that declares its own nested type named View is
  a clear compiler error instead of a duplicate class.
- Removes the unused, unchecked static ArrayCursor helpers.
- Documents views, and the reuse of child views, in the usage guide.

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>
@jasperpotts
jasperpotts requested a review from a team as a code owner October 30, 2026 07:40
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
@jasperpotts

Copy link
Copy Markdown
Member Author

Review follow-ups (second review, sections 1.3, 1.4 and 4), on this branch:

  • hasView cycles (1.3): views are now a greatest fixed point. Start with every message that asks for a view and supports one on its own, then drop those with a message-typed field whose type was dropped, until nothing changes. A cycle through a message without a view now gives none of its members a view, whichever is looked at first. ViewGenerationTest covers self, 2- and 3-message cycles (with views and array parsers), and the 3-cycle through a map-disqualified message, which gave CycleC a view referencing CycleA.View before. (hasArrayParser reads only the message's own option, so it has no cycle logic; the test checks the array parsers of the cycles anyway.)
  • Limits and strict mode (1.4): wrap(bytes, strictMode, maxDepth, maxSize) and wrap(in, offset, length, strictMode, maxDepth, maxSize) validate as parse does with the same arguments, and pass them to child views and repeated-field cursors. Strict mode rejects unknown fields and repeated singular messages (including a oneof message alternative after another alternative). The Javadoc and usage guide say what is validated lazily: nested messages, repeated message elements and wrapper values, when read. ViewValidationTest covers each.
  • Child views (4.1): a nested accessor returns its child without indexing it again if it is still wrapped over the field since the parent was wrapped (View.wraps), including when the caller re-wrapped the child or the parent was re-wrapped over a reused buffer.
  • Docs (4.3): docs/usage-guide.md has a "Zero-Copy Views" section with the reuse pitfall and the right pattern (toModel() or copy).
  • View name clash (4.4): a message with views that declares its own nested type named View is a clear compiler error.
  • Unchecked helpers (4.5): the unused static ArrayCursor.varintAt/fixed32At/fixed64At are removed.
  • Tests (4.6): packed and unpacked elements of one repeated field mixed through a view; messages that ask for a view but contain one without (or have a map) get none, and the build prints why (only for messages that ask in their own or their file's comments, so -PpbjPerformance=max doesn't report every HAPI message with a map).
  • Repeated string elements are checked to be valid UTF-8 while wrapping too.
  • Methods over 8,000 bytes: TransactionBody.View.index was 8,074 bytes in max mode, so it never ran compiled; perf: keep every generated method under HotSpot's 8,000-byte compile limit #1015 splits views of messages with more than 32 fields into per-field helpers.

Code size (4.7): with every option on, the generated code grows by about 17%. That is expected and fine: the 15% cap in the plan applies to tier A only, and views are tier C (opt-in), so it doesn't count against it.

Perf (4.2): not profiled yet. Child re-indexing is fixed; profiling index(...) and the cursors' next() against a hand-written reader is the next step.

jasperpotts and others added 4 commits October 30, 2026 01:05
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
…th limits

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>
jasperpotts and others added 7 commits October 30, 2026 12:19
… the wrapped array

Strict mode is fail fast, but a strict wrap only validated the top-level
message: unknown fields, depth and UTF-8 inside nested messages, repeated
message elements and wrapper values were only checked if they were read, so a
strict wrap accepted input parseStrict rejects. In strict mode wrap now
validates all of them, at any depth, before returning. Non-strict views stay
lazy, and the docs now say plainly that a non-strict wrap does not validate
untrusted input.

Bytes accessors returned Bytes.wrap over the wrapped array, and xxxArray()
returned the array itself, so a view over a plain array handed out bytes that
changed when the caller reused it, and a view over Bytes exposed the Bytes'
mutable backing array. Views now remember the Bytes they wrap: bytes fields,
wrapper bytes and toModel() share it, or copy from a plain array, as the array
parser does. xxxArray(), xxxOffset() and valueOffset() are replaced by
xxxCopyTo() and valueCopyTo(), which copy without allocating.

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>
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