Skip to content

perf: generate straight-line byte array writers with direct nested calls - #995

Open
jasperpotts wants to merge 8 commits into
988-single-store-varintfrom
989-byte-array-writer-codegen
Open

jasperpotts wants to merge 8 commits into
988-single-store-varintfrom
989-byte-array-writer-codegen

Conversation

@jasperpotts

@jasperpotts jasperpotts commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

What and why

write(T, byte[], offset) now writes each field with straight-line generated code, instead of calling a generic ProtoArrayWriterTools helper that takes a FieldDefinition. This implements docs/fast-codegen-plan.md section 6.1, items 1-5:

Performance (AMD EPYC 9275F): write(T, byte[]) is 20-22% faster for HAPI block items (cached 379 → 296 ns, fresh 602 → 483 ns) and 14-21% faster for signed transactions, still with 0 B/op. Tier A, on by default.

  • Constant tags inline: output[offset++] = 0x0A;, with two literals for two-byte tags.
  • Proto3 default checks inline: if (nonce != 0) { … }. Oneof members are always written, as before.
  • Direct calls between generated codecs: each codec gets public static int writeTo(data, output, startOffset, end), and nested messages generated in the same build call it directly. The call is static, so it can be inlined; before, it went through the megamorphic Codec.write. Messages from dependency jars keep the instance call.
  • Single-store varints via ProtoArrayWriterTools.writeVarInt (Write array varints with exact-size bulk stores #988), bounded by the message end.
  • Oneofs as a switch: each run of oneof alternatives that are adjacent in field-number order becomes one switch on the kind.

writeImpl(T, byte[], startOffset) delegates to writeTo(data, output, startOffset, startOffset + data.protobufSize()). Map entries, google.protobuf wrapper types and repeated scalars keep their existing helpers.

Generated code: BlockItem before and after

Before:

// [1] - block_header
if (data.item().kind() == BlockItem.ItemOneOfType.BLOCK_HEADER)
offset += ProtoArrayWriterTools.writeMessage(output, offset, BlockItemSchema.BLOCK_HEADER, (BlockHeader)data.item().as(), com.hedera.hapi.block.stream.output.BlockHeader.PROTOBUF);
// [4] - signed_transaction
if (data.item().kind() == BlockItem.ItemOneOfType.SIGNED_TRANSACTION)
offset += ProtoArrayWriterTools.writeBytes(output, offset, BlockItemSchema.SIGNED_TRANSACTION, (Bytes)data.item().as(), true);

After:

switch (data.item().kind()) {
    // [1] - block_header
    case BLOCK_HEADER -> {
        final BlockHeader blockHeader = data.item().as();
        output[offset++] = 0x0A; // tag: field 1, wire type 2
        if (blockHeader == null) {
            output[offset++] = 0;
        } else {
            final int size = blockHeader.protobufSize();
            offset += ProtoArrayWriterTools.writeVarInt(output, offset, end, size);
            offset += com.hedera.hapi.block.stream.output.codec.BlockHeaderProtoCodec.writeTo(blockHeader, output, offset, offset + size);
        }
    }
    // [4] - signed_transaction
    case SIGNED_TRANSACTION -> {
        final Bytes signedTransaction = data.item().as();
        output[offset++] = 0x22; // tag: field 4, wire type 2
        offset += ProtoArrayWriterTools.writeBytesNoTag(output, offset, end, signedTransaction);
    }
    ...
    default -> {}
}

and for a message with scalars (TransactionID):

// [4] - nonce
final var nonce = data.nonce();
if (nonce != 0) {
    output[offset++] = 0x20; // tag: field 4, wire type 0
    offset += ProtoArrayWriterTools.writeVarInt(output, offset, end, nonce);
}

Tests

  • ByteArrayWriterGenerationTest (new):
    • nested messages from this build, whether singular, repeated or in a oneof, get the direct writeTo call;
    • messages from a dependency keep the Codec instance call;
    • oneofs become a switch.
  • pbj-core: spotlessApply qualityGate build passes.
  • pbj-integration-tests:
    • ./gradlew build passes, 326,323 tests: protoc byte comparisons, the non-canonical-order tests, and AllocationTest with cached-size writes still at 0 B/op;
    • -PpbjPerformance=max passes, 326,323 tests.
  • Intermittent gRPC tests. Two options-off runs each had gRPC client tests fail with Broken pipe / Socket closed: GrpcCloseClientConnectionTest once, then GrpcClientComprehensiveTest.testClientStreamingMethod…InTheMiddle (two tests) once. A third run passed. These tests have the server deliberately end a call while the client is still writing, and the client's next write hits the closed socket. They pass 6 of 6 times when run alone on this branch, and they involve no generated codec. This looks like a pre-existing race under the parallel full suite, not this change.

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

3 forks, 3 × 1 s warmup, 5 × 1 s measurement, -prof gc. Per item or transaction for BlockItemBench; whole block for SampleBlockBench.

Benchmark Before ns/op After ns/op Change Before B/op After B/op
BlockItemBench.itemToBytesCached 1,308 ± 87.2 1,266 ± 89.7 -3.2% 419 419
BlockItemBench.itemToBytesFresh 967.9 ± 35.4 969.8 ± 48.3 +0.2% 2,142 2,142
BlockItemBench.itemWriteByteArrayCached 378.8 ± 10.2 296.4 ± 11.1 -21.7% 0 0
BlockItemBench.itemWriteByteArrayFresh 601.9 ± 27.2 483.3 ± 12.6 -19.7% 1,723 1,723
BlockItemBench.transactionToBytesFresh 96.3 ± 1.6 93.9 ± 3.7 -2.5% 752 752
BlockItemBench.transactionWriteByteArrayCached 39.4 ± 0.5 31.2 ± 1.4 -20.6% 0 0
BlockItemBench.transactionWriteByteArrayFresh 54.9 ± 2.2 47.4 ± 0.9 -13.7% 399 399
SampleBlockBench.writePbjByteArray 57,973,603 ± 1,753,587 57,289,139 ± 1,390,887 -1.2% 376 385
SampleBlockBench.writePbjByteBuffer 57,775,052 ± 1,701,890 57,514,555 ± 1,235,217 -0.5% 380 392
SampleBlockBench.writePbjByteDirect 40,493,277 ± 1,731,401 40,065,832 ± 1,218,275 -1.1% 271 279
SampleBlockBench.writePbjOutputStream 66,680,315 ± 1,501,298 67,003,336 ± 1,202,114 +0.5% 19,605,943 19,605,947
SampleBlockBench.writePbjToBytes 72,193,310 ± 1,993,239 75,216,791 ± 2,135,534 +4.2% 19,606,048 19,606,065

Generated code size (full HAPI build)

main / #988 This PR Change
All classes 57,723,772 58,317,471 +1.0%
main classes 45,502,478 46,042,637 +1.2%
*ProtoCodec classes 7,140,244 7,680,403 +7.6%

Stacked on #994.

Fixes #989

🤖 Generated with Claude Code

The generated write(T, byte[], offset) now writes each field inline instead of through a generic
ProtoArrayWriterTools helper (docs/fast-codegen-plan.md section 6.1, items 1-5):
- constant tags as byte literals;
- proto3 default checks inline; oneof members always written, as before;
- a new public static writeTo(data, output, startOffset, end) on each codec, called directly for
  nested messages generated in the same build, so the call is static and inlinable; messages from
  dependency jars keep the Codec instance call;
- single-store varints via ProtoArrayWriterTools.writeVarInt, bounded by the message end;
- each run of adjacent oneof alternatives written as one switch on the kind.

Map entries, google.protobuf wrapper types and repeated scalars keep their helpers. Output bytes are
unchanged.

Fixes #989

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 29, 2026 •

Copy link
Copy Markdown

JUnit Test Report

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

Results for 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      623 suites   41m 31s ⏱️
163 876 tests 163 876 ✅ 0 💤 0 ❌
326 467 runs  326 467 ✅ 0 💤 0 ❌

Results for commit 890bc4f.

♻️ This comment has been updated with latest results.

jasperpotts and others added 4 commits October 29, 2026 19:26
A field whose name the generated writer uses for its own locals (size, end, offset, data, item, ...) has
its local renamed with a "Value" suffix. That name could clash with another field's local: a message
with fields size and size_value did not compile. Renamed locals now get a numeric suffix until they are
unique against the method's names and every field of the message. localNames.proto adds such pairs, so
the generated tests compile and round-trip them.

Refs #989

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jasper Potts <1466205+jasperpotts@users.noreply.github.com>
Generated test data cannot build a repeated field of a message's own type, so the item fields use a small
nested message instead.

Refs #989

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

  • FIX: unique local names. A renamed local (for example size → sizeValue) is now checked against every field of the message; on a clash it gets a numeric suffix (sizeValue2). The new localNames.proto (LocalNameClash, with size/size_value, end/end_value, offset/offset_value/offset_value2, a oneof tag/tag_value, and others) compiles and round-trips against protoc. The same fix is in the shared generator from perf: generate straight-line size calculation and stream writers #996, so it covers the stream writer and protobufSize() too.
  • FIX: VarIntSingleStoreVariantsBench. It showed as deleted here only because it was committed on perf: exact-size bulk varint writes for byte arrays #994's branch after this one branched. perf: exact-size bulk varint writes for byte arrays #994 is now merged into this branch, so the file is kept.
  • Oneof switches. One switch per oneof is already generated wherever field order allows. The oneof is split into several switches only when ordinary fields have field numbers between its alternatives. In the HAPI build that is TransactionBody, CustomFee, CryptoUpdateTransactionBody and ContractCreateTransactionBody; BlockItem, SchedulableTransactionBody and StateValue get one switch each. Merging a split oneof into a single switch would write an alternative before a lower-numbered ordinary field, breaking canonical field order and byte-identity with protoc, which the protoc comparison tests would catch. So I left it as it is.
  • end. Agreed. Nested writes pass the nested item's own end. With the exact-size bulk varint stores from perf: exact-size bulk varint writes for byte arrays #994, nothing writes past a varint, so end is now only a safety bound; the parameter stays for the reverse writer and any store-based writer.
  • Thanks for the checks of multi-byte tags, sign-extended negative int32s, uint32, zigzag sint32, null oneof messages, empty messages and empty lists.

Writes on this branch are now also faster from #994's bulk varint stores: 12-23% on block items, measured on the #997 code.

…egen

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

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

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

abies commented Nov 3, 2026

Copy link
Copy Markdown

@anthony-swirldslabs This sounds very similar to #546. Somehow, during the implementation of that, it was decided it cannot be done in compatible way. I haven't really followed the original reason why it wasn't possible to be done and I wonder if it still applies here.


/**
* Build the lines that write every field in field-number order, with oneof alternatives that are adjacent in that
* order grouped into one switch on the oneof's kind.

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.

@abies :

@anthony-swirldslabs This sounds very similar to #546. Somehow, during the implementation of that, it was decided it cannot be done in compatible way. I haven't really followed the original reason why it wasn't possible to be done and I wonder if it still applies here.

Thanks for the note. Indeed we've discovered that a switch for oneofs would reorder fields compared to what Google Protobuf produces. While I'm unsure why we strive to maintain the binary compatibility, we nevertheless do, and it's the founding principle of PBJ. We'd need to look further into this when we get to reviewing this particular PR.

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.

@abies as I noted at #546 (comment) , there was a theoretical idea about grouping adjacent oneof variants in separate switch statements, and from the description of this PR ( #995 ), it sounds like exactly this idea was implemented here. So I think there's no issues, and passing integration tests (which this PR doesn't modify) prove that, too. This, of course, adds some extra complexity, but it looks like there's a performance win. I'm about to review the actual code changes here, but this concern seems resolved currently.

Please let me know if you have other concerns regarding this issue.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No other issues. Thank you for looking into that.

Comment on lines 44 to +45
protected final int writeImpl(@NonNull $modelClass data, @NonNull byte[] output, final int startOffset) {
return writeTo(data, output, startOffset, startOffset + data.protobufSize());

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: @artemananiev introduced a writeImpl(RandomAccessData out) at #1017 , which could benefit from a similar optimization. But this probably belongs in a separate PR.

Comment on lines +126 to +129
sb.append("""
switch (data.$oneOf().kind()) {
$cases default -> {}
}

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:

  1. a degenerate case of single adjacent oneof variant could use a simple if(){}. This might in theory require less byte code.
  2. Could we use a normal switch/case:/break instead of exploiting the case-> to avoid break statements? This looks like a trick and may be difficult to read. case-> is totally fine when we need to return a value, but not for hiding absent fall-throughs.

Comment on lines +147 to +159
while (true) {
final int b = (tag >>> 7) == 0 ? (int) tag : (int) ((tag & 0x7F) | 0x80);
sb.append("output[offset++] = ")
.append(b > Byte.MAX_VALUE ? "(byte) " : "")
.append("0x%02X;".formatted(b));
if (first) {
sb.append(" // tag: field %d, wire type %d".formatted(fieldNumber, wireType));
first = false;
}
sb.append("\n");
if ((tag >>> 7) == 0) return sb.toString();
tag >>>= 7;
}

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.

Why not use the writeVarInt() method from #994 ?

}
final String local = localName(field, fieldNames);
// outside a oneof, var avoids naming the type; inside, the type is needed to read the OneOf's value
final String javaType = !inOneOf ? "var" : field.type() == Field.FieldType.ENUM ? "int" : field.javaFieldType();

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 replace var with the actual type of the field to improve the code readability.

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

This will produce a multi-line if() w/o a {} block, which goes against Java Coding Conventions. Could we replace the trailing %n with a simple space character?

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.

3 participants