Skip to content

perf: parser clean-ups and direct nested parse calls - #997

Open
jasperpotts wants to merge 10 commits into
990-size-and-stream-writer-codegenfrom
993-parser-cleanups
Open

jasperpotts wants to merge 10 commits into
990-size-and-stream-writer-codegenfrom
993-parser-cleanups

Conversation

@jasperpotts

@jasperpotts jasperpotts commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

What and why

These are parser clean-ups for the generated stream parser parseImpl(ReadableSequentialData, …), which serves every PBJ parse (docs/fast-codegen-plan.md section 6.2):

Performance (AMD EPYC 9275F): every PBJ parse is 5-11% faster, streams included: block items from Bytes 10% (612 → 551 ns), whole blocks 5.5% (byte array) to 11% (input stream). Allocation unchanged. Tier A, on by default.

  • Schema lookup only for unknown fields. Before, every field computed tag >>> 3 and called XxxSchema.getField(field) before the switch. Now defaultCase does that itself, and only fields the parser doesn't recognize reach it.
  • No try/finally per nested message. The generated code checks the length once, narrows the limit, parses, restores the limit and checks the position. If a nested parse throws, the limit is not restored at each level. Instead, the final Codec.parse(…) restores the caller's limit once before rethrowing. Callers see the same input limit as before, on success and on failure.
  • Direct nested parse calls. This is a deviation from the plan, which lists direct calls only for writers (6.1, item 3). Each codec gets public static T parseFrom(…), and nested messages generated in the same build call it directly. Before, they went through X.PROTOBUF.parse(…) → the virtual parseImpl, the same megamorphic call site perf: generate straight-line byte array writers with direct nested calls #995 removed for writes. Dependencies keep X.PROTOBUF.parse.

A small fix on the side. Map entries now pass their own tag to defaultCase. Before, they passed the enclosing message's tag, which was wrong for unknown fields inside a map entry.

Generated code: BlockItem before and after

Before (every field):

final int field = tag >>> TAG_FIELD_OFFSET;
final var f = BlockItemSchema.getField(field);
switch (tag) {
    case 10 /* type=2 [MESSAGE] field=1 [block_header] */ -> {
        ...
        final var startPos = input.position();
        try {
            if ((startPos + messageLength) > limitBefore) {
                throw new BufferUnderflowException();
            }
            input.limit(startPos + messageLength);
            value = BlockHeader.PROTOBUF.parse(input, strictMode, parseUnknownFields, maxDepth - 1, maxSize);
            if ((startPos + messageLength) != input.position()) {
                throw new BufferOverflowException();
            }
        } finally {
            input.limit(limitBefore);
        }

After:

switch (tag) {
    case 10 /* type=2 [MESSAGE] field=1 [block_header] */ -> {
        ...
        final var limitBefore = input.limit();
        final var end = input.position() + messageLength;
        if (end > limitBefore) {
            throw new BufferUnderflowException();
        }
        input.limit(end);
        // If this throws, the limit stays narrowed: Codec.parse restores the caller's limit
        value = com.hedera.hapi.block.stream.output.codec.BlockHeaderProtoCodec.parseFrom(input, strictMode, parseUnknownFields, maxDepth - 1, maxSize);
        input.limit(limitBefore);
        if (input.position() != end) {
            throw new BufferOverflowException();
        }

Tests

  • ParseLimitRestoreTest (new): after a nested parse fails, the caller's BufferedData limit is what it was before the call, and repeated parses of the same Bytes behave the same.
  • pbj-core: spotlessApply qualityGate build passes.
  • pbj-integration-tests: ./gradlew build and -PpbjPerformance=max both pass, 326,325 tests each. That includes MaxDepthTest, MaxSizeTest, UnknownFieldsTest, MalformedMessageTest, TruncatedDataTests, ParserNeverWrapsTest, the fuzz tests, the non-canonical-order tests, and AllocationTest's parse ceilings.

Performance: AMD EPYC 9275F, before = #996's branch, after = this PR

3 forks, 3 × 1 s warmup, 5 × 1 s measurement, -prof gc.

Benchmark Before ns/op After ns/op Change Before B/op After B/op
BlockItemBench.itemParseBytes 611.8 ± 28.7 551.3 ± 19.6 -9.9% 1,719 1,719
BlockItemBench.transactionParseBytes 73.5 ± 1.3 72.5 ± 1.5 -1.3% 395 395
ProtobufObjectBench.AccountDetailsBench.parsePbjByteArray 1,130 ± 32.0 1,078 ± 58.0 -4.6% 4,280 4,280
ProtobufObjectBench.EverythingBench.parsePbjByteArray 29,099 ± 621.8 26,991 ± 550.3 -7.2% 49,752 49,752
SampleBlockBench.parsePbjByteArray 49,889,906 ± 1,477,081 47,167,138 ± 1,705,551 -5.5% 127,042,089 127,042,069
SampleBlockBench.parsePbjByteBufferDirect 70,930,183 ± 2,629,690 65,751,412 ± 1,389,682 -7.3% 127,042,232 127,042,198
SampleBlockBench.parsePbjInputStream 55,016,235 ± 1,566,035 48,744,965 ± 1,205,097 -11.4% 127,156,324 127,156,280
  • Parsing is 5-11% faster, within the plan's 5-15% for these clean-ups. Block items parse 10% faster from Bytes, and whole blocks 5.5% (byte array), 7% (direct buffer) and 11% (input stream). Every Hiero parse, including streams, gets this.
  • Allocation is unchanged.
  • Signed transactions are unchanged (-1%). They are shallow messages, so there is little nesting for the direct calls to help.
  • What remains. Most of the remaining parse cost is in reading through ReadableSequentialData and in allocating OneOf, Bytes and lists. The opt-in byte-array parser (plan PRs 6-7) and unboxed oneofs (PR 11) address those.

Generated code size (full HAPI build)

main #996 This PR
All classes 57,723,772 58,478,383 58,373,007 (+1.1% vs main)
main classes 45,502,478 46,203,717 46,094,080 (+1.3%)
*ProtoCodec classes 7,140,244 7,852,023 7,742,386 (+8.4%)

Stacked on #996.

Fixes #993

🤖 Generated with Claude Code

Following docs/fast-codegen-plan.md section 6.2, the generated stream parser:
- looks up the field number and Schema.getField only for unknown fields, in defaultCase, instead
  of before the switch for every field;
- parses nested messages without a try/finally per field: the limit is restored inline on success,
  and the final Codec.parse restores the caller's limit once if parsing fails;
- calls a new public static parseFrom(...) on nested codecs generated in the same build, a static,
  monomorphic call (section 6.1 item 3, applied to parsing). Dependencies keep X.PROTOBUF.parse.

Map entries now pass their own tag to defaultCase; before, they passed the enclosing message's tag.

Fixes #993

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 29, 2026
@jasperpotts
jasperpotts requested review from a team as code owners October 29, 2026 23:21
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

JUnit Test Report

   551 files  ±0     551 suites  ±0   46s ⏱️ -6s
 2 052 tests ±0   2 048 ✅ ±0   4 💤 ±0  0 ❌ ±0 
11 584 runs  ±0  11 556 ✅ ±0  28 💤 ±0  0 ❌ ±0 

Results for commit a288342. ± Comparison against base commit 0949dcf.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Integration Test Report

    625 files  +2      625 suites  +2   50m 48s ⏱️ + 3m 28s
163 885 tests +9  163 885 ✅ +9  0 💤 ±0  0 ❌ ±0 
326 476 runs  +9  326 476 ✅ +9  0 💤 ±0  0 ❌ ±0 

Results for commit a288342. ± Comparison against base commit 0949dcf.

♻️ This comment has been updated with latest results.

jasperpotts and others added 4 commits October 29, 2026 19:28
… entries

Review follow-ups for the parser clean-ups:
- map entries no longer wrap their parse loop in try/finally to restore the limit, like nested
  messages: the limit is restored inline on success, and Codec.parse restores the caller's limit on
  failure; ParseLimitRestoreTest gains a corrupted map-entry case;
- MapEntryUnknownFieldTest covers the bug this PR fixes: an unknown field inside a map entry is now
  skipped (or kept, or rejected in strict mode) instead of always failing with "Bad tag", and a key
  with the wrong wire type is rejected;
- ByteArrayWriterGenerationTest checks that the parser calls nested codecs from the same build
  directly and dependencies through their Codec.

Refs #993

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

Copy link
Copy Markdown
Member Author

Review follow-ups. This PR also fixes a bug that exists on main.

The bug. In the generated map-entry parse loop, the default arm called defaultCase(tag, field, f, …) with the enclosing map field's tag, field and f instead of the entry's own. So an unknown or wrong-wire-type field inside a map entry always threw IOException("Bad tag…"), ignoring strictMode and parseUnknownFields. This PR makes defaultCase take only the tag and passes the entry's own ($prefixtag). I filed #1009 so the fix can be backported separately.

FIXes:

  • MapEntryUnknownFieldTest (new), using MessageWithMaps:

    • an unknown field inside an entry is skipped when lenient, collected on the message with parseUnknownFields, and rejected in strict mode;
    • a key with the wrong wire type is rejected.

    It runs in both integration configurations, so it covers the array parser too.

  • Map entries without try/finally. They now restore the limit inline on success, like nested messages, and rely on Codec.parse on failure. ParseLimitRestoreTest gains a corrupted map-entry case.

  • A dependency-jar test for the parser. ByteArrayWriterGenerationTest now also checks that the parser calls LocalProtoCodec.parseFrom(input, …) for messages from the same build and External.PROTOBUF.parse(input, …) for dependencies.

  • Javadoc. The static parseFrom already says that on failure it may leave the input's limit narrowed, and that only Codec.parse restores it.

The map change affects map-heavy messages only. HAPI block items, transactions and records have no maps, so I didn't re-measure them.

jasperpotts and others added 4 commits October 30, 2026 01:05
…anups

Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
…anups

Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
parseFrom is public and static so generated codecs can call each other
directly, but on failure it left the input's limit narrowed to a nested
message and relied on Codec.parse to restore it. A direct caller reusing its
buffer would then read with the wrong limit. parseFrom now restores the limit
it was called with on any failure, which costs one limit() read per message.

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

Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
try {
return parseImpl(input, strictMode, parseUnknownFields, maxDepth, maxSize);
} catch (final ParseException | RuntimeException e) {
input.limit(limit);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: doing this in finally could be more robust, regardless of what the parseImpl does or throws.

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.

2 participants