Repository navigation
perf: byte array parser with in-order fast paths, part 1 - #999
jasperpotts wants to merge 14 commits into
Conversation
Adds the opt-in byte-array parser of docs/fast-codegen-plan.md section 6.3 (pbj.fast_parse), part 1: - ArrayCursor (runtime): a position and limit over a byte[], with PBJ's XOR-chain varint reader and inline one-byte cases, tag checks for the in-order fast path, length and limit checks, and unknown field handling identical to the stream parser's defaultCase; - Codec.parse routes array-backed BufferedData, and parse(Bytes) / parseStrict(Bytes), to the generated parser when the codec has one; streams and direct buffers keep parseImpl; - generated codecs with fast_parse get a static parseArray: an in-order fast path testing each field's tag in field order, then a general loop with the same cases and errors as parseImpl. Nested messages with array parsers are called directly; others are parsed with parseWith. Part 1 covers messages whose fields are all singular scalars, strings, bytes, enums or messages, and whose fast_parse nested messages also qualify. Repeated fields, maps and oneofs follow in part 2. Fixes #998 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Integration Test Report 630 files 630 suites 43m 27s ⏱️ Results for commit aa60849. ♻️ This comment has been updated with latest results. |
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com> # Conflicts: # pbj-core/pbj-runtime/src/main/java/com/hedera/pbj/runtime/io/buffer/ByteArrayBufferedData.java # pbj-core/pbj-runtime/src/main/java/com/hedera/pbj/runtime/io/buffer/Bytes.java
|
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 |
Adds ArrayCursorTest cases for a varint of ten bytes that all have the continuation bit, and for unknown fields with wire types above 5 in strict mode, and says in the generator why tags of three or more bytes are left out of the in-order fast path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
|
Review follow-ups (second review, section 5), on this branch:
Two pre-existing bugs turned up while extending the test protos: #1011 ( |
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>
… the array parser The fast path of ArrayCursor.varintWide only checked the tenth byte for a continuation bit, so it accepted over-long varints such as nine 0x80 bytes and 0x00 (returning Long.MIN_VALUE) that ByteArrayBufferedData rejects. A message with fast_parse could then parse bytes the stream parser refuses. Reject a tenth byte with the continuation bit or with its lowest bit clear, as the stream parser does, and test every tenth byte against it. 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> # Conflicts: # pbj-core/pbj-runtime/src/main/java/com/hedera/pbj/runtime/Codec.java
…rser Codec.parse(ReadableSequentialData, ...) only used the array parser for an array-backed BufferedData. The input Bytes.toReadableSequentialData() returns, which is how Hiero usually parses Bytes with all five parse arguments, went to the stream parser. ArrayCursor.of now accepts it too, from its position to its limit, and Codec.parse advances it as for BufferedData. Adds parse(Bytes, strictMode, parseUnknownFields, maxDepth, maxSize), so callers that need parseUnknownFields or maxSize can parse Bytes directly, and BlockItemBench benchmarks for parsing transactions and transaction bodies through the sequential view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
ArrayCursor.varintWide was 383 bytes of bytecode, over HotSpot's 325-byte FreqInlineSize, so it was never inlined and was a real call for every multi-byte varint: the top self frame of decode and view profiles. Its fast path now handles varints of up to 8 bytes with at least 8 bytes left, in 261 bytes; 9- and 10-byte varints, varints near the limit and malformed ones go to a separate varintSlow, which also applies the tenth-byte check. InlineBudgetTest fails if varintWide or the other per-field ArrayCursor methods grow past their inline budgets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Splitting ArrayCursor.varintWide so it fits HotSpot's 325-byte inlining limit made parsing slower on AMD EPYC, 5 forks: block items 581 vs 490 ns (+18%), routing by parse 349 vs 279 ns (+25%), a sample block 23.9 vs 19.1 ms (+25%), TransactionBody 220 vs 199 ns (+11%). Inlining the varint read at every call site of the large generated parse methods costs more than the call it saves. Views were 5-7% faster, not enough to outweigh it. Restores the single varintWide and drops InlineBudgetTest, which enforced the split. Keeps the new test of every varint length at every distance from the limit. 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>
What and why
This PR adds part 1 of the opt-in byte-array parser from
docs/fast-codegen-plan.mdsection 6.3 (pbj.fast_parse, #987), following the techniques in sections 5.2 and 5.3.Performance (AMD EPYC 9275F): with
fast_parse,Timestampparses 31% faster (12.1 → 8.4 ns) and signed transactions 5% faster; block items are unchanged until part 2 (#1002), which covers the messages they contain. Tier B, opt-in.ArrayCursor(io.buffer), a read position and limit over abyte[]:at(tag), for one- and two-byte tags, used by the in-order fast path;varint(), PBJ's XOR-chain reader fromByteArrayBufferedDatawith limit checks and the one-byte case inline;fixed32/fixed64, andbool, which rejects values other than 0 and 1;string, with strict UTF-8 decoding and an ASCII fast path;bytes, a copy;length, giving the samemaxSizeerror messages, pluspushLimit/popLimitfor nested messages;parseWith(codec, …), for nested messages that have no array parser;unknownField(…), with exactly the behaviour ofparseImpl'sdefaultCase.Codec.parsemethods: when the codec has an array parser (hasArrayParser()), array-backedBufferedDatagoes toparseArrayImpl, and the input is advanced by the bytes consumed.parse(Bytes)andparseStrict(Bytes)go there directly. Streams and direct buffers keepparseImpl.fast_parsemessages:public static T parseArray(ArrayCursor, …).parseImpl: out-of-order and repeated values, unknown fields, and strict-mode duplicate checks.input.parseWith(X.PROTOBUF, …).Part 1 scope. Array parsers are generated only for messages whose fields are all singular scalars, strings, bytes, enums or messages, and whose
fast_parsenested messages also qualify. The second condition is needed because a nested message parsed withparseWithallocates aBufferedDatawrapper, which madeAllocationTestfail forSignedTransaction→SignatureMap. Part 2 (#next PR) supports every field kind, and the rule goes away.Scope adjustment. The plan puts unknown fields in part 2, but every message needs them to be correct, so they are in part 1.
Known gap, fixed in part 2. For
parse(Bytes),parseImplreturnsbytesfields as views of the input, becauseBytesis immutable. In this partArrayCursoralways copies, so results are equal but allocation is higher forbytes-heavy messages. Part 2 makes the cursor return views when it reads aBytes.Generated code for
com.hedera.hapi.node.base.TransactionID, before and afterBefore: only
parseImpl. After, added:Tests
ArrayCursorTest: 20,000 random varints checked againstBufferedData(fast and near-limit paths); truncation and limit checks; fixed values; strings (UTF-8, malformed,maxSize); bytes; bools; tags; nested limits; unknown fields (skip, collect, strict, bad tags); and cursors overBufferedDataandBytesslices.PerformanceOptionsTest:fast_parseaddsparseArrayonly where enabled. A message comment opts out of the project setting, and models are unchanged.fastParseMixed.proto(new): flagged messages nesting unflagged ones and the reverse, plus a two-byte tag. Its generated tests run in both configurations.pbj-core:spotlessApply qualityGate buildpasses.pbj-integration-tests:./gradlew buildpasses, 326,405 tests;./gradlew build -PpbjPerformance=maxpasses, 326,405 tests, withfast_parseon for every message (233 HAPI and test messages get an array parser in this part). That covers the protoc comparisons, the non-canonical-order tests (the fallback loop),MaxDepthTest,MaxSizeTest,UnknownFieldsTest,MalformedMessageTest,TruncatedDataTests,ParserNeverWrapsTest,ParseLimitRestoreTest, the fuzz tests andAllocationTest.Performance: AMD EPYC 9275F, before = #997 (no
fast_parse), after = this PR withfast_parseon3 forks, 3 × 1 s warmup, 5 × 1 s measurement,
-prof gc.Timestampparses 31% faster (12.1 → 8.4 ns), and signed transactions 5% faster. Block items and whole blocks are unchanged. In this part most HAPI messages (BlockItem,StateChanges,AccountID…) contain oneofs or repeated fields, so they keepparseImpl. Part 2 measures the full effect.ArrayCursoritself (TimeStampBench: 40 → 72 B/op). It is passed through the overridableparseArrayImpl, so escape analysis does not remove it.Generated code size (full HAPI build)
main-PpbjPerformance=maxWith options off, generated HAPI code is unchanged; the difference is the new test proto. With options on, codec classes grow to 8,181,412 bytes, from 7,742,386 (+5.7%), for the 233 messages with array parsers.
Stacked on #997.
Fixes #998
🤖 Generated with Claude Code