Skip to content

perf: 1031: cheaper nested decoding: oneofs in order, lists sized once, inlined varints - #1035

Open
jasperpotts wants to merge 12 commits into
1030-view-child-costfrom
1031-array-parser-nested
Open

jasperpotts wants to merge 12 commits into
1030-view-child-costfrom
1031-array-parser-nested

Conversation

@jasperpotts

@jasperpotts jasperpotts commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

Closes #1031. Stacked on #1034 (1030-view-child-cost).

Summary

The byte-array parser (pbj.fast_parse, part of performance = "max") and the views share ArrayCursor. This PR makes decoding nested HAPI messages cheaper in both: oneofs no longer leave the in-order fast path, short repeated fields are sized once, and the common varints are read by code the JIT inlines. Nothing changes without the options on (tier B).

Performance (AMD EPYC 9275F): on the synthetic HAPI block, decoding is 3.5% faster (1,417 → 1,367 ns per transaction, 414 B less), decode-then-read-every-field 11.5% faster, and reading every field through views 5.9% faster. Tier B, on with performance = "max".

  • Small oneofs in the parser's in-order fast path (273e4af3): up to four alternatives are tried in order, as views already do (perf: 1030: views pay less per nested message, allocate nothing, and read bytes in place #1034), instead of falling through to the tag switch.
  • Short repeated fields are sized once (273e4af3): before reading a run of a repeated message field, ArrayCursor.run(tag) counts its consecutive one-byte-tag elements without decoding them, and the list is created at that size (ProtoParserTools.presize). It gives up past 4,096 elements or 64 KiB, as counting a list that spans megabytes costs more in cache misses than growing it.
  • A varint read the JIT inlines (fe149849): two to eight bytes are read with a branch-light XOR chain, and everything else (nine and ten bytes, the end of the input, malformed values) goes to a separate byte-at-a-time method. The common path became small enough to inline everywhere.
  • 64-bit fields read with a varint that inlines whole (6adb96ad): ArrayCursor.varint64() and varint64ZigZag(), used for int64, uint64 and sint64 fields by the parser and the views.

EPYC results

AMD EPYC 9275F, JDK 25.0.4, one CCD (taskset -c 0-2,24-26) of an idle machine, 3 forks × 5 iterations of 2 s, -prof gc. ns per transaction of the synthetic HAPI block (#1028), every option on.

Step by step, in one run:

Step decode scanViews txDecode txScanViews
Oneofs in order, lists sized once 1,456 ± 9 2,261 ± 8 160 ± 3 218 ± 3
+ the inlinable varint 1,349 ± 7 (−7.3%) 2,280 ± 7 148 ± 3 (−7.5%) 191 ± 3 (−12.6%)
+ #1034's shared oneof slot (merge) 1,351 ± 14 2,292 ± 48 149 ± 3 202 ± 7
+ varint64 1,390 ± 44 2,120 ± 11 (−7.5%) 150 ± 3 203 ± 1

Against the parent PR, in the full benchmark at each head:

#1034 (before its last change) This PR Change
decode 1,417 ± 13 (8,565 B) 1,367 ± 20 (8,151 B) −3.5%, −414 B
scanObjects: decode, then read every field 2,035 ± 67 1,801 ± 22 −11.5%
scanViews 2,258 ± 14 2,125 ± 18 −5.9%
txDecode 148 ± 3 (1,169 B) 151 ± 3 (1,073 B) flat, −96 B
txScanObjects 175 ± 4 167 ± 2 −4.8%
txScanViews 217 ± 1 207 ± 3 −4.8%

varint64 is a trade-off: it made the block view scan 7.5% faster with transaction scans flat here, and on a second, external block workload block scans 12% faster and transaction scans 12 ns slower. For a block stream, the block side matters far more, so it stays.

Decode is now 1.38× the hand-written reference (989 ns in the same run), from 1.46× at #1028, and transactions 1.31×.

Tests

  • ArrayCursorTest: runs of delimited fields are counted without reading them, and the count gives up at its limits; varints read the same near the limit and near the array's end, for every length; a bad tenth byte past the limit is an underflow, as the stream parser reports it; varint64 reads exactly as varint does.
  • The whole integration suite with every option on parses protoc's encoding of every test object with the array parser, in field order and with the fields reversed, and compares it with the model.

🤖 Generated with Claude Code

jasperpotts and others added 11 commits October 31, 2026 00:30
…ts once

parseArray's in-order fast path includes oneofs of at most four
alternatives, so an id's account number no longer falls to the tag
switch. A run of repeated delimited elements with a one-byte tag is
counted first (ArrayCursor.run, at most 4096 fields in 64 KB, as counting
a block's items was slower than growing) and its list allocated once at
that size. Synthetic block decode allocates 414 B less per transaction.

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>
ArrayCursor.varintWide was 417 bytes of bytecode, over FreqInlineSize,
so it was never inlined. It now reads two to eight bytes with the XOR
chain and sends everything else, and every error, to varintSlow: 9-10
byte values, the last bytes of the array, and varints that end past the
limit. Prototyped on the external workload by the validation session.
Tests: every length near the limit and near the array's end, cut short,
and a bad tenth byte before and past 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>

# Conflicts:
#	pbj-core/pbj-runtime/src/test/java/com/hedera/pbj/runtime/io/buffer/ArrayCursorTest.java
ArrayCursor.varint64 is varint with the multi-byte read in the same
method (304 bytes, under FreqInlineSize): generated parsers and views use
it for int64, uint64 and sint64 fields, whose values are often large, so
the JIT can inline the whole read there. Suggested by the validation
session. Mac: block decode and views scan faster, transaction views scan
slower; to be decided on EPYC.

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

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

JUnit Test Report

   559 files     559 suites   1m 25s ⏱️
 2 118 tests  2 114 ✅  4 💤 0 ❌
11 650 runs  11 622 ✅ 28 💤 0 ❌

Results for commit ce64ed7.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Integration Test Report

    661 files  ±0      661 suites  ±0   57m 54s ⏱️ + 11m 1s
164 383 tests ±0  164 383 ✅ ±0  0 💤 ±0  0 ❌ ±0 
652 346 runs  ±0  652 346 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit ce64ed7. ± Comparison against base commit dadf12c.

♻️ This comment has been updated with latest results.

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