Skip to content

perf: byte array parser with in-order fast paths, part 2 - #1002

Open
jasperpotts wants to merge 15 commits into
998-array-parser-part1from
1000-array-parser-part2
Open

jasperpotts wants to merge 15 commits into
998-array-parser-part1from
1000-array-parser-part2

Conversation

@jasperpotts

@jasperpotts jasperpotts commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

What and why

This PR is part 2 of the opt-in byte-array parser (docs/fast-codegen-plan.md section 6.3, pbj.fast_parse). Every field kind is now supported, so a message has an array parser exactly when it has fast_parse.

Performance (AMD EPYC 9275F): with fast_parse, a whole HAPI block parses 2.3× faster (46.5 → 20.4 ms, about 3× faster than protoc), block items 1.9× (548 → 290 ns), signed transactions 1.4×, and allocation is down 4-27%. Tier B, opt-in.

  • Repeated messages, strings and bytes loop on their tag in the fast path (while (input.at(tag))), with parseImpl's list-size maxSize check.
  • Repeated numbers and enums. Writers emit them packed, so the fast path reads the packed form, and the general loop accepts both packed and unpacked. Lists are made read-only at the end.
  • Oneofs are in the switch only, as the plan requires: copying every alternative into the fast path doubled the method and made parsing slower in the prototype. They keep the same strict-mode duplicate check, and the last value wins.
  • Maps read each entry's key and value without an entry object. Like parseImpl, unknown fields inside an entry go to the enclosing message's unknown fields.
  • google.protobuf wrapper types are read as parseImpl reads them.
  • bytes fields from parse(Bytes) are now views of the input, as parseImpl returns them, rather than copies. This fixes the parity gap noted in perf: byte array parser with in-order fast paths, part 1 #999.
  • Staying under HotSpot's method size limit. HotSpot never JIT-compiles a method over 8,000 bytes of bytecode. With every field kind inline, Everything's parseArray grew to 8,435 bytes and ran interpreted, 45% slower than parseImpl; TransactionBody's grew to 9,198. So nested message, wrapper, repeated-element and packed-run reads are now small per-field static methods, which the fast path and the general loop both call. That brings the two methods down to 4,877 and 5,136 bytes, and the JIT still inlines the helpers where they're hot.

Not in this PR: list presizing (plan 5.3). It isn't needed for these gains, and I'm leaving it until a profile shows list growth matters.

Pre-existing finding, for a follow-up (tier A). The stream parsers of several large HAPI messages are already over the 8,000-byte limit on main, so HotSpot runs them interpreted:

Stream parser (parseFrom) Bytecode size
TransactionBody 15,454 bytes
StateValue 14,081 bytes
SchedulableTransactionBody 9,290 bytes

The same per-field helper technique would fix them for every Hiero parse.

Generated code: StateChanges (excerpt)

// In-order fast path: writers emit fields in field-number order, so test for each tag in turn
if (input.at((byte) 0x0A)) {
    temp_consensusTimestamp = readArray_consensus_timestamp(input, strictMode, parseUnknownFields, maxDepth, maxSize);
}
while (input.at((byte) 0x12)) {
    temp_stateChanges = addArray_state_changes(input, temp_stateChanges, strictMode, parseUnknownFields, maxDepth, maxSize);
}
...
private static List<StateChange> addArray_state_changes(final ArrayCursor input, final List<StateChange> list, ...) {
    if (list.size() >= maxSize) {
        throw new ParseException("state_changes size %d is greater than max maxSize".formatted(list.size()));
    }
    final StateChange value;
    final int messageLength = input.length(maxSize, "state_changes");
    if (messageLength == 0) {
        value = StateChange.DEFAULT;
    } else {
        final int limitBefore = input.pushLimit(messageLength);
        value = com.hedera.hapi.block.stream.output.codec.StateChangeProtoCodec.parseArray(input, strictMode, parseUnknownFields, maxDepth - 1, maxSize);
        input.popLimit(limitBefore);
    }
    return addToList(list, value);
}

Tests

  • pbj-core: spotlessApply qualityGate build passes. ArrayCursorTest covers views versus copies, and PerformanceOptionsTest covers repeated fields getting array parsers.
  • pbj-integration-tests, ./gradlew build -PpbjPerformance=max: 326,405 tests pass, with an array parser on all 578 HAPI and test messages. That includes the protoc comparisons, the non-canonical-order tests (the fallback loop), MaxDepthTest, MaxSizeTest, UnknownFieldsTest, MalformedMessageTest, TruncatedDataTests, ParseLimitRestoreTest, the fuzz tests, and AllocationTest, whose parse ceilings hold. Allocation is lower for every kind of block item; for example BLOCK_HEADER drops from 1,048 to 416 B.
  • ./gradlew build (options off) passes, 326,405 tests.

Performance: AMD EPYC 9275F, before = #999 with fast_parse on, after = this PR with fast_parse on

3 forks, 3 × 1 s warmup, 5 × 1 s measurement, -prof gc. Parses are measured from Bytes (BlockItemBench) and from heap BufferedData (ProtobufObjectBench, SampleBlockBench).

Benchmark Before ns/op After ns/op Change Before B/op After B/op
BlockItemBench.itemParseBytes 547.7 ± 19.7 289.7 ± 7.9 -47.1% 1,719 1,565
BlockItemBench.transactionParseBytes 73.3 ± 0.8 51.1 ± 0.8 -30.2% 395 379
ProtobufObjectBench.AccountDetailsBench.parsePbjByteArray 1,042 ± 30.1 548.5 ± 25.3 -47.4% 4,280 3,112
ProtobufObjectBench.AccountDetailsBench.parseProtoCByteArray 1,184 ± 29.5 1,183 ± 28.8 -0.1% 5,648 5,648
ProtobufObjectBench.EverythingBench.parsePbjByteArray 27,035 ± 539.0 11,305 ± 264.1 -58.2% 49,752 41,728
ProtobufObjectBench.EverythingBench.parseProtoCByteArray 14,460 ± 350.9 14,245 ± 362.3 -1.5% 46,096 45,856
ProtobufObjectBench.TimeStampBench.parsePbjByteArray 8.8 ± 0.7 8.4 ± 0.2 -4.7% 72 72
ProtobufObjectBench.TimeStampBench.parseProtoCByteArray 7.0 ± 0.2 7.1 ± 0.2 +1.6% 40 40
SampleBlockBench.parsePbjByteArray 46,509,946 ± 1,804,108 20,417,612 ± 578,292 -56.1% 127,042,066 117,669,501
SampleBlockBench.parsePbjByteBufferDirect 65,375,887 ± 2,697,340 54,856,297 ± 1,350,018 -16.1% 127,042,195 127,042,126
SampleBlockBench.parsePbjInputStream 49,489,277 ± 2,005,928 35,329,948 ± 1,053,675 -28.6% 127,156,287 127,156,188
  • A whole block parses 2.3× faster (46.5 → 20.4 ms), now about 3× faster than protoc's 63 ms on the same block.
  • Block items parse 1.9× faster (548 → 290 ns), signed transactions 1.4×, AccountDetails 1.9×.
  • Everything (every field type) parses 2.4× faster (27.0 → 11.3 µs), now faster than protoc's 14.2 µs.
  • Allocation is down 4-27%, from bytes views and no ArrayCursor per nested message.
  • Direct-buffer and stream parses are 16-29% faster too, although they still use parseImpl. I have not established why; possibly better JIT budget once the nested array-capable codecs are compiled. I'm reporting it as measured.

This is in line with the plan's 2.5-3× for nested messages.

Generated code size (full HAPI build)

main -PpbjPerformance=max, this PR
All classes 57,723,772 60,424,468 (+4.7%)
*ProtoCodec classes 7,140,244 9,514,455 (+33%)

With options off, generated main code is unchanged. Tier B: the parse code of flagged messages roughly doubles, as the plan predicted.

Stacked on #999.

Fixes #1000

🤖 Generated with Claude Code

jasperpotts and others added 2 commits October 29, 2026 17:42
Extends the fast_parse array parser (docs/fast-codegen-plan.md section 6.3) to every field kind:
- repeated messages, strings and bytes loop on their tag in the fast path; repeated numbers and
  enums read the packed form there, and the general loop accepts packed and unpacked forms;
- oneofs are handled in the switch only, with parseImpl's strict-mode duplicate check;
- map entries are read as in parseImpl, without an entry object;
- google.protobuf wrapper types are read as in parseImpl.

ArrayCursor now returns views for bytes fields when it reads a Bytes, as parseImpl does over Bytes,
instead of copies. With every kind supported, a message has an array parser exactly when it has
fast_parse.

Fixes #1000

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
HotSpot does not JIT-compile methods over 8000 bytes of bytecode. With every field kind in parseArray,
large messages crossed it: Everything's parseArray was 8435 bytes and ran interpreted, 45% slower than
parseImpl on AMD EPYC, and TransactionBody's was 9198 bytes.

Nested message, wrapper type, repeated element and packed-run reads now go in small per-field static
methods, called from both the fast path and the general loop. Everything's parseArray is 4877 bytes and
TransactionBody's 5136; the JIT still inlines the helpers where they are hot.

Refs #1000

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

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

JUnit Test Report

   554 files  ±0     554 suites  ±0   1m 11s ⏱️ +2s
 2 073 tests ±0   2 069 ✅ ±0   4 💤 ±0  0 ❌ ±0 
11 605 runs  ±0  11 577 ✅ ±0  28 💤 ±0  0 ❌ ±0 

Results for commit 3b16f3d. ± Comparison against base commit aa60849.

♻️ 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  + 2      632 suites  +2   52m 44s ⏱️ + 9m 17s
163 974 tests +42  163 974 ✅ +42  0 💤 ±0  0 ❌ ±0 
326 639 runs  +80  326 639 ✅ +80  0 💤 ±0  0 ❌ ±0 

Results for commit 3b16f3d. ± Comparison against base commit aa60849.

♻️ 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 2 commits October 29, 2026 23:28
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/protobuf/CodecParseArrayMethodGenerator.java
…, oneofs and maps

MixedFastCollections and MixedSlowCollections hold repeated fields, a
oneof and maps of messages with and without fast_parse, so the generated
tests round-trip them through both parsers.

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 (second review, section 5.1 and section 2):

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