Skip to content

perf: generate straight-line size calculation and stream writers - #996

Open
jasperpotts wants to merge 9 commits into
989-byte-array-writer-codegenfrom
990-size-and-stream-writer-codegen
Open

jasperpotts wants to merge 9 commits into
989-byte-array-writer-codegenfrom
990-size-and-stream-writer-codegen

Conversation

@jasperpotts

@jasperpotts jasperpotts commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

What and why

This PR applies the straight-line approach from #995 to the other two hot paths (docs/fast-codegen-plan.md section 6.1, items 1-3 and 6):

Performance (AMD EPYC 9275F): toBytes of a freshly built block item is 2.1× faster (968 → 452 ns), of a signed transaction 1.7× (96 → 58 ns), and whole-block stream writes are 15-23% faster. Step 1 as a whole (#994-#996) makes block item array writes 28-30% faster. Tier A, on by default.

  • protobufSize() in the model. It now uses literal tag sizes and inline default checks, and measures each nested message through that model's own protobufSize(). That is a direct call on the model type. Before, it went through Codec.measureRecord on the shared, megamorphic call site. Every block item that is built and written once is measured once, so this is part of every fresh write.
  • The stream writer write(T, WritableSequentialData). It writes constant tags with writeByte / writeByte2..4 and checks defaults inline. Each codec gets a new public static void writeTo(T, WritableSequentialData), which nested messages from the same build call directly.
  • Codec.toBytes now writes through write(T, byte[], 0), the array writer, instead of the stream writer wrapped around a BufferedData.
  • One shared generator. InlineFieldCodeGenerator produces the inline code for all three targets: byte array, stream and size. The field order, default checks, oneof switches and fallbacks are written once. The byte-array writer from perf: generate straight-line byte array writers with direct nested calls #995 moves into it unchanged.

ProtoWriterTools.sizeOfUnsignedVarInt64 and sizeOfStringNoTag become public, for generated models. New writeStringNoTag and writeBytesNoTag stream helpers are added.

Generated code: TransactionID.protobufSize() before and after

Before:

// [1] - transactionValidStart
_size += sizeOfMessage(TransactionIDSchema.TRANSACTION_VALID_START, transactionValidStart, com.hedera.hapi.node.base.Timestamp.PROTOBUF);
// [4] - nonce
_size += sizeOfInteger(TransactionIDSchema.NONCE, nonce, true);

After:

// [1] - transactionValidStart
final var transactionValidStart = transactionValidStart();
if (transactionValidStart != null) {
    final int size = transactionValidStart.protobufSize();
    _size += 1 + sizeOfVarInt32(size) + size;
}
// [4] - nonce
final var nonce = nonce();
if (nonce != 0) {
    _size += 1 + sizeOfVarInt32(nonce);
}

The stream writer, for the same message:

final var transactionValidStart = data.transactionValidStart();
if (transactionValidStart != null) {
    out.writeByte((byte) 0x0A); // tag: field 1, wire type 2
    final int size = transactionValidStart.protobufSize();
    out.writeVarInt(size, false);
    com.hedera.hapi.node.base.codec.TimestampProtoCodec.writeTo(transactionValidStart, out);
}

Tests

  • ByteArrayWriterGenerationTest is extended: the stream writer and protobufSize() use direct calls for messages from this build, and the Codec / sizeOfMessage helpers for dependencies.
  • pbj-core: spotlessApply qualityGate build passes.
  • pbj-integration-tests: ./gradlew build and -PpbjPerformance=max both pass, 326,323 tests each. The protoc comparisons check measureRecord, stream writes and toBytes for every test object.

Performance: AMD EPYC 9275F, before = #995'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.itemToBytesCached 1,266 ± 89.7 935.2 ± 37.0 -26.2% 419 339
BlockItemBench.itemToBytesFresh 969.8 ± 48.3 451.8 ± 16.3 -53.4% 2,142 2,062
BlockItemBench.itemWriteByteArrayCached 296.4 ± 11.1 274.0 ± 4.7 -7.6% 0 0
BlockItemBench.itemWriteByteArrayFresh 483.3 ± 12.6 420.7 ± 27.0 -12.9% 1,723 1,723
BlockItemBench.transactionToBytesFresh 93.9 ± 3.7 57.9 ± 1.8 -38.3% 752 672
BlockItemBench.transactionWriteByteArrayCached 31.2 ± 1.4 32.4 ± 0.7 +3.6% 0 0
BlockItemBench.transactionWriteByteArrayFresh 47.4 ± 0.9 48.2 ± 2.2 +1.6% 399 399
SampleBlockBench.writePbjByteArray 57,289,139 ± 1,390,887 45,463,554 ± 830,612 -20.6% 385 313
SampleBlockBench.writePbjByteBuffer 57,514,555 ± 1,235,217 44,365,160 ± 1,025,929 -22.9% 392 295
SampleBlockBench.writePbjByteDirect 40,065,832 ± 1,218,275 34,305,294 ± 654,058 -14.4% 279 236
SampleBlockBench.writePbjOutputStream 67,003,336 ± 1,202,114 64,092,798 ± 2,631,824 -4.3% 19,605,947 19,605,926
SampleBlockBench.writePbjToBytes 75,216,791 ± 2,135,534 56,885,573 ± 2,100,181 -24.4% 19,606,065 19,605,870
  • toBytes of a freshly built block item is 2.1× faster (968 → 452 ns), and 1.7× for a signed transaction (96 → 58 ns), with 80 B/op less allocated. This is the measuring pass plus routing toBytes to the array writer.
  • Whole-block stream writes are 15-23% faster: SampleBlockBench into heap and direct BufferedData, and toBytes. Writing to an OutputStream is unchanged (-4%, within noise), because the stream itself dominates.
  • write(T, byte[]) for fresh block items is 13% faster, because measuring is part of it. Cached-size writes and signed transactions are unchanged within noise.

Step 1 as a whole (#994 → this PR), BlockItemBench per block item:

Before step 1 After Change
write(byte[]), freshly built 602 ns 421 ns -30%
write(byte[]), sizes cached 379 ns 274 ns -28%
toBytes, freshly built 968 ns 452 ns -53% (2.1×)
signed transaction toBytes 96 ns 58 ns -40%

The plan expected about 2× for writing deeply nested messages. Step 1 reaches it for toBytes of fresh block items, and reaches 1.4× for the array write itself.

Generated code size (full HAPI build)

main #995 This PR
All classes 57,723,772 58,317,471 58,478,383 (+1.3% vs main)
main classes 45,502,478 46,042,637 46,203,717 (+1.5%)
*ProtoCodec classes 7,140,244 7,680,403 7,852,023 (+10.0%)

The plan expected step 1 to shrink generated code; it grows slightly, well within the 15% tier A limit.

Stacked on #995.

Fixes #990

🤖 Generated with Claude Code

Applies the straight-line code of the byte-array writer to the model's protobufSize() and to the
stream writer (docs/fast-codegen-plan.md section 6.1, items 1-3 and 6):
- protobufSize() uses literal tag sizes, inline default checks and the nested model's own
  protobufSize() instead of Codec.measureRecord;
- the stream writer writes constant tags with writeByte/writeByte2..4, checks defaults inline, and
  calls a new public static writeTo(T, WritableSequentialData) on nested codecs from the same build;
- Codec.toBytes writes through write(T, byte[], 0), the array writer.

One generator, InlineFieldCodeGenerator, now produces the inline code for all three targets; the
byte-array writer moves into it unchanged. Output bytes and sizes are unchanged.

Fixes #990

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

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

JUnit Test Report

   551 files  ±0     551 suites  ±0   52s ⏱️ -1s
 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 0949dcf. ± Comparison against base commit 890bc4f.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Integration Test Report

    623 files  ±0      623 suites  ±0   47m 20s ⏱️ + 5m 49s
163 876 tests ±0  163 876 ✅ ±0  0 💤 ±0  0 ❌ ±0 
326 467 runs  ±0  326 467 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 0949dcf. ± Comparison against base commit 890bc4f.

♻️ This comment has been updated with latest results.

jasperpotts and others added 5 commits October 29, 2026 19:27
generateMeasureMethod built field size lines for a placeholder the template no longer has; measureRecord
returns data.protobufSize(). The helpers stay, for map entries.

Refs #990

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

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/CodecWriteByteArrayMethodGenerator.java
@jasperpotts

Copy link
Copy Markdown
Member Author

Review follow-ups

  • FIX: dead code. CodecMeasureRecordMethodGenerator.generateMeasureMethod no longer computes size lines for the placeholder the template no longer has. The helpers stay, for map entries. (This predates the series.)
  • Merged in from perf: generate straight-line byte array writers with direct nested calls #995: the unique local names now apply here too, in InlineFieldCodeGenerator, so the stream writer and protobufSize() are covered.
  • Thanks for checking the shared generator and the SINT32 size formula. Generating all three targets from one place was the aim, so that size and writer can't disagree.

…-writer-codegen

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

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

Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
.formatted(oneOfField.nameCamelFirstLower(), oneOfType, Common.camelToUpperSnake(field.name()));
prefix = oneOfGuard
? prefix
+ "if (data.%s().kind() == %s.%s)%n"

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.

Many review comments from #995 apply to this fix, including the newly introduced class here, because they iterate on the same code.

Comment on lines +126 to +128
prefix += "\n";
// The statement below is the (unbraced) body of the "if" above, so indent it one level
// deeper than the "if" itself to make that visually clear.

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.

Please don't generate unbraced multi-line if's. Removing the \n might be the nicest fix.

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