Skip to content

feat: zero-copy views over encoded messages, part 1 - #1005

Open
jasperpotts wants to merge 20 commits into
1001-reverse-writerfrom
1004-views-part1
Open

jasperpotts wants to merge 20 commits into
1001-reverse-writerfrom
1004-views-part1

Conversation

@jasperpotts

@jasperpotts jasperpotts commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

What and why

This PR is step 4 of docs/fast-codegen-plan.md (section 6.5), part 1, opt-in with pbj.views. It adds reusable, zero-copy views that read fields in place from encoded bytes, with no model objects and no allocation, for routing, filtering and hashing block items.

Performance (AMD EPYC 9275F): reading every field of a flat message through a view is 1.6× faster than parsing it (26.5 → 16.4 ns) and allocates 0 B instead of 152 B. Tier B, opt-in.

  • Xxx.View, nested in each flagged model (for example MixedFastInner.View). The plan names the class XxxView; nesting it means nested messages get nested views, and no new generated file type is needed.
  • wrap(Bytes) and wrap(byte[], offset, length) index the message in one pass. They use an in-order fast path, then a general loop with the same validation as parsing: tags, wire types, lengths, maxSize, bools, maxDepth, and unknown fields skipped. Numbers are decoded into primitive fields once. Strings, bytes and nested messages are recorded as offsets and lengths. The view reuses its own ArrayCursor.
  • Accessors return defaults when a field is absent.
    • Numbers, bools and enums: xProtoOrdinal() and x().
    • Strings and bytes: xOffset(), xLength(), xArray() and xEquals(byte[]), none of which allocate, plus x(), which returns a String or a Bytes view.
    • Nested messages: hasX() and x(), which returns a child view created once and re-wrapped on each call, or null when absent.
    • toModel() parses the wrapped message into the model.
  • Runtime: ArrayCursor gains reset(…), array(), a public skip, and static varintAt / fixed32At / fixed64At.
  • Part 1 scope: views are generated for messages whose fields are all singular, and whose message-typed fields' types also have views. That is 234 HAPI and test messages with -PpbjPerformance=max. Part 2 adds repeated fields (reusable cursors), maps, oneofs (xKind()) and wrapper types, which is what BlockItem needs.

Also in this PR, a design change after measuring. The first version decoded each number on every accessor call and indexed with the general loop only. On EPYC it was slower than parsing (31.9 vs 26.2 ns) for a flat message. Decoding once in wrap and adding the in-order fast path fixed it; numbers below.

Generated code: MixedFastInner.View (excerpt)

public static final class View {
    private final ArrayCursor cursor = new ArrayCursor(new byte[0], 0, 0);
    private long deltaValue;
    ...
    private int farOffset = -1;
    private int farLength;

    public View wrap(@NonNull final Bytes bytes) throws ParseException { ... }

    private View index(final int depth) throws ParseException {
        ...
        // in-order fast path, then anything else in any order, as parsing does
        if (cursor.at((byte) 0x08)) {
            deltaValue = cursor.varint();
        }
        ...
        while (cursor.hasRemaining()) { final int tag = cursor.tag(); switch (tag) { ... } }
    }

    /** @return {@code delta}, or its default when absent */
    public long delta() {
        return (deltaValue >>> 1) ^ -(deltaValue & 1);
    }
    ...
    public boolean farEquals(@NonNull final byte[] other) { ... }
}

Tests

  • A generated test<Message>View for every message with a view. For every test object it checks each accessor and toModel() against the model. It does this from canonical bytes, then re-wraps the same view over reversed-order bytes with an unknown field and checks again.
  • AllocationTest: wrapping a view and reading every field allocates 0 bytes.
  • pbj-core: spotlessApply qualityGate build passes.
  • pbj-integration-tests:

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

Benchmark ns/op ± B/op
ViewBench.readEveryFieldWithParse 26.5 0.7 152
ViewBench.readEveryFieldWithView 16.4 0.2 0
  • Reading every field through a view is 1.6× faster than parsing into a model object, with 0 B/op instead of 152 B/op.
  • This is a flat message with 9 scalar, enum and string fields. The plan's 4-6× is for nested block items, where parsing allocates a whole object graph. That comparison (BlockItemBench read-every-field) needs part 2's oneof and repeated-field support.

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

#1003 This PR
All classes 61,397,386 62,932,994

With options off, generated main code is unchanged (the test proto flags MixedFastInner with pbj.views).

Stacked on #1003.

Fixes #1004

🤖 Generated with Claude Code

jasperpotts and others added 2 commits October 29, 2026 18:32
Adds step 4 of docs/fast-codegen-plan.md (section 6.5), part 1, opt-in with pbj.views: a reusable,
zero-copy View nested in each flagged model (for example Timestamp.View).
- wrap(Bytes) / wrap(byte[], offset, length) index the message in one pass, with an in-order fast
  path and the same validation as parsing; numbers are decoded into primitive fields once, strings,
  bytes and nested messages are recorded as offsets and lengths;
- accessors return defaults when a field is absent; strings and bytes also offer offset, length,
  array and equals accessors that do not allocate; nested messages return child views created once
  and re-wrapped; toModel() parses into the model;
- ArrayCursor gains reset, array, skip and static decoders.

Part 1 covers messages whose fields are all singular. Generated tests check every view accessor and
toModel against the model, from canonical and reversed-order bytes; AllocationTest checks that wrap
and reading every field allocate nothing; ViewBench compares a view with parsing.

Fixes #1004

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Refs #1004

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:33
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

JUnit Test Report

   556 files  ±0     556 suites  ±0   1m 8s ⏱️ -2s
 2 080 tests +1   2 076 ✅ +1   4 💤 ±0  0 ❌ ±0 
11 612 runs  +1  11 584 ✅ +1  28 💤 ±0  0 ❌ ±0 

Results for commit 0f0c516. ± Comparison against base commit 7a209a4.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Integration Test Report

    632 files  ±    0      632 suites  ±0   58m 31s ⏱️ + 11m 57s
164 005 tests +   29  164 005 ✅ +   29  0 💤 ±0  0 ❌ ±0 
329 932 runs  +3 291  329 932 ✅ +3 291  0 💤 ±0  0 ❌ ±0 

Results for commit 0f0c516. ± Comparison against base commit 7a209a4.

♻️ 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 5 commits October 29, 2026 23:16
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Views indexed strings like bytes and decoded them leniently, so malformed
UTF-8 that parsing rejects read back as U+FFFD. wrap now checks each
string with the same validator as parsing (ArrayCursor.stringLength), and
the generated view tests check every string field rejects a 0xFF byte.

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

Copy link
Copy Markdown
Member Author

Review follow-up (second review, 1.2): views now check string fields are valid UTF-8 while wrapping, with the same validator as parsing (ArrayCursor.stringLength, sharing Utf8Tools.isWellFormedUtf8 from #1010). A string field holding the byte 0xFF made parse throw while View.wrap succeeded and returned "�"; now both throw. The generated view tests check every string field of every message with a view rejects a 0xFF byte.

The other view items (limits and strict mode, child view reuse, cycles, the View name clash, docs and tests) are on #1007's branch; see the comment there.

jasperpotts and others added 3 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>
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>
jasperpotts and others added 4 commits October 30, 2026 13:47
On this branch ArrayCursor knows the Bytes it reads, so the cursor for the
input Bytes.toReadableSequentialData() returns now carries that Bytes too, and
bytes fields are views of it, as the stream parser over Bytes returns them,
rather than copies.

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